2026-08-12 14:02:25 +03:00
|
|
|
import { vi } from 'vitest';
|
2026-09-04 14:08:48 +03:00
|
|
|
import type { PendingEffect } from '../../src/generated/scenarios.js';
|
2026-08-12 14:02:25 +03:00
|
|
|
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 {
|
2026-09-04 14:08:48 +03:00
|
|
|
scenarios: Map<string, PendingEffect[]>;
|
|
|
|
|
callbacks: Map<string, PendingEffect | null>;
|
|
|
|
|
callbackErrors: Map<string, { status: number; code?: string }>;
|
|
|
|
|
pendingEffects: PendingEffect[];
|
|
|
|
|
counterCompleted: boolean;
|
|
|
|
|
counterEffect?: PendingEffect;
|
2026-08-12 14:02:25 +03:00
|
|
|
remoteConfigs: Record<string, RemoteConfig>;
|
|
|
|
|
stores: Store[];
|
|
|
|
|
quests: ListQuestsResponse;
|
|
|
|
|
questClaims: Map<string, ClaimQuestResponse>;
|
|
|
|
|
storage: Map<string, StorageItem>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-04 14:08:48 +03:00
|
|
|
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;
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 14:02:25 +03:00
|
|
|
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(),
|
2026-09-04 14:08:48 +03:00
|
|
|
pendingEffects: seed?.pendingEffects ?? [],
|
|
|
|
|
counterCompleted: seed?.counterCompleted ?? false,
|
|
|
|
|
counterEffect: seed?.counterEffect,
|
2026-08-12 14:02:25 +03:00
|
|
|
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'] = [];
|
|
|
|
|
|
2026-09-04 14:08:48 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 14:02:25 +03:00
|
|
|
async function handle(req: RecordedRequest): Promise<Response> {
|
|
|
|
|
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 ?? '';
|
2026-09-04 14:08:48 +03:00
|
|
|
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() });
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
|
|
|
|
if (method === 'POST' && path === '/sdk/v1/scenarios/callback') {
|
2026-09-04 14:08:48 +03:00
|
|
|
const b = body as { nodeId?: string; handle?: string; runId?: string };
|
2026-08-12 14:02:25 +03:00
|
|
|
const key = `${b.nodeId}:${b.handle}`;
|
2026-09-04 14:08:48 +03:00
|
|
|
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({});
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
|
|
|
|
if (method === 'POST' && path === '/sdk/v1/scenarios/counter') {
|
2026-09-04 14:08:48 +03:00
|
|
|
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 });
|
2026-08-12 14:02:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<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,
|
2026-09-04 14:08:48 +03:00
|
|
|
onEvent(event, ...effects) {
|
|
|
|
|
state.scenarios.set(event, effects);
|
2026-08-12 14:02:25 +03:00
|
|
|
},
|
2026-09-04 14:08:48 +03:00
|
|
|
onCallback(nodeId, handle, next) {
|
|
|
|
|
state.callbacks.set(`${nodeId}:${handle}`, next);
|
2026-08-12 14:02:25 +03:00
|
|
|
},
|
2026-09-04 14:08:48 +03:00
|
|
|
onCallbackError(nodeId, handle, status = 500, code) {
|
|
|
|
|
state.callbackErrors.set(`${nodeId}:${handle}`, { status, code });
|
2026-08-12 14:02:25 +03:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|