Files
rudder-js-sdk/test/ScenarioService.test.ts
T

417 lines
16 KiB
TypeScript

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<Response>): 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);
});
});
});