319 lines
13 KiB
TypeScript
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 },
|
|
});
|
|
});
|
|
});
|