- migration 0033: rule_group (layers, hard excludes, scope, source, revision,
fingerprint, default_order) and rule_atom; acquisition_slot.rule_group_ids;
preference tags migrated into rule groups (same id/fingerprint), suppressions
linked; seeded default groups for acquisition / upgrade / search
- contracts: RuleGroup/RuleAtom/RuleExpr/RuleDefaults, parse/serialize/preview
schemas; Api.rules, Api.ruleAtoms, Api.rulePolicies, Api.ruleSuppressions
- server: GET/POST/PATCH/DELETE /rules, /rules/{id}/revisions, /rules/{id}/preview,
/rules/parse, /rules/serialize, /rules/keywords, /rules/defaults, /rules/atoms;
disable/delete writes suppression, agent re-add -> 409
- /preference-tags now aliases rule groups; /policy-revisions and
/preference-suppressions kept as aliases of /rules/policies, /rules/suppressions
- evaluateAcquisition resolves slot -> subscription -> default chain (+ upgrade
defaults) and records pri / rule_group@revision / per-layer trace
Co-Authored-By: Claude Opus 5.5 <[email protected]>
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
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<ReturnType<typeof seedBase>>;
|
|
|
|
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");
|
|
});
|
|
});
|