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'; export interface RecordedRequest { method: string; path: string; query: URLSearchParams; body: unknown; authToken: string | null; } export interface FakeGatewayState { scenarios: Map; callbacks: Map; callbackErrors: Map; pendingEffects: PendingEffect[]; counterCompleted: boolean; counterEffect?: PendingEffect; remoteConfigs: Record; stores: Store[]; quests: ListQuestsResponse; questClaims: Map; storage: Map; } export interface FakeGateway { state: FakeGatewayState; recorded: RecordedRequest[]; purchases: Array<{ storeSlug: string; offerId: string; idempotencyKey: string; authToken: string | null }>; questClaims: Array<{ questId: 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 { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); } 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): 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(), }; 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 { const { method, path, query, body } = req; 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'}`, }); } if (method === 'GET' && path === '/sdk/v1/player/information') { return json({ player: null, wallets: [] }); } if (method === 'GET' && path === '/sdk/v1/sync') { return json({}); } if (method === 'GET' && path === '/sdk/v1/catalog') { return json({ items: [] }); } 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 }); } 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 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 }); } 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)), ); } 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]), 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); } 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' }); } 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, ...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 }); }, }; }