2026-08-12 14:02:25 +03:00
|
|
|
import { describe, it, expect, beforeAll } from 'vitest';
|
2026-09-06 09:53:45 +03:00
|
|
|
import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js';
|
2026-08-12 14:02:25 +03:00
|
|
|
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 {
|
2026-08-19 17:48:55 +03:00
|
|
|
slug: string;
|
2026-08-12 14:02:25 +03:00
|
|
|
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);
|
|
|
|
|
|
2026-09-06 09:53:45 +03:00
|
|
|
// 1. Grant currency via the admin endpoint.
|
|
|
|
|
await grantCurrency(artifact, playerId, currency, 1000);
|
2026-08-12 14:02:25 +03:00
|
|
|
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>(
|
2026-09-06 21:44:54 +03:00
|
|
|
`/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`,
|
2026-08-12 14:02:25 +03:00
|
|
|
);
|
2026-08-19 17:48:55 +03:00
|
|
|
const inv = details.inventory?.find((i) => i.slug === artifact.item.id);
|
2026-08-12 14:02:25 +03:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|