Files

95 lines
3.8 KiB
TypeScript

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<OfferHandle> {
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<number> {
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<AdminPlayerDetails>(
`/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');
});
});