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 type { ExecutionPlan, ExecutionPlanNode, PlanEdge } from '../src/generated/common.js'; import { NotificationSession, StoreSession, WaitSession, LeaderboardSession } from '../src/scenario/engine/sessions.js'; /** Builds a simple execution plan with the given nodes and edges. */ function makePlan(overrides?: Partial): ExecutionPlan { return { planId: 'plan-1', scenarioId: 'scenario-1', userId: 'user-1', startNodeId: 'start', runId: 'run-1', nodes: [], edges: [], boundaryNodes: [], context: undefined, ...overrides, }; } function makeNode(id: string, type: string, data?: Record): ExecutionPlanNode { return { id, type, data: data as ExecutionPlanNode['data'] }; } function makeEdge(source: string, sourceHandle: string, target: string): PlanEdge { return { id: `edge-${source}-${target}`, source, sourceHandle, target }; } async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> { const client = new RudderClient({ baseUrl: 'https://api.test.rudder.build', projectKey: 'test-key', tokenStore: createFakeTokenStore(), runtime: { planStateStore: null, loginEvent: null }, // Disable IndexedDB for tests }); return { client, scenarios: await getScenarioRuntime(client) }; } describe('ScenarioService', () => { beforeEach(() => { vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-a000-000000000001', }); }); describe('send (trigger)', () => { it('calls POST /sdk/v1/scenarios/trigger and starts plans', async () => { const { client, scenarios } = await createClientWithScenario(); const plan = makePlan({ nodes: [makeNode('start', 'notification')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); const onNotification = vi.fn(); scenarios.onNotification = onNotification; const response = await scenarios.send('level_complete'); expect(response.plans).toHaveLength(1); expect(scenarios.isRunning).toBe(true); expect(onNotification).toHaveBeenCalledOnce(); expect(onNotification.mock.calls[0][0]).toBeInstanceOf(NotificationSession); }); }); describe('node dispatch', () => { it('notification node fires onNotification', async () => { const { scenarios } = await createClientWithScenario(); const onNotif = vi.fn(); scenarios.onNotification = onNotif; const plan = makePlan({ nodes: [makeNode('start', 'notification')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onNotif).toHaveBeenCalledOnce(); }); it('store node fires onStore', async () => { const { scenarios } = await createClientWithScenario(); const onStore = vi.fn(); scenarios.onStore = onStore; const plan = makePlan({ nodes: [makeNode('start', 'store')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onStore).toHaveBeenCalledOnce(); expect(onStore.mock.calls[0][0]).toBeInstanceOf(StoreSession); }); it('wait node fires onWait', async () => { const { scenarios } = await createClientWithScenario(); const onWait = vi.fn(); scenarios.onWait = onWait; const plan = makePlan({ nodes: [makeNode('start', 'wait', { duration: 5, unit: 'minutes' })], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onWait).toHaveBeenCalledOnce(); expect(onWait.mock.calls[0][0]).toBeInstanceOf(WaitSession); }); it('leaderboard node fires onLeaderboard', async () => { const { scenarios } = await createClientWithScenario(); const onLb = vi.fn(); scenarios.onLeaderboard = onLb; const plan = makePlan({ nodes: [makeNode('start', 'leaderboard')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onLb).toHaveBeenCalledOnce(); expect(onLb.mock.calls[0][0]).toBeInstanceOf(LeaderboardSession); }); it('quest node dispatches onQuest instead of stalling', async () => { const { client, scenarios } = await createClientWithScenario(); const onQuest = vi.fn(); client.effects.onQuest(onQuest); const plan = makePlan({ nodes: [ makeNode('start', 'quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }], }), ], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onQuest).toHaveBeenCalledOnce(); expect(onQuest.mock.calls[0][0].name).toBe('Daily'); // Active and waiting for progress — not stalled, not failed. expect(scenarios.isRunning).toBe(true); }); it('battlepass node dispatches onBattlePass', async () => { const { client, scenarios } = await createClientWithScenario(); const onBp = vi.fn(); client.effects.onBattlePass(onBp); const plan = makePlan({ nodes: [makeNode('start', 'battlepass', { premiumPrice: 100 })], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); 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); const plan = makePlan({ nodes: [makeNode('start', 'battlepass_level', { level: 3 })], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onLevel).toHaveBeenCalledOnce(); expect(onLevel.mock.calls[0][0].level).toBe(3); }); it('remote_config_override node applies patches internally and auto-completes', async () => { const { client, scenarios } = await createClientWithScenario(); const plan = makePlan({ nodes: [ makeNode('start', 'remote_config_override', { patches: [ { path: 'difficulty', valueType: 'string', value: 'hard' }, ], }), ], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(client.remoteConfig.get('difficulty', '')).toBe('hard'); // RemoteConfigOverride auto-completes asynchronously — wait for the promise. await new Promise((r) => setTimeout(r, 50)); expect(scenarios.isRunning).toBe(false); }); 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); const plan = makePlan({ nodes: [makeNode('start', 'unknown_type')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(warn).toHaveBeenCalledWith( expect.stringContaining('Unsupported scenario node type'), ); // The run must NOT stall: it fails and the game is notified. expect(onFailed).toHaveBeenCalledOnce(); expect(scenarios.isRunning).toBe(false); warn.mockRestore(); }); }); describe('DAG traversal', () => { it('completing a node follows matching edges', async () => { const { scenarios } = await createClientWithScenario(); const onNotif = vi.fn(); scenarios.onNotification = onNotif; // start(notif) → complete("output") → node2(notif) const plan = makePlan({ nodes: [ makeNode('start', 'notification'), makeNode('node2', 'notification'), ], edges: [makeEdge('start', 'output', 'node2')], }); let callCount = 0; vi.stubGlobal('fetch', vi.fn().mockImplementation(() => { callCount++; if (callCount === 1) { return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })); } return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); })); await scenarios.send('test'); expect(onNotif).toHaveBeenCalledOnce(); // start node dispatched // Complete the notification node with handle "output" const session = onNotif.mock.calls[0][0] as NotificationSession; await session.complete(); expect(onNotif).toHaveBeenCalledTimes(2); }); it('completing a node with already-completed handle is idempotent', async () => { const { scenarios } = await createClientWithScenario(); const onNotif = vi.fn(); scenarios.onNotification = onNotif; const plan = makePlan({ nodes: [ makeNode('start', 'notification'), makeNode('node2', 'notification'), ], edges: [makeEdge('start', 'output', 'node2')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(scenarios.activeRuns).toHaveLength(1); const session = onNotif.mock.calls[0][0] as NotificationSession; await session.complete(); // node2 should now be active expect(scenarios.activeRuns).toHaveLength(1); // Complete node2 const session2 = onNotif.mock.calls[1][0] as NotificationSession; await session2.complete(); // Run should be completed expect(scenarios.isRunning).toBe(false); }); it('run completion fires onRunCompleted and onCompleted', async () => { const { scenarios } = await createClientWithScenario(); const onCompleted = vi.fn(); const onRunCompleted = vi.fn(); scenarios.onCompleted = onCompleted; scenarios.onRunCompleted = onRunCompleted; const plan = makePlan({ nodes: [makeNode('start', 'notification')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); const session = (scenarios as unknown as { onNotification?: (s: NotificationSession) => void }).onNotification?.( // Use the mock calls to get the session vi.mocked(vi.fn()).mock.calls[0]?.[0] as NotificationSession, ); // We need to get the session from the mock spy // Actually let's trigger via respond() scenarios.respond('output'); // Fire-and-forget — wait a tick await new Promise((r) => setTimeout(r, 10)); expect(onRunCompleted).toHaveBeenCalled(); expect(onCompleted).toHaveBeenCalled(); }); }); describe('wait nodes', () => { it('sets a deadline and fires onWait', async () => { const { scenarios } = await createClientWithScenario(); const onWait = vi.fn(); scenarios.onWait = onWait; const plan = makePlan({ nodes: [makeNode('start', 'wait', { duration: 10, unit: 'minutes' })], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onWait).toHaveBeenCalledOnce(); const session = onWait.mock.calls[0][0] as WaitSession; expect(session.deadlineUtc).toBeInstanceOf(Date); // Deadline should be ~10 minutes from now. const diff = session.deadlineUtc.getTime() - Date.now(); expect(diff).toBeGreaterThan(9 * 60 * 1000); expect(diff).toBeLessThan(11 * 60 * 1000); }); it('completes immediately if deadline has already passed', async () => { const { scenarios } = await createClientWithScenario(); const onWait = vi.fn(); const onCompleted = vi.fn(); scenarios.onWait = onWait; scenarios.onCompleted = onCompleted; // Duration of 0 should result in an immediate completion. const plan = makePlan({ nodes: [makeNode('start', 'wait', { duration: 0, unit: 'seconds' })], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(onWait).toHaveBeenCalledOnce(); // Wait for the setTimeout(0) to fire. await new Promise((r) => setTimeout(r, 50)); expect(onCompleted).toHaveBeenCalled(); }); }); describe('store session', () => { it('buy() purchases the selected offer and completes with onPurchase', async () => { const { scenarios } = await createClientWithScenario(); const onStore = vi.fn(); scenarios.onStore = onStore; const plan = makePlan({ nodes: [makeNode('start', 'store', { storeSlug: 'starter' })], }); vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => { const parsed = new URL(url); if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/scenarios/trigger') { return Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })); } if (init?.method === 'GET' && parsed.pathname === '/sdk/v1/stores/starter') { return Promise.resolve(new Response(JSON.stringify({ name: 'Starter', slug: 'starter', offers: [{ id: 'pack_1', name: 'Starter Pack' }], }), { status: 200 })); } if (init?.method === 'POST' && parsed.pathname === '/sdk/v1/stores/starter/offers/pack_1/purchase') { return Promise.resolve(new Response(JSON.stringify({ success: true, purchaseId: 'purchase-1' }), { status: 200 })); } return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); })); await scenarios.send('test'); const session = onStore.mock.calls[0][0] as StoreSession; const onCompleted = vi.fn(); scenarios.onCompleted = onCompleted; const store = await session.getStore(); const purchase = await session.buy(store.offers[0]); expect(purchase.success).toBe(true); expect(session.isResolved).toBe(true); // Second call should be a no-op. const duplicate = await session.buy(store.offers[0]); expect(duplicate.success).toBe(false); }); it('decline() completes with onDecline', async () => { const { scenarios } = await createClientWithScenario(); const onStore = vi.fn(); scenarios.onStore = onStore; const plan = makePlan({ nodes: [makeNode('start', 'store')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); const session = onStore.mock.calls[0][0] as StoreSession; await session.decline(); expect(session.isResolved).toBe(true); }); }); describe('respond / clear', () => { it('respond completes the first active node', async () => { const { scenarios } = await createClientWithScenario(); const onNotif = vi.fn(); scenarios.onNotification = onNotif; const plan = makePlan({ nodes: [ makeNode('start', 'notification'), makeNode('node2', 'notification'), ], edges: [makeEdge('start', 'output', 'node2')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); // respond() completes the first active node with the given handle. scenarios.respond('output'); // Wait for async completion. await new Promise((r) => setTimeout(r, 50)); // node2 should now be active (second notification dispatched). expect(scenarios.isRunning).toBe(true); }); it('clear removes all runs', async () => { const { scenarios } = await createClientWithScenario(); const plan = makePlan({ nodes: [makeNode('start', 'notification')], }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ plans: [plan] }), { status: 200 }), )); await scenarios.send('test'); expect(scenarios.isRunning).toBe(true); scenarios.clear(); expect(scenarios.isRunning).toBe(false); }); }); describe('runId filtering', () => { it('rejects plans with duplicate runId', async () => { const { scenarios } = await createClientWithScenario(); const onNotification = vi.fn(); scenarios.onNotification = onNotification; const plan = makePlan({ runId: 'run-1', nodes: [makeNode('start', 'notification')], }); vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ plans: [plan] }), { status: 200 })), )); // First send creates a run await scenarios.send('first-event'); expect(scenarios.activeRuns).toHaveLength(1); expect(onNotification).toHaveBeenCalledTimes(1); // Second send with same runId is rejected by startPlan dedup await scenarios.send('second-event'); expect(scenarios.activeRuns).toHaveLength(1); expect(onNotification).toHaveBeenCalledTimes(1); }); }); });