- 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]>
105 lines
3.5 KiB
TypeScript
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)),
|
|
};
|
|
}
|