refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries
This commit is contained in:
1 parent
3f1c6d02f6
commit
27c0f27434
268 files changed
+7468
-7852
No files matched your search
@@ -99,8 +99,8 @@ describe.skipIf(!up)("元数据语言回刷", () => {
|
||||
.values({ key: "metadata", value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) } as never)
|
||||
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) }))
|
||||
.execute();
|
||||
const prev = Deno.env.get("TMDB_API_KEY");
|
||||
Deno.env.set("TMDB_API_KEY", "test-tmdb-key");
|
||||
const prev = process.env.TMDB_API_KEY;
|
||||
process.env.TMDB_API_KEY = "test-tmdb-key";
|
||||
try {
|
||||
const rt = new SourcesRuntime(h.ctx, {});
|
||||
const t0 = performance.now();
|
||||
@@ -112,8 +112,8 @@ describe.skipIf(!up)("元数据语言回刷", () => {
|
||||
const second = await backfillLanguages(rt, { minIntervalMs: 100 });
|
||||
expect(second).toMatchObject({ refreshed: 2, requests: 0 });
|
||||
} finally {
|
||||
if (prev === undefined) Deno.env.delete("TMDB_API_KEY");
|
||||
else Deno.env.set("TMDB_API_KEY", prev);
|
||||
if (prev === undefined) delete process.env.TMDB_API_KEY;
|
||||
else process.env.TMDB_API_KEY = prev;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ describe.skipIf(!up)("CF-2 Cloudflare 级联", () => {
|
||||
}
|
||||
cf.passed++;
|
||||
return globalThis.fetch(input, init);
|
||||
}) as typeof fetch;
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow();
|
||||
const account = () =>
|
||||
|
||||
@@ -285,7 +285,7 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => {
|
||||
const u = new URL(PG_URL);
|
||||
u.searchParams.set("options", `-c search_path=${schema},public`);
|
||||
const run = (...args: string[]) =>
|
||||
execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, "--merge-duplicates", ...args], {
|
||||
execFileSync(process.execPath, [SCRIPT, "--merge-duplicates", ...args], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, DATABASE_URL: u.toString(), NO_COLOR: "1" },
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_SETTINGS } from "@umt/contracts";
|
||||
import { type HttpServer, serve } from "@umt/runtime";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
directHttpClient,
|
||||
@@ -27,66 +28,58 @@ const PROXY_KEYS = [
|
||||
];
|
||||
|
||||
describe("出口选择(直连 / 代理)", () => {
|
||||
let proxy: Deno.HttpServer<Deno.NetAddr>;
|
||||
let target: Deno.HttpServer<Deno.NetAddr>;
|
||||
let proxy: HttpServer;
|
||||
let target: HttpServer;
|
||||
let proxyUrl = "";
|
||||
let targetUrl = "";
|
||||
const proxied: string[] = [];
|
||||
const saved = new Map<string, string | undefined>();
|
||||
|
||||
beforeAll(() => {
|
||||
proxy = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, (req) => {
|
||||
proxy = serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, (req) => {
|
||||
proxied.push(req.url);
|
||||
return new Response("via-proxy");
|
||||
});
|
||||
target = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, () => new Response("direct"));
|
||||
target = serve({ hostname: "127.0.0.1", port: 0, onListen() {} }, () => new Response("direct"));
|
||||
proxyUrl = `http://127.0.0.1:${proxy.addr.port}`;
|
||||
targetUrl = `http://127.0.0.1:${target.addr.port}/x`;
|
||||
for (const k of PROXY_KEYS) {
|
||||
saved.set(k, Deno.env.get(k));
|
||||
Deno.env.delete(k);
|
||||
saved.set(k, process.env[k]);
|
||||
delete process.env[k];
|
||||
}
|
||||
// 模拟容器环境:HTTP_PROXY 指向"中继",没有 NO_PROXY
|
||||
Deno.env.set("HTTP_PROXY", proxyUrl);
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const [k, v] of saved) {
|
||||
if (v === undefined) Deno.env.delete(k);
|
||||
else Deno.env.set(k, v);
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
await proxy.shutdown();
|
||||
await target.shutdown();
|
||||
});
|
||||
|
||||
const body = async (f: typeof fetch) => (await f(targetUrl)).text();
|
||||
|
||||
it("Deno 2.9 语义:createHttpClient({}) 仍会读 HTTP_PROXY(所以直连要显式做)", async () => {
|
||||
const implicit = Deno.createHttpClient({});
|
||||
try {
|
||||
expect(await body(fetchVia(implicit))).toBe("via-proxy");
|
||||
} finally {
|
||||
implicit.close();
|
||||
}
|
||||
});
|
||||
const body = async (f: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) =>
|
||||
(await f(targetUrl)).text();
|
||||
|
||||
it("directHttpClient:环境变量里有代理也直连;创建后环境变量原样还原", async () => {
|
||||
proxied.length = 0;
|
||||
expect(await body(fetchVia(directHttpClient()))).toBe("direct");
|
||||
expect(proxied).toEqual([]);
|
||||
expect(Deno.env.get("HTTP_PROXY")).toBe(proxyUrl);
|
||||
expect(process.env.HTTP_PROXY).toBe(proxyUrl);
|
||||
});
|
||||
|
||||
it("proxyHttpClient:只走给定代理(NO_PROXY 命中也不绕过)", async () => {
|
||||
Deno.env.set("NO_PROXY", "127.0.0.1");
|
||||
process.env.NO_PROXY = "127.0.0.1";
|
||||
try {
|
||||
proxied.length = 0;
|
||||
const client = proxyHttpClient(proxyUrl);
|
||||
expect(await body(fetchVia(client))).toBe("via-proxy");
|
||||
expect(proxied).toHaveLength(1);
|
||||
expect(Deno.env.get("NO_PROXY")).toBe("127.0.0.1");
|
||||
expect(process.env.NO_PROXY).toBe("127.0.0.1");
|
||||
} finally {
|
||||
Deno.env.delete("NO_PROXY");
|
||||
delete process.env.NO_PROXY;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const mockFetch = (async (input: string | URL | Request) => {
|
||||
if (url.href === EVIL)
|
||||
return new Response("<html><title>x</title><body>x</body></html>", { headers: { "content-type": "text/html" } });
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
const resolveHost = async () => ["93.184.216.34"];
|
||||
|
||||
/** fake 模型:照网页给值,简介是改写过的(不是原文),海报给一个网页里没有的地址(应被丢弃) */
|
||||
@@ -360,7 +360,7 @@ describe.skipIf(!pg)("META-1 元数据补全", () => {
|
||||
return new Response("User-agent: *\nDisallow: /series/\n", { headers: { "content-type": "text/plain" } });
|
||||
if (u.hostname === "robots-b.example.com") return new Response("err", { status: 503 });
|
||||
return new Response("nf", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
const o = { fetch: f, resolveHost };
|
||||
expect(await robotsAllowedFor(h.ctx, "https://robots-a.example.com/series/1", o)).toBe(false);
|
||||
expect(await robotsAllowedFor(h.ctx, "https://robots-a.example.com/detail?id=1", o)).toBe(true);
|
||||
|
||||
@@ -42,7 +42,7 @@ describe.skipIf(!up)("站点失败留样", () => {
|
||||
new Response(page, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8", server: "nginx" },
|
||||
})) as typeof fetch;
|
||||
})) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow();
|
||||
const rows = () =>
|
||||
h.db.selectFrom("site_failure_sample").selectAll().where("site_id", "=", siteId).orderBy("created_at").execute();
|
||||
|
||||
@@ -18,7 +18,7 @@ describe.skipIf(!up)("identity-cleanup 孤立单元判定", () => {
|
||||
let mappedNoLayout: string;
|
||||
|
||||
const run = (...args: string[]) =>
|
||||
execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, ...args], {
|
||||
execFileSync(process.execPath, [SCRIPT, ...args], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, DATABASE_URL: url, NO_COLOR: "1" },
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -123,7 +123,9 @@ export function trimTmdb(body: unknown): unknown {
|
||||
}
|
||||
|
||||
/** 离线回放:按键取录制的响应;没录到的请求直接报错(说明召回逻辑变了,需要重新录制) */
|
||||
export function fixtureFetch(fixtures: Record<string, unknown>): typeof fetch {
|
||||
export function fixtureFetch(
|
||||
fixtures: Record<string, unknown>,
|
||||
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
||||
return (async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const key = fixtureKey(url);
|
||||
@@ -134,10 +136,13 @@ export function fixtureFetch(fixtures: Record<string, unknown>): typeof fetch {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
export function tmdbFor(fetchImpl: typeof fetch, baseUrl = "http://tmdb.fixture/3"): TmdbClient {
|
||||
export function tmdbFor(
|
||||
fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
||||
baseUrl = "http://tmdb.fixture/3",
|
||||
): TmdbClient {
|
||||
return new TmdbClient({ apiKey: "x", baseUrl, fetch: fetchImpl, retries: 0 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 重新录制身份匹配差分(ID-2):我们侧的 TMDB 请求走本地缓存代理(scratchpad tmdb_proxy.py,上行计数封顶),
|
||||
* 录下的响应裁剪后写入 tmdb-fixtures.json,逐条结果写入 ours.json 并打印汇总。
|
||||
*
|
||||
* TMDB_PROXY=http://127.0.0.1:18731 deno run -A apps/server/test/sources-identity/identity-differential.run.ts
|
||||
* TMDB_PROXY=http://127.0.0.1:18731 bun apps/server/test/sources-identity/identity-differential.run.ts
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
trimTmdb,
|
||||
} from "./identity-differential.lib.ts";
|
||||
|
||||
const proxy = Deno.env.get("TMDB_PROXY") ?? "http://127.0.0.1:18731";
|
||||
const proxy = process.env.TMDB_PROXY ?? "http://127.0.0.1:18731";
|
||||
const fixtures: Record<string, unknown> = {};
|
||||
const recording: typeof fetch = async (input, init) => {
|
||||
const recording: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const res = await fetch(url, init);
|
||||
const body = await res.clone().json();
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,13 @@ const TiB = 1024 ** 4;
|
||||
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
|
||||
|
||||
describe("站点能力规格", () => {
|
||||
it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个 + 指定整理作品);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(47);
|
||||
it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个 + 指定整理作品 + 资源属性补全);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(48);
|
||||
expect(capabilitySpec("release.apply_facts")).toMatchObject({
|
||||
kind: "execute",
|
||||
riskLevel: "medium",
|
||||
replay: "safe",
|
||||
});
|
||||
expect(capabilitySpec("episode_mapping.inspect")).toMatchObject({ kind: "read", replay: "safe" });
|
||||
expect(capabilitySpec("episode_mapping.simulate")).toMatchObject({ kind: "preview", replay: "safe" });
|
||||
expect(capabilitySpec("episode_mapping.apply_rule")).toMatchObject({
|
||||
|
||||
@@ -380,7 +380,7 @@ describe.skipIf(!up)("站点中心(馒头 API 基址)", () => {
|
||||
return new Response(JSON.stringify({ code: "0", data: { id: 7, username: "mt", memberCount: {} } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
const h = await createHarness("sitecenter_mtbase", { fetch });
|
||||
try {
|
||||
const site = await json(
|
||||
|
||||
@@ -18,7 +18,10 @@ describe.skipIf(!up)("快照截止时间", () => {
|
||||
let h: Harness;
|
||||
let siteId: string;
|
||||
// 永不返回,也不理会 signal
|
||||
const hang = (() => new Promise<Response>(() => {})) as typeof fetch;
|
||||
const hang = (() => new Promise<Response>(() => {})) as (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("snapdeadline", { fetch: hang });
|
||||
|
||||
@@ -70,7 +70,7 @@ describe.skipIf(!up)("TMDB 多语言保留规则", () => {
|
||||
const actor = { type: "user" as const, id: "tester" };
|
||||
|
||||
const run = (...args: string[]) =>
|
||||
execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, ...args], {
|
||||
execFileSync(process.execPath, [SCRIPT, ...args], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, DATABASE_URL: url, NO_COLOR: "1" },
|
||||
encoding: "utf8",
|
||||
|
||||
Reference in new issue
Block a user