2026-08-12 14:02:25 +03:00
|
|
|
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';
|
2026-09-04 14:08:48 +03:00
|
|
|
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';
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
async function createClientWithScenario(): Promise<{ client: RudderClient; scenarios: ScenarioService }> {
|
|
|
|
|
const client = new RudderClient({
|
|
|
|
|
baseUrl: 'https://api.test.rudder.build',
|
|
|
|
|
projectKey: 'test-key',
|
|
|
|
|
tokenStore: createFakeTokenStore(),
|
2026-09-04 14:08:48 +03:00
|
|
|
runtime: { loginEvent: null },
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
return { client, scenarios: await getScenarioRuntime(client) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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));
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 14:02:25 +03:00
|
|
|
describe('ScenarioService', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.stubGlobal('crypto', {
|
|
|
|
|
randomUUID: () => '00000000-0000-4000-a000-000000000001',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-12 14:02:25 +03:00
|
|
|
describe('send (trigger)', () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
it('calls POST /sdk/v1/scenarios/trigger and emits effects', async () => {
|
2026-08-12 14:02:25 +03:00
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-09-04 14:08:48 +03:00
|
|
|
const onNotification = vi.fn();
|
|
|
|
|
client.effects.onNotification(onNotification);
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
stubFetch((path, method) => {
|
|
|
|
|
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
|
|
|
|
return jsonResponse({
|
|
|
|
|
effects: [effect('notification', { title: 'Hi', message: 'Welcome' })],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return jsonResponse({});
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const response = await scenarios.send('level_complete');
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
expect(response.effects).toHaveLength(1);
|
2026-08-12 14:02:25 +03:00
|
|
|
expect(scenarios.isRunning).toBe(true);
|
|
|
|
|
expect(onNotification).toHaveBeenCalledOnce();
|
2026-09-04 14:08:48 +03:00
|
|
|
expect(onNotification.mock.calls[0][0].title).toBe('Hi');
|
|
|
|
|
expect(onNotification.mock.calls[0][0].message).toBe('Welcome');
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
describe('effect emission', () => {
|
2026-08-12 14:02:25 +03:00
|
|
|
it('notification node fires onNotification', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onNotif = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onNotification(onNotif);
|
|
|
|
|
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
|
2026-08-12 14:02:25 +03:00
|
|
|
await scenarios.send('test');
|
|
|
|
|
expect(onNotif).toHaveBeenCalledOnce();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
it('store node fires onStoreOffer', async () => {
|
|
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onStore = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
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', name: 'Pack' }] });
|
|
|
|
|
}
|
|
|
|
|
return jsonResponse({});
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
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');
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('wait node fires onWait', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onWait = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onWait(onWait);
|
|
|
|
|
const deadline = new Date(Date.now() + 60_000).toISOString();
|
|
|
|
|
stubFetch(() => jsonResponse({
|
|
|
|
|
effects: [effect('wait', {}, { waitDeadline: deadline })],
|
|
|
|
|
}));
|
2026-08-12 14:02:25 +03:00
|
|
|
await scenarios.send('test');
|
|
|
|
|
expect(onWait).toHaveBeenCalledOnce();
|
2026-09-04 14:08:48 +03:00
|
|
|
expect(onWait.mock.calls[0][0].deadlineUtc).toBeInstanceOf(Date);
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaderboard node fires onLeaderboard', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onLb = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onLeaderboard(onLb);
|
|
|
|
|
stubFetch(() => jsonResponse({ effects: [effect('leaderboard')] }));
|
2026-08-12 14:02:25 +03:00
|
|
|
await scenarios.send('test');
|
|
|
|
|
expect(onLb).toHaveBeenCalledOnce();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
it('quest node dispatches onQuest', async () => {
|
2026-08-12 14:02:25 +03:00
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
|
|
|
|
const onQuest = vi.fn();
|
|
|
|
|
client.effects.onQuest(onQuest);
|
2026-09-04 14:08:48 +03:00
|
|
|
stubFetch(() => jsonResponse({
|
|
|
|
|
effects: [effect('quest', { name: 'Daily', objectives: [{ objectiveId: 'kills', target: 10 }] })],
|
|
|
|
|
}));
|
2026-08-12 14:02:25 +03:00
|
|
|
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);
|
2026-09-04 14:08:48 +03:00
|
|
|
stubFetch(() => jsonResponse({ effects: [effect('battlepass', { premiumPrice: 100 })] }));
|
2026-08-12 14:02:25 +03:00
|
|
|
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);
|
2026-09-04 14:08:48 +03:00
|
|
|
stubFetch(() => jsonResponse({
|
|
|
|
|
effects: [effect('battlepass_level', { levelNumber: 3 })],
|
|
|
|
|
}));
|
2026-08-12 14:02:25 +03:00
|
|
|
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);
|
2026-09-04 14:08:48 +03:00
|
|
|
stubFetch(() => jsonResponse({ effects: [effect('unknown_type')] }));
|
2026-08-12 14:02:25 +03:00
|
|
|
await scenarios.send('test');
|
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining('Unsupported scenario node type'),
|
|
|
|
|
);
|
|
|
|
|
expect(onFailed).toHaveBeenCalledOnce();
|
|
|
|
|
expect(scenarios.isRunning).toBe(false);
|
|
|
|
|
warn.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
describe('callback continuation', () => {
|
|
|
|
|
it('completing an effect posts callback and emits the next effect', async () => {
|
|
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onNotif = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onNotification(onNotif);
|
|
|
|
|
const first = effect('notification', { message: 'one' }, { nodeId: 'n1' });
|
|
|
|
|
const second = effect('notification', { message: 'two' }, { nodeId: 'n2' });
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
stubFetch((path, method) => {
|
|
|
|
|
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
|
|
|
|
return jsonResponse({ effects: [first] });
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
2026-09-04 14:08:48 +03:00
|
|
|
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
|
|
|
|
|
return jsonResponse({ effect: second });
|
|
|
|
|
}
|
|
|
|
|
return jsonResponse({});
|
|
|
|
|
});
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
expect(onNotif).toHaveBeenCalledOnce();
|
|
|
|
|
const session = onNotif.mock.calls[0][0] as NotificationEffect;
|
|
|
|
|
await session.done();
|
2026-08-12 14:02:25 +03:00
|
|
|
expect(onNotif).toHaveBeenCalledTimes(2);
|
2026-09-04 14:08:48 +03:00
|
|
|
expect(onNotif.mock.calls[1][0].message).toBe('two');
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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({
|
|
|
|
|
scenarioId: 'scenario-1',
|
|
|
|
|
nodeId: 'n1',
|
|
|
|
|
handle: 'output',
|
|
|
|
|
runId: 'run-1',
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
it('completing the last effect emits onScenarioCompleted', async () => {
|
|
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
|
|
|
|
const onCompleted = vi.fn();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onNotif = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onScenarioCompleted(onCompleted);
|
|
|
|
|
client.effects.onNotification(onNotif);
|
|
|
|
|
stubFetch((path) => {
|
|
|
|
|
if (path === '/sdk/v1/scenarios/trigger') {
|
|
|
|
|
return jsonResponse({ effects: [effect('notification')] });
|
|
|
|
|
}
|
|
|
|
|
return jsonResponse({});
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
|
|
|
|
|
expect(onCompleted).toHaveBeenCalledOnce();
|
|
|
|
|
expect(onCompleted.mock.calls[0][0]).toEqual({
|
|
|
|
|
runId: 'run-1',
|
|
|
|
|
scenarioId: 'scenario-1',
|
|
|
|
|
});
|
2026-08-12 14:02:25 +03:00
|
|
|
expect(scenarios.isRunning).toBe(false);
|
|
|
|
|
});
|
2026-09-04 14:08:48 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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);
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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];
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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({});
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
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);
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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' });
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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({});
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
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();
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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);
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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({});
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
await (onNotif.mock.calls[0][0] as NotificationEffect).done();
|
|
|
|
|
expect(onFailed).not.toHaveBeenCalled();
|
|
|
|
|
expect(scenarios.isRunning).toBe(true);
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('store session', () => {
|
|
|
|
|
it('buy() purchases the selected offer and completes with onPurchase', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onStore = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
client.effects.onStoreOffer(onStore);
|
2026-08-12 14:02:25 +03:00
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
stubFetch((path, method) => {
|
|
|
|
|
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
|
|
|
|
return jsonResponse({ effects: [effect('store', { storeSlug: 'starter' })] });
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
2026-09-04 14:08:48 +03:00
|
|
|
if (method === 'GET' && path === '/sdk/v1/stores/starter') {
|
|
|
|
|
return jsonResponse({
|
2026-08-12 14:02:25 +03:00
|
|
|
name: 'Starter',
|
|
|
|
|
slug: 'starter',
|
|
|
|
|
offers: [{ id: 'pack_1', name: 'Starter Pack' }],
|
2026-09-04 14:08:48 +03:00
|
|
|
});
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
2026-09-04 14:08:48 +03:00
|
|
|
if (method === 'POST' && path === '/sdk/v1/stores/starter/offers/pack_1/purchase') {
|
|
|
|
|
return jsonResponse({ success: true, purchaseId: 'purchase-1' });
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
2026-09-04 14:08:48 +03:00
|
|
|
return jsonResponse({});
|
|
|
|
|
});
|
2026-08-12 14:02:25 +03:00
|
|
|
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
await vi.waitFor(() => expect(onStore).toHaveBeenCalledOnce());
|
|
|
|
|
const session = onStore.mock.calls[0][0] as StoreOfferEffect;
|
|
|
|
|
const purchase = await session.buy(session.offers[0]);
|
2026-08-12 14:02:25 +03:00
|
|
|
expect(purchase.success).toBe(true);
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
const duplicate = await session.buy(session.offers[0]);
|
2026-08-12 14:02:25 +03:00
|
|
|
expect(duplicate.success).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
it('dismiss() completes with onDecline', async () => {
|
|
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
2026-08-12 14:02:25 +03:00
|
|
|
const onStore = vi.fn();
|
2026-09-04 14:08:48 +03:00
|
|
|
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' }] });
|
|
|
|
|
}
|
|
|
|
|
return jsonResponse({});
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
await scenarios.send('test');
|
2026-09-04 14:08:48 +03:00
|
|
|
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');
|
2026-08-12 14:02:25 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
describe('clear', () => {
|
2026-08-12 14:02:25 +03:00
|
|
|
it('clear removes all runs', async () => {
|
2026-09-04 14:08:48 +03:00
|
|
|
const { client, scenarios } = await createClientWithScenario();
|
|
|
|
|
client.effects.onNotification(vi.fn());
|
|
|
|
|
stubFetch(() => jsonResponse({ effects: [effect('notification')] }));
|
2026-08-12 14:02:25 +03:00
|
|
|
await scenarios.send('test');
|
|
|
|
|
expect(scenarios.isRunning).toBe(true);
|
|
|
|
|
scenarios.clear();
|
|
|
|
|
expect(scenarios.isRunning).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|