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
-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');
});