import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { RudderClient } from '../../src/client/RudderClient.js'; import { createFakeTokenStore } from '../helpers/FakeTokenStore.js'; import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js'; import { stubDeterministicUuid } from '../helpers/memoryPlanStore.js'; import { effect } from '../helpers/plan.js'; import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js'; const MINUTE = 60_000; function makeClient(): RudderClient { return new RudderClient({ baseUrl: 'https://api.test.rudder.build', projectKey: 'test-project', tokenStore: createFakeTokenStore(), }); } function offerScenario(now: number) { const waitIntro = effect('wait', {}, { scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'wait_intro', waitDeadline: new Date(now + MINUTE).toISOString(), }); const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, { scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); const waitReminder = effect('wait', {}, { scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'wait_reminder', waitDeadline: new Date(now + 2 * MINUTE).toISOString(), }); const reminder = effect('notification', { message: 'Your offer is still available!' }, { scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'reminder', }); const thanks = effect('notification', { message: 'Thanks for your purchase!' }, { scenarioSlug: 'offer_flow', runId: 'offer_flow-run', nodeId: 'thanks', }); return { waitIntro, offer, waitReminder, reminder, thanks }; } describe('E2E: player offer journey', () => { let gateway: FakeGateway; let client: RudderClient; beforeEach(() => { stubDeterministicUuid(); vi.useFakeTimers(); gateway = createFakeGateway({ stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }], }], }); gateway.install(); client = makeClient(); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it('authenticates the device and persists tokens for later calls', async () => { const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' }); expect(res.accessToken).toBeTruthy(); expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken); const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device'); expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' }); expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy(); const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger'); expect(trigger?.authToken).toBe(res.accessToken); }); it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => { const now = Date.now(); const nodes = offerScenario(now); gateway.onEvent('player_login', nodes.waitIntro); gateway.onCallback('wait_intro', 'onComplete', nodes.offer); gateway.onCallback('offer', 'onDecline', nodes.waitReminder); gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder); const notifications: NotificationEffect[] = []; let offer: StoreOfferEffect | undefined; client.effects.onStoreOffer((e) => { offer = e; }); client.effects.onNotification((e) => { notifications.push(e); }); await client.auth.loginWithDevice(); expect(offer).toBeUndefined(); await vi.advanceTimersByTimeAsync(MINUTE - 1_000); expect(offer).toBeUndefined(); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => expect(offer).toBeDefined()); expect(offer!.message).toBe('Limited starter pack!'); await offer!.dismiss(); expect(notifications).toHaveLength(0); await vi.advanceTimersByTimeAsync(MINUTE - 1_000); expect(notifications).toHaveLength(0); await vi.advanceTimersByTimeAsync(1_000); expect(notifications).toHaveLength(1); expect(notifications[0].message).toBe('Your offer is still available!'); }); it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => { const now = Date.now(); const nodes = offerScenario(now); gateway.onEvent('player_login', nodes.waitIntro); gateway.onCallback('wait_intro', 'onComplete', nodes.offer); gateway.onCallback('offer', 'onPurchase', nodes.thanks); let offer: StoreOfferEffect | undefined; const notifications: NotificationEffect[] = []; client.effects.onStoreOffer((e) => { offer = e; }); client.effects.onNotification((e) => { notifications.push(e); }); const accessToken = (await client.auth.loginWithDevice()).accessToken; await vi.advanceTimersByTimeAsync(MINUTE); await vi.waitFor(() => expect(offer).toBeDefined()); const selectedOffer = offer!.offers[0]; const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' }); expect(purchase.success).toBe(true); expect(gateway.purchases).toEqual([ { storeSlug: 'starter', offerSlug: 'pack-1', idempotencyKey: 'idem-key-123', authToken: accessToken }, ]); expect(notifications).toHaveLength(1); expect(notifications[0].message).toBe('Thanks for your purchase!'); }); it('declining does NOT take the purchase branch (handles are isolated)', async () => { const now = Date.now(); const nodes = offerScenario(now); gateway.onEvent('player_login', nodes.waitIntro); gateway.onCallback('wait_intro', 'onComplete', nodes.offer); gateway.onCallback('offer', 'onDecline', nodes.waitReminder); gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder); gateway.onCallback('offer', 'onPurchase', nodes.thanks); const messages: string[] = []; let offer: StoreOfferEffect | undefined; client.effects.onStoreOffer((e) => { offer = e; }); client.effects.onNotification((e) => { messages.push(e.message); }); await client.auth.loginWithDevice(); await vi.advanceTimersByTimeAsync(MINUTE); await vi.waitFor(() => expect(offer).toBeDefined()); await offer!.dismiss(); await vi.advanceTimersByTimeAsync(MINUTE); expect(messages).toEqual(['Your offer is still available!']); expect(messages).not.toContain('Thanks for your purchase!'); expect(gateway.purchases).toHaveLength(0); }); });