import { makeTorrent } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot, } from "../../src/modules/acquisition-organize/__tests__/harness.ts"; import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; // W9-D 规则组:关键字表、规则串解析/序列化、CRUD + 修订、抑制记录、试算、默认链、评估接入、旧端点别名。 const hasPg = await pgAvailable(); describe.skipIf(!hasPg)("规则组 API(真实 PG)", () => { let h: Harness; const disk = fakeDisk(1_000_000_000); let base: Awaited>; beforeAll(async () => { disk.setPct(10); h = await createHarness("rulegroups", { diskUsage: disk.fn }); base = await seedBase(h.db, { libraryPath: "/tmp/nonexistent-lib", cachePath: "/tmp", qbUrl: "http://127.0.0.1:9", }); }, 60_000); afterAll(async () => { await h?.close(); }); it("关键字表对齐 MP;规则串解析 / 序列化 / 错误位置", async () => { const kw = await h.api("GET", "/rules/keywords"); expect(kw.status).toBe(200); const byId = new Map(kw.body.data.items.map((k: { id: string }) => [k.id, k])); expect(byId.get("4K")).toMatchObject({ builtin: true, supported: true, basis: "field" }); expect(byId.get("CNSUB")).toMatchObject({ mp: { tmdb: { original_language: "zh,cn" } } }); expect(byId.get("FLAC")).toMatchObject({ supported: false }); const p = await h.api("POST", "/rules/parse", { ruleString: "specsub & cnsub > CNSUB & 4K > 1080P & !BLU" }); expect(p.status).toBe(200); expect(p.body.data.ruleString).toBe("SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU"); expect(p.body.data.layers).toHaveLength(3); const bad = await h.api("POST", "/rules/parse", { ruleString: "4K & CNSUBB > (1080P" }); expect(bad.body.data.ruleString).toBeNull(); expect(bad.body.data.errors[0]).toMatchObject({ code: "unknown_atom", position: 5, suggestions: ["CNSUB"] }); const ser = await h.api("POST", "/rules/serialize", { layers: p.body.data.layers }); expect(ser.body.data.ruleString).toBe("SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU"); }); it("默认三套组;CRUD + revision 乐观锁 + 修订历史;名称唯一", async () => { const defs = await h.api("GET", "/rules/defaults"); expect(defs.body.data.acquisition).toHaveLength(1); expect(defs.body.data.upgrade).toHaveLength(1); expect(defs.body.data.search).toHaveLength(1); const c = await h.api("POST", "/rules", { name: "4K 优先", ruleString: "4K > 1080P", hardExcludes: ["BLU"] }); expect(c.status).toBe(201); expect(c.body.data).toMatchObject({ kind: "acquisition", source: "manual", ruleString: "4K > 1080P", ladderRequired: true, revision: 1, hardExcludes: [{ op: "atom", id: "BLU" }], }); expect((await h.api("POST", "/rules", { name: "4K 优先" })).status).toBe(409); expect((await h.api("POST", "/rules", { name: "坏", ruleString: "4K & NOPE" })).status).toBe(422); const id = c.body.data.id; expect((await h.api("PATCH", `/rules/${id}`, { expectedRevision: 5, ruleString: "4K" })).status).toBe(409); const u = await h.api("PATCH", `/rules/${id}`, { expectedRevision: 1, ruleString: "4K & !REMUX > 1080P" }); expect(u.body.data).toMatchObject({ revision: 2, ruleString: "4K & !REMUX > 1080P" }); const rev = await h.api("GET", `/rules/${id}/revisions`); expect(rev.body.data.items.map((r: { action: string }) => r.action)).toEqual([ "rule_group.updated", "rule_group.created", ]); expect(rev.body.data.items[0].changedFields).toEqual(["layers"]); const list = await h.api("GET", "/rules?kind=acquisition"); expect(list.body.data.items.some((g: { id: string }) => g.id === id)).toBe(true); }); it("自定义条件:不能覆盖内置;被引用不能删", async () => { expect((await h.api("POST", "/rules/atoms", { key: "4K" })).status).toBe(409); expect((await h.api("POST", "/rules/atoms", { key: "BADRE", include: ["(a+)+"] })).status).toBe(422); const a = await h.api("POST", "/rules/atoms", { key: "sizemovie", unitSizeMb: { min: 2000, max: 30000 } }); expect(a.status).toBe(201); expect(a.body.data.key).toBe("SIZEMOVIE"); const g = await h.api("POST", "/rules", { name: "体积", ruleString: "SIZEMOVIE" }); expect(g.status).toBe(201); expect((await h.api("DELETE", `/rules/atoms/${a.body.data.id}`)).status).toBe(409); const kw = await h.api("GET", "/rules/keywords"); expect(kw.body.data.items.find((k: { id: string }) => k.id === "SIZEMOVIE")).toMatchObject({ builtin: false }); }); it("抑制:停用/删除写抑制记录;Agent 不能加回(409);手动新建解除", async () => { const body = { name: "不要 HDR", source: "agent", hardExcludes: ["HDR"] }; const c = await h.api("POST", "/rules", body); expect(c.status).toBe(201); expect((await h.api("POST", "/rules", { ...body, name: "不要 HDR 2" })).status).toBe(409); const off = await h.api("PATCH", `/rules/${c.body.data.id}`, { enabled: false }); expect(off.body.data.suppressedAt).not.toBeNull(); const sup = await h.api("GET", "/rules/suppressions?active=true"); expect(sup.body.data.items.map((s: { fingerprint: string }) => s.fingerprint)).toContain(c.body.data.fingerprint); expect((await h.api("DELETE", `/rules/${c.body.data.id}`)).status).toBe(200); expect((await h.api("POST", "/rules", { ...body, name: "又来" })).status).toBe(409); const again = await h.api("POST", "/rules", { ...body, name: "用户自己加", source: "manual" }); expect(again.status).toBe(201); const still = await h.api("GET", "/preference-suppressions?active=true"); expect(still.body.data.items.map((s: { fingerprint: string }) => s.fingerprint)).not.toContain( c.body.data.fingerprint, ); }); it("试算:最近资源 + 粘贴标题,给 pri / 命中层 / 拒绝原因", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "试算片", year: 2024 }); for (const [i, t] of [ "Try.2024.2160p.WEB-DL.H265-A", "Try.2024.1080p.BluRay.x264-B", "Try.2024.720p.WEB-DL.x264-C", ].entries()) await seedRelease(h.db, { siteId: base.siteId, entityId: e, title: t, size: 1000, hash: `${"ab".repeat(19)}${i}${i}`, }); const g = await h.api("POST", "/rules", { name: "试算组", ruleString: "4K > 1080P", hardExcludes: ["BLU"] }); const pv = await h.api("POST", `/rules/${g.body.data.id}/preview`, { limit: 20, titles: [{ title: "X.2023.1080p.Blu-ray.AVC.DTS-HD.MA-GRP" }], sourceReleaseIds: [], }); expect(pv.status).toBe(200); const byTitle = new Map(pv.body.data.items.map((i: { title: string }) => [i.title, i])); expect(byTitle.get("Try.2024.2160p.WEB-DL.H265-A")).toMatchObject({ passed: true, pri: 100, level: 0 }); expect(byTitle.get("Try.2024.1080p.BluRay.x264-B")).toMatchObject({ passed: true, pri: 99 }); expect(byTitle.get("Try.2024.720p.WEB-DL.x264-C")).toMatchObject({ passed: false, reasons: ["no_layer_matched"] }); expect(byTitle.get("X.2023.1080p.Blu-ray.AVC.DTS-HD.MA-GRP")).toMatchObject({ passed: false, excludedBy: ["BLU"], reasons: ["hard_exclude"], }); const draft = await h.api("POST", `/rules/${g.body.data.id}/preview`, { titles: [{ title: "Try.2024.720p.WEB-DL.x264-C" }], draft: { ruleString: "720P" }, }); expect(draft.body.data.items[0]).toMatchObject({ passed: true, pri: 100 }); }); it("评估接入:版本目标引用规则组 → 硬排除拒绝、pri 进候选评估 trace;被引用的组不能删", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "规则片", year: 2024 }); const slot = await seedSlot(h.db, { entityId: e }); const g = await h.api("POST", "/rules", { name: "片源", ruleString: "4K > 1080P", hardExcludes: ["REMUX"] }); const up = await h.api("PATCH", `/acquisition-slots/${slot}`, { expectedRevision: 1, ruleGroupIds: [g.body.data.id], }); expect(up.body.data.ruleGroupIds).toEqual([g.body.data.id]); expect( ( await h.api("PATCH", `/acquisition-slots/${slot}`, { expectedRevision: 2, ruleGroupIds: ["7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60"], }) ).status, ).toBe(400); const t1 = makeTorrent({ name: "Rule.2024.1080p.BluRay.REMUX.mkv", length: 1000, pieceSeed: 41 }); const r1 = await seedRelease(h.db, { siteId: base.siteId, entityId: e, title: "Rule.2024.1080p.BluRay.REMUX.AVC-EPSiLON", size: 1000, hash: t1.hash, }); const ev1 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 }); expect(ev1.plans).toHaveLength(0); const cand = async (rid: string) => ( await h.db .selectFrom("artifact") .select("content") .where("subject_id", "=", slot) .where("title", "=", "candidate_evaluation") .execute() ) .map( (a) => a.content as { sourceReleaseId: string; decision: string; pri: number | null; trace: string[]; rejected: { code: string }[]; reasons: string[]; }, ) .find((x) => x.sourceReleaseId === rid)!; const c1 = await cand(r1); expect(c1.decision).toBe("reject"); expect(c1.rejected.map((x) => x.code)).toContain("rule.hard_exclude"); expect(c1.trace.join("\n")).toContain("规则组「片源」@r1 硬排除 REMUX 命中"); const t2 = makeTorrent({ name: "Rule.2024.1080p.WEB-DL.mkv", length: 1000, pieceSeed: 42 }); const r2 = await seedRelease(h.db, { siteId: base.siteId, entityId: e, title: "Rule.2024.1080p.WEB-DL.H264-GRP", size: 1000, hash: t2.hash, }); const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: r2 }); expect(ev2.plans).toHaveLength(1); const c2 = await cand(r2); expect(c2).toMatchObject({ decision: "accept", pri: 99 }); expect(c2.reasons).toContain(`rule_group:${g.body.data.id}@1`); expect((await h.api("DELETE", `/rules/${g.body.data.id}`)).status).toBe(409); }); it("默认链:订阅默认组有层时对未引用规则组的版本目标生效;PUT /rules/defaults 校验类型", async () => { const defs = (await h.api("GET", "/rules/defaults")).body.data; const dflt = defs.acquisition[0]; expect((await h.api("PUT", "/rules/defaults", { ...defs, acquisition: defs.upgrade })).status).toBe(400); await h.api("PATCH", `/rules/${dflt}`, { ruleString: "WEBDL" }); const e = await seedEntity(h.db, { kind: "movie", title: "默认片", year: 2024 }); const slot = await seedSlot(h.db, { entityId: e }); const t = makeTorrent({ name: "Dflt.2024.1080p.BluRay.mkv", length: 1000, pieceSeed: 51 }); const r = await seedRelease(h.db, { siteId: base.siteId, entityId: e, title: "Dflt.2024.1080p.BluRay.x264-GRP", size: 1000, hash: t.hash, }); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r }); expect(ev.plans).toHaveLength(0); const a = await h.db .selectFrom("artifact") .select("content") .where("subject_id", "=", slot) .where("title", "=", "candidate_evaluation") .executeTakeFirstOrThrow(); expect((a.content as { rejected: { code: string }[] }).rejected.map((x) => x.code)).toContain( "rule.no_layer_matched", ); // 复原,避免影响其它用例 await h.api("PATCH", `/rules/${dflt}`, { ruleString: "" }); }); it("旧端点别名:偏好标签落在规则组;/rules/policies 与 /policy-revisions 同一数据", async () => { const tag = await h.api("POST", "/preference-tags", { label: "不要国配", kind: "hard", rule: { ruleString: "CNVOI" }, origin: "conversation", }); expect(tag.status).toBe(201); const g = await h.api("GET", `/rules/${tag.body.data.id}`); expect(g.body.data).toMatchObject({ legacyKind: "hard", source: "chat", hardExcludes: [{ op: "atom", id: "CNVOI" }], }); const pol = await h.api("POST", "/rules/policies", { policyKey: "w9d", kind: "acquisition", title: "t", rules: {}, }); expect(pol.status).toBe(201); const old = await h.api("GET", `/policy-revisions/${pol.body.data.id}`); expect(old.body.data.policyKey).toBe("w9d"); }); });