import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { getScenarioRuntime, 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 { StoreOfferEffect } from '../../src/index.js'; function makeClient(): RudderClient { return new RudderClient({ baseUrl: 'https://api.test.rudder.build', projectKey: 'test-project', tokenStore: createFakeTokenStore(), }); } describe('E2E: boundary nodes (server-side continuation)', () => { let gateway: FakeGateway; let client: RudderClient; beforeEach(() => { stubDeterministicUuid(); gateway = createFakeGateway({ stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', name: 'Starter Pack' }], }], }); gateway.install(); client = makeClient(); }); afterEach(() => vi.unstubAllGlobals()); it('purchase crosses the boundary → callback fires and continues the scenario', async () => { const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, { scenarioId: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, { scenarioId: 'offer_flow', runId: 'offer_flow-run', nodeId: 'reward', }); gateway.onEvent('player_login', offerEffect); gateway.onCallback('offer', 'onPurchase', reward); const messages: string[] = []; const completed: string[] = []; let offer: StoreOfferEffect | undefined; client.effects.onStoreOffer((e) => { offer = e; }); client.effects.onNotification((e) => { messages.push(e.message); }); client.effects.onScenarioCompleted((e) => { completed.push(e.scenarioId); }); const token = (await client.auth.loginWithDevice()).accessToken; await vi.waitFor(() => expect(offer).toBeDefined()); await offer!.buy(offer!.offers[0]); const callback = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/callback'); expect(callback?.body).toEqual({ scenarioId: 'offer_flow', nodeId: 'offer', handle: 'onPurchase', runId: 'offer_flow-run', }); expect(callback?.authToken).toBe(token); expect(messages).toEqual(['Reward granted: 500 gems!']); expect(messages).not.toContain('Maybe next time!'); expect(completed).toEqual([]); }); it('decline posts callback and continues with the server-supplied next effect', async () => { const offerEffect = effect('store', { storeSlug: 'starter', message: 'Buy the starter pack!' }, { scenarioId: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); const consolation = effect('notification', { message: 'Maybe next time!' }, { scenarioId: 'offer_flow', runId: 'offer_flow-run', nodeId: 'consolation', }); gateway.onEvent('player_login', offerEffect); gateway.onCallback('offer', 'onDecline', consolation); 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.waitFor(() => expect(offer).toBeDefined()); await offer!.dismiss(); expect(gateway.recorded.some((r) => r.path === '/sdk/v1/scenarios/callback')).toBe(true); expect(messages).toEqual(['Maybe next time!']); }); it('a failing callback does not fail or crash the run', async () => { const offerEffect = effect('store', { storeSlug: 'starter' }, { scenarioId: 'offer_flow', runId: 'offer_flow-run', nodeId: 'offer', }); gateway.onEvent('player_login', offerEffect); gateway.onCallbackError('offer', 'onPurchase', 500); const failures: unknown[] = []; let offer: StoreOfferEffect | undefined; const runtime = await getScenarioRuntime(client); client.effects.onStoreOffer((e) => { offer = e; }); client.effects.onScenarioFailed((e) => { failures.push(e); }); await client.auth.loginWithDevice(); await vi.waitFor(() => expect(offer).toBeDefined()); await expect(offer!.buy(offer!.offers[0])).resolves.toMatchObject({ success: true }); expect(failures).toHaveLength(0); expect(runtime.isRunning).toBe(true); }); });