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>; 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 }, }); }); });