import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { applySeasonTotals } from "../../src/modules/sources-identity/service/metadata-totals.ts"; import { searchSubscription } from "../../src/modules/sources-identity/tasks.ts"; import { createHarness, type Harness, json, pgAvailable } from "./harness.ts"; // W5-C 订阅主动搜索(mock NexusPHP 站点:适配器 search + 限流 + 快照)与季总集数账本(refreshMetadata 的写入部分)。 const up = await pgAvailable(); describe.skipIf(!up)("订阅主动搜索与季总集数", () => { let h: Harness; let mock: MockNexusPhpSite; let siteId: string; beforeAll(async () => { mock = await createMockNexusPhpSite(); h = await createHarness("subsearch"); const ok = await json( await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/`, rateLimit: { minIntervalMs: 0, concurrency: 4 }, }, }), ); expect(ok.status).toBe(200); siteId = ok.body.data.id; await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: mock.cookie } }); }); afterAll(async () => { await h?.close(); await mock?.close(); }); const entity = async (title: string, kind: "movie" | "series" = "movie") => ( await h.db .insertInto("media_entity") .values({ kind, title, original_title: title, year: 2021, status: "active" }) .returning("id") .executeTakeFirstOrThrow() ).id; const subscribe = async (entityId: string, extra: Record = {}) => ( await h.db .insertInto("subscription") .values({ media_entity_id: entityId, media_kind: "movie", mode: "once", ...extra }) .returning("id") .executeTakeFirstOrThrow() ).id; it("按标题在已开启站点搜索:结果 upsert 为 source_release(新结果发 release.observed),排下次搜索并入队订阅重评", async () => { const e = await entity("Dune"); const subId = await subscribe(e); const searches = () => mock.requests.filter((r) => r.path.includes("search=")).length; const before = searches(); const r = await searchSubscription(h.rt, subId); expect(r).toMatchObject({ status: "ok", keywords: ["Dune"], sites: [siteId], searches: 1, errors: [] }); expect(searches() - before).toBe(1); const rels = await h.db .selectFrom("source_release") .select(["id", "title"]) .where("site_id", "=", siteId) .execute(); expect(rels.some((x) => /Dune/i.test(x.title))).toBe(true); expect((await h.outbox("release.observed")).length).toBeGreaterThanOrEqual(1); const snap = await h.db .selectFrom("site_snapshot") .selectAll() .where("query", "=", "Dune") .executeTakeFirstOrThrow(); expect(snap.kind).toBe("live_search"); const sub = await h.db.selectFrom("subscription").selectAll().where("id", "=", subId).executeTakeFirstOrThrow(); expect(sub.last_searched_at).not.toBeNull(); expect(sub.next_search_at!.getTime() - sub.last_searched_at!.getTime()).toBe(24 * 3600_000); expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "evaluateAcquisition", input: { subscriptionId: subId } }); }); it("站点范围外不搜;暂停订阅只允许手动;searchIntervalHours=0 关闭周期", async () => { const e = await entity("Bear"); const scoped = await subscribe(e, { params: JSON.stringify({ siteIds: ["00000000-0000-4000-8000-000000000000"] }), }); expect(await searchSubscription(h.rt, scoped)).toMatchObject({ status: "ok", sites: [], searches: 0 }); const paused = await subscribe(e, { status: "paused", params: JSON.stringify({ searchIntervalHours: 0 }) }); expect(await searchSubscription(h.rt, paused)).toMatchObject({ status: "skipped", reason: "subscription_paused" }); const manual = await searchSubscription(h.rt, paused, { manual: true }); expect(manual).toMatchObject({ status: "ok", searches: 1, nextSearchAt: null }); }); it("季总集数:TMDB 集数写季单元(S00 不计、未公布保持未知);回退接受但被已覆盖最高集兜底;锁定不改;pending 订阅转 active", async () => { const e = await entity("Series T", "series"); const sub = await subscribe(e, { media_kind: "series", status: "pending", status_reason: "metadata_pending" }); const now = new Date(); const r1 = await h.db.transaction().execute((trx) => applySeasonTotals( trx, e, [ { seasonNumber: 0, episodeCount: 3, name: "特别篇", airDate: null }, { seasonNumber: 1, episodeCount: 8, name: "第 1 季", airDate: null }, { seasonNumber: 2, episodeCount: 0, name: "第 2 季", airDate: null }, ], now, ), ); expect(r1.seasons).toEqual([ { season: 1, total: 8, known: true, changed: true }, { season: 2, total: null, known: false, changed: false }, ]); expect(r1.activated).toBe(1); expect( (await h.db.selectFrom("subscription").select("status").where("id", "=", sub).executeTakeFirstOrThrow()).status, ).toBe("active"); const r2 = await h.db .transaction() .execute((trx) => applySeasonTotals(trx, e, [{ seasonNumber: 1, episodeCount: 12, name: null, airDate: null }], now), ); expect(r2.seasons[0]).toMatchObject({ total: 12, changed: true }); await h.db .updateTable("content_unit") .set({ episode_total_locked: true }) .where("media_entity_id", "=", e) .where("season_number", "=", 1) .execute(); const r3 = await h.db .transaction() .execute((trx) => applySeasonTotals(trx, e, [{ seasonNumber: 1, episodeCount: 20, name: null, airDate: null }], now), ); expect(r3.seasons[0]).toMatchObject({ total: 12, changed: false }); const units = await h.db .selectFrom("content_unit") .select(["kind", "season_number", "episode_total", "episode_total_source"]) .where("media_entity_id", "=", e) .orderBy("season_number") .execute(); // 只建季单元,不预建集单元 expect(units).toEqual([ { kind: "season", season_number: 1, episode_total: 12, episode_total_source: "tmdb" }, { kind: "season", season_number: 2, episode_total: null, episode_total_source: null }, ]); }); });