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; /** Plans returned by POST /scenarios/callback, keyed by `nodeId:handle`. */ callbacks: Map; /** HTTP status to fail a callback with, keyed by `nodeId:handle`. */ callbackErrors: Map; /** Optional response for POST /scenarios/counter. */ counterPlan?: ExecutionPlan; remoteConfigs: Record; stores: Store[]; quests: ListQuestsResponse; questClaims: Map; /** Player KV storage, keyed by item id. */ storage: Map; } 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): 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 { 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 => { 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); }, }; }