import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ScenarioService } from '../src/scenario/ScenarioService.js'; import { getScenarioRuntime, RudderClient } from '../src/client/RudderClient.js'; import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; import { effect } from './helpers/plan.js'; import { RudderHttpError } from '../src/client/RudderError.js'; import type { NotificationEffect, StoreOfferEffect, WaitEffect } from '../src/index.js'; import type { PendingEffect } from '../src/generated/scenarios.js'; async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> { const client = new RudderClient({ baseUrl: 'https://api.test.rudder.build', projectKey: 'test-key', tokenStore: createFakeTokenStore(), runtime: { loginEvent: null }, }); return { client, scenarios: await getScenarioRuntime(client) }; } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status }); } function stubFetch(handler: (path: string, method: string, body: unknown) => Response | Promise): void { vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => { const parsed = new URL(url); const method = (init?.method ?? 'GET').toUpperCase(); const body = init?.body ? JSON.parse(init.body as string) : undefined; return Promise.resolve(handler(parsed.pathname, method, body)); })); } describe('ScenarioService', () => { beforeEach(() => { vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-a000-000000000001', }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); describe('send (trigger)', () => { it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => { const { client, scenarios } = await createClientWithScenario(); const onNotification = vi.fn(); client.effects.onNotification(onNotification); stubFetch((path, method) => { if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [effect('notification', { title: 'Hi', message: 'Welcome' })], }); } return jsonResponse({}); }); const response = await scenarios.send('level_complete'); expect(response.effects).toHaveLength(1); expect(scenarios.isRunning).toBe(true); expect(onNotification).toHaveBeenCalledOnce(); expect(onNotification.mock.calls[0][0].title).toBe('Hi'); expect(onNotification.mock.calls[0][0].message).toBe('Welcome'); }); }); describe('effect emission', () => { it('notification node fires onNotification', async () => { const { client, scenarios } = await createClientWithScenario(); const onNotif = vi.fn(); client.effects.onNotification(onNotif); stubFetch(() => jsonResponse({ effects: [effect('notification')] })); await scenarios.send('test'); expect(onNotif).toHaveBeenCalledOnce(); }); it('store node fires onStoreOffer', async () => { const { client, scenarios } = await createClientWithScenario(); const onStore = vi.fn(); client.effects.onStoreOffer(onStore); stubFetch((path) => { if (path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [effect('store', { storeSlug: 'starter', message: 'Buy!' })], }); } if (path === '/sdk/v1/stores/starter') { return jsonResponse({ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }); } return jsonResponse({}); }); await scenarios.send('test'); await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce()); const offer = onStore.mock.calls[0][0] as StoreOfferEffect; expect(offer.message).toBe('Buy!'); expect(offer.store.slug).toBe('starter'); }); it('wait node fires onWait', async () => { const { client, scenarios } = await createClientWithScenario(); const onWait = vi.fn(); client.effects.onWait(onWait); const deadline = new Date(Date.now() + 60_000).toISOString(); stubFetch(() => jsonResponse({ effects: [effect('wait', {}, { waitDeadline: deadline })], })); await scenarios.send('test'); expect(onWait).toHaveBeenCalledOnce(); expect(onWait.mock.calls[0][0].deadlineUtc).toBeInstanceOf(Date); }); it('leaderboard node fires onLeaderboard', async () => { const { client, scenarios } = await createClientWithScenario(); const onLb = vi.fn(); client.effects.onLeaderboard(onLb); stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] })); await scenarios.send('test'); expect(onLb).toHaveBeenCalledOnce(); }); it('quest node dispatches onQuest', async () => { const { client, scenarios } = await createClientWithScenario(); const onQuest = vi.fn(); client.effects.onQuest(onQuest); stubFetch(() => jsonResponse({ effects: [effect('quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }] })], })); await scenarios.send('test'); expect(onQuest).toHaveBeenCalledOnce(); expect(onQuest.mock.calls[0][0].name).toBe('Daily'); expect(scenarios.isRunning).toBe(true); }); it('battlepass node dispatches onBattlePass', async () => { const { client, scenarios } = await createClientWithScenario(); const onBp = vi.fn(); client.effects.onBattlePass(onBp); stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] })); await scenarios.send('test'); expect(onBp).toHaveBeenCalledOnce(); expect(scenarios.isRunning).toBe(true); }); it('battlepass_level node dispatches onBattlePassLevel', async () => { const { client, scenarios } = await createClientWithScenario(); const onLevel = vi.fn(); client.effects.onBattlePassLevel(onLevel); stubFetch(() => jsonResponse({ effects: [effect('battlepass_level', { levelNumber: 3 })], })); await scenarios.send('test'); expect(onLevel).toHaveBeenCalledOnce(); expect(onLevel.mock.calls[0][0].level).toBe(3); }); it('unsupported node type fails the run and surfaces onScenarioFailed', async () => { const { client, scenarios } = await createClientWithScenario(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const onFailed = vi.fn(); client.effects.onScenarioFailed(onFailed); stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] })); await scenarios.send('test'); expect(warn).toHaveBeenCalledWith( expect.stringContaining('Unsupported scenario node type'), ); expect(onFailed).toHaveBeenCalledOnce(); expect(scenarios.isRunning).toBe(false); warn.mockRestore(); }); }); describe('callback continuation', () => { it('completing an effect posts callback and emits the next effect', async () => { const { client, scenarios } = await createClientWithScenario(); const onNotif = vi.fn(); client.effects.onNotification(onNotif); const first = effect('notification', { message: 'one' }, { nodeId: 'n1' }); const second = effect('notification', { message: 'two' }, { nodeId: 'n2' }); stubFetch((path, method) => { if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [first] }); } if (method === 'POST' && path === '/sdk/v1/scenarios/callback') { return jsonResponse({ effect: second }); } return jsonResponse({}); }); await scenarios.send('test'); expect(onNotif).toHaveBeenCalledOnce(); const session = onNotif.mock.calls[0][0] as NotificationEffect; await session.done(); expect(onNotif).toHaveBeenCalledTimes(2); expect(onNotif.mock.calls[1][0].message).toBe('two'); const fetchMock = vi.mocked(fetch); const callbackCall = fetchMock.mock.calls.find((call) => { const url = String(call[0]); return url.includes('/sdk/v1/scenarios/callback'); }); expect(JSON.parse(callbackCall![1]!.body as string)).toEqual({ scenarioSlug: 'scenario-1', nodeId: 'n1', handle: 'output', runId: 'run-1', }); }); it('completing the last effect emits onScenarioCompleted', async () => { const { client, scenarios } = await createClientWithScenario(); const onCompleted = vi.fn(); const onNotif = vi.fn(); client.effects.onScenarioCompleted(onCompleted); client.effects.onNotification(onNotif); stubFetch((path) => { if (path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [effect('notification')] }); } return jsonResponse({}); }); await scenarios.send('test'); await (onNotif.mock.calls[0][0] as NotificationEffect).done(); expect(onCompleted).toHaveBeenCalledOnce(); expect(onCompleted.mock.calls[0][0]).toEqual({ runId: 'run-1', scenarioSlug: 'scenario-1', }); expect(scenarios.isRunning).toBe(false); }); }); describe('pending dedup', () => { it('does not re-emit an effect with the same runId and nodeId', async () => { const { client, scenarios } = await createClientWithScenario(); const onNotification = vi.fn(); client.effects.onNotification(onNotification); const pending = effect('notification', { message: 'once' }); stubFetch(() => jsonResponse({ effects: [pending] })); await scenarios.send('first-event'); await scenarios.send('second-event'); expect(onNotification).toHaveBeenCalledTimes(1); expect(scenarios.isRunning).toBe(true); }); }); describe('waitDeadline timer', () => { it('polls pending at waitDeadline and emits the next effect', async () => { vi.useFakeTimers(); const { client, scenarios } = await createClientWithScenario(); const onWait = vi.fn(); const onNotif = vi.fn(); client.effects.onWait(onWait); client.effects.onNotification(onNotif); const deadline = new Date(Date.now() + 5_000).toISOString(); const wait = effect('wait', {}, { waitDeadline: deadline, nodeId: 'wait-1' }); const next = effect('notification', { message: 'after wait' }, { nodeId: 'n2' }); let pending: PendingEffect[] = [wait]; stubFetch((path, method) => { if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [wait] }); } if (method === 'GET' && path === '/sdk/v1/scenarios/pending') { return jsonResponse({ effects: pending }); } return jsonResponse({}); }); await scenarios.send('test'); expect(onWait).toHaveBeenCalledOnce(); expect(onNotif).not.toHaveBeenCalled(); pending = [next]; await vi.advanceTimersByTimeAsync(5_000); expect(onNotif).toHaveBeenCalledOnce(); expect(onNotif.mock.calls[0][0].message).toBe('after wait'); const session = onWait.mock.calls[0][0] as WaitEffect; expect(session.deadlineUtc.toISOString()).toBe(deadline); }); }); describe('expired-run drop', () => { it('drops the run on unknown_run and does not retry it', async () => { const { client, scenarios } = await createClientWithScenario(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const onFailed = vi.fn(); const onNotif = vi.fn(); client.effects.onScenarioFailed(onFailed); client.effects.onNotification(onNotif); const pending = effect('notification', { message: 'hello' }); stubFetch((path) => { if (path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [pending] }); } if (path === '/sdk/v1/scenarios/callback') { return jsonResponse({ code: 'unknown_run', error: 'gone' }, 410); } if (path === '/sdk/v1/scenarios/pending') { return jsonResponse({ effects: [pending] }); } return jsonResponse({}); }); await scenarios.send('test'); expect(onNotif).toHaveBeenCalledOnce(); await expect((onNotif.mock.calls[0][0] as NotificationEffect).done()) .rejects.toBeInstanceOf(RudderHttpError); expect(onFailed).toHaveBeenCalledOnce(); expect(onFailed.mock.calls[0][0].runId).toBe('run-1'); expect(scenarios.isRunning).toBe(false); await scenarios.start(); expect(onNotif).toHaveBeenCalledTimes(1); warn.mockRestore(); client.dispose(); }); it('leaves the effect active on a transient callback error', async () => { const { client, scenarios } = await createClientWithScenario(); const onFailed = vi.fn(); const onNotif = vi.fn(); client.effects.onScenarioFailed(onFailed); client.effects.onNotification(onNotif); stubFetch((path) => { if (path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [effect('notification')] }); } if (path === '/sdk/v1/scenarios/callback') { return jsonResponse({ error: 'boom' }, 500); } return jsonResponse({}); }); await scenarios.send('test'); await (onNotif.mock.calls[0][0] as NotificationEffect).done(); expect(onFailed).not.toHaveBeenCalled(); expect(scenarios.isRunning).toBe(true); }); }); describe('store session', () => { it('buy() purchases the selected offer and completes with onPurchase', async () => { const { client, scenarios } = await createClientWithScenario(); const onStore = vi.fn(); client.effects.onStoreOffer(onStore); stubFetch((path, method) => { if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] }); } if (method === 'GET' && path === '/sdk/v1/stores/starter') { return jsonResponse({ name: 'Starter', slug: 'starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }], }); } if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack-1/purchase') { return jsonResponse({ success: true, purchaseId: 'purchase-1' }); } return jsonResponse({}); }); await scenarios.send('test'); await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce()); const session = onStore.mock.calls[0][0] as StoreOfferEffect; const purchase = await session.buy(session.offers[0]); expect(purchase.success).toBe(true); const duplicate = await session.buy(session.offers[0]); expect(duplicate.success).toBe(false); }); it('dismiss() completes with onDecline', async () => { const { client, scenarios } = await createClientWithScenario(); const onStore = vi.fn(); client.effects.onStoreOffer(onStore); stubFetch((path) => { if (path === '/sdk/v1/scenarios/trigger') { return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] }); } if (path === '/sdk/v1/stores/starter') { return jsonResponse({ slug: 'starter', offers: [{ id: 'pack_1', slug: 'pack-1' }] }); } return jsonResponse({}); }); await scenarios.send('test'); await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce()); await (onStore.mock.calls[0][0] as StoreOfferEffect).dismiss(); const fetchMock = vi.mocked(fetch); const callbackCall = fetchMock.mock.calls.find((call) => String(call[0]).includes('/sdk/v1/scenarios/callback'), ); expect(JSON.parse(callbackCall![1]!.body as string).handle).toBe('onDecline'); }); }); describe('clear', () => { it('clear removes all runs', async () => { const { client, scenarios } = await createClientWithScenario(); client.effects.onNotification(vi.fn()); stubFetch(() => jsonResponse({ effects: [effect('notification')] })); await scenarios.send('test'); expect(scenarios.isRunning).toBe(true); scenarios.clear(); expect(scenarios.isRunning).toBe(false); }); }); });