Files
UHD_Media_Tool/apps/server/test/acquisition-organize/policy-combinations.test.ts
T

319 lines
13 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { AcquisitionPolicyRules, PolicyRevision, RuleGroup } from "@umt/contracts";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { loadRuleCatalog } from "../../src/modules/acquisition-organize/services/rules.ts";
import { slotSpecWithRules } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// 实际 PG + 路由/评估入口:模板引用、补齐与洗版组合,以及发布/删除时的引用保护。
const pg = await pgAvailable();
describe.skipIf(!pg)("收录策略与规则组合", () => {
let h: Harness;
let base: Awaited<ReturnType<typeof seedBase>>;
let n = 0;
const disk = fakeDisk(100_000_000_000);
beforeAll(async () => {
disk.setPct(10);
h = await createHarness("policy_combinations", { diskUsage: disk.fn });
base = await seedBase(h.db, {
libraryPath: "/tmp/umt-policy-combinations-library",
cachePath: "/tmp/umt-policy-combinations-cache",
qbUrl: "http://127.0.0.1:9",
});
});
beforeEach(async () => {
const defaults = await h.api("PUT", "/rules/defaults", { acquisition: [], upgrade: [], search: [] });
expect(defaults.status).toBe(200);
await h.db
.updateTable("policy_revision")
.set({ status: "archived" })
.where("status", "in", ["draft", "published"])
.execute();
await h.db.updateTable("acquisition_plan").set({ status: "cancelled" }).where("status", "=", "reserved").execute();
});
afterAll(async () => {
await h?.close();
});
async function group(ruleString = "H265", kind: "acquisition" | "upgrade" = "acquisition") {
const response = await h.api("POST", "/rules", { name: `组合规则-${++n}`, kind, ruleString });
expect(response.status, JSON.stringify(response.body)).toBe(201);
return response.body.data as RuleGroup;
}
async function draft(rules: AcquisitionPolicyRules, policyKey = `combinations-${++n}`) {
const response = await h.api("POST", "/policy-revisions", {
policyKey,
kind: "acquisition",
title: "组合策略",
rules,
});
expect(response.status, JSON.stringify(response.body)).toBe(201);
return response.body.data as PolicyRevision;
}
async function publish(policy: PolicyRevision) {
const response = await h.api("POST", `/policy-revisions/${policy.id}/publish`, {
expectedRevision: policy.revision,
});
expect(response.status, JSON.stringify(response.body)).toBe(200);
return response.body.data as PolicyRevision;
}
const release = (entityId: string, title: string) =>
seedRelease(h.db, {
siteId: base.siteId,
entityId,
title,
size: 300_000_000,
hash: (++n).toString(16).padStart(40, "0"),
});
it("策略预览执行模板的 H265 规则组,硬条件相同的 H264 资源被排除", async () => {
const filter = await group();
const entityId = await seedEntity(h.db, { kind: "movie", title: "Preview Film", year: 2024 });
const h265 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H265-GRP");
const h264 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H264-GRP");
const policy = await draft({
defaultSlots: [{ name: "只要 H265", hardConditions: { resolutions: ["1080p"] }, ruleGroupIds: [filter.id] }],
});
const response = await h.api("POST", `/policy-revisions/${policy.id}/preview`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
const preview = response.body.data.preview as {
matched: number;
excluded: number;
samples: { sourceReleaseId: string; decision: string; reasons: string[] }[];
};
expect(preview).toMatchObject({ matched: 1, excluded: 1 });
expect(preview.samples.find((sample) => sample.sourceReleaseId === h265)).toMatchObject({ decision: "accept" });
expect(preview.samples.find((sample) => sample.sourceReleaseId === h264)).toMatchObject({ decision: "reject" });
expect(await h.db.selectFrom("acquisition_plan").select("id").execute()).toHaveLength(0);
});
it("新订阅允许先补 WEB:即使模板开启升级,也不把 REMUX 洗版默认链叠到补齐规则", async () => {
const fill = await group("H265");
const wash = await group("REMUX", "upgrade");
expect(
(await h.api("PUT", "/rules/defaults", { acquisition: [fill.id], upgrade: [wash.id], search: [] })).status,
).toBe(200);
await publish(
await draft({
defaultSlots: [
{
name: "先补齐再洗版",
hardConditions: { resolutions: ["1080p"] },
upgradeThreshold: { enabled: true, mode: "movie", basis: "priority" },
},
],
}),
);
const entityId = await seedEntity(h.db, { kind: "movie", title: "Fill Film", year: 2024 });
const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
const slot = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("subscription_id", "=", subscription.body.data.id)
.executeTakeFirstOrThrow();
expect(slot.upgrade_threshold).toMatchObject({ enabled: true });
const resource = await release(entityId, "Fill.Film.2024.1080p.WEB-DL.H265-GRP");
const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
expect(result.plans, JSON.stringify(result)).toHaveLength(1);
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", result.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({ slot_id: slot.id, purpose: "fill", status: "reserved" });
const spec = slotSpecWithRules(
await loadRuleCatalog(h.db),
slot,
null,
{ id: entityId, kind: "movie" },
base.siteId,
);
expect(spec.rules?.groups.map((g) => g.id)).toEqual([fill.id]);
expect(spec.upgradeRules?.groups.map((g) => g.id)).toEqual([fill.id, wash.id]);
});
it("规则组被收录版本的洗版引用时不能删;通过版本更新移除引用后可删", async () => {
const wash = await group("REMUX", "upgrade");
const entityId = await seedEntity(h.db, { kind: "movie", title: "Slot Reference", year: 2024 });
const created = await h.api("POST", "/acquisition-slots", {
mediaEntityId: entityId,
name: "洗版引用",
upgradeThreshold: { enabled: true, ruleGroupIds: [wash.id] },
});
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(409);
expect(
(await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
.deleted_at,
).toBeNull();
const updated = await h.api("PATCH", `/acquisition-slots/${created.body.data.id}`, {
expectedRevision: created.body.data.revision,
upgradeThreshold: { enabled: true, ruleGroupIds: [] },
});
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(200);
expect(
(await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
.deleted_at,
).not.toBeNull();
});
it.each([
{ label: "草案 defaultSlots 补齐", published: false, newTitle: false, wash: false },
{ label: "已发布 defaultSlots 洗版", published: true, newTitle: false, wash: true },
{ label: "草案 newTitle 洗版", published: false, newTitle: true, wash: true },
{ label: "已发布 newTitle 补齐", published: true, newTitle: true, wash: false },
])("$label 引用阻止删除,解除有效策略引用后可删", async ({ published, newTitle, wash }) => {
const referenced = await group(wash ? "REMUX" : "H265", wash ? "upgrade" : "acquisition");
const template = {
name: "引用模板",
...(wash
? { upgradeThreshold: { enabled: true, ruleGroupIds: [referenced.id] } }
: { ruleGroupIds: [referenced.id] }),
};
const rules: AcquisitionPolicyRules = newTitle
? { newTitle: { enabled: true, slots: [template] } }
: { defaultSlots: [template] };
const policy = await draft(rules);
if (published) await publish(policy);
expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(409);
if (published) {
await publish(await draft({}, policy.policyKey));
expect(
(
await h.db
.selectFrom("policy_revision")
.select("status")
.where("id", "=", policy.id)
.executeTakeFirstOrThrow()
).status,
).toBe("superseded");
} else {
const updated = await h.api("PATCH", `/policy-revisions/${policy.id}`, { rules: {} });
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
}
expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(200);
});
it.each(["fill", "wash"] as const)("发布拒绝不存在的模板 %s 规则组,旧发布和授权保留", async (phase) => {
const existing = await publish(await draft({}));
const missing = randomUUID();
const rules: AcquisitionPolicyRules =
phase === "fill"
? { defaultSlots: [{ name: "坏补齐引用", ruleGroupIds: [missing] }] }
: {
newTitle: {
enabled: true,
slots: [{ name: "坏洗版引用", upgradeThreshold: { enabled: true, ruleGroupIds: [missing] } }],
},
};
const invalid = await draft(rules, existing.policyKey);
const response = await h.api("POST", `/policy-revisions/${invalid.id}/publish`, {
expectedRevision: invalid.revision,
});
expect(response.status, JSON.stringify(response.body)).toBe(400);
expect(response.body.error.message).toContain("规则组不存在");
expect(response.body.error.message).toContain(missing);
const policies = await h.db
.selectFrom("policy_revision")
.select(["id", "status"])
.where("id", "in", [existing.id, invalid.id])
.execute();
expect(policies).toEqual(
expect.arrayContaining([
{ id: existing.id, status: "published" },
{ id: invalid.id, status: "draft" },
]),
);
expect(
await h.db
.selectFrom("automation_grant")
.select(["status"])
.where("policy_revision_id", "=", existing.id)
.execute(),
).toEqual([{ status: "active" }]);
expect(
await h.db.selectFrom("automation_grant").select("id").where("policy_revision_id", "=", invalid.id).execute(),
).toHaveLength(0);
});
it("电影忽略季级一致性,仍能从已有 WEB 资产生成 REMUX 升级计划", async () => {
const entityId = await seedEntity(h.db, { kind: "movie", title: "Movie Upgrade", year: 2024 });
const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
const slot = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("subscription_id", "=", subscription.body.data.id)
.executeTakeFirstOrThrow();
const update = await h.api("PATCH", `/acquisition-slots/${slot.id}`, {
expectedRevision: slot.revision,
hardConditions: { resolutions: ["1080p"] },
upgradeThreshold: { enabled: true, mode: "movie", basis: "tier", consistency: "season_release" },
});
expect(update.status, JSON.stringify(update.body)).toBe(200);
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: entityId,
kind: "video",
size_bytes: 300_000_000,
media_info: JSON.stringify({
_umt: {
quality: {
resolution: "1080p",
source: "WEB-DL",
videoCodec: "H.264",
releaseGroup: { name: "OLD", source: "suffix" },
hdr: [],
},
},
}),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("acquisition_slot")
.set({ status: "satisfied", current_asset_id: asset.id })
.where("id", "=", slot.id)
.execute();
const persisted = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", slot.id)
.executeTakeFirstOrThrow();
expect(persisted.upgrade_threshold).toMatchObject({ enabled: true, consistency: "season_release" });
const spec = slotSpecWithRules(
await loadRuleCatalog(h.db),
persisted,
null,
{ id: entityId, kind: "movie" },
base.siteId,
);
expect(spec.upgrade).toMatchObject({ enabled: true, mode: "movie" });
expect(spec.upgrade?.consistency).toBeUndefined();
const resource = await release(entityId, "Movie.Upgrade.2024.1080p.BluRay.REMUX.H264-NEW");
const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
expect(result.plans, JSON.stringify(result)).toHaveLength(1);
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", result.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({
slot_id: slot.id,
purpose: "upgrade",
evaluation: { automaticReplacement: true, replacesAssetId: asset.id },
});
});
});