import { SITE_KINDS, siteCapabilities } from "@umt/contracts"; import { createMockRssServer, type MockRssServer } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { tickFeeds, tickSnapshots } from "../../src/modules/sources-identity/scheduler.ts"; import { ingestFeed } from "../../src/modules/sources-identity/service/feeds.ts"; import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts"; import { capabilitiesOf, hostSlug } from "../../src/modules/sources-identity/site-kinds.ts"; import { createHarness, type Harness, json, pgAvailable, seedFeed, seedSite } from "./harness.ts"; // IA-2 站点类型:类型声明获取方式,实际可用 = 类型 ∩ 站点定义;调度 / 搜索 / 登录按 capabilities 判断。 describe("站点类型与获取方式(纯函数)", () => { it("实际可用 = 类型允许的 ∩ 定义实现的;RSS 不依赖定义;torznab 未实现为空", () => { const def = { search: true, list: true, detail: false }; expect(siteCapabilities("pt", def)).toEqual(["search", "list", "rss", "login", "userinfo"]); expect(siteCapabilities("pt", null)).toEqual(["rss"]); expect(siteCapabilities("public_bt", def)).toEqual(["search", "list", "rss"]); expect(siteCapabilities("public_bt", null)).toEqual(["rss"]); expect(siteCapabilities("rss_feed", def)).toEqual(["rss"]); expect(siteCapabilities("torznab", def)).toEqual([]); expect(SITE_KINDS.torznab.implemented).toBe(false); expect(SITE_KINDS.rss_feed.capabilities).toEqual(["rss"]); }); it("generic_rss 站点不查定义;key 片段", () => { expect(capabilitiesOf({ kind: "pt", key: "audiences", adapter: "generic_rss" })).toEqual(["rss"]); expect(capabilitiesOf({ kind: "pt", key: "audiences", adapter: "nexusphp" })).toContain("login"); expect(hostSlug("www.Nyaa.si:8443")).toBe("nyaa-si-8443"); expect(hostSlug("")).toBe("feed"); }); }); const up = await pgAvailable(); describe.skipIf(!up)("站点类型:API、调度与 RSS", () => { let h: Harness; let rss: MockRssServer; beforeAll(async () => { h = await createHarness("kinds"); rss = await createMockRssServer({ items: [ { guid: "nyaa-1", title: "[SubsPlease] Frieren - 12 (1080p) [ABCDEF12].mkv", link: "https://nyaa.example/view/1812345", enclosureUrl: "https://nyaa.example/download/1812345.torrent", size: 1_400_000_000, pubDate: new Date("2026-09-20T08:00:00Z"), categories: ["Anime"], }, ], }); }); afterAll(async () => { await rss?.close(); await h?.close(); }); it("添加自定义 RSS 源:一个事务建站点 + RSS,key 按主机生成,不参与默认搜索", async () => { const r = await json( await h.req("/sites", { body: { kind: "rss_feed", adapter: "generic_rss", name: "Nyaa", baseUrl: "https://nyaa.example/", rssUrl: rss.feedUrl, }, }), ); expect(r.status).toBe(200); expect(r.body.data).toMatchObject({ kind: "rss_feed", capabilities: ["rss"], searchDefault: false, snapshotEnabled: false, }); expect(r.body.data.key).toMatch(/^rss-/); const feeds = await json(await h.req(`/feed-sources?siteId=${r.body.data.id}`)); expect(feeds.body.data.items).toHaveLength(1); expect(feeds.body.data.items[0]).toMatchObject({ name: "Nyaa RSS", siteId: r.body.data.id }); // 抓取:自定义 RSS 源的条目按站点聚合成资源(以前无站点的 Feed 只留观察) const res = await ingestFeed(h.rt, feeds.body.data.items[0].id); expect(res).toMatchObject({ status: "ok", newReleases: 1 }); const rel = await h.db.selectFrom("source_release").selectAll().where("site_id", "=", r.body.data.id).execute(); expect(rel).toHaveLength(1); expect(rel[0]!.title).toContain("Frieren"); }); it("不支持的获取方式 400:自定义 RSS 源不能登录 / 填凭证 / 抓列表;torznab 不能添加;PT 站缺 key 400", async () => { const list = await json(await h.req("/sites")); const feedSite = list.body.data.items.find((s: { kind: string }) => s.kind === "rss_feed"); for (const [path, body] of [ [`/sites/${feedSite.id}/refresh-login`, {}], [`/sites/${feedSite.id}/snapshot`, {}], ] as const) { const res = await json(await h.req(path, { body })); expect(res.status, path).toBe(400); expect(res.body.error.message).toContain("自定义 RSS 源"); } const cred = await h.req(`/sites/${feedSite.id}/credentials`, { method: "PUT", body: { kind: "cookie", cookie: "c=1" }, }); expect(cred.status).toBe(400); const tz = await h.req("/sites", { body: { kind: "torznab", adapter: "generic_rss", name: "Jackett", baseUrl: "https://jackett.example/" }, }); expect(tz.status).toBe(400); const noKey = await h.req("/sites", { body: { kind: "pt", adapter: "nexusphp", name: "x", baseUrl: "https://x.example/" }, }); expect(noKey.status).toBe(400); const pubWrongAdapter = await h.req("/sites", { body: { kind: "public_bt", adapter: "nexusphp", name: "x", key: "audiences", baseUrl: "https://x.example/" }, }); expect(pubWrongAdapter.status).toBe(400); }); it("内置公开 BT 站:定义标为 public_bt;按定义添加用专用适配器,定时抓最新,不默认进搜索,不能登录", async () => { const defs = await json(await h.req("/site-definitions")); const items = defs.body.data.items as { key: string; kind: string; adapter: string }[]; const pub = ["nyaa", "mikan", "anibt", "animegarden", "bangumimoe"]; expect( items .filter((d) => d.kind === "public_bt") .map((d) => d.key) .sort(), ).toEqual([...pub].sort()); // 内置字幕站(SubHD、字幕库)也在定义列表里,类型 subtitle const subs = ["subhd", "zimuku"]; expect( items .filter((d) => d.kind === "subtitle") .map((d) => d.key) .sort(), ).toEqual(subs); expect(items.filter((d) => !pub.includes(d.key) && !subs.includes(d.key)).every((d) => d.kind === "pt")).toBe(true); const nyaa = await json( await h.req("/sites", { body: { key: "nyaa", name: "Nyaa", adapter: "api", baseUrl: "https://nyaa.si/" } }), ); expect(nyaa.status).toBe(200); expect(nyaa.body.data).toMatchObject({ kind: "public_bt", adapter: "api", snapshotEnabled: true, proxy: true, searchDefault: false, }); expect(nyaa.body.data.capabilities).toEqual(expect.arrayContaining(["search", "list"])); expect(nyaa.body.data.capabilities).not.toContain("login"); const wrong = await h.req("/sites", { body: { key: "mikan", kind: "public_bt", name: "蜜柑", adapter: "generic_rss", baseUrl: "https://mikanani.me/" }, }); expect(wrong.status).toBe(400); }); it("PT 站定义带登录与账号数据", async () => { const pt = await json( await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "https://audiences.invalid/" }, }), ); expect(pt.status).toBe(200); expect(pt.body.data.kind).toBe("pt"); expect(pt.body.data.capabilities).toEqual(expect.arrayContaining(["search", "list", "rss", "login", "userinfo"])); }); it("调度按获取方式:快照只排能抓列表的站点;Feed 只排能抓 RSS 的站点", async () => { const pt = await seedSite(h, { key: "hdhome", baseUrl: "https://hdhome.invalid/" }); // 自定义 RSS 源即使 snapshot_enabled 被写成 true 也不排快照 const feedOnly = await h.db .insertInto("site") .values({ key: "rss-only", name: "only", kind: "rss_feed", adapter: "generic_rss", base_url: "https://only.invalid/", snapshot_enabled: true, }) .returningAll() .executeTakeFirstOrThrow(); const tz = await h.db .insertInto("site") .values({ key: "tz", name: "tz", kind: "torznab", adapter: "generic_rss", base_url: "https://tz.invalid/" }) .returningAll() .executeTakeFirstOrThrow(); const tzFeed = await seedFeed(h, { siteId: tz.id, url: "https://tz.invalid/api?t=search" }); const okFeed = await seedFeed(h, { siteId: feedOnly.id, url: "https://only.invalid/rss" }); h.engine.enqueued.length = 0; await tickSnapshots(h.rt); const snapSites = h.engine.enqueued .filter((e) => e.task === "snapshotSite") .map((e) => (e.input as { siteId: string }).siteId); expect(snapSites).toContain(pt.site.id); expect(snapSites).not.toContain(feedOnly.id); await tickFeeds(h.rt); const due = (await h.outbox("feed.due")).map((e) => e.payload.feedId); expect(due).toContain(okFeed.id); expect(due).not.toContain(tzFeed.id); expect((await ingestFeed(h.rt, tzFeed.id)).reason).toBe("unsupported"); // 实时搜索 / 列表快照在不支持的站点上直接跳过,不请求站点 expect(await snapshotSite(h.rt, { siteId: feedOnly.id, kind: "live_search", query: "x" })).toMatchObject({ status: "skipped", reason: "unsupported", }); }); it("字幕站:只能从内置字幕站添加;不排快照、不进搜索、能力为空", async () => { const ok = await json( await h.req("/sites", { body: { kind: "subtitle", key: "zimuku", name: "字幕库", adapter: "subtitle", baseUrl: "https://zmk.pw/" }, }), ); expect(ok.status).toBe(200); expect(ok.body.data).toMatchObject({ kind: "subtitle", snapshotEnabled: false, searchDefault: false, capabilities: [], }); const bad = await h.req("/sites", { body: { kind: "subtitle", key: "random", name: "x", adapter: "subtitle", baseUrl: "https://x.example/" }, }); expect(bad.status).toBe(400); }); });