/** * 路由表式 fetch 替身(站点用户信息解析器 / API spider 测试用)。 * 路由键:`METHOD path?query`(query 可省略,省略时匹配任意 query;METHOD 省略为任意方法); * 值为响应描述或函数。未命中返回 404。记录全部请求(含头与 body)供断言。 */ export interface MockRouteResponse { status?: number; body?: string | object; contentType?: string; headers?: Record; } export type MockRouteHandler = MockRouteResponse | ((req: MockFetchRequest) => MockRouteResponse); export interface MockFetchRequest { method: string; url: URL; headers: Record; body: string | null; } export interface MockFetch { fetch: typeof globalThis.fetch; requests: MockFetchRequest[]; /** 追加/覆盖路由 */ route(key: string, handler: MockRouteHandler): void; } function match(key: string, req: MockFetchRequest): boolean { let method: string | null = null; let target = key; const sp = key.indexOf(" "); if (sp > 0 && /^[A-Z]+$/.test(key.slice(0, sp))) { method = key.slice(0, sp); target = key.slice(sp + 1); } if (method && method !== req.method) return false; // 可带主机:"https://api.x.com/path" let host: string | null = null; if (/^https?:\/\//.test(target)) { const u = new URL(target); host = u.host; target = u.pathname + u.search; } if (host && host !== req.url.host) return false; const [path, query] = target.split("?"); if (path !== req.url.pathname) return false; if (query === undefined) return true; const want = new URLSearchParams(query); for (const [k, v] of want) if (req.url.searchParams.get(k) !== v) return false; return true; } export function createMockFetch(routes: Record = {}): MockFetch { const table = new Map(Object.entries(routes)); const requests: MockFetchRequest[] = []; const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); const headers: Record = {}; new Headers(init?.headers).forEach((v, k) => { headers[k] = v; }); const req: MockFetchRequest = { method: (init?.method ?? "GET").toUpperCase(), url, headers, body: typeof init?.body === "string" ? init.body : null, }; requests.push(req); // 后加入的路由优先,便于测试覆盖默认路由;同时更具体(带 query)的键优先 const keys = [...table.keys()].reverse().sort((a, b) => Number(b.includes("?")) - Number(a.includes("?"))); for (const key of keys) { if (!match(key, req)) continue; const h = table.get(key)!; const r = typeof h === "function" ? h(req) : h; const isJson = r.body !== undefined && typeof r.body !== "string"; const body = r.body === undefined ? "" : isJson ? JSON.stringify(r.body) : (r.body as string); return new Response(body, { status: r.status ?? 200, headers: { "content-type": r.contentType ?? (isJson ? "application/json" : "text/html; charset=utf-8"), ...(r.headers ?? {}), }, }); } return new Response("not found", { status: 404 }); }; return { fetch: fetchImpl as typeof globalThis.fetch, requests, route(key, handler) { table.delete(key); table.set(key, handler); }, }; }