Files
UHD_Media_Tool/packages/testkit/src/tmdb/mock-http.ts
T
rootandClaude Fable 5.1 ef2e504a94 feat(adapters): qBittorrent, filesystem, TMDB and Emby adapters with testkit mocks (W1-3a)
- qbittorrent: WebUI API v2 client (cookie/API-key auth, add/info/files/filePrio,
  pause/resume with 5.x stop/start fallback, tags, delete with explicit deleteFiles,
  maindata), error classes, idempotent submit with local infohash (own bencode)
  and post-timeout hash/probe-tag lookup; state mapping per MoviePilot
- fs: copyWithVerify (tmp + fsync + size/sha256/sample + no-clobber commit),
  moveSafe (EXDEV surfaced), resumable crossDeviceMove, statSafe/checkRoot with
  root sentinel, budgeted incremental scan with cursor, ffprobe, statfs usage
- tmdb: multi-language search/details/seasons, LRU+TTL cache with coalescing,
  rate-limit backoff, MoviePilot match chain (name normalization, year +-1)
- emby: Emby/Jellyfin items/seasons/episodes/MediaSources, failures -> unknown
- testkit: mock qB / TMDB / Emby servers, torrent builder, temp-tree fixtures
- docs: W1-3a porting table appended to downloader-and-sites ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:07:48 +08:00

105 lines
3.5 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
/** 通用 JSON mock 服务底座:路由、调用日志、可编程故障。供 mock TMDB / mock Emby 使用。 */
export interface MockCall {
method: string;
path: string;
query: Record<string, string>;
headers: Record<string, string | string[] | undefined>;
status: number;
}
export interface MockFault {
status?: number;
body?: unknown;
headers?: Record<string, string>;
delayMs?: number;
/** 直接断开连接 */
drop?: boolean;
times?: number;
}
export interface MockReply {
status?: number;
json?: unknown;
headers?: Record<string, string>;
}
export type MockRoute = (m: RegExpMatchArray, q: Record<string, string>, req: IncomingMessage) => MockReply;
export interface MockHttp {
url: string;
calls: MockCall[];
close(): Promise<void>;
/** 对路径匹配 pattern(字符串前缀或正则)的请求注入故障 */
fault(pattern: string | RegExp, f: MockFault): void;
clearFaults(): void;
callsTo(pattern: string | RegExp): MockCall[];
}
const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? path.startsWith(p) : p.test(path));
export async function startMockHttp(
routes: [RegExp, MockRoute][],
opts: { port?: number; guard?: (req: IncomingMessage, q: Record<string, string>) => MockReply | null } = {},
): Promise<MockHttp> {
const calls: MockCall[] = [];
const faults: { pattern: string | RegExp; f: MockFault }[] = [];
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const u = new URL(req.url ?? "/", "http://mock");
const q = Object.fromEntries(u.searchParams);
const call: MockCall = { method: req.method ?? "GET", path: u.pathname, query: q, headers: req.headers, status: 0 };
calls.push(call);
const reply = (r: MockReply) => {
call.status = r.status ?? 200;
if (res.destroyed) return;
res.writeHead(call.status, { "content-type": "application/json", ...r.headers });
res.end(r.json === undefined ? "" : JSON.stringify(r.json));
};
const fi = faults.findIndex((x) => matches(x.pattern, u.pathname));
const fault = fi >= 0 ? faults[fi]!.f : undefined;
if (fault) {
fault.times = (fault.times ?? 1) - 1;
if (fault.times <= 0) faults.splice(fi, 1);
if (fault.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs));
if (fault.drop) {
call.status = -1;
req.socket.destroy();
return;
}
if (fault.status)
return reply({ status: fault.status, json: fault.body, ...(fault.headers ? { headers: fault.headers } : {}) });
}
const denied = opts.guard?.(req, q);
if (denied) return reply(denied);
for (const [re, h] of routes) {
const m = re.exec(u.pathname);
if (m) {
try {
return reply(h(m, q, req));
} catch (e) {
return reply({ status: 500, json: { error: String(e) } });
}
}
}
reply({ status: 404, json: { error: "no route" } });
});
await new Promise<void>((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
calls,
close: () =>
new Promise<void>((ok) => {
server.closeAllConnections();
server.close(() => ok());
}),
fault: (pattern, f) => faults.push({ pattern, f: { ...f } }),
clearFaults: () => {
faults.length = 0;
},
callsTo: (p) => calls.filter((c) => matches(p, c.path)),
};
}