import { createMockRssServer, type MockFeedItem, type MockRssServer } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { applyFeedItems, buildRssLinkRequest, computeBackoffMs, createRssFetcher, emptyFeedCursor, extractRssLink, FeedError, type FeedFetchResult, initialFeedState, parseFeed, parseRetryAfter, redactText, redactUrl, resetFeedCircuit, unredactUrl, } from "./index.ts"; const PASSKEY = "abcdef0123456789secret"; function sample(n: number, host = "https://pt.example"): MockFeedItem[] { return Array.from({ length: n }, (_, i) => ({ guid: `${host}/details.php?id=${1000 + i}`, title: `Show.S01E${String(i + 1).padStart(2, "0")}.2160p.WEB-DL.H265-GRP`, link: `${host}/details.php?id=${1000 + i}`, enclosureUrl: `${host}/download.php?id=${1000 + i}&passkey=${PASSKEY}`, size: 1_000_000 * (i + 1), pubDate: new Date(Date.UTC(2026, 8, 20, 12, i)), categories: ["402"], })); } function okFeed(r: FeedFetchResult) { if (r.kind !== "ok") throw new Error(`expected ok, got ${r.kind}`); return r.feed; } let server: MockRssServer | undefined; afterEach(async () => { await server?.close(); server = undefined; }); describe("parseFeed 安全解析", () => { it("解析 RSS 2.0 字段并脱敏 enclosure 中的 passkey", () => { const xml = `T & F Dune & Part.Two.2024.2160phttps://pt.example/details.php?id=1 https://pt.example/details.php?id=1 Sat, 26 Sep 2026 13:14:03 GMT401电影 `; const feed = parseFeed(xml, { fetchedAt: new Date("2026-09-27T00:00:00Z") }); expect(feed.format).toBe("rss"); expect(feed.title).toBe("T & F"); const item = feed.items[0]!; expect(item.title).toBe("Dune & Part.Two.2024.2160p"); expect(item.guid).toBe("https://pt.example/details.php?id=1"); expect(item.enclosureUrl).toBe("https://pt.example/download.php?id=1&passkey=[REDACTED]"); expect(item.enclosureHasSecret).toBe(true); expect(item.size).toBe(84202833838); expect(item.publishedAt).toBe("2026-09-26T13:14:03.000Z"); expect(item.categories).toEqual(["401", "电影"]); expect(item.rawDigest).toMatch(/^[0-9a-f]{64}$/); expect(item.fetchedAt).toBe("2026-09-27T00:00:00.000Z"); expect(JSON.stringify(feed)).not.toContain(PASSKEY); }); it("解析 Atom(MP 语义:enclosure 缺失回退首个 link)", () => { const xml = `A urn:x:1X 2026-09-01T00:00:00Z `; const feed = parseFeed(xml); expect(feed.format).toBe("atom"); expect(feed.items[0]).toMatchObject({ guid: "urn:x:1", link: "https://a/1", enclosureUrl: "https://a/1", size: 0, categories: ["tv"], }); }); it("拒绝外部实体(XXE)", () => { const xml = `]> &xxe;`; expect(() => parseFeed(xml)).toThrowError(FeedError); try { parseFeed(xml); } catch (e) { expect((e as FeedError).kind).toBe("forbidden_dtd"); } }); it("拒绝实体膨胀(billion laughs)与内部 DTD 子集", () => { const lol = `]>&lol2;`; expect(() => parseFeed(lol)).toThrow(/ENTITY/); const subset = ` ]>`; expect(() => parseFeed(subset)).toThrow(/DTD/); }); it("条目数超过上限截断,非 Feed 报 not_feed", () => { const xml = `${Array.from({ length: 20 }, (_, i) => `g${i}t${i}https://x/${i}`).join("")}`; const feed = parseFeed(xml, { maxItems: 5 }); expect(feed.items).toHaveLength(5); expect(feed.truncated).toBe(true); expect(() => parseFeed("hi")).toThrow(/不是 RSS/); }); }); describe("MP RssHelper 条目语义", () => { it("无标题跳过、无 enclosure 且无 link 跳过、creator → nickname、过期提示识别", () => { const xml = ` https://x/1 no link okhttps://x/3豆友Sat, 26 Sep 2026 13:14:03 GMT `; const feed = parseFeed(xml); expect(feed.items).toHaveLength(1); expect(feed.items[0]).toMatchObject({ title: "ok", enclosureUrl: "https://x/3", nickname: "豆友", size: 0 }); expect(() => parseFeed("RSS Link has expired, You need to get a new one!")).toThrow(/过期/); expect(() => parseFeed("not xml")).toThrow(/不是有效的 XML/); }); }); describe("站点 RSS 地址获取(MP get_rss_link)", () => { it("观众使用专用参数,默认站点回退 default;取最后一个 faqlink", () => { const req = buildRssLinkRequest("https://audiences.me/"); expect(req.url).toBe("https://audiences.me/getrss.php"); expect(req.form).toMatchObject({ torrent_type: "1", exp: "180", showrows: "50" }); expect(buildRssLinkRequest("https://pt.example.org").form.torrent_type).toBeUndefined(); const html = `12`; expect(extractRssLink("https://audiences.me/", html)).toContain("torrentrss.php"); }); }); describe("redact", () => { it("redactUrl/redactText/unredactUrl", () => { const u = `https://user:pw@pt.example/rss.php?rows=50&passkey=${PASSKEY}&linktype=dl`; const r = redactUrl(u); expect(r).toBe("https://pt.example/rss.php?rows=50&passkey=[REDACTED]&linktype=dl"); expect(redactText(`fetch failed for ${u}: ECONNRESET`)).not.toContain(PASSKEY); expect(redactText(`fetch failed for ${u}`)).not.toContain("pw@"); expect(unredactUrl(r, { passkey: PASSKEY })).toBe( `https://pt.example/rss.php?rows=50&passkey=${PASSKEY}&linktype=dl`, ); expect(redactUrl("https://api.m-team.cc/api/rss/dlv2?sign=abc&t=1&tid=9")).toContain("sign=[REDACTED]"); }); }); describe("createRssFetcher + mock RSS", () => { it("ETag/Last-Modified 条件请求命中 304", async () => { server = await createMockRssServer({ items: sample(3), passkey: PASSKEY }); const fetcher = createRssFetcher(); const r1 = await fetcher.fetchFeed(server.feedUrl); expect(okFeed(r1).items).toHaveLength(3); expect(r1.state.etag).toBeTruthy(); const r2 = await fetcher.fetchFeed(server.feedUrl, r1.state); expect(r2.kind).toBe("not_modified"); expect(server.notModifiedCount).toBe(1); expect(server.requests[1]!.headers["if-none-match"]).toBe(r1.state.etag); server.addItem(sample(1, "https://other.example")[0]!); const r3 = await fetcher.fetchFeed(server.feedUrl, r2.state); expect(okFeed(r3).items).toHaveLength(4); }); it("5xx + Retry-After:退避窗口取 max(指数退避, Retry-After),窗口内跳过", async () => { server = await createMockRssServer({ items: sample(2) }); let now = new Date("2026-09-27T00:00:00Z"); const fetcher = createRssFetcher({ now: () => now, backoff: { baseMs: 10_000, maxMs: 3600_000 } }); server.failNext(1, { status: 503, retryAfter: "120" }); const r1 = await fetcher.fetchFeed(server.feedUrl); expect(r1.kind).toBe("error"); if (r1.kind !== "error") return; expect(r1.error.kind).toBe("rate_limited"); expect(r1.error.retryAfterMs).toBe(120_000); expect(r1.state.nextAttemptAt).toBe("2026-09-27T00:02:00.000Z"); const r2 = await fetcher.fetchFeed(server.feedUrl, r1.state); expect(r2).toMatchObject({ kind: "skipped", reason: "backoff" }); expect(server.requests).toHaveLength(1); now = new Date("2026-09-27T00:02:01Z"); const r3 = await fetcher.fetchFeed(server.feedUrl, r2.state); expect(r3.kind).toBe("ok"); expect(r3.state.consecutiveFailures).toBe(0); expect(r3.state.nextAttemptAt).toBeUndefined(); }); it("computeBackoffMs 指数增长封顶,parseRetryAfter 支持 HTTP-date", () => { expect([1, 2, 3, 4].map((n) => computeBackoffMs(n, 1000, 5000))).toEqual([1000, 2000, 4000, 5000]); const now = new Date("2026-09-27T00:00:00Z"); expect(parseRetryAfter("Sun, 27 Sep 2026 00:00:30 GMT", now)).toBe(30_000); expect(parseRetryAfter("garbage", now)).toBeUndefined(); }); it("连续失败 N 次进入熔断,手动恢复后可继续抓取", async () => { server = await createMockRssServer({ items: sample(1) }); const fetcher = createRssFetcher({ circuitThreshold: 3, backoff: { baseMs: 0, maxMs: 0 } }); server.failNext(3, { status: 500 }); let state = initialFeedState(); for (let i = 0; i < 3; i++) state = (await fetcher.fetchFeed(server.feedUrl, state)).state; expect(state.circuit).toBe("open"); expect(state.consecutiveFailures).toBe(3); const skipped = await fetcher.fetchFeed(server.feedUrl, state, { ignoreBackoff: true }); expect(skipped).toMatchObject({ kind: "skipped", reason: "circuit_open" }); expect(server.requests).toHaveLength(3); const ok = await fetcher.fetchFeed(server.feedUrl, resetFeedCircuit(state)); expect(ok.kind).toBe("ok"); expect(ok.state.circuit).toBe("closed"); }); it("熔断冷却后半开探测", async () => { server = await createMockRssServer({ items: sample(1) }); let now = new Date("2026-09-27T00:00:00Z"); const fetcher = createRssFetcher({ now: () => now, circuitThreshold: 1, circuitCooldownMs: 60_000 }); server.failNext(1, { status: 502 }); const r1 = await fetcher.fetchFeed(server.feedUrl); expect(r1.state.circuit).toBe("open"); expect((await fetcher.fetchFeed(server.feedUrl, r1.state)).kind).toBe("skipped"); now = new Date("2026-09-27T00:01:00Z"); expect((await fetcher.fetchFeed(server.feedUrl, r1.state)).kind).toBe("ok"); }); it("错误信息与状态中不含 passkey(HTTP 错误与网络错误)", async () => { server = await createMockRssServer({ items: sample(1), passkey: "the-right-one" }); const fetcher = createRssFetcher(); const wrong = `${server.baseUrl}/rss.xml?passkey=${PASSKEY}`; const r = await fetcher.fetchFeed(wrong); expect(r.kind).toBe("error"); expect(JSON.stringify(r)).not.toContain(PASSKEY); if (r.kind === "error") expect(r.error.message).toContain("[REDACTED]"); const dead = `http://127.0.0.1:1/rss.xml?passkey=${PASSKEY}`; const r2 = await fetcher.fetchFeed(dead); expect(r2.kind).toBe("error"); expect(JSON.stringify(r2)).not.toContain(PASSKEY); }); it("响应体超过上限拒绝;恶意 DTD 响应计为失败", async () => { server = await createMockRssServer({ items: sample(1) }); server.setRawBody(`${"x".repeat(10_000)}`); const fetcher = createRssFetcher({ maxBytes: 1024 }); const r = await fetcher.fetchFeed(server.feedUrl); expect(r.kind === "error" && r.error.kind).toBe("too_large"); server.setRawBody(`]>`); const r2 = await createRssFetcher().fetchFeed(server.feedUrl); expect(r2.kind === "error" && r2.error.kind).toBe("forbidden_dtd"); expect(r2.state.consecutiveFailures).toBe(1); }); }); describe("重叠窗口去重", () => { it("乱序/置顶不产生新条目,同 GUID 内容变化视为更新", async () => { server = await createMockRssServer({ items: sample(5) }); server.setConditional(false); const fetcher = createRssFetcher(); const a = applyFeedItems(emptyFeedCursor(), okFeed(await fetcher.fetchFeed(server.feedUrl)).items); expect(a.newItems).toHaveLength(5); server.shuffle(7); server.pinItem("https://pt.example/details.php?id=1003"); const b = applyFeedItems(a.cursor, okFeed(await fetcher.fetchFeed(server.feedUrl)).items); expect(b.newItems).toHaveLength(0); expect(b.updatedItems).toHaveLength(0); expect(b.unchangedCount).toBe(5); server.updateItem("https://pt.example/details.php?id=1001", { title: "Show.S01E02.REPACK.2160p.WEB-DL.H265-GRP" }); server.addItem({ guid: "https://pt.example/details.php?id=2000", title: "New.Movie.2026.1080p", link: "https://pt.example/details.php?id=2000", }); const c = applyFeedItems(b.cursor, okFeed(await fetcher.fetchFeed(server.feedUrl)).items); expect(c.newItems.map((i) => i.guid)).toEqual(["https://pt.example/details.php?id=2000"]); expect(c.updatedItems.map((i) => i.title)).toEqual(["Show.S01E02.REPACK.2160p.WEB-DL.H265-GRP"]); expect(c.possibleGap).toBe(false); expect(c.cursor.lastGuidSet).toHaveLength(6); }); it("与上次窗口零重叠标记疑似缺口;窗口按上限裁剪", () => { const mk = (g: string) => ({ guid: g, title: g, link: null, size: 0, categories: [], rawDigest: g, fetchedAt: "2026-09-27T00:00:00.000Z", }); const a = applyFeedItems(emptyFeedCursor(), [mk("a"), mk("b"), mk("c")], { windowSize: 2 }); expect(a.possibleGap).toBe(false); expect(a.cursor.lastGuidSet).toEqual(["a", "b"]); expect(Object.keys(a.cursor.digests)).toEqual(["a", "b"]); const b = applyFeedItems(a.cursor, [mk("x"), mk("y")]); expect(b.possibleGap).toBe(true); expect(b.cursor.lastFetchedAt).toBe("2026-09-27T00:00:00.000Z"); }); });