Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
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.map((x: { name: string }) => x.name).sort()).toEqual(["1080p", "默认版本"].sort());
|
|
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]);
|
|
});
|
|
});
|