Files
UHD_Media_Tool/packages/testkit/src/rss/mock-rss.ts
T
rootandClaude Fable 5.1 304feea141 chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock;
  remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports)
- tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions),
  test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types
- tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl
- server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting;
  drop @hono/node-server, tsx, esbuild build.mjs
- logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests
- relative imports get explicit extensions (sites/mp-sync paths left for follow-up)
- crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas
- single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml
- docs: README, runbook, ADR-001 superseded, plan §1 validation row

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

207 lines
7.0 KiB
TypeScript

import { createHash } from "node:crypto";
import { type StartedServer, startHttpServer } from "./server.ts";
export interface MockFeedItem {
guid: string;
title: string;
link?: string;
enclosureUrl?: string;
size?: number;
pubDate?: Date;
categories?: string[];
description?: string;
}
export interface MockFailure {
status: number;
retryAfter?: string;
body?: string;
}
export interface MockRssOptions {
format?: "rss" | "atom";
items?: MockFeedItem[];
/** 访问需带的 passkey(query ?passkey=);缺失或错误返回 403 */
passkey?: string;
/** Feed 路径,默认 /rss.xml */
path?: string;
title?: string;
}
export interface MockRssServer extends StartedServer {
/** 带 passkey(若配置)的完整 Feed URL */
feedUrl: string;
items: MockFeedItem[];
setItems(items: MockFeedItem[]): void;
/** 新条目放到最前(RSS 惯例新→旧) */
addItem(item: MockFeedItem): void;
/** 同 GUID 修改内容(标题修正/促销等) */
updateItem(guid: string, patch: Partial<MockFeedItem>): void;
/** 置顶:把条目挪到最前 */
pinItem(guid: string): void;
/** 打乱顺序(确定性:按 seed 洗牌) */
shuffle(seed?: number): void;
/** 接下来 n 次请求返回失败(如 503 + Retry-After) */
failNext(n: number, failure: MockFailure): void;
/** 直接返回原始 XML(用于 DTD/XXE/超大响应等恶意样本);传 null 恢复正常 */
setRawBody(xml: string | null): void;
/** 是否支持 ETag/Last-Modified 条件请求(默认 true) */
setConditional(enabled: boolean): void;
/** 统计:返回 304 的次数 */
readonly notModifiedCount: number;
}
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export function renderRss(title: string, items: MockFeedItem[]): string {
const body = items
.map((it) => {
const parts = [
`<title>${esc(it.title)}</title>`,
`<guid isPermaLink="false">${esc(it.guid)}</guid>`,
it.link ? `<link>${esc(it.link)}</link>` : "",
it.enclosureUrl
? `<enclosure url="${esc(it.enclosureUrl)}" length="${it.size ?? 0}" type="application/x-bittorrent"/>`
: "",
it.pubDate ? `<pubDate>${it.pubDate.toUTCString()}</pubDate>` : "",
...(it.categories ?? []).map((c) => `<category>${esc(c)}</category>`),
it.description ? `<description><![CDATA[${it.description}]]></description>` : "",
];
return `<item>${parts.join("")}</item>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel><title>${esc(title)}</title>\n${body}\n</channel></rss>`;
}
export function renderAtom(title: string, items: MockFeedItem[]): string {
const body = items
.map((it) => {
const parts = [
`<id>${esc(it.guid)}</id>`,
`<title>${esc(it.title)}</title>`,
it.link ? `<link rel="alternate" href="${esc(it.link)}"/>` : "",
it.enclosureUrl ? `<link rel="enclosure" href="${esc(it.enclosureUrl)}" length="${it.size ?? 0}"/>` : "",
it.pubDate ? `<published>${it.pubDate.toISOString()}</published>` : "",
...(it.categories ?? []).map((c) => `<category term="${esc(c)}"/>`),
it.description ? `<summary>${esc(it.description)}</summary>` : "",
];
return `<entry>${parts.join("")}</entry>`;
})
.join("\n");
return `<?xml version="1.0" encoding="utf-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom"><title>${esc(title)}</title>\n${body}\n</feed>`;
}
/** 可编程 mock RSS/Atom 服务器:ETag/Last-Modified/304、乱序/置顶/更新、5xx + Retry-After。 */
export async function createMockRssServer(opts: MockRssOptions = {}): Promise<MockRssServer> {
const path = opts.path ?? "/rss.xml";
const title = opts.title ?? "Mock Feed";
let items = [...(opts.items ?? [])];
let version = 1;
let lastModified = new Date(Date.UTC(2026, 8, 1));
const failures: MockFailure[] = [];
let raw: string | null = null;
let conditional = true;
let notModified = 0;
const bump = () => {
version++;
lastModified = new Date(lastModified.getTime() + 60_000);
};
const server = await startHttpServer((req, res) => {
const url = new URL(req.url ?? "/", "http://mock");
if (url.pathname !== path) {
res.writeHead(404).end("not found");
return;
}
if (opts.passkey && url.searchParams.get("passkey") !== opts.passkey) {
res.writeHead(403, { "content-type": "text/plain" }).end("invalid passkey");
return;
}
const failure = failures.shift();
if (failure) {
const headers: Record<string, string> = { "content-type": "text/plain" };
if (failure.retryAfter !== undefined) headers["retry-after"] = failure.retryAfter;
res.writeHead(failure.status, headers).end(failure.body ?? `mock failure ${failure.status}`);
return;
}
const body = raw ?? (opts.format === "atom" ? renderAtom(title, items) : renderRss(title, items));
const etag = `"${createHash("sha1").update(body).digest("hex").slice(0, 16)}-${version}"`;
const lm = lastModified.toUTCString();
if (conditional) {
const inm = req.headers["if-none-match"];
const ims = req.headers["if-modified-since"];
if ((inm && inm === etag) || (!inm && ims && Date.parse(ims) >= lastModified.getTime())) {
notModified++;
res.writeHead(304, { etag, "last-modified": lm }).end();
return;
}
}
const headers: Record<string, string> = {
"content-type":
opts.format === "atom" ? "application/atom+xml; charset=utf-8" : "application/rss+xml; charset=utf-8",
};
if (conditional) {
headers.etag = etag;
headers["last-modified"] = lm;
}
res.writeHead(200, headers).end(body);
});
const feedUrl = `${server.baseUrl}${path}${opts.passkey ? `?passkey=${encodeURIComponent(opts.passkey)}` : ""}`;
return {
...server,
feedUrl,
get items() {
return items;
},
setItems(next) {
items = [...next];
bump();
},
addItem(item) {
items = [item, ...items];
bump();
},
updateItem(guid, patch) {
items = items.map((it) => (it.guid === guid ? { ...it, ...patch, guid } : it));
bump();
},
pinItem(guid) {
const it = items.find((i) => i.guid === guid);
if (!it) return;
items = [it, ...items.filter((i) => i.guid !== guid)];
bump();
},
shuffle(seed = 42) {
let s = seed;
const rnd = () => {
s = (s * 1103515245 + 12345) % 2147483648;
return s / 2147483648;
};
const arr = [...items];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
}
items = arr;
bump();
},
failNext(n, failure) {
for (let i = 0; i < n; i++) failures.push(failure);
},
setRawBody(xml) {
raw = xml;
bump();
},
setConditional(enabled) {
conditional = enabled;
},
get notModifiedCount() {
return notModified;
},
};
}