Files
UHD_Media_Tool/apps/server/test/sources-identity/release-facts-recovery.test.ts
T

439 lines
20 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
CAPABILITY_CATALOG,
type DecisionStatus,
type KnownCapabilityId,
ReleaseApplyFactsInput,
type ReleaseStaticFacts,
} from "@umt/contracts";
import { evaluateCandidate, parseRelease, type SlotSpec } from "@umt/domain";
import { toParsedColumns } from "@umt/mapping";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { autoInvestigate, checkRecovery, recoveryActions } from "../../src/modules/agent/investigate.ts";
import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts";
import { parsedOf } from "../../src/modules/sources-identity/service/release-facts.ts";
import { applyReleaseFacts } from "../../src/modules/sources-identity/service/release-facts-admin.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
// 真实 PG、假引擎、不调用模型或站点:事实补全必须受事项和授权约束,补完仍按原规则判断。
const pg = await pgAvailable();
const EVIDENCE = "资源详情 MediaInfo 的视频轨道标明宽 1920、高 1080,编码 AVC。";
describe.skipIf(!pg)("资源事实补全与恢复", () => {
let h: Harness;
let siteId: string;
let entityId: string;
let anotherEntityId: string;
let n = 0;
beforeAll(async () => {
h = await createHarness("release_facts_recovery");
siteId = (await seedSite(h, { baseUrl: "https://facts.example.test/" })).site.id;
const entities = await h.db
.insertInto("media_entity")
.values([
{ kind: "movie", title: "Silent Meridian", year: 2025, status: "active" },
{ kind: "movie", title: "Another Film", year: 2024, status: "active" },
])
.returning("id")
.execute();
entityId = entities[0]!.id;
anotherEntityId = entities[1]!.id;
for (const [key, value] of Object.entries({
automation: { enabled: true },
agent: { autoInvestigate: true, autoInvestigateMode: "recover", investigateMaxAttempts: 3 },
})) {
await h.db
.insertInto("settings")
.values({ key, value: JSON.stringify(value) })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(value) }))
.execute();
}
const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
registerSourcesCapabilities(registry, () => h.rt);
h.ctx.capabilities = registry;
});
beforeEach(async () => {
await h.db.deleteFrom("automation_grant").execute();
});
afterAll(async () => {
await h?.close();
});
async function seed(opts: { title?: string; missing?: string[]; status?: DecisionStatus } = {}) {
const title = opts.title ?? "Silent Meridian 2025";
const columns = toParsedColumns(parseRelease(title));
const release = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: String(++n),
title,
media_entity_id: entityId,
resolution_status: "resolved",
parsed: JSON.stringify(columns.parsed),
parser_version: columns.parserVersion,
raw: JSON.stringify({ _site: { marker: "snapshot" }, _detail: { imdbId: "tt1234567" } }),
})
.returningAll()
.executeTakeFirstOrThrow();
const decision = await h.db
.insertInto("decision")
.values({
kind: "other",
title: "资源属性缺失",
status: opts.status ?? "open",
subject_type: "source_release",
subject_id: release.id,
dedupe_key: `release_facts:${release.id}`,
evidence: JSON.stringify({
sourceReleaseId: release.id,
mediaEntityId: entityId,
missing: opts.missing ?? ["resolution"],
}),
})
.returningAll()
.executeTakeFirstOrThrow();
return { releaseId: release.id, decisionId: decision.id };
}
function input(target: Awaited<ReturnType<typeof seed>>, facts: ReleaseStaticFacts = { resolution: "1080p" }) {
return ReleaseApplyFactsInput.parse({
decisionId: target.decisionId,
sourceReleaseId: target.releaseId,
facts,
evidence: EVIDENCE,
evidenceUrl: "https://facts.example.test/details.php?id=1",
});
}
const releaseOf = (id: string) =>
h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const decisionOf = (id: string) =>
h.db.selectFrom("decision").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const eventsOf = async (id: string) =>
(await h.outbox("release.resolved")).filter((event) => event.payload.sourceReleaseId === id);
const actionOf = (target: Awaited<ReturnType<typeof seed>>) => [
{
capabilityId: "release.apply_facts",
input: input(target),
status: "succeeded",
},
];
async function grant(allowedEntityId = entityId, action = "release.apply_facts") {
return h.db
.insertInto("automation_grant")
.values({
action,
scope: JSON.stringify({ mediaEntityIds: [allowedEntityId] }),
granted_by: "admin:test",
reason: "只补全资源事实",
})
.returning("id")
.executeTakeFirstOrThrow();
}
function invoke(target: Awaited<ReturnType<typeof seed>>, runId: string) {
return h.ctx.capabilities.invoke("release.apply_facts", input(target), {
actorId: "system:auto-recover",
taskId: randomUUID(),
scopeIds: [],
signal: new AbortController().signal,
source: "agent",
agentRunId: runId,
invocationId: randomUUID(),
});
}
it("事项与资源必须一致,拒绝后没有写入或重评事件", async () => {
const first = await seed();
const second = await seed();
const before = await releaseOf(second.releaseId);
await expect(applyReleaseFacts(h.ctx, { ...input(first), sourceReleaseId: second.releaseId })).rejects.toThrow(
/事项.*资源.*不一致/,
);
expect((await releaseOf(second.releaseId)).raw).toEqual(before.raw);
expect((await decisionOf(first.decisionId)).status).toBe("open");
expect(await eventsOf(second.releaseId)).toHaveLength(0);
});
it("只填事项里仍缺失的字段,已知字段及未请求字段均不能覆盖", async () => {
const target = await seed({ title: "Silent Meridian 2025 2160p", missing: ["resolution", "codec"] });
const before = await releaseOf(target.releaseId);
await expect(applyReleaseFacts(h.ctx, input(target, { resolution: "1080p", videoCodec: "H.264" }))).rejects.toThrow(
/只能补全/,
);
await expect(applyReleaseFacts(h.ctx, input(target, { source: "WEB-DL" }))).rejects.toThrow(/只能补全/);
expect((await releaseOf(target.releaseId)).raw).toEqual(before.raw);
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "2160p" });
expect(await eventsOf(target.releaseId)).toHaveLength(0);
expect(await applyReleaseFacts(h.ctx, input(target, { videoCodec: "H.264" }))).toEqual({
resolved: true,
remaining: [],
});
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "2160p", videoCodec: "H.264" });
});
it.each(["dismissed", "expired"] as const)("%s 的事项不能再写事实或重新打开", async (status) => {
const target = await seed({ status });
const before = await releaseOf(target.releaseId);
await expect(applyReleaseFacts(h.ctx, input(target))).rejects.toThrow(/已关闭/);
expect((await releaseOf(target.releaseId)).raw).toEqual(before.raw);
expect((await decisionOf(target.decisionId)).status).toBe(status);
expect(await eventsOf(target.releaseId)).toHaveLength(0);
});
it("事实与出处持久化,解析版本过期仍保留事实和规范季集,后来的明确解析优先", async () => {
const target = await seed({ title: "Silent Meridian S01E25", missing: ["resolution", "codec", "source", "group"] });
await h.db
.updateTable("source_release")
.set({
episode_resolution: JSON.stringify({
method: "absolute_cross_season",
canonical: { season: 2, episodes: { start: 13, end: 13 } },
}),
})
.where("id", "=", target.releaseId)
.execute();
const facts = { resolution: "1080p", videoCodec: "H.264", source: "BluRay", releaseGroup: "EvidenceTeam" } as const;
expect(await applyReleaseFacts(h.ctx, input(target, facts))).toEqual({ resolved: true, remaining: [] });
const stored = await releaseOf(target.releaseId);
expect(stored.raw).toMatchObject({
_site: { marker: "snapshot" },
_detail: { imdbId: "tt1234567" },
_facts: {
values: facts,
evidence: { resolution: { text: EVIDENCE, url: "https://facts.example.test/details.php?id=1" } },
},
});
expect(parsedOf(stored)).toMatchObject({
resolution: "1080p",
videoCodec: "H.264",
source: "BluRay",
isEncode: true,
releaseGroup: { name: "EvidenceTeam" },
});
await h.db.updateTable("source_release").set({ parser_version: "0" }).where("id", "=", target.releaseId).execute();
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({
resolution: "1080p",
videoCodec: "H.264",
source: "BluRay",
releaseGroup: { name: "EvidenceTeam" },
season: 2,
episodes: { start: 13, end: 13 },
seasonInferred: false,
});
const reparsed = toParsedColumns(parseRelease("Silent Meridian S02E13 2160p WEB-DL H.265"));
await h.db
.updateTable("source_release")
.set({ parsed: JSON.stringify(reparsed.parsed), parser_version: reparsed.parserVersion })
.where("id", "=", target.releaseId)
.execute();
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({
resolution: "2160p",
videoCodec: "H.265",
source: "WEB-DL",
isEncode: false,
});
});
it("原生站点字段刷新后空补丁可以关闭事项,并触发原资源重评", async () => {
const target = await seed({ missing: ["size", "seeders"] });
await h.db
.updateTable("source_release")
.set({ size_bytes: 400_000_000, seeders: 0 })
.where("id", "=", target.releaseId)
.execute();
expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] });
expect((await decisionOf(target.decisionId)).status).toBe("resolved");
expect(await eventsOf(target.releaseId)).toEqual([
expect.objectContaining({
payload: expect.objectContaining({
sourceReleaseId: target.releaseId,
mediaEntityId: entityId,
contentUnitIds: [],
}),
}),
]);
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered");
});
it("部分补全保持待处理,补齐后恢复;重复调用不会重复发重评事件", async () => {
const target = await seed({ missing: ["resolution", "codec"] });
expect(await applyReleaseFacts(h.ctx, input(target))).toEqual({ resolved: false, remaining: ["codec"] });
expect((await decisionOf(target.decisionId)).status).toBe("open");
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending");
const finalInput = input(target, { videoCodec: "H.264" });
expect(await applyReleaseFacts(h.ctx, finalInput)).toEqual({ resolved: true, remaining: [] });
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered");
const events = await eventsOf(target.releaseId);
expect(events).toHaveLength(2);
expect(await applyReleaseFacts(h.ctx, finalInput)).toEqual({ resolved: true, remaining: [] });
expect(await eventsOf(target.releaseId)).toEqual(events);
});
it("部分完成后的同值补丁可以重试,保留事实和原出处;不同值仍拒绝", async () => {
const target = await seed({ missing: ["resolution", "size"] });
const result = { resolved: false, remaining: ["size"] };
expect(await applyReleaseFacts(h.ctx, input(target))).toEqual(result);
const stored = await releaseOf(target.releaseId);
const events = await eventsOf(target.releaseId);
expect(events).toHaveLength(1);
expect(stored.raw).toMatchObject({
_facts: {
values: { resolution: "1080p" },
evidence: { resolution: { text: EVIDENCE, url: "https://facts.example.test/details.php?id=1" } },
},
});
const retry = {
...input(target),
evidence: "重试提供了另一段说明,不应覆盖第一次补全的出处。",
evidenceUrl: "https://facts.example.test/retry",
};
expect(await applyReleaseFacts(h.ctx, retry)).toEqual(result);
expect((await releaseOf(target.releaseId)).raw).toEqual(stored.raw);
expect(await eventsOf(target.releaseId)).toEqual(events);
expect((await decisionOf(target.decisionId)).status).toBe("open");
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending");
await expect(applyReleaseFacts(h.ctx, input(target, { resolution: "2160p" }))).rejects.toThrow(/只能补全/);
expect((await releaseOf(target.releaseId)).raw).toEqual(stored.raw);
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBe("1080p");
expect(await eventsOf(target.releaseId)).toEqual(events);
expect((await decisionOf(target.decisionId)).status).toBe("open");
});
it("先补分辨率、后由原生详情补容量:空补丁完成时另发重评事件", async () => {
const target = await seed({ missing: ["resolution", "size"] });
expect(await applyReleaseFacts(h.ctx, input(target))).toEqual({ resolved: false, remaining: ["size"] });
const partialEvents = await eventsOf(target.releaseId);
expect(partialEvents).toHaveLength(1);
expect((await decisionOf(target.decisionId)).status).toBe("open");
await h.db
.updateTable("source_release")
.set({ size_bytes: 400_000_000 })
.where("id", "=", target.releaseId)
.execute();
expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] });
const completeEvents = await eventsOf(target.releaseId);
expect(completeEvents).toHaveLength(2);
expect(completeEvents.map((event) => event.idempotency_key)).not.toContain(null);
expect(new Set(completeEvents.map((event) => event.idempotency_key)).size).toBe(2);
expect((await decisionOf(target.decisionId)).status).toBe("resolved");
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "1080p", sizeBytes: 400_000_000 });
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered");
expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] });
expect(await eventsOf(target.releaseId)).toEqual(completeEvents);
});
it("补出的事实超出原收录范围时仍拒绝,事实能力不接受策略字段", async () => {
const target = await seed();
const spec: SlotSpec = { name: "1080p", hard: { resolutions: ["1080p"] }, unknownPolicy: "hold" };
const original = structuredClone(spec);
expect(evaluateCandidate(spec, parsedOf(await releaseOf(target.releaseId))).decision).toBe("unknown_field");
await applyReleaseFacts(h.ctx, input(target, { resolution: "2160p" }));
expect(evaluateCandidate(spec, parsedOf(await releaseOf(target.releaseId)))).toMatchObject({
decision: "reject",
violations: [{ field: "resolution", code: "not_allowed" }],
});
expect(spec).toEqual(original);
expect(
ReleaseApplyFactsInput.safeParse({ ...input(target), facts: { resolution: "2160p", priority: 100 } }).success,
).toBe(false);
expect(ReleaseApplyFactsInput.safeParse({ ...input(target), resolutions: ["2160p"] }).success).toBe(false);
});
it("无事实授权时只调查,身份授权和其他作品的事实授权不能代替", async () => {
const target = await seed();
const investigation = await autoInvestigate(h.ctx, target.decisionId);
expect(investigation).toMatchObject({ created: true, mode: "investigate" });
await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权/);
await grant(entityId, "policy.identity");
await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权/);
await grant(anotherEntityId);
await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权|作品/);
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBeUndefined();
expect(await eventsOf(target.releaseId)).toHaveLength(0);
});
it("资源事实会话只开放读取,有授权时只增加事实补全能力", async () => {
const readTarget = await seed();
const read = await autoInvestigate(h.ctx, readTarget.decisionId);
expect(read).toMatchObject({ created: true, mode: "investigate" });
const readRun = await h.db
.selectFrom("agent_run")
.selectAll()
.where("id", "=", read.runId!)
.executeTakeFirstOrThrow();
const readCapabilities = (readRun.scope as { capabilities: string[] }).capabilities;
expect(readCapabilities).toEqual(
expect.arrayContaining(["release.inspect", "decision.list", "web.search", "web.fetch"]),
);
expect(readCapabilities.filter((id) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind !== "read")).toEqual([]);
expect(readCapabilities).not.toContain("release.apply_facts");
await grant();
const recoverTarget = await seed();
const recover = await autoInvestigate(h.ctx, recoverTarget.decisionId);
expect(recover).toMatchObject({ created: true, mode: "recover" });
const recoverRun = await h.db
.selectFrom("agent_run")
.selectAll()
.where("id", "=", recover.runId!)
.executeTakeFirstOrThrow();
const recoverCapabilities = (recoverRun.scope as { capabilities: string[] }).capabilities;
expect(recoverCapabilities.filter((id) => id !== "release.apply_facts").sort()).toEqual(
[...readCapabilities].sort(),
);
expect(recoverCapabilities.filter((id) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind !== "read")).toEqual([
"release.apply_facts",
]);
for (const id of [
"identity.resolve",
"task.retry",
"download.recheck",
"episode_mapping.apply_rule",
"policy.apply",
"download.submit",
]) {
expect(readCapabilities).not.toContain(id);
expect(recoverCapabilities).not.toContain(id);
}
});
it("有持续授权时仅写本次调查资源,恢复动作进入账本并按持久事实核实", async () => {
await grant();
const target = await seed();
const other = await seed();
const investigation = await autoInvestigate(h.ctx, target.decisionId);
expect(investigation).toMatchObject({ created: true, mode: "recover" });
await expect(invoke(other, investigation.runId!)).rejects.toThrow(/当前调查事项/);
expect(await invoke(target, investigation.runId!)).toEqual({ resolved: true, remaining: [] });
const acts = await recoveryActions(h.ctx, investigation.runId!);
expect(acts.actions.filter((action) => action.status === "succeeded")).toEqual([
expect.objectContaining({
capabilityId: "release.apply_facts",
input: expect.objectContaining({ decisionId: target.decisionId, sourceReleaseId: target.releaseId }),
}),
]);
expect((await checkRecovery(h.ctx, acts.actions)).state).toBe("recovered");
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBe("1080p");
expect(parsedOf(await releaseOf(other.releaseId)).resolution).toBeUndefined();
expect((await decisionOf(other.decisionId)).status).toBe("open");
expect(await eventsOf(target.releaseId)).toHaveLength(1);
expect(await eventsOf(other.releaseId)).toHaveLength(0);
});
it("事项状态单独为 resolved 不足以证明缺失事实已经恢复", async () => {
const target = await seed({ status: "resolved" });
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBeUndefined();
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending");
});
});