import { describe, it, expect, beforeAll } from 'vitest'; import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js'; import type { SeedArtifact } from './_setup/types.js'; import type { RudderClient } from '../../src/client/RudderClient.js'; import type { OfferHandle } from '../../src/state/shops.js'; // Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end: // admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds. interface AdminWalletDetails { currencyCode: string; balance: number; } interface AdminInventoryItem { slug: string; amount: number; } interface AdminPlayerDetails { wallets?: AdminWalletDetails[]; inventory?: AdminInventoryItem[]; } describe('e2e-prod: purchase (wallet/inventory/limits)', () => { let artifact: SeedArtifact; beforeAll(() => { artifact = loadArtifact(); }); async function resolvePaidOffer(client: RudderClient): Promise { const stores = await withReadRetry( () => client.stores.reload(), (s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0), ); const store = stores.find((s) => s.name === artifact.store.name)!; return store.offers.find((o) => o.name === artifact.store.paidOfferName)!; } async function balance(client: RudderClient, currency: string): Promise { const profile = await client.player.reload(); // int64 fields serialize as JSON strings via protojson — coerce to number. return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0); } it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => { const client = await freshPlayer(artifact); const playerId = (await client.player.reload()).player!.id!; const api = adminApi(artifact); const currency = artifact.store.paidPrice.currency; const price = artifact.store.paidPrice.amount; const offer = await resolvePaidOffer(client); // 1. Grant currency via the admin endpoint. await grantCurrency(artifact, playerId, currency, 1000); const granted = await withReadRetry( () => balance(client, currency), (b) => b === 1000, ); expect(granted).toBe(1000); // 2. First paid buy: success + wallet debited. const buy1 = await offer.buy(); expect(buy1.success).toBe(true); expect(buy1.purchaseId).toBeTruthy(); expect(await balance(client, currency)).toBe(1000 - price); // 3. Inventory credited (verified via admin player details). const details = await api.get( `/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`, ); const inv = details.inventory?.find((i) => i.slug === artifact.item.id); expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount); // 4. Idempotency: the same key twice charges once. const key = crypto.randomUUID(); const a = await offer.buy({ idempotencyKey: key }); const b = await offer.buy({ idempotencyKey: key }); expect(a.success).toBe(true); expect(b.success).toBe(true); expect(await balance(client, currency)).toBe(1000 - price * 2); // 5. max_purchases (2) reached -> a new distinct buy fails (no charge). const overLimit = await offer.buy(); expect(overLimit.success).toBe(false); expect(overLimit.error ?? '').toContain('limit'); expect(await balance(client, currency)).toBe(1000 - price * 2); }); it('rejects a paid purchase with insufficient funds', async () => { const poor = await freshPlayer(artifact); const offer = await resolvePaidOffer(poor); const res = await offer.buy(); expect(res.success).toBe(false); expect(res.error ?? '').toContain('insufficient'); }); });