@@ -0,0 +1,20 @@
|
||||
import type { TokenStore } from '../../src/token/TokenStore.js';
|
||||
|
||||
/** In-memory token store for testing. */
|
||||
export function createFakeTokenStore(): TokenStore {
|
||||
let access: string | null = null;
|
||||
let refresh: string | null = null;
|
||||
|
||||
return {
|
||||
getAccessToken: () => access,
|
||||
getRefreshToken: () => refresh,
|
||||
saveTokens: (a, r) => {
|
||||
access = a;
|
||||
refresh = r;
|
||||
},
|
||||
clear: () => {
|
||||
access = null;
|
||||
refresh = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RudderClient } from '../../src/client/RudderClient.js';
|
||||
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
|
||||
import { createFakeTokenStore } from './FakeTokenStore.js';
|
||||
|
||||
/** Creates a RudderClient with test-friendly defaults and a fake token store. */
|
||||
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project-key',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { ExecutionPlan } from '../../src/generated/common.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';
|
||||
|
||||
/**
|
||||
* In-process fake of the LiveOps API gateway.
|
||||
*
|
||||
* Stubs the global `fetch` and routes requests the same way the real gateway
|
||||
* does — so tests exercise the *real* SDK transport, auth-header injection,
|
||||
* scenario engine and DAG traversal, just against deterministic in-memory state
|
||||
* instead of a live backend.
|
||||
*
|
||||
* Seed state up front (scenarios per event, remote configs, stores), then drive
|
||||
* the SDK through a journey and assert on `recorded` requests / server state.
|
||||
*/
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
query: URLSearchParams;
|
||||
body: unknown;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
export interface FakeGatewayState {
|
||||
/** Plans returned by POST /scenarios/trigger, keyed by event name. */
|
||||
scenarios: Map<string, ExecutionPlan[]>;
|
||||
/** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */
|
||||
callbacks: Map<string, ExecutionPlan>;
|
||||
/** HTTP status to fail a callback with, keyed by `nodeId:handle`. */
|
||||
callbackErrors: Map<string, number>;
|
||||
/** Optional response for POST /scenarios/counter. */
|
||||
counterPlan?: ExecutionPlan;
|
||||
remoteConfigs: Record<string, RemoteConfig>;
|
||||
stores: Store[];
|
||||
quests: ListQuestsResponse;
|
||||
questClaims: Map<string, ClaimQuestResponse>;
|
||||
/** Player KV storage, keyed by item id. */
|
||||
storage: Map<string, StorageItem>;
|
||||
}
|
||||
|
||||
export interface FakeGateway {
|
||||
state: FakeGatewayState;
|
||||
recorded: RecordedRequest[];
|
||||
/** Purchases received, in order. */
|
||||
purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>;
|
||||
/** Quest claim requests received, in order. */
|
||||
questClaims: Array<{ questId: string; authToken: string | null }>;
|
||||
install(): void;
|
||||
/** Convenience: register the plans returned for a trigger event. */
|
||||
onEvent(event: string, ...plans: ExecutionPlan[]): void;
|
||||
/** Convenience: register the plan returned when a boundary handle calls back. */
|
||||
onCallback(nodeId: string, handle: string, plan: ExecutionPlan): void;
|
||||
/** Convenience: make a boundary callback fail with an HTTP status (default 500). */
|
||||
onCallbackError(nodeId: string, handle: string, status?: number): void;
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const noContent = (): Response => new Response(null, { status: 204 });
|
||||
|
||||
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(),
|
||||
counterPlan: seed?.counterPlan,
|
||||
remoteConfigs: seed?.remoteConfigs ?? {},
|
||||
stores: seed?.stores ?? [],
|
||||
quests: seed?.quests ?? { quests: [] },
|
||||
questClaims: seed?.questClaims ?? new Map(),
|
||||
storage: seed?.storage ?? new Map(),
|
||||
};
|
||||
|
||||
const recorded: RecordedRequest[] = [];
|
||||
const purchases: FakeGateway['purchases'] = [];
|
||||
const questClaims: FakeGateway['questClaims'] = [];
|
||||
|
||||
async function handle(req: RecordedRequest): Promise<Response> {
|
||||
const { method, path, query, body } = req;
|
||||
|
||||
// --- Auth ---
|
||||
if (method === 'POST' && path === '/sdk/v1/authorization/device') {
|
||||
const b = body as { deviceId?: string };
|
||||
return json({
|
||||
accessToken: `access-${b.deviceId ?? 'dev'}`,
|
||||
refreshToken: `refresh-${b.deviceId ?? 'dev'}`,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Player ---
|
||||
if (method === 'GET' && path === '/sdk/v1/player/information') {
|
||||
return json({ player: null, wallets: [] });
|
||||
}
|
||||
|
||||
// --- Sync (baseline revision poll after login) ---
|
||||
if (method === 'GET' && path === '/sdk/v1/sync') {
|
||||
return json({});
|
||||
}
|
||||
|
||||
// --- Catalog ---
|
||||
if (method === 'GET' && path === '/sdk/v1/catalog') {
|
||||
return json({ items: [] });
|
||||
}
|
||||
|
||||
// --- Scenarios ---
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/trigger') {
|
||||
const event = (body as { event?: string }).event ?? '';
|
||||
return json({ plans: state.scenarios.get(event) ?? [] });
|
||||
}
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
|
||||
const b = body as { nodeId?: string; handle?: string };
|
||||
const key = `${b.nodeId}:${b.handle}`;
|
||||
const errStatus = state.callbackErrors.get(key);
|
||||
if (errStatus) return json({ error: 'callback failed' }, errStatus);
|
||||
const plan = state.callbacks.get(key);
|
||||
return json(plan ? { plan } : {});
|
||||
}
|
||||
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
|
||||
return json(state.counterPlan ? { plan: state.counterPlan, completed: false } : { completed: false });
|
||||
}
|
||||
|
||||
// --- Remote config ---
|
||||
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);
|
||||
}
|
||||
|
||||
// --- Stores ---
|
||||
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]),
|
||||
offerId: 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);
|
||||
}
|
||||
|
||||
// --- Quests ---
|
||||
if (method === 'POST' && path === '/sdk/v1/quests/list') {
|
||||
return json(state.quests);
|
||||
}
|
||||
if (method === 'POST' && path === '/sdk/v1/quests/claim') {
|
||||
const b = body as { questId?: string };
|
||||
const questId = b.questId ?? '';
|
||||
questClaims.push({ questId, authToken: req.authToken });
|
||||
return json(state.questClaims.get(questId) ?? { success: false, error: 'not found' });
|
||||
}
|
||||
|
||||
// --- Storage ---
|
||||
if (path === '/sdk/v1/storage') {
|
||||
if (method === 'GET') {
|
||||
const typeFilter = query.get('types');
|
||||
const items = [...state.storage.values()].filter(
|
||||
(it) => !typeFilter || it.type === typeFilter,
|
||||
);
|
||||
return json({ items, nextCursor: '' });
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
const items = (body as { items?: StorageItem[] }).items ?? [];
|
||||
for (const item of items) {
|
||||
if (item.id) state.storage.set(item.id, item);
|
||||
}
|
||||
return noContent();
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
const type = query.get('type');
|
||||
for (const [id, item] of state.storage) {
|
||||
if (!type || item.type === type) state.storage.delete(id);
|
||||
}
|
||||
return noContent();
|
||||
}
|
||||
}
|
||||
|
||||
return json({ error: `unhandled route: ${method} ${path}` }, 500);
|
||||
}
|
||||
|
||||
function install(): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.toString());
|
||||
const headers = new Headers(init?.headers);
|
||||
const auth = headers.get('Authorization');
|
||||
const req: RecordedRequest = {
|
||||
method: (init?.method ?? 'GET').toUpperCase(),
|
||||
path: url.pathname,
|
||||
query: url.searchParams,
|
||||
body: init?.body ? JSON.parse(init.body as string) : undefined,
|
||||
authToken: auth ? auth.replace(/^Bearer\s+/, '') : null,
|
||||
};
|
||||
recorded.push(req);
|
||||
return handle(req);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
recorded,
|
||||
purchases,
|
||||
questClaims,
|
||||
install,
|
||||
onEvent(event, ...plans) {
|
||||
state.scenarios.set(event, plans);
|
||||
},
|
||||
onCallback(nodeId, handle, plan) {
|
||||
state.callbacks.set(`${nodeId}:${handle}`, plan);
|
||||
},
|
||||
onCallbackError(nodeId, handle, status = 500) {
|
||||
state.callbackErrors.set(`${nodeId}:${handle}`, status);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PlanStateStore } from '../../src/scenario/engine/IndexedDbPlanStore.js';
|
||||
|
||||
/**
|
||||
* In-memory PlanStateStore that survives across RudderClient instances — lets a
|
||||
* test simulate a page reload: drive scenario A on one client, construct a fresh
|
||||
* client sharing the same `backing`, call `scenarios.restore()`, and assert the
|
||||
* run resumed mid-DAG.
|
||||
*/
|
||||
export function createMemoryPlanStore(backing: { value: string | null } = { value: null }): PlanStateStore {
|
||||
return {
|
||||
get state() {
|
||||
return backing.value;
|
||||
},
|
||||
set state(v: string | null) {
|
||||
backing.value = v;
|
||||
},
|
||||
async load() {
|
||||
/* state already in `backing` */
|
||||
},
|
||||
async save(s: string | null) {
|
||||
backing.value = s;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic, collision-free crypto.randomUUID() stub for scenario run IDs. */
|
||||
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}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ExecutionPlan, ExecutionPlanNode, PlanEdge, BoundaryNode } from '../../src/generated/common.js';
|
||||
|
||||
/**
|
||||
* Small fluent builder for ExecutionPlans, so scenario journeys read like the
|
||||
* DAGs they model rather than walls of object literals.
|
||||
*
|
||||
* plan('offer_flow')
|
||||
* .node('wait1', 'wait', { duration: 1, unit: 'minutes' })
|
||||
* .node('offer', 'store', { storeSlug: 'starter', offerId: 'pack_1' })
|
||||
* .edge('wait1', 'onComplete', 'offer')
|
||||
* .build();
|
||||
*
|
||||
* The first node added becomes the start node unless `.start(id)` is called.
|
||||
*/
|
||||
export class PlanBuilder {
|
||||
private readonly nodes: ExecutionPlanNode[] = [];
|
||||
private readonly edges: PlanEdge[] = [];
|
||||
private readonly boundaryNodes: BoundaryNode[] = [];
|
||||
private startNodeId?: string;
|
||||
private runIdValue?: string;
|
||||
|
||||
constructor(
|
||||
private readonly scenarioId: string,
|
||||
private readonly opts: { planId?: string; userId?: string } = {},
|
||||
) {}
|
||||
|
||||
/** Sets a specific run ID (default: auto-derived from scenarioId). */
|
||||
runId(id: string): this {
|
||||
this.runIdValue = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
node(id: string, type: string, data?: Record<string, unknown>): this {
|
||||
this.nodes.push({ id, type, data: data as ExecutionPlanNode['data'] });
|
||||
if (!this.startNodeId) this.startNodeId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
edge(source: string, sourceHandle: string, target: string): this {
|
||||
this.edges.push({ id: `e-${source}-${sourceHandle}-${target}`, source, sourceHandle, target });
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Registers a server-side boundary node (handle that calls back to the gateway). */
|
||||
boundary(sourceNodeId: string, sourceHandle: string, nodeId = `b-${sourceNodeId}-${sourceHandle}`): this {
|
||||
this.boundaryNodes.push({ sourceNodeId, sourceHandle, nodeId });
|
||||
return this;
|
||||
}
|
||||
|
||||
start(id: string): this {
|
||||
this.startNodeId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): ExecutionPlan {
|
||||
return {
|
||||
planId: this.opts.planId ?? `${this.scenarioId}-plan`,
|
||||
scenarioId: this.scenarioId,
|
||||
userId: this.opts.userId ?? 'player-1',
|
||||
startNodeId: this.startNodeId,
|
||||
runId: this.runIdValue ?? `${this.scenarioId}-run`,
|
||||
nodes: this.nodes,
|
||||
edges: this.edges,
|
||||
boundaryNodes: this.boundaryNodes,
|
||||
context: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function plan(scenarioId: string, opts?: { planId?: string; userId?: string }): PlanBuilder {
|
||||
return new PlanBuilder(scenarioId, opts);
|
||||
}
|
||||
Reference in New Issue
Block a user