import { makeTorrent } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts"; // 规则 API:订阅/Slot/策略版本(发布=持续授权、回滚)/偏好标签抑制;新作品收录走已发布策略;outbox 路由。 const hasPg = await pgAvailable(); describe("outbox 路由", () => { const routes = createAcquisitionOrganizeModule().outboxRoutes!({} as never); it("release.resolved → evaluateAcquisition;只有下载完成才建 verifyAndImport", () => { const ev = (type: string, payload: unknown) => ({ id: "1", type, payload, at: new Date() }); expect( routes["release.resolved"]!(ev("release.resolved", { sourceReleaseId: "a", contentUnitIds: ["u"] })), ).toEqual({ task: "evaluateAcquisition", input: { sourceReleaseId: "a", contentUnitIds: ["u"] }, }); expect( routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "completed" })), ).toEqual({ task: "verifyAndImport", input: { downloadTaskId: "d" }, }); expect( routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "paused" })), ).toBeNull(); }); }); describe.skipIf(!hasPg)("规则 API(真实 PG)", () => { let h: Harness; const disk = fakeDisk(1_000_000_000); beforeAll(async () => { disk.setPct(10); h = await createHarness("rules", { diskUsage: disk.fn }); }, 60_000); afterAll(async () => { await h?.close(); }); it("订阅与 Slot:缺省季范围、同名冲突、revision 乐观锁", async () => { const e = await seedEntity(h.db, { kind: "series", title: "追更剧", year: 2025 }); const sub = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series" }); expect(sub.status).toBe(201); expect(sub.body.data).toMatchObject({ mode: "continuous", seasonScope: "following_seasons", origin: "user", // 剧集没有已接受的 TMDB 身份 → 总集数无来源:pending(照常搜索/评估,不能完成) status: "pending", statusReason: "metadata_pending", }); const once = await h.api("PATCH", `/subscriptions/${sub.body.data.id}`, { seasonScope: "current_season", params: { stopAfter: "2030-01-01T00:00:00Z" }, }); expect(once.body.data.seasonScope).toBe("current_season"); expect((await h.api("POST", "/subscriptions", { mediaKind: "movie" })).status).toBe(422); const s = await h.api("POST", "/acquisition-slots", { mediaEntityId: e, subscriptionId: sub.body.data.id, name: "1080p", hardConditions: { resolutions: ["1080p"] }, }); expect(s.status).toBe(201); expect(s.body.data).toMatchObject({ status: "empty", revision: 1 }); expect((await h.api("POST", "/acquisition-slots", { mediaEntityId: e, name: "1080p" })).status).toBe(409); expect( ( await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, { expectedRevision: 9, softPreferences: { hdr: ["DV"] }, }) ).status, ).toBe(409); const ok = await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, { expectedRevision: 1, softPreferences: { hdr: ["DV"] }, }); expect(ok.body.data).toMatchObject({ revision: 2, softPreferences: { hdr: ["DV"] } }); const list = await h.api("GET", `/acquisition-slots?mediaEntityId=${e}`); expect(list.body.data.items).toHaveLength(1); expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true); }); it("策略版本:草案 → 发布(持续授权)→ 新版本发布(旧授权吊销)→ 回滚(新版本号,内容同目标)", async () => { const bad = await h.api("POST", "/policy-revisions", { policyKey: "acq", kind: "acquisition", title: "x", rules: { newTitle: { enabled: "yes" } }, }); expect(bad.status).toBe(422); const rules = { defaultSlots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }], newTitle: { enabled: true, windowDays: 60, mediaKinds: ["movie"], slots: [{ name: "新片", hardConditions: { resolutions: ["1080p", "2160p"] } }], }, }; const r1 = await h.api("POST", "/policy-revisions", { policyKey: "acq", kind: "acquisition", title: "默认收录", rules, }); expect(r1.body.data).toMatchObject({ revision: 1, status: "draft" }); expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/preview`)).body.data.preview).toMatchObject({ valid: true, templates: 2, }); expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 2 })).status).toBe( 409, ); const p1 = await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 1 }); expect(p1.body.data.status).toBe("published"); expect((await h.api("PATCH", `/policy-revisions/${r1.body.data.id}`, { title: "改" })).status).toBe(409); const g1 = await h.db .selectFrom("automation_grant") .selectAll() .where("policy_revision_id", "=", r1.body.data.id) .executeTakeFirstOrThrow(); expect(g1).toMatchObject({ status: "active", action: "policy.acquisition" }); const r2 = await h.api("POST", "/policy-revisions", { policyKey: "acq", kind: "acquisition", title: "只要 4K", rules: { defaultSlots: [{ name: "4k", hardConditions: { resolutions: ["2160p"] } }] }, }); expect(r2.body.data.revision).toBe(2); await h.api("POST", `/policy-revisions/${r2.body.data.id}/publish`, { expectedRevision: 2 }); expect((await h.api("GET", `/policy-revisions/${r1.body.data.id}`)).body.data.status).toBe("superseded"); expect( (await h.db.selectFrom("automation_grant").select("status").where("id", "=", g1.id).executeTakeFirstOrThrow()) .status, ).toBe("revoked"); const rb = await h.api("POST", `/policy-revisions/${r1.body.data.id}/rollback`); expect(rb.body.data).toMatchObject({ revision: 3, status: "published", basedOnRevisionId: r1.body.data.id, rules }); expect((await h.api("GET", `/policy-revisions/${r2.body.data.id}`)).body.data.status).toBe("rolled_back"); const active = await h.db .selectFrom("automation_grant") .select("policy_revision_id") .where("status", "=", "active") .execute(); expect(active.map((a) => a.policy_revision_id)).toEqual([rb.body.data.id]); // 订阅没有 Slot 时套用已发布策略的缺省 Slot const e = await seedEntity(h.db, { kind: "movie", title: "套模板", year: 2025 }); await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" }); const slots = await h.db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", e).execute(); expect(slots.map((s) => s.name)).toEqual(["1080p"]); }); it("新作品收录:库里没有、首映在窗口内 → 按已发布策略建订阅与 Slot 并预留计划;旧片不算新片", async () => { const base = await seedBase(h.db, { libraryPath: "/tmp/nonexistent-lib", cachePath: "/tmp", qbUrl: "http://127.0.0.1:9", }); const recent = new Date(Date.now() - 5 * 86_400_000).toISOString().slice(0, 10); const fresh = ( await h.db .insertInto("media_entity") .values({ kind: "movie", title: "新片", year: 2026, status: "active", metadata: JSON.stringify({ releaseDate: recent }), }) .returning("id") .executeTakeFirstOrThrow() ).id; const t = makeTorrent({ name: "Fresh.2026.2160p.WEB-DL.mkv", length: 1000, pieceSeed: 3 }); const rel = await seedRelease(h.db, { siteId: base.siteId, entityId: fresh, title: "Fresh.2026.2160p.WEB-DL.H265-NEW", size: 1000, hash: t.hash, }); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel }); expect(ev.plans).toHaveLength(1); const sub = await h.db .selectFrom("subscription") .selectAll() .where("media_entity_id", "=", fresh) .executeTakeFirstOrThrow(); expect(sub).toMatchObject({ origin: "policy", mode: "once", priority: "new_work" }); const plan = await h.db .selectFrom("acquisition_plan") .selectAll() .where("id", "=", ev.plans[0]!) .executeTakeFirstOrThrow(); expect(plan).toMatchObject({ status: "reserved", priority: "new_work", purpose: "fill" }); expect((plan.evaluation as { mode: string }).mode).toBe("new_title"); const old = await seedEntity(h.db, { kind: "movie", title: "老片", year: 1999 }); const t2 = makeTorrent({ name: "Old.1999.1080p.mkv", length: 1000, pieceSeed: 4 }); const rel2 = await seedRelease(h.db, { siteId: base.siteId, entityId: old, title: "Old.1999.1080p.BluRay-OLD", size: 1000, hash: t2.hash, }); const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 }); expect(ev2.plans).toHaveLength(0); expect(ev2.skipped[0]?.reason).toBe("new_title:not_new_release"); // 总开关关闭:不建计划 await h.setSettings("automation", { enabled: false }); const t3 = makeTorrent({ name: "Fresh2.mkv", length: 1000, pieceSeed: 5 }); const rel3 = await seedRelease(h.db, { siteId: base.siteId, entityId: fresh, title: "Fresh.2026.1080p.WEB-DL.H264-NEW", size: 1000, hash: t3.hash, }); const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: rel3 }); expect(ev3.plans).toHaveLength(0); await h.setSettings("automation", { enabled: true }); }); it("偏好标签:删除/停用写抑制记录;Agent 不能据旧证据加回;用户重新添加解除抑制", async () => { const tag = { label: "不收恐怖片", kind: "hard", scope: { genres: ["horror"] }, rule: { exclude: true } }; const c = await h.api("POST", "/preference-tags", tag); expect(c.status).toBe(201); expect((await h.api("POST", "/preference-tags", tag)).status).toBe(409); expect((await h.api("DELETE", `/preference-tags/${c.body.data.id}`)).status).toBe(200); const sup = await h.api("GET", "/preference-suppressions?active=true"); expect(sup.body.data.items).toHaveLength(1); expect(sup.body.data.items[0].fingerprint).toBe(c.body.data.fingerprint); expect((await h.api("POST", "/preference-tags", { ...tag, origin: "agent_inferred" })).status).toBe(409); const again = await h.api("POST", "/preference-tags", { ...tag, origin: "manual" }); expect(again.status).toBe(201); expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(0); const off = await h.api("PATCH", `/preference-tags/${again.body.data.id}`, { status: "disabled" }); expect(off.body.data).toMatchObject({ status: "disabled", revision: 2 }); expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(1); const list = await h.api("GET", "/preference-tags"); expect(list.body.data.items.map((t: { id: string }) => t.id)).toEqual([again.body.data.id]); }); });