Files
UHD_Media_Tool/apps/server/test/rules/rule-groups.test.ts
T
rootandClaude Opus 5.5 c9e8120fc6 test: server 测试共用建库与假引擎,sse-pg 归入串行组
- 新增 apps/server/test/support/{pg,engine}.ts:测试库地址、可用性探测、schema 咨询锁、
  建 schema + 加锁迁移 + 删除、可选去重的假引擎;6 份 harness 改用它(acquisition-organize 与
  file-manager 原先迁移不加锁,并行下有冲突隐患,一并修正)
- sse-pg 起子进程,并行下子进程启动超时,归入 serial
- 修正 b61335e 挪动 / 改名测试后遗留的 import 排序与格式(当时 lint 检查经管道被吞掉退出码)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 17:40:43 +08:00

343 lines
15 KiB
TypeScript

import { makeTorrent } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "../acquisition-organize/harness.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("洗版:现有资产按规则组重算 pri,只接受 pri 严格更高的候选", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "洗版片", year: 2024 });
const slot = await seedSlot(h.db, { entityId: e, upgrade: { enabled: true } });
const g = await h.api("POST", "/rules", { name: "洗版阶梯", ruleString: "REMUX > WEBDL & CNSUB > WEBDL" });
await h.api("PATCH", `/acquisition-slots/${slot}`, { expectedRevision: 1, ruleGroupIds: [g.body.data.id] });
const tOld = makeTorrent({ name: "Up.2024.1080p.WEB-DL.mkv", length: 1000, pieceSeed: 61 });
const oldRel = await seedRelease(h.db, {
siteId: base.siteId,
entityId: e,
title: "Up.2024.1080p.WEB-DL.H264-OLD",
size: 1000,
hash: tOld.hash,
});
await h.db.updateTable("source_release").set({ subtitle: "中字" }).where("id", "=", oldRel).execute();
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: e,
kind: "video",
size_bytes: 1000,
source_release_id: oldRel,
media_info: JSON.stringify({ _umt: { quality: { resolution: "1080p", source: "WEB-DL" } } }),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("acquisition_slot")
.set({ status: "satisfied", current_asset_id: asset.id })
.where("id", "=", slot)
.execute();
// 现有资产 = WEB-DL + 中字 → pri 99;同档 WEB-DL 中字不换,REMUX(pri 100)升级
const tSame = makeTorrent({ name: "Up.2024.1080p.WEB-DL.2.mkv", length: 1000, pieceSeed: 62 });
const same = await seedRelease(h.db, {
siteId: base.siteId,
entityId: e,
title: "Up.2024.1080p.WEB-DL.H264-NEW",
size: 1000,
hash: tSame.hash,
});
await h.db.updateTable("source_release").set({ subtitle: "中字" }).where("id", "=", same).execute();
expect((await evaluateAcquisition(h.d, { sourceReleaseId: same })).plans).toHaveLength(0);
const tBetter = makeTorrent({ name: "Up.2024.1080p.REMUX.mkv", length: 1000, pieceSeed: 63 });
const better = await seedRelease(h.db, {
siteId: base.siteId,
entityId: e,
title: "Up.2024.1080p.BluRay.REMUX.AVC-GRP",
size: 1000,
hash: tBetter.hash,
});
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: better });
expect(ev.plans).toHaveLength(1);
const plan = await h.db
.selectFrom("acquisition_plan")
.select(["purpose", "evaluation"])
.where("id", "=", ev.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan.purpose).toBe("upgrade");
expect(plan.evaluation).toMatchObject({ pri: 100 });
});
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");
});
});