feat: modules and resources domains, drop removed domains, regenerated models

This commit is contained in:
edmand46
2026-09-17 12:08:03 +03:00
parent f45261439f
commit 6f37b00fb4
71 changed files with 516 additions and 4184 deletions
+41 -115
View File
@@ -6,9 +6,30 @@ function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
function stubKernelFetch(
extra?: (url: string) => Response | undefined,
): ReturnType<typeof vi.fn> {
return vi.fn().mockImplementation((url: string) => {
const custom = extra?.(url);
if (custom) return Promise.resolve(custom);
if (String(url).includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
}));
}
if (String(url).includes('/sdk/v1/catalog')) {
return Promise.resolve(jsonResponse({ currencies: [] }));
}
if (String(url).includes('/sdk/v1/sync')) {
return Promise.resolve(jsonResponse({}));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
}
describe('AuthService', () => {
beforeEach(() => {
// Mock crypto.randomUUID for deterministic device ID.
vi.stubGlobal('crypto', {
randomUUID: () => '00000000-0000-4000-a000-000000000001',
});
@@ -17,20 +38,11 @@ describe('AuthService', () => {
it('loginWithDevice sends correct request body', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
}));
});
const fetchMock = stubKernelFetch();
vi.stubGlobal('fetch', fetchMock);
const response = await client.auth.loginWithDevice({ region: 'us', language: 'en' });
expect(fetchMock).toHaveBeenCalledTimes(9);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('/sdk/v1/authorization/device');
const body = JSON.parse(init.body);
@@ -40,29 +52,24 @@ describe('AuthService', () => {
expect(body.language).toBe('en');
expect(response.accessToken).toBe('access-token-123');
expect(response.refreshToken).toBe('refresh-token-456');
expect(fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
'/sdk/v1/authorization/device',
'/sdk/v1/remote-configs',
'/sdk/v1/player/information',
'/sdk/v1/stores',
'/sdk/v1/catalog',
'/sdk/v1/scenarios/pending',
'/sdk/v1/scenarios/trigger',
'/sdk/v1/player/information',
'/sdk/v1/sync',
]);
const paths = fetchMock.mock.calls.map(([callUrl]) => new URL(String(callUrl)).pathname);
expect(paths[0]).toBe('/sdk/v1/authorization/device');
expect(paths).toContain('/sdk/v1/player/information');
expect(paths).toContain('/sdk/v1/catalog');
expect(paths).toContain('/sdk/v1/sync');
expect(paths).not.toContain('/sdk/v1/remote-configs');
expect(paths).not.toContain('/sdk/v1/stores');
expect(paths).not.toContain('/sdk/v1/scenarios/pending');
expect(paths).not.toContain('/sdk/v1/scenarios/trigger');
});
it('loginWithDevice saves tokens on success', async () => {
const client = createTestClient();
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
vi.stubGlobal('fetch', stubKernelFetch((url) => {
if (url.includes('/sdk/v1/authorization/device')) {
return jsonResponse({ accessToken: 'at', refreshToken: 'rt' });
}
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
return undefined;
}));
await client.auth.loginWithDevice();
@@ -94,23 +101,16 @@ describe('AuthService', () => {
it('loginWithDevice sends token in Authorization header after login', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
const fetchMock = stubKernelFetch((url) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at2',
refreshToken: 'rt2',
}));
return jsonResponse({ accessToken: 'at2', refreshToken: 'rt2' });
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
return undefined;
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
// Subsequent authenticated calls should include the token.
const response = await client.player.reload();
expect(response).toEqual({ player: null, wallets: [] });
const playerCall = fetchMock.mock.calls.find(([url]) =>
@@ -120,71 +120,9 @@ describe('AuthService', () => {
expect(headers['Authorization']).toBe('Bearer at2');
});
it('loginWithDevice only fires player_login from the client runtime', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at-new',
refreshToken: 'rt-new',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
const triggerBodies = fetchMock.mock.calls
.filter(([url]) => String(url).includes('/sdk/v1/scenarios/trigger'))
.map(([, init]) => JSON.parse(init.body));
expect(triggerBodies.map((body) => body.event)).toEqual(['player_login']);
});
it('loginWithDevice isolates post-login scenario trigger failures', async () => {
const client = createTestClient();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/authorization/device')) {
return Promise.resolve(jsonResponse({
accessToken: 'at',
refreshToken: 'rt',
}));
}
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(new Response('scenario failed', { status: 500, statusText: 'Internal Server Error' }));
}
if (url.includes('/sdk/v1/stores')) {
return Promise.resolve(jsonResponse({ stores: [], total: 0 }));
}
return Promise.resolve(jsonResponse({ player: null, wallets: [] }));
}));
await expect(client.auth.loginWithDevice()).resolves.toMatchObject({ accessToken: 'at' });
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('loginWithDevice defaults to region global and language en', async () => {
const client = createTestClient();
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
});
const fetchMock = stubKernelFetch();
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
@@ -203,21 +141,9 @@ describe('auth state', () => {
localStorage.clear();
});
function stubLoginFlow(): void {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => {
if (url.includes('/sdk/v1/remote-configs')) {
return Promise.resolve(jsonResponse({ configs: {} }));
}
if (url.includes('/sdk/v1/scenarios/trigger')) {
return Promise.resolve(jsonResponse({ plans: [] }));
}
return Promise.resolve(jsonResponse({ accessToken: 'at', refreshToken: 'rt' }));
}));
}
it('onAuthStateChange fires immediately, then on login and logout', async () => {
const client = createTestClient();
stubLoginFlow();
vi.stubGlobal('fetch', stubKernelFetch());
const states: string[] = [];
const unsubscribe = client.auth.onAuthStateChange((state) => states.push(state));
+28 -29
View File
@@ -14,20 +14,17 @@ describe('public API surface', () => {
});
it('keeps helpers, domain classes, and service constructors out of the barrel', () => {
// Device id helper — internal.
expect('getOrCreateDeviceId' in sdk).toBe(false);
// Domain classes — exported as types only.
for (const name of [
'PlayerDomain',
'CatalogDomain',
'InventoryDomain',
'ConfigDomain',
'StorageDomain',
'StoresDomain',
'ProjectStorageDomain',
'ModulesDomain',
'ResourcesDomain',
]) {
expect(name in sdk).toBe(false);
}
// Service constructors — exported as types only.
for (const name of [
'AuthService',
'LeaderboardsService',
@@ -48,43 +45,45 @@ describe('public API surface', () => {
'RudderHttpError',
'RudderAuthError',
'SDK_ERROR_INVALID_OPTIONS',
'RudderErrorCodes',
'createDefaultTokenStore',
'createLocalStorageTokenStore',
'SyncedState',
'RemoteConfigState',
'ShopHandle',
'OfferHandle',
]) {
expect(name in sdk, name).toBe(true);
}
expect('RemoteConfigState' in sdk).toBe(false);
expect('ShopHandle' in sdk).toBe(false);
expect('OfferHandle' in sdk).toBe(false);
expect('QuestMetrics' in sdk).toBe(false);
});
it('exposes effects without a public scenarios service on the client', () => {
it('exposes kernel members and no module-specific namespaces on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { loginEvent: null },
});
expect(client.auth).toBeDefined();
expect(client.player).toBeDefined();
expect(client.catalog).toBeDefined();
expect(client.storage).toBeDefined();
expect(client.projectStorage).toBeDefined();
expect(client.modules).toBeDefined();
expect(typeof client.modules.call).toBe('function');
expect(typeof client.resources).toBe('function');
expect(client.effects).toBeDefined();
expect('battlePass' in client).toBe(false);
expect('quests' in client).toBe(false);
expect('leaderboards' in client).toBe(false);
expect('stores' in client).toBe(false);
expect('inventory' in client).toBe(false);
expect('remoteConfig' in client).toBe(false);
expect('scenarios' in client).toBe(false);
});
it('uses the canonical glossary property names on the client', () => {
const client = new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
runtime: { loginEvent: null },
});
expect(client.remoteConfig).toBeDefined();
expect('config' in client).toBe(false);
expect(client.battlePass).toBeDefined();
expect('battlepass' in client).toBe(false);
expect(typeof client.effects.onBattlePass).toBe('function');
expect(typeof client.effects.onBattlePassLevel).toBe('function');
expect('onBattlepass' in client.effects).toBe(false);
expect('onBattlepassLevel' in client.effects).toBe(false);
expect('onBattlePass' in client.effects).toBe(false);
expect('onStoreOffer' in client.effects).toBe(false);
expect('onQuest' in client.effects).toBe(false);
expect('onLeaderboard' in client.effects).toBe(false);
expect('onConfigChanged' in client.effects).toBe(false);
});
});
+41 -57
View File
@@ -135,83 +135,67 @@ describe('transport error mapping', () => {
});
});
describe('lazy scenario runtime', () => {
beforeEach(() => {
localStorage.clear();
});
describe('modules and resources', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not create the scenario runtime until first use', async () => {
it('modules.call posts to /sdk/v1/modules/:module/:fn', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: { loginEvent: null },
tokenStore: createFakeTokenStore(),
});
const internals = client as unknown as {
scenarioRuntime: unknown;
ensureScenarioRuntime(): Promise<unknown>;
};
expect(internals.scenarioRuntime).toBeNull();
await internals.ensureScenarioRuntime();
expect(internals.scenarioRuntime).not.toBeNull();
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const result = await client.modules.call('quests', 'list', { limit: 10 });
expect(result).toEqual({ result: { ok: true }, log: [] });
const [url, init] = fetchMock.mock.calls[0];
expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list');
expect(JSON.parse(String(init.body))).toEqual({ args: { limit: 10 } });
});
it('delivers effects to handlers subscribed before the runtime is ready', async () => {
it('resources(slug) lists and mutates rows', async () => {
const client = new RudderClient({
baseUrl: 'https://api.example.com',
projectKey: 'proj_123',
runtime: {},
tokenStore: createFakeTokenStore(),
});
const notifications: string[] = [];
client.effects.onNotification((effect) => {
notifications.push(effect.message);
return effect.done();
});
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const row = { id: 'row-1', data: { name: 'Sword' }, version: 1 };
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = new URL(url).pathname;
if (path === '/sdk/v1/authorization/device') {
return Promise.resolve(new Response(
JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }),
{ status: 200 },
));
const method = (init?.method ?? 'GET').toUpperCase();
if (method === 'GET' && path === '/sdk/v1/resources/items') {
return Promise.resolve(new Response(JSON.stringify({ items: [row], total: 1 }), { status: 200 }));
}
if (path === '/sdk/v1/scenarios/trigger') {
return Promise.resolve(new Response(JSON.stringify({
effects: [{
runId: 'run-1',
scenarioSlug: 'scenario-1',
nodeId: 'start',
type: 'notification',
data: { message: 'Welcome!' },
}],
}), { status: 200 }));
if (method === 'GET' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
}
if (path === '/sdk/v1/scenarios/pending') {
return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 }));
if (method === 'POST' && path === '/sdk/v1/resources/items') {
return Promise.resolve(new Response(JSON.stringify(row), { status: 200 }));
}
if (path === '/sdk/v1/remote-configs') {
return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 }));
if (method === 'PATCH' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(JSON.stringify({ ...row, version: 2 }), { status: 200 }));
}
if (path === '/sdk/v1/stores') {
return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 }));
if (method === 'DELETE' && path === '/sdk/v1/resources/items/row-1') {
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path === '/sdk/v1/catalog') {
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 }));
}
if (path === '/sdk/v1/sync') {
return Promise.resolve(new Response(JSON.stringify({}), { status: 200 }));
}
void init;
return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 }));
}));
return Promise.resolve(new Response('unhandled', { status: 500 }));
});
vi.stubGlobal('fetch', fetchMock);
await client.auth.loginWithDevice();
await vi.waitFor(() => expect(notifications).toEqual(['Welcome!']));
client.dispose();
const handle = client.resources('items');
expect(await handle.list()).toEqual({ items: [row], total: 1 });
expect(await handle.get('row-1')).toEqual(row);
expect(await handle.create({ data: { name: 'Sword' } })).toEqual(row);
expect(await handle.update('row-1', { data: { name: 'Sword' }, version: 1 })).toEqual({
...row,
version: 2,
});
expect(await handle.delete('row-1')).toBeUndefined();
});
});
-416
View File
@@ -1,416 +0,0 @@
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);
});
});
});
+7 -15
View File
@@ -2,16 +2,11 @@
// reach the admin API for wallet grants / player inspection.
import { RudderClient } from '../../../src/client/RudderClient.js';
import type { RemoteConfigShape } from '../../../src/state/RemoteConfigState.js';
import type { TokenStore } from '../../../src/token/TokenStore.js';
import { createAdminClient, type AdminClient } from './adminClient.js';
import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js';
type RuntimeOptions = {
loginEvent?: string | null;
};
/** Loads the seed artifact; RUDDER_E2E_* env vars override file values. */
export function loadArtifact(): SeedArtifact {
const file = readArtifact();
@@ -44,25 +39,22 @@ export function createInMemoryTokenStore(): TokenStore {
};
}
/** A client with no auth yet (in-memory tokens, no IndexedDB persistence). */
export function makeProdClient<TConfig extends RemoteConfigShape = RemoteConfigShape>(
/** A client with no auth yet (in-memory tokens). */
export function makeProdClient(
artifact: SeedArtifact = loadArtifact(),
runtime: RuntimeOptions = { loginEvent: null },
): RudderClient<TConfig> {
return new RudderClient<TConfig>({
): RudderClient {
return new RudderClient({
baseUrl: artifact.baseUrl,
projectKey: artifact.projectKey,
tokenStore: createInMemoryTokenStore(),
runtime,
});
}
/** A device-authenticated client. In Node each call mints a fresh player (ephemeral device id). */
export async function freshPlayer<TConfig extends RemoteConfigShape = RemoteConfigShape>(
export async function freshPlayer(
artifact: SeedArtifact = loadArtifact(),
runtime?: RuntimeOptions,
): Promise<RudderClient<TConfig>> {
const client = makeProdClient<TConfig>(artifact, runtime);
): Promise<RudderClient> {
const client = makeProdClient(artifact);
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
return client;
}
+4 -288
View File
@@ -1,108 +1,20 @@
// Production seed: registers an operator, creates a project, configures staging
// content (counter, item, remote configs, leaderboard, store with free+paid offers, a
// global quest, a scenario), and promotes it to the prod snapshot the runtime SDK serves.
//
// The scenario flow uses the server engine's node types, `data` keys and handle names —
// the server walks the graph itself (see liveops-backend/internal/domain/scenario/engine/planbuilder
// and .../fsm), so what we store is what the server runs.
// Production seed: registers an operator and creates a project. Creating a
// project provisions staging + prod environments; the prod SDK key is what
// the runtime SDK sends as projectKey.
import { createAdminClient, type AdminClient } from './adminClient.js';
import { createAdminClient } from './adminClient.js';
import type { SeedArtifact } from './types.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface ReleaseResponse {
id: string;
status: string;
version: number;
}
interface QuestResponse {
id: string;
slug: string;
name: string;
status: string;
objectives?: Array<{ id: string; metric: string; target: number }>;
rewards?: Array<{ itemId?: string; currency?: string; amount: number }>;
}
interface StoreResponse {
id: string;
name: string;
slug: string;
offers?: Array<{
id: string;
slug: string;
name: string;
contents?: Array<{ itemId: string; amount: number }>;
}>;
}
function buildFlow(scenarioSlug: string) {
return {
nodes: [
{ id: 'trigger_1', type: 'trigger', data: { triggerType: 'event', onEvent: 'session_start' } },
{ id: 'notify_1', type: 'notification', data: { title: 'Welcome', message: 'Welcome to the game!' } },
{
id: 'rc_1',
type: 'remote_config_override',
data: { patches: [{ path: 'spawn_rate', valueType: 'float', value: '3.0' }] },
},
{ id: 'store_1', type: 'store', data: { storeSlug: scenarioSlug } },
{ id: 'cond_1', type: 'condition', data: { logic: 'AND', rules: [] } },
{ id: 'notify_2', type: 'notification', data: { title: 'Thanks', message: 'Enjoy your purchase!' } },
],
edges: [
{ id: 'e1', source: 'trigger_1', sourceHandle: 'onActivate', target: 'notify_1' },
{ id: 'e2', source: 'notify_1', sourceHandle: 'output', target: 'rc_1' },
{ id: 'e3', source: 'rc_1', sourceHandle: 'output', target: 'store_1' },
{ id: 'e4', source: 'store_1', sourceHandle: 'onPurchase', target: 'cond_1' },
{ id: 'e5', source: 'cond_1', sourceHandle: 'true', target: 'notify_2' },
],
};
}
async function pollRelease(
api: AdminClient,
projPath: string,
env: string,
releaseId: string,
log: (m: string) => void,
): Promise<void> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const releases = await api.get<ReleaseResponse[]>(
`${projPath}/releases?environment=${env}&limit=20&offset=0`,
);
const rel = releases.find((r) => r.id === releaseId);
if (rel) {
if (rel.status === 'completed') {
log(`release ${releaseId} completed (v${rel.version})`);
return;
}
if (rel.status === 'failed') {
throw new Error(`release ${releaseId} failed`);
}
}
await sleep(1500);
}
throw new Error(`release ${releaseId} did not complete within 60s`);
}
export async function runSeed(
baseUrl: string,
log: (m: string) => void = () => {},
): Promise<SeedArtifact> {
const api = createAdminClient(baseUrl);
const ts = Date.now();
const authoringEnv = 'staging';
const runtimeEnv = 'prod';
const q = `?environment=${authoringEnv}`;
const prodQ = `?environment=${runtimeEnv}`;
const adminEmail = `e2e+${ts}@rudder.build`;
const password = `Passw0rd!e2e-${ts}`;
// 1. Register operator (open registration; no approval gate at the API layer).
const auth = await api.post<{ accessToken: string; refreshToken: string }>(
'/platform/v1/authorization/register',
{ email: adminEmail, password, fullName: 'E2E Bot' },
@@ -111,8 +23,6 @@ export async function runSeed(
api.setToken(auth.accessToken);
log(`registered ${adminEmail}`);
// 2. Project. Creating one provisions the staging + prod environments, each with
// its own SDK key — that key is what the runtime SDK sends as projectKey.
const project = await api.post<{ id: string; name: string }>('/platform/v1/projects', {
name: `e2e-${ts}`,
});
@@ -126,168 +36,6 @@ export async function runSeed(
}
log(`project ${project.id} ${runtimeEnv}Key=${projectKey}`);
// 3. Soft currency counter — offer prices are validated against counter slugs, and
// the purchase debit resolves the currency through the catalog counter cache.
const currency = 'soft';
await api.post(`${projPath}/counters${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Soft Currency',
slug: currency,
kind: 'currency',
});
// 4. Item (granted as a paid offer's contents).
const item = await api.post<{ id: string; name: string }>(`${projPath}/items${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Gold Coin',
slug: 'gold-coin',
});
// 5. Remote configs — one per value type. RCs are live per-env flags served
// straight from the cache and are not copied by a promote, so they go directly
// into the runtime env.
const remoteConfigs: Array<{ key: string; value: string; valueType: string }> = [
{ key: 'max_energy', value: '100', valueType: 'int' },
{ key: 'spawn_rate', value: '1.5', valueType: 'float' },
{ key: 'feature_x', value: 'true', valueType: 'bool' },
{ key: 'welcome_text', value: 'hi', valueType: 'string' },
{ key: 'shop_layout', value: '{"cols":3}', valueType: 'json' },
];
for (const rc of remoteConfigs) {
await api.post(`${projPath}/remote-configs${prodQ}`, {
projectId: project.id,
environment: runtimeEnv,
...rc,
});
}
log(`created ${remoteConfigs.length} remote configs`);
// 6. Leaderboard.
await api.post(`${projPath}/leaderboards${q}`, {
projectId: project.id,
environment: authoringEnv,
name: 'Weekly Score',
slug: 'weekly-score',
metric: 'score',
resetPeriod: 'weekly',
sortingOrder: 'desc',
maxEntries: 100,
});
// 7. Store with a free offer and a paid (currency) offer with a purchase limit.
// Stores and offers carry an authored slug that survives promotes.
const storeSlug = 'starter-shop';
const paidOfferSlug = 'gold-pack';
const store = await api.post<StoreResponse>(`${projPath}/stores${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: storeSlug,
name: 'Starter Shop',
description: 'E2E shop',
offers: [
{
slug: 'welcome-gift',
name: 'Welcome Gift',
contents: [{ itemId: item.id, amount: 10 }],
price: { currency, amount: 0 },
},
{
slug: paidOfferSlug,
name: 'Gold Pack',
contents: [{ itemId: item.id, amount: 50 }],
price: { currency, amount: 100 },
maxPurchases: 2,
},
],
});
log(`store ${storeSlug} (${store.id}) with ${store.offers?.length ?? 0} offers`);
// 8. Global quest: buying the paid offer completes it; claim grants extra item reward.
const questName = 'Buy Gold Pack Quest';
const questObjectiveId = 'buy-gold-pack';
const questTarget = 1;
const questSlug = 'buy-gold-pack-quest';
const questMetric = `purchase.offer:${paidOfferSlug}`;
const questRewardAmount = 7;
const quest = await api.post<QuestResponse>(`${projPath}/quests${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: questSlug,
name: questName,
objectives: [{ id: questObjectiveId, metric: questMetric, target: questTarget }],
rewards: [{ itemId: item.id, amount: questRewardAmount }],
});
log(`quest ${quest.id} staged for metric ${questMetric}`);
// 9. Scenario (must be live now: startAt <= now <= endAt).
const startAt = new Date(ts - 3600_000).toISOString();
const endAt = new Date(ts + 365 * 24 * 3600_000).toISOString();
const scenarioEvent = 'session_start';
const scenarioSlug = 'onboarding';
const scenario = await api.post<{ id: string }>(`${projPath}/scenarios${q}`, {
projectId: project.id,
environment: authoringEnv,
slug: scenarioSlug,
name: 'Onboarding',
description: 'e2e onboarding',
startAt,
endAt,
});
// 10. Scenario flow (trigger -> notification -> rc override -> store -> condition -> notification).
await api.put(`${projPath}/scenarios/${scenario.id}?environment=${authoringEnv}`, {
projectId: project.id,
flow: buildFlow(storeSlug),
});
log(`scenario ${scenario.id} flow set`);
// 11. Promote staging content to prod (the runtime serves the prod snapshot).
const release = await api.post<ReleaseResponse>(`${projPath}/releases/promote`, {
projectId: project.id,
fromEnvironment: authoringEnv,
toEnvironment: runtimeEnv,
});
await pollRelease(api, projPath, runtimeEnv, release.id, log);
// Promotion upserts by slug: prod keeps its own ids, so read the prod rows back by slug.
const prodStores = await api.get<StoreResponse[]>(`${projPath}/stores${prodQ}`);
const prodStore = prodStores.find((s) => s.slug === storeSlug);
if (!prodStore) {
throw new Error(`promoted store ${storeSlug} was not found in ${runtimeEnv}`);
}
const prodPaidOffer = prodStore.offers?.find((o) => o.slug === paidOfferSlug);
if (!prodPaidOffer?.slug) {
throw new Error('promoted paid offer did not return a slug');
}
const prodRewardItemId = prodPaidOffer.contents?.[0]?.itemId;
if (!prodRewardItemId) {
throw new Error('promoted paid offer did not return a reward item id');
}
const prodQuests = await api.get<QuestResponse[]>(`${projPath}/quests${prodQ}`);
const prodQuest = prodQuests.find((qq) => qq.slug === questSlug);
if (!prodQuest?.slug) {
throw new Error(`promoted quest ${questSlug} was not found in ${runtimeEnv}`);
}
const prodQuestMetric = prodQuest.objectives?.find((o) => o.id === questObjectiveId)?.metric;
if (!prodQuestMetric) {
throw new Error(`promoted quest ${questSlug} did not return metric ${questObjectiveId}`);
}
const prodQuestRewardItemId = prodQuest.rewards?.[0]?.itemId;
if (!prodQuestRewardItemId) {
throw new Error(`promoted quest ${questSlug} did not return reward item id`);
}
const prodScenarios = await api.get<Array<{ id: string; slug: string; name: string }>>(
`${projPath}/scenarios${prodQ}`,
);
const prodScenario = prodScenarios.find((s) => s.slug === scenarioSlug);
if (!prodScenario?.slug) {
throw new Error(`promoted scenario ${scenarioSlug} was not found in ${runtimeEnv}`);
}
return {
baseUrl,
projectId: project.id,
@@ -296,38 +44,6 @@ export async function runSeed(
adminEmail,
environment: runtimeEnv,
seededAt: new Date(ts).toISOString(),
releaseId: release.id,
store: {
name: 'Starter Shop',
slug: storeSlug,
freeOfferName: 'Welcome Gift',
paidOfferName: 'Gold Pack',
paidOfferSlug: prodPaidOffer.slug,
paidPrice: { currency, amount: 100 },
paidMaxPurchases: 2,
grantedItemName: 'Gold Coin',
paidGrantAmount: 50,
},
quest: {
slug: prodQuest.slug,
name: questName,
objectiveId: questObjectiveId,
metric: prodQuestMetric,
target: questTarget,
rewardItemId: prodQuestRewardItemId,
rewardAmount: questRewardAmount,
},
leaderboard: { slug: 'weekly-score', metric: 'score' },
item: { id: prodRewardItemId, name: item.name },
remoteConfigs: {
maxEnergy: 100,
spawnRate: 1.5,
featureX: true,
welcomeText: 'hi',
shopLayout: { cols: 3 },
spawnRateOverride: 3.0,
},
scenario: { slug: prodScenario.slug, event: scenarioEvent },
};
}
-33
View File
@@ -10,37 +10,4 @@ export interface SeedArtifact {
adminEmail: string;
environment: string;
seededAt: string;
releaseId: string;
store: {
name: string;
slug: string;
freeOfferName: string;
paidOfferName: string;
paidOfferSlug: string;
paidPrice: { currency: string; amount: number };
paidMaxPurchases: number;
grantedItemName: string;
paidGrantAmount: number;
};
quest: {
slug: string;
name: string;
objectiveId: string;
metric: string;
target: number;
rewardItemId: string;
rewardAmount: number;
};
leaderboard: { slug: string; metric: string };
item: { id: string; name: string };
remoteConfigs: {
maxEnergy: number;
spawnRate: number;
featureX: boolean;
welcomeText: string;
shopLayout: Record<string, unknown>;
/** spawn_rate value the scenario's remote_config_override applies. */
spawnRateOverride: number;
};
scenario: { slug: string; event: string };
}
+1 -1
View File
@@ -20,6 +20,6 @@ describe('e2e-prod: health', () => {
expect(res.ok).toBe(true);
const body = (await res.json()) as { status?: string; checks?: Record<string, string> };
expect(body.status).toBe('ready');
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok', s3: 'ok' });
expect(body.checks).toMatchObject({ db: 'ok', redis: 'ok' });
});
});
-30
View File
@@ -1,30 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: leaderboards', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('submits a score and sees it in the ranking', async () => {
const client = await freshPlayer(artifact);
const me = (await client.player.reload()).player?.id;
expect(me).toBeTruthy();
const lb = client.leaderboards.findBySlug(artifact.leaderboard.slug);
const score = 4242;
await lb.submit(score);
const entries = await withReadRetry(
() => lb.list(50),
(list) => list.some((e) => e.playerId === me),
);
const mine = entries.find((e) => e.playerId === me);
expect(mine).toBeDefined();
// int64 fields (score, rank) serialize as JSON strings via protojson — coerce to number.
expect(Number(mine?.score)).toBe(score);
expect(Number(mine?.rank ?? 0)).toBeGreaterThanOrEqual(1);
});
});
-94
View File
@@ -1,94 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { adminApi, freshPlayer, grantCurrency, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { RudderClient } from '../../src/client/RudderClient.js';
import type { OfferHandle } from '../../src/state/shops.js';
// Exercises the (newly implemented) wallet + inventory + purchase backend end-to-end:
// admin grant -> debit on buy -> inventory credit -> idempotency -> max_purchases -> insufficient funds.
interface AdminWalletDetails {
currencyCode: string;
balance: number;
}
interface AdminInventoryItem {
slug: string;
amount: number;
}
interface AdminPlayerDetails {
wallets?: AdminWalletDetails[];
inventory?: AdminInventoryItem[];
}
describe('e2e-prod: purchase (wallet/inventory/limits)', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
async function resolvePaidOffer(client: RudderClient): Promise<OfferHandle> {
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
return store.offers.find((o) => o.name === artifact.store.paidOfferName)!;
}
async function balance(client: RudderClient, currency: string): Promise<number> {
const profile = await client.player.reload();
// int64 fields serialize as JSON strings via protojson — coerce to number.
return Number(profile.wallets?.find((w) => w.currency === currency)?.balance ?? 0);
}
it('grants currency, debits on buy, credits inventory, is idempotent, and enforces the purchase limit', async () => {
const client = await freshPlayer(artifact);
const playerId = (await client.player.reload()).player!.id!;
const api = adminApi(artifact);
const currency = artifact.store.paidPrice.currency;
const price = artifact.store.paidPrice.amount;
const offer = await resolvePaidOffer(client);
// 1. Grant currency via the admin endpoint.
await grantCurrency(artifact, playerId, currency, 1000);
const granted = await withReadRetry(
() => balance(client, currency),
(b) => b === 1000,
);
expect(granted).toBe(1000);
// 2. First paid buy: success + wallet debited.
const buy1 = await offer.buy();
expect(buy1.success).toBe(true);
expect(buy1.purchaseId).toBeTruthy();
expect(await balance(client, currency)).toBe(1000 - price);
// 3. Inventory credited (verified via admin player details).
const details = await api.get<AdminPlayerDetails>(
`/platform/v1/projects/${artifact.projectId}/players/${playerId}?environment=${artifact.environment}`,
);
const inv = details.inventory?.find((i) => i.slug === artifact.item.id);
expect(Number(inv?.amount)).toBe(artifact.store.paidGrantAmount);
// 4. Idempotency: the same key twice charges once.
const key = crypto.randomUUID();
const a = await offer.buy({ idempotencyKey: key });
const b = await offer.buy({ idempotencyKey: key });
expect(a.success).toBe(true);
expect(b.success).toBe(true);
expect(await balance(client, currency)).toBe(1000 - price * 2);
// 5. max_purchases (2) reached -> a new distinct buy fails (no charge).
const overLimit = await offer.buy();
expect(overLimit.success).toBe(false);
expect(overLimit.error ?? '').toContain('limit');
expect(await balance(client, currency)).toBe(1000 - price * 2);
});
it('rejects a paid purchase with insufficient funds', async () => {
const poor = await freshPlayer(artifact);
const offer = await resolvePaidOffer(poor);
const res = await offer.buy();
expect(res.success).toBe(false);
expect(res.error ?? '').toContain('insufficient');
});
});
-33
View File
@@ -1,33 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
interface GameRemoteConfig extends Record<string, unknown> {
max_energy: number;
spawn_rate: number;
feature_x: boolean;
welcome_text: string;
shop_layout: Record<string, unknown>;
}
describe('e2e-prod: remoteConfig', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('loads typed configs matching the seeded values', async () => {
const rc = artifact.remoteConfigs;
const client = await withReadRetry(
() => freshPlayer<GameRemoteConfig>(artifact),
(candidate) => candidate.remoteConfig.get('max_energy', 0) === rc.maxEnergy,
);
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(rc.maxEnergy);
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(rc.spawnRate);
expect(client.remoteConfig.get('feature_x', false)).toBe(rc.featureX);
expect(client.remoteConfig.get('welcome_text', '')).toBe(rc.welcomeText);
expect(client.remoteConfig.get('shop_layout', {} as Record<string, unknown>)).toEqual(rc.shopLayout);
});
});
-60
View File
@@ -1,60 +0,0 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { grantCurrency, loadArtifact, makeProdClient } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
import type { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
import { getScenarioRuntime } from '../../src/client/RudderClient.js';
describe('e2e-prod: scenarios', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('runs onboarding: notification -> rc override -> store -> boundary callback -> notification', async () => {
const notifications: NotificationEffect[] = [];
let storeOffer: StoreOfferEffect | undefined;
let runCompleted = false;
const client = makeProdClient(artifact, {
loginEvent: artifact.scenario.event,
});
client.effects.onNotification((effect) => { notifications.push(effect); });
client.effects.onStoreOffer((effect) => {
storeOffer = effect;
});
const runtime = await getScenarioRuntime(client);
client.effects.onScenarioCompleted(() => {
runCompleted = true;
});
await client.auth.loginWithDevice({ region: 'en', language: 'en' });
// The boundary buy uses the paid offer — fund the wallet so the purchase succeeds.
const playerId = (await client.player.reload()).player!.id!;
await grantCurrency(
artifact,
playerId,
artifact.store.paidPrice.currency,
artifact.store.paidPrice.amount,
);
// First notification dispatched.
await vi.waitFor(() => expect(notifications.length).toBe(1), { timeout: 10_000 });
await notifications[0].done();
await vi.waitFor(() => expect(storeOffer).toBeDefined(), { timeout: 10_000 });
await client.remoteConfig.reload();
expect(client.remoteConfig.get('spawn_rate', 0)).toBeCloseTo(
artifact.remoteConfigs.spawnRateOverride,
);
// Buy crosses the server boundary (POST /sdk/v1/scenarios/callback) -> second notification.
const offer = storeOffer!.offers.find((o) => o.name === artifact.store.paidOfferName)!;
await storeOffer!.buy(offer);
await vi.waitFor(() => expect(notifications.length).toBe(2), { timeout: 15_000 });
await notifications[1].done();
await vi.waitFor(() => expect(runtime.isRunning).toBe(false), { timeout: 10_000 });
expect(runCompleted).toBe(true);
});
});
-37
View File
@@ -1,37 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { freshPlayer, loadArtifact, withReadRetry } from './_setup/harness.js';
import type { SeedArtifact } from './_setup/types.js';
describe('e2e-prod: stores', () => {
let artifact: SeedArtifact;
beforeAll(() => {
artifact = loadArtifact();
});
it('lists the seeded store with its free + paid offers', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(r) => r.length > 0,
);
const store = stores.find((s) => s.name === artifact.store.name);
expect(store).toBeDefined();
const offerNames = store!.offers.map((o) => o.name);
expect(offerNames).toContain(artifact.store.freeOfferName);
expect(offerNames).toContain(artifact.store.paidOfferName);
});
it('buys the free offer successfully', async () => {
const client = await freshPlayer(artifact);
const stores = await withReadRetry(
() => client.stores.reload(),
(s) => s.some((store) => store.name === artifact.store.name && store.offers.length > 0),
);
const store = stores.find((s) => s.name === artifact.store.name)!;
const free = store.offers.find((o) => o.name === artifact.store.freeOfferName)!;
const res = await free.buy();
expect(res.success).toBe(true);
expect(res.purchaseId).toBeTruthy();
});
});
-126
View File
@@ -1,126 +0,0 @@
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', slug: '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!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const reward = effect('notification', { message: 'Reward granted: 500 gems!' }, {
scenarioSlug: '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.scenarioSlug); });
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({
scenarioSlug: '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!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const consolation = effect('notification', { message: 'Maybe next time!' }, {
scenarioSlug: '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' }, {
scenarioSlug: '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);
});
});
-173
View File
@@ -1,173 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { 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 { NotificationEffect, StoreOfferEffect } from '../../src/index.js';
const MINUTE = 60_000;
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
});
}
function offerScenario(now: number) {
const waitIntro = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { storeSlug: 'starter', offerSlug: 'pack-1', message: 'Limited starter pack!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
const waitReminder = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_reminder',
waitDeadline: new Date(now + 2 * MINUTE).toISOString(),
});
const reminder = effect('notification', { message: 'Your offer is still available!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'reminder',
});
const thanks = effect('notification', { message: 'Thanks for your purchase!' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'thanks',
});
return { waitIntro, offer, waitReminder, reminder, thanks };
}
describe('E2E: player offer journey', () => {
let gateway: FakeGateway;
let client: RudderClient;
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{
slug: 'starter',
name: 'Starter',
offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Starter Pack' }],
}],
});
gateway.install();
client = makeClient();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('authenticates the device and persists tokens for later calls', async () => {
const res = await client.auth.loginWithDevice({ region: 'eu', language: 'en' });
expect(res.accessToken).toBeTruthy();
expect(client.options.tokenStore.getAccessToken()).toBe(res.accessToken);
const login = gateway.recorded.find((r) => r.path === '/sdk/v1/authorization/device');
expect(login?.body).toMatchObject({ key: 'test-project', region: 'eu', language: 'en' });
expect((login?.body as { deviceId?: string }).deviceId).toBeTruthy();
const trigger = gateway.recorded.find((r) => r.path === '/sdk/v1/scenarios/trigger');
expect(trigger?.authToken).toBe(res.accessToken);
});
it('offer appears after 1 min, player declines, reminder fires 1 min later', async () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
const notifications: NotificationEffect[] = [];
let offer: StoreOfferEffect | undefined;
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
await client.auth.loginWithDevice();
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
await offer!.dismiss();
expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(MINUTE - 1_000);
expect(notifications).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000);
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Your offer is still available!');
});
it('offer appears, player buys → "thanks" branch, and the purchase transacts', async () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
let offer: StoreOfferEffect | undefined;
const notifications: NotificationEffect[] = [];
client.effects.onStoreOffer((e) => { offer = e; });
client.effects.onNotification((e) => { notifications.push(e); });
const accessToken = (await client.auth.loginWithDevice()).accessToken;
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
const selectedOffer = offer!.offers[0];
const purchase = await offer!.buy(selectedOffer, { idempotencyKey: 'idem-key-123' });
expect(purchase.success).toBe(true);
expect(gateway.purchases).toEqual([
{ storeSlug: 'starter', offerSlug: 'pack-1', idempotencyKey: 'idem-key-123', authToken: accessToken },
]);
expect(notifications).toHaveLength(1);
expect(notifications[0].message).toBe('Thanks for your purchase!');
});
it('declining does NOT take the purchase branch (handles are isolated)', async () => {
const now = Date.now();
const nodes = offerScenario(now);
gateway.onEvent('player_login', nodes.waitIntro);
gateway.onCallback('wait_intro', 'onComplete', nodes.offer);
gateway.onCallback('offer', 'onDecline', nodes.waitReminder);
gateway.onCallback('wait_reminder', 'onComplete', nodes.reminder);
gateway.onCallback('offer', 'onPurchase', nodes.thanks);
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.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
await offer!.dismiss();
await vi.advanceTimersByTimeAsync(MINUTE);
expect(messages).toEqual(['Your offer is still available!']);
expect(messages).not.toContain('Thanks for your purchase!');
expect(gateway.purchases).toHaveLength(0);
});
});
-73
View File
@@ -1,73 +0,0 @@
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, WaitEffect } from '../../src/index.js';
const MINUTE = 60_000;
describe('E2E: pending effects resume after a new login', () => {
let gateway: FakeGateway;
function makeClient(): RudderClient {
return new RudderClient({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
}
beforeEach(() => {
stubDeterministicUuid();
vi.useFakeTimers();
gateway = createFakeGateway({
stores: [{ slug: 'starter', name: 'Starter', offers: [{ id: 'pack_1', slug: 'pack-1', name: 'Pack' }] }],
});
const now = Date.now();
const wait = effect('wait', {}, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'wait_intro',
waitDeadline: new Date(now + MINUTE).toISOString(),
});
const offer = effect('store', { message: 'Limited starter pack!', storeSlug: 'starter' }, {
scenarioSlug: 'offer_flow',
runId: 'offer_flow-run',
nodeId: 'offer',
});
gateway.onEvent('session_start', wait);
gateway.onCallback('wait_intro', 'onComplete', offer);
gateway.install();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('a wait started before reload resumes from GET pending after login', async () => {
const client1 = makeClient();
await client1.auth.loginWithDevice();
const runtime1 = await getScenarioRuntime(client1);
await runtime1.send('session_start');
expect(runtime1.isRunning).toBe(true);
const client2 = makeClient();
const resumedWaits: WaitEffect[] = [];
let offer: StoreOfferEffect | undefined;
client2.effects.onWait((s) => { resumedWaits.push(s); });
client2.effects.onStoreOffer((s) => { offer = s; });
await client2.auth.loginWithDevice();
expect(resumedWaits).toHaveLength(1);
expect(offer).toBeUndefined();
await vi.advanceTimersByTimeAsync(MINUTE);
await vi.waitFor(() => expect(offer).toBeDefined());
expect(offer!.message).toBe('Limited starter pack!');
});
});
-78
View File
@@ -1,78 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { 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 type { RemoteConfig } from '../../src/generated/remote-config.js';
interface GameRemoteConfig extends Record<string, unknown> {
max_energy: number;
drop_rate: number;
pvp_enabled: boolean;
welcome_text: string;
boss: { hp: number; name: string };
legacy: string;
missing_declared: string;
}
function cfg(key: string, value: string, valueType: NonNullable<RemoteConfig['valueType']>, active = true): RemoteConfig {
return { key, value, valueType, active };
}
function makeClient(): RudderClient<GameRemoteConfig> {
return new RudderClient<GameRemoteConfig>({
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
}
describe('E2E: remote config', () => {
let gateway: FakeGateway;
let client: RudderClient<GameRemoteConfig>;
beforeEach(() => {
stubDeterministicUuid();
gateway = createFakeGateway({
remoteConfigs: {
max_energy: cfg('max_energy', '50', 'int'),
drop_rate: cfg('drop_rate', '0.25', 'float'),
pvp_enabled: cfg('pvp_enabled', 'true', 'bool'),
welcome_text: cfg('welcome_text', 'Hello!', 'string'),
boss: cfg('boss', '{"hp":1000,"name":"Drake"}', 'json'),
legacy: cfg('legacy', 'old', 'string', false), // inactive — must be excluded
},
});
gateway.install();
client = makeClient();
});
afterEach(() => vi.unstubAllGlobals());
it('loads configs and reads them back with type coercion', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.status).toBe('ready');
expect(client.remoteConfig.get('max_energy', 0)).toBe(50);
expect(client.remoteConfig.get('drop_rate', 0)).toBeCloseTo(0.25);
expect(client.remoteConfig.get('pvp_enabled', false)).toBe(true);
expect(client.remoteConfig.get('welcome_text', '')).toBe('Hello!');
expect(client.remoteConfig.get('boss', { hp: 0, name: '' })).toEqual({
hp: 1000,
name: 'Drake',
});
});
it('inactive configs are not exposed; missing keys fall back to default', async () => {
await client.auth.loginWithDevice();
expect(client.remoteConfig.get('legacy', 'DEFAULT')).toBe('DEFAULT');
expect(client.remoteConfig.get('missing_declared', 'fallback')).toBe('fallback');
});
it('get() before login returns the default (no throw)', () => {
expect(client.remoteConfig.status).toBe('idle');
expect(client.remoteConfig.get('max_energy', 99)).toBe(99);
});
});
-3
View File
@@ -8,7 +8,6 @@ function makeClient(): RudderClient {
baseUrl: 'https://api.test.rudder.build',
projectKey: 'test-project',
tokenStore: createFakeTokenStore(),
runtime: { loginEvent: null },
});
}
@@ -46,13 +45,11 @@ describe('E2E: player storage', () => {
{ id: 'b', type: 'prefs', data: '{}' },
]);
// v0.2 fetches the whole storage; filtering by type is client-side.
const all = await client.storage.reload();
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
expect(prefs).toHaveLength(1);
expect(prefs[0].id).toBe('b');
// The read went out as a storage fetch with limit as a query param, not a path segment.
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
expect(getReq?.query.get('limit')).toBe('100');
});
+66 -145
View File
@@ -1,12 +1,9 @@
import { vi } from 'vitest';
import type { PendingEffect } from '../../src/generated/scenarios.js';
import type { RemoteConfig } from '../../src/generated/remote-config.js';
import type { Store } from '../../src/generated/stores.js';
import type { StorageItem } from '../../src/generated/storage.js';
import type {
ClaimQuestResponse,
ListQuestsResponse,
} from '../../src/generated/quests.js';
import type { ResourceRow } from '../../src/generated/resources.js';
import type { CallModuleFunctionClientResponse } from '../../src/generated/modules.js';
import type { CatalogCurrency } from '../../src/generated/catalog.js';
import type { ProjectStorageItem } from '../../src/generated/project-storage.js';
export interface RecordedRequest {
method: string;
@@ -17,28 +14,17 @@ export interface RecordedRequest {
}
export interface FakeGatewayState {
scenarios: Map<string, PendingEffect[]>;
callbacks: Map<string, PendingEffect | null>;
callbackErrors: Map<string, { status: number; code?: string }>;
pendingEffects: PendingEffect[];
counterCompleted: boolean;
counterEffect?: PendingEffect;
remoteConfigs: Record<string, RemoteConfig>;
stores: Store[];
quests: ListQuestsResponse;
questClaims: Map<string, ClaimQuestResponse>;
storage: Map<string, StorageItem>;
projectStorage: Map<string, ProjectStorageItem>;
currencies: CatalogCurrency[];
resources: Map<string, Map<string, ResourceRow>>;
moduleCalls: CallModuleFunctionClientResponse;
}
export interface FakeGateway {
state: FakeGatewayState;
recorded: RecordedRequest[];
purchases: Array<{ storeSlug: string; offerSlug: string; idempotencyKey: string; authToken: string | null }>;
questClaims: Array<{ questSlug: string; authToken: string | null }>;
install(): void;
onEvent(event: string, ...effects: PendingEffect[]): void;
onCallback(nodeId: string, handle: string, next: PendingEffect | null): void;
onCallbackError(nodeId: string, handle: string, status?: number, code?: string): void;
}
function json(body: unknown, status = 200): Response {
@@ -50,52 +36,16 @@ function json(body: unknown, status = 200): Response {
const noContent = (): Response => new Response(null, { status: 204 });
function replaceRun(pending: PendingEffect[], next: PendingEffect[]): PendingEffect[] {
const runIds = new Set(next.map((effect) => effect.runId));
return [...pending.filter((effect) => !runIds.has(effect.runId)), ...next];
}
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = {
scenarios: seed?.scenarios ?? new Map(),
callbacks: seed?.callbacks ?? new Map(),
callbackErrors: seed?.callbackErrors ?? new Map(),
pendingEffects: seed?.pendingEffects ?? [],
counterCompleted: seed?.counterCompleted ?? false,
counterEffect: seed?.counterEffect,
remoteConfigs: seed?.remoteConfigs ?? {},
stores: seed?.stores ?? [],
quests: seed?.quests ?? { quests: [] },
questClaims: seed?.questClaims ?? new Map(),
storage: seed?.storage ?? new Map(),
projectStorage: seed?.projectStorage ?? new Map(),
currencies: seed?.currencies ?? [],
resources: seed?.resources ?? new Map(),
moduleCalls: seed?.moduleCalls ?? { result: {} },
};
const recorded: RecordedRequest[] = [];
const purchases: FakeGateway['purchases'] = [];
const questClaims: FakeGateway['questClaims'] = [];
function advanceExpiredWaits(): PendingEffect[] {
const now = Date.now();
const next: PendingEffect[] = [];
for (const effect of state.pendingEffects) {
if (
effect.type === 'wait' &&
effect.waitDeadline &&
Date.parse(effect.waitDeadline) <= now
) {
const continued = state.callbacks.get(`${effect.nodeId}:onComplete`);
if (continued === undefined) {
next.push(effect);
} else if (continued !== null) {
next.push(continued);
}
} else {
next.push(effect);
}
}
state.pendingEffects = next;
return next;
}
async function handle(req: RecordedRequest): Promise<Response> {
const { method, path, query, body } = req;
@@ -117,85 +67,54 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
}
if (method === 'GET' && path === '/sdk/v1/catalog') {
return json({ items: [] });
return json({ currencies: state.currencies });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
const event = (body as { event?: string }).event ?? '';
const effects = state.scenarios.get(event) ?? [];
state.pendingEffects = replaceRun(state.pendingEffects, effects);
return json({ effects });
const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/);
if (method === 'POST' && moduleMatch) {
return json(state.moduleCalls);
}
if (method === 'GET' && path === '/sdk/v1/scenarios/pending') {
return json({ effects: advanceExpiredWaits() });
}
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
const b = body as { nodeId?: string; handle?: string; runId?: string };
const key = `${b.nodeId}:${b.handle}`;
const err = state.callbackErrors.get(key);
if (err) {
return json({ code: err.code ?? 'callback failed', error: 'callback failed' }, err.status);
const resourceItemMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)\/([^/]+)$/);
const resourceListMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)$/);
if (resourceItemMatch) {
const slug = decodeURIComponent(resourceItemMatch[1]);
const id = decodeURIComponent(resourceItemMatch[2]);
const rows = state.resources.get(slug) ?? new Map();
if (method === 'GET') {
const row = rows.get(id);
return row ? json(row) : json({ error: 'not found' }, 404);
}
const next = state.callbacks.get(key);
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
if (next) {
state.pendingEffects = replaceRun(state.pendingEffects, [next]);
return json({ effect: next });
if (method === 'PATCH') {
const existing = rows.get(id);
if (!existing) return json({ error: 'not found' }, 404);
const next = { ...existing, ...(body as ResourceRow), id };
rows.set(id, next);
state.resources.set(slug, rows);
return json(next);
}
return json({});
}
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
if (state.counterCompleted && state.counterEffect) {
state.pendingEffects = replaceRun(state.pendingEffects, [state.counterEffect]);
} else if (state.counterCompleted) {
const b = body as { nodeId?: string; runId?: string };
state.pendingEffects = state.pendingEffects.filter(
(effect) => !(effect.nodeId === b.nodeId && effect.runId === (b.runId ?? effect.runId)),
);
if (method === 'DELETE') {
rows.delete(id);
state.resources.set(slug, rows);
return noContent();
}
return json({ completed: state.counterCompleted, effect: state.counterEffect });
}
if (method === 'GET' && path === '/sdk/v1/remote-configs') {
return json({ configs: state.remoteConfigs });
}
if (method === 'GET' && path.startsWith('/sdk/v1/remote-configs/')) {
const key = decodeURIComponent(path.slice('/sdk/v1/remote-configs/'.length));
const cfg = state.remoteConfigs[key];
return cfg ? json(cfg) : json({ error: 'not found' }, 404);
}
if (method === 'GET' && path === '/sdk/v1/stores') {
return json({ stores: state.stores, total: state.stores.length });
}
const purchaseMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)\/offers\/([^/]+)\/purchase$/);
if (method === 'POST' && purchaseMatch) {
const b = body as { idempotencyKey?: string };
purchases.push({
storeSlug: decodeURIComponent(purchaseMatch[1]),
offerSlug: decodeURIComponent(purchaseMatch[2]),
idempotencyKey: b.idempotencyKey ?? '',
authToken: req.authToken,
});
return json({ success: true, purchaseId: `purchase-${purchases.length}` });
}
const storeMatch = path.match(/^\/sdk\/v1\/stores\/([^/]+)$/);
if (method === 'GET' && storeMatch) {
const slug = decodeURIComponent(storeMatch[1]);
const store = state.stores.find((s) => s.slug === slug);
return store ? json(store) : json({ error: 'not found' }, 404);
}
if (method === 'POST' && path === '/sdk/v1/quests/list') {
return json(state.quests);
}
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
const b = body as { questSlug?: string };
const questSlug = b.questSlug ?? '';
questClaims.push({ questSlug, authToken: req.authToken });
return json(state.questClaims.get(questSlug) ?? { success: false, error: 'not found' });
if (resourceListMatch) {
const slug = decodeURIComponent(resourceListMatch[1]);
const rows = state.resources.get(slug) ?? new Map();
if (method === 'GET') {
return json({ items: [...rows.values()], total: rows.size });
}
if (method === 'POST') {
const created: ResourceRow = {
id: (body as ResourceRow).id ?? `row-${rows.size + 1}`,
data: (body as ResourceRow).data,
version: 1,
};
rows.set(created.id!, created);
state.resources.set(slug, rows);
return json(created);
}
}
if (path === '/sdk/v1/storage') {
@@ -222,6 +141,19 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
}
}
if (path === '/sdk/v1/project-storage') {
if (method === 'GET') {
return json({ items: [...state.projectStorage.values()], nextCursor: '' });
}
if (method === 'PUT') {
const items = (body as { items?: ProjectStorageItem[] }).items ?? [];
for (const item of items) {
if (item.id) state.projectStorage.set(item.id, item);
}
return noContent();
}
}
return json({ error: `unhandled route: ${method} ${path}` }, 500);
}
@@ -248,17 +180,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
return {
state,
recorded,
purchases,
questClaims,
install,
onEvent(event, ...effects) {
state.scenarios.set(event, effects);
},
onCallback(nodeId, handle, next) {
state.callbacks.set(`${nodeId}:${handle}`, next);
},
onCallbackError(nodeId, handle, status = 500, code) {
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
},
};
}
-15
View File
@@ -1,15 +0,0 @@
export function stubDeterministicUuid(): void {
let n = 0;
const existing = (globalThis as { crypto?: Crypto }).crypto ?? ({} as Crypto);
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
...existing,
randomUUID: () => {
n += 1;
const hex = n.toString(16).padStart(12, '0');
return `00000000-0000-4000-a000-${hex}` as `${string}-${string}-${string}-${string}-${string}`;
},
},
});
}
-16
View File
@@ -1,16 +0,0 @@
import type { PendingEffect } from '../../src/generated/scenarios.js';
export function effect(
type: string,
data: Record<string, unknown> = {},
overrides: Partial<PendingEffect> = {},
): PendingEffect {
return {
runId: 'run-1',
scenarioSlug: 'scenario-1',
nodeId: `${type}-1`,
type,
data,
...overrides,
};
}