refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries

This commit is contained in:
root committed 2026-10-13 14:37:03 +08:00
1 parent 3f1c6d02f6
commit 27c0f27434
268 files changed
+7468 -7852

No files matched your search

@@ -0,0 +1,258 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { applyEpisodeTotal } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("自动洗版物理替换(真实 PG、qB协议与临时文件)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let base: Awaited<ReturnType<typeof seedBase>>;
let groupId: string;
const disk = fakeDisk(100_000_000_000);
const torrents = new Map<string, Uint8Array>();
let pieceSeed = 0;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-auto-up-lib-" });
cache = await createTempTree({}, { prefix: "umt-auto-up-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("auto_upgrade_replace", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
await h.setSettings("naming", {
movie: "{{title}} ({{year}})/{{title}} ({{year}}) - {{videoFormat}}{{fileExt}}",
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
});
const g = await h.api("POST", "/rules", { name: "自动洗版优先REMUX", ruleString: "REMUX > WEBDL" });
expect(g.status, JSON.stringify(g.body)).toBe(201);
groupId = g.body.data.id;
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
async function tracked(kind: "movie" | "series", title: string, versions: { name: string; resolutions: string[] }[]) {
const entityId = await seedEntity(h.db, { kind, title, year: 2024 });
const sub = await h.db
.insertInto("subscription")
.values({
media_entity_id: entityId,
media_kind: kind,
mode: "once",
season_scope: "current_season",
params: JSON.stringify({ autoFill: "on" }),
})
.returning("id")
.executeTakeFirstOrThrow();
const slotIds: string[] = [];
for (const [position, v] of versions.entries()) {
const slot = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: v.name,
position,
hard_conditions: JSON.stringify({ resolutions: v.resolutions }),
soft_preferences: JSON.stringify({ groups: ["GOOD", "OTHER", "LOW"] }),
upgrade_threshold: JSON.stringify({
enabled: true,
basis: "priority",
mode: kind === "movie" ? "movie" : "per_unit",
...(kind === "series" ? { consistency: "season_release" } : {}),
}),
rule_group_ids: JSON.stringify([groupId]),
})
.returning("id")
.executeTakeFirstOrThrow();
slotIds.push(slot.id);
}
if (kind === "series")
await h.db
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() }));
return { entityId, slotIds };
}
async function release(entityId: string, title: string) {
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: ++pieceSeed });
const id = await seedRelease(h.db, { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
torrents.set(id, t.bytes);
return { id, hash: t.hash };
}
async function acquire(entityId: string, title: string, expectedSlot: string, purpose: "fill" | "upgrade") {
const rel = await release(entityId, title);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
const planId = ev.plans[0]!;
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", planId)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({ slot_id: expectedSlot, purpose });
if (purpose === "upgrade") expect(plan.evaluation).toMatchObject({ automaticReplacement: true });
const submitted = await submitDownload(h.d, planId);
expect(submitted.status, JSON.stringify(submitted)).toBe("submitted");
await qb.complete(rel.hash, { writeFiles: true });
expect((await syncDownloads(h.d)).completed).toContain(submitted.downloadTaskId);
const imported = await planImport(h.d, {
downloadTaskId: submitted.downloadTaskId!,
trigger: "auto",
actorType: "system",
});
expect(imported.status, JSON.stringify(imported)).toBe("running");
expect((await executeRecord(h.d, imported.recordId!)).status).toBe("succeeded");
const result = await h.db
.selectFrom("organization_file_result")
.select("media_asset_id")
.where("organization_record_id", "=", imported.recordId!)
.where("role", "=", "main")
.where("status", "=", "succeeded")
.executeTakeFirstOrThrow();
const location = await h.db
.selectFrom("asset_location")
.select("relative_path")
.where("media_asset_id", "=", result.media_asset_id!)
.where("location_kind", "=", "library")
.where("status", "=", "available")
.executeTakeFirstOrThrow();
const bytes = await readFile(lib.path(location.relative_path));
expect(bytes).toHaveLength(SIZE);
return { assetId: result.media_asset_id!, path: location.relative_path, bytes, recordId: imported.recordId! };
}
async function expectKept(file: Awaited<ReturnType<typeof acquire>>) {
const a = await h.db
.selectFrom("media_asset")
.select("status")
.where("id", "=", file.assetId)
.executeTakeFirstOrThrow();
expect(a.status).toBe("active");
expect((await readFile(lib.path(file.path))).equals(file.bytes)).toBe(true);
expect(
await h.db.selectFrom("trash_item").select("id").where("original_path", "=", file.path).execute(),
).toHaveLength(0);
}
async function expectTrashed(file: Awaited<ReturnType<typeof acquire>>, samePath = false) {
const a = await h.db
.selectFrom("media_asset")
.select("status")
.where("id", "=", file.assetId)
.executeTakeFirstOrThrow();
expect(a.status).toBe("retired");
const oldLocation = await h.db
.selectFrom("asset_location")
.select("status")
.where("media_asset_id", "=", file.assetId)
.where("location_kind", "=", "library")
.executeTakeFirstOrThrow();
expect(oldLocation.status).toBe("removed");
if (!samePath) expect(existsSync(lib.path(file.path))).toBe(false);
const items = await h.db
.selectFrom("trash_item")
.select("trash_path")
.where("original_path", "=", file.path)
.execute();
expect(items).toHaveLength(1);
expect((await readFile(items[0]!.trash_path)).equals(file.bytes)).toBe(true);
}
it("电影低档自动入库后升级替换同路径旧文件并回收,另一保留分辨率保持原字节", async () => {
const f = await tracked("movie", "Auto Movie", [
{ name: "HD", resolutions: ["1080p"] },
{ name: "UHD", resolutions: ["2160p"] },
]);
const uhd = await acquire(f.entityId, "Auto.Movie.2024.2160p.BluRay.REMUX.HEVC-GOOD", f.slotIds[1]!, "fill");
const lower = await acquire(f.entityId, "Auto.Movie.2024.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
const higher = await acquire(f.entityId, "Auto.Movie.2024.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
expect(higher.path).toBe(lower.path);
expect(higher.assetId).not.toBe(lower.assetId);
expect(higher.bytes.equals(lower.bytes)).toBe(false);
await expectTrashed(lower, true);
await expectKept(uhd);
expect((await readFile(lib.path(higher.path))).equals(higher.bytes)).toBe(true);
await executeRecord(h.d, higher.recordId);
await expectTrashed(lower, true);
await expectKept(uhd);
}, 60_000);
it("旧单文件E1-E2在同套新E1入库时保留,新E2补齐后自动回收旧合集", async () => {
const f = await tracked("series", "Auto Merged", [{ name: "单保留", resolutions: ["720p", "1080p"] }]);
const old = await acquire(f.entityId, "Auto.Merged.S01E01-E02.720p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
const first = await acquire(f.entityId, "Auto.Merged.S01E01.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
await expectKept(old);
const second = await acquire(
f.entityId,
"Auto.Merged.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
f.slotIds[0]!,
"upgrade",
);
await expectTrashed(old);
await expectKept(first);
await expectKept(second);
await executeRecord(h.d, second.recordId);
await expectTrashed(old);
}, 60_000);
it("不同组或另一保留分辨率的已入库E2不能凑齐新E1的合集覆盖", async () => {
const f = await tracked("series", "Auto Isolated", [
{ name: "HD", resolutions: ["1080p"] },
{ name: "UHD", resolutions: ["2160p"] },
]);
const old = await acquire(f.entityId, "Auto.Isolated.S01E01-E02.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
const otherResolution = await acquire(
f.entityId,
"Auto.Isolated.S01E02.2160p.BluRay.REMUX.AVC-GOOD",
f.slotIds[1]!,
"fill",
);
const otherGroup = await acquire(
f.entityId,
"Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-OTHER",
f.slotIds[0]!,
"upgrade",
);
await expectKept(old);
const first = await acquire(
f.entityId,
"Auto.Isolated.S01E01.1080p.BluRay.REMUX.AVC-GOOD",
f.slotIds[0]!,
"upgrade",
);
await expectKept(old);
await expectKept(otherGroup);
await expectKept(otherResolution);
const second = await acquire(
f.entityId,
"Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
f.slotIds[0]!,
"upgrade",
);
await expectTrashed(old);
await expectTrashed(otherGroup, true);
await expectKept(first);
await expectKept(otherResolution);
expect((await readFile(lib.path(second.path))).equals(second.bytes)).toBe(true);
}, 60_000);
});
@@ -0,0 +1,334 @@
import { randomUUID } from "node:crypto";
import type { AcquisitionPolicyScope, SlotTemplate, SubscriptionParams } from "@umt/contracts";
import type { AcquisitionSlotRow } from "@umt/db";
import { type QualityFacts, type SlotSoftPreferences, type SlotSpec, seasonReleaseKey } from "@umt/domain";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureSubscriptionSlot } from "../../src/modules/acquisition-organize/services/collection-strategy.ts";
import { seasonReleaseTarget } from "../../src/modules/acquisition-organize/services/season-release.ts";
import { slotComplete } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { applySlotPreset } from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("收录策略与成套洗版边界(真实 PG)", () => {
let h: Harness;
let siteId: string;
beforeAll(async () => {
h = await createHarness("collection_strategy");
siteId = (
await h.db
.insertInto("site")
.values({
key: "collection-test",
name: "收录策略测试站",
adapter: "nexusphp",
base_url: "https://pt.example.test",
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
});
afterAll(async () => {
await h?.close();
});
async function policy(scope: AcquisitionPolicyScope, slots: SlotTemplate[], publishedAt = "2026-01-01T00:00:00Z") {
return (
await h.db
.insertInto("policy_revision")
.values({
policy_key: randomUUID(),
revision: 1,
kind: "acquisition",
status: "published",
title: "收录策略",
scope: JSON.stringify(scope),
rules: JSON.stringify({ defaultSlots: slots }),
published_at: new Date(publishedAt),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function subscription(
kind: "movie" | "series",
policyId: string | null = null,
params: SubscriptionParams = {},
) {
const entityId = await seedEntity(h.db, { kind, title: `Collection ${randomUUID()}`, year: 2026 });
const sub = await h.db
.insertInto("subscription")
.values({
media_entity_id: entityId,
media_kind: kind,
mode: "once",
season_scope: "current_season",
policy_revision_id: policyId,
params: JSON.stringify(params),
})
.returningAll()
.executeTakeFirstOrThrow();
return { entityId, sub };
}
const slotsOf = (subId: string) =>
h.db.selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
async function series(opts: { resolutions?: string[]; wash?: boolean; soft?: SlotSoftPreferences } = {}) {
const { entityId, sub } = await subscription("series");
await h.db
.insertInto("content_unit")
.values({ media_entity_id: entityId, kind: "season", season_number: 1, status: "confirmed", episode_total: 2 })
.execute();
const eps = new Map<number, string>();
for (const ep of [1, 2]) {
const unit = await h.db
.insertInto("content_unit")
.values({
media_entity_id: entityId,
kind: "episode",
season_number: 1,
episode_number: ep,
status: "confirmed",
})
.returning("id")
.executeTakeFirstOrThrow();
eps.set(ep, unit.id);
}
const slot = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: "保留版本",
hard_conditions: JSON.stringify({ resolutions: opts.resolutions ?? ["1080p"] }),
soft_preferences: JSON.stringify(opts.soft ?? {}),
upgrade_threshold: JSON.stringify(
opts.wash === false
? {}
: { enabled: true, basis: "priority", consistency: "season_release", targetPri: 100 },
),
})
.returningAll()
.executeTakeFirstOrThrow();
return { entityId, sub, slot, eps };
}
const quality = (group: string, resolution: QualityFacts["resolution"] = "1080p"): QualityFacts => ({
resolution,
source: "WEB-DL",
videoCodec: "H.264",
releaseGroup: { name: group, source: "suffix" },
hdr: [],
pri: 100,
});
async function asset(f: Awaited<ReturnType<typeof series>>, episodes: number[], q: QualityFacts, ledger = true) {
const a = await h.db
.insertInto("media_asset")
.values({
media_entity_id: f.entityId,
kind: "video",
status: "active",
media_info: JSON.stringify({ _umt: { quality: q } }),
})
.returning("id")
.executeTakeFirstOrThrow();
for (const ep of episodes) {
await h.db
.insertInto("asset_unit_coverage")
.values({ media_asset_id: a.id, content_unit_id: f.eps.get(ep)!, coverage: "full" })
.execute();
if (ledger)
await h.db
.insertInto("slot_unit")
.values({
slot_id: f.slot.id,
content_unit_id: f.eps.get(ep)!,
media_asset_id: a.id,
quality: JSON.stringify(q),
pri: q.pri,
})
.onConflict((oc) =>
oc
.columns(["slot_id", "content_unit_id"])
.doUpdateSet({ media_asset_id: a.id, quality: JSON.stringify(q), pri: q.pri }),
)
.execute();
}
return a.id;
}
async function plan(
slot: AcquisitionSlotRow,
q: QualityFacts,
units: string[],
status: "submitted" | "reserved" = "submitted",
) {
const relId = await seedRelease(h.db, {
siteId,
entityId: slot.media_entity_id,
title: `Collection.S01.${q.resolution}.WEB-DL.x264-${q.releaseGroup!.name}`,
size: 1_000_000,
hash: randomUUID().replaceAll("-", "").padEnd(40, "0"),
});
const p = await h.db
.insertInto("acquisition_plan")
.values({
slot_id: slot.id,
source_release_id: relId,
status,
status_reason: status === "submitted" ? "download_accepted" : null,
purpose: "upgrade",
unit_keys: units,
payload_hash: randomUUID(),
idempotency_key: randomUUID(),
evaluation: JSON.stringify({ pri: q.pri, quality: q, season: 1, seasonRelease: seasonReleaseKey(q) }),
})
.returning("id")
.executeTakeFirstOrThrow();
return { planId: p.id, relId };
}
it("显式选择优先;分类按 movie/tv 匹配,未命中分类时使用媒体类型默认模板", async () => {
const allId = await policy({}, [{ name: "通用" }], "2026-06-01T00:00:00Z");
const movieId = await policy({ mediaKinds: ["movie"] }, [{ name: "电影默认" }]);
const tvId = await policy({ mediaKinds: ["series"] }, [{ name: "剧集默认" }]);
const movieCategoryId = await policy({ categories: { movie: ["精选"] } }, [{ name: "电影精选" }]);
const tvCategoryId = await policy({ categories: { tv: ["精选"] } }, [{ name: "剧集精选" }]);
for (const [kind, category, explicitId, expectedId, name] of [
["series", "精选", allId, allId, "通用"],
["movie", "精选", null, movieCategoryId, "电影精选"],
["series", "精选", null, tvCategoryId, "剧集精选"],
["movie", "其他", null, movieId, "电影默认"],
["series", "其他", null, tvId, "剧集默认"],
] as const) {
const { sub } = await subscription(kind, explicitId);
expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(1);
expect(await slotsOf(sub.id)).toMatchObject([{ name, policy_revision_id: expectedId, position: 0 }]);
expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(0);
expect(await slotsOf(sub.id)).toHaveLength(1);
}
});
it("显式选择没有模板的策略时报告错误,不静默回退到分类或通用策略", async () => {
const policyId = await policy({}, []);
const { sub } = await subscription("series", policyId);
await expect(ensureSubscriptionSlot(h.db, sub, { mediaKind: "series", category: "精选" })).rejects.toThrow(
"所选收录策略",
);
expect(await slotsOf(sub.id)).toHaveLength(0);
});
it("双分辨率模板保留各自范围,统一预设即使与某模板相同也不能覆盖它", async () => {
const policyId = await policy({}, [
{ name: "HD", hardConditions: { resolutions: ["1080p"] } },
{ name: "UHD", hardConditions: { resolutions: ["2160p"] } },
]);
const { sub } = await subscription("series", policyId, { resolution: "1080p" });
expect(await ensureSubscriptionSlot(h.db, sub)).toBe(2);
const expected = [
{ name: "HD", hard_conditions: { resolutions: ["1080p"] } },
{ name: "UHD", hard_conditions: { resolutions: ["2160p"] } },
];
expect(await slotsOf(sub.id)).toMatchObject(expected);
await applySlotPreset(h.db, {
subscriptionId: sub.id,
prev: { resolution: "1080p" },
next: { resolution: "720p" },
});
expect(await slotsOf(sub.id)).toMatchObject(expected);
});
it("单保留4K版本时,作品已有完整720p不能填补4K覆盖", async () => {
const f = await series({ resolutions: ["2160p"], wash: false });
await asset(f, [1, 2], quality("LOW", "720p"), false);
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: false, atTarget: false });
await asset(f, [1, 2], quality("UHD", "2160p"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
});
it("已接收的旧720p计划同样不能填补改为4K后的范围", async () => {
const f = await series({ resolutions: ["2160p"], wash: false });
await plan(f.slot, quality("LOW", "720p"), ["s1e1", "s1e2"]);
expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
complete: false,
atTarget: false,
});
await plan(f.slot, quality("UHD", "2160p"), ["s1e1", "s1e2"]);
expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
complete: true,
atTarget: true,
});
});
it("HD/UHD分别完成,另一个保留分辨率的资产不能借来填缺", async () => {
const f = await series({ resolutions: ["1080p"], wash: false });
const uhd = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: f.entityId,
subscription_id: f.sub.id,
name: "UHD",
hard_conditions: JSON.stringify({ resolutions: ["2160p"] }),
})
.returningAll()
.executeTakeFirstOrThrow();
await asset(f, [1, 2], quality("HD"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: false });
await asset({ ...f, slot: uhd }, [1, 2], quality("UHD", "2160p"));
expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: true });
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
});
it("各集pri100但组不同仍未到成套目标;同组的分集资源合齐后到目标", async () => {
const f = await series({ soft: { groups: ["B", "A"] } });
await asset(f, [1], quality("B"));
await asset(f, [2], quality("A"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
await asset(f, [2], quality("B"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
});
it("完整A不能盖过更优但只有部分集的B,成套洗版继续以B为目标", async () => {
const f = await series({ soft: { groups: ["B", "A"] } });
await asset(f, [1, 2], quality("A"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
await asset(f, [1], quality("B"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
await asset(f, [2], quality("B"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
});
it("在途季包冻结版本事实,发布属性修改不漂移锚点,失败后才释放", async () => {
const f = await series({ soft: { groups: ["C", "A", "B"] } });
const frozen = quality("A");
const p = await plan(f.slot, frozen, ["s1*"], "reserved");
const spec: SlotSpec = {
name: "成套目标",
hard: { resolutions: ["1080p"] },
soft: { groups: ["C", "A", "B"] },
upgrade: { enabled: true, basis: "priority", consistency: "season_release" },
};
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
seasonReleaseKey(frozen),
);
await h.db
.updateTable("source_release")
.set({ title: "Collection.S01.1080p.WEB-DL.x264-C", parsed: JSON.stringify({}), parser_version: null })
.where("id", "=", p.relId)
.execute();
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
seasonReleaseKey(frozen),
);
await h.db.updateTable("acquisition_plan").set({ status: "failed" }).where("id", "=", p.planId).execute();
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
seasonReleaseKey(quality("B")),
);
});
});
@@ -0,0 +1,186 @@
import { createQbClient } from "@umt/adapters";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { MISSING_CONFIRM_ROUNDS, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase } from "./harness.ts";
const hasPg = await pgAvailable();
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe.skipIf(!hasPg)("下载观测顺序(真实 PG + 受控 qB 响应)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let base: Awaited<ReturnType<typeof seedBase>>;
let tick = 0;
let hashIndex = 0;
const epoch = Date.parse("2026-10-13T00:00:00Z");
let pending: {
method: "list" | "findByHash";
hash?: string;
captured: ReturnType<typeof deferred>;
resume: ReturnType<typeof deferred>;
} | null = null;
beforeAll(async () => {
qb = await createMockQb();
lib = await createTempTree({}, { sentinel: true });
cache = await createTempTree({});
h = await createHarness("download_order", { diskUsage: fakeDisk(10_000_000_000).fn });
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
h.d.ports.now = () => new Date(epoch + tick);
const original = h.d.ports.downloader;
h.d.ports.downloader = async (inst) => {
const client = await original(inst);
const hold = async <T>(method: "list" | "findByHash", read: () => Promise<T>, hash?: string): Promise<T> => {
const gate = pending?.method === method && (!pending.hash || pending.hash === hash) ? pending : null;
if (gate) pending = null;
const snapshot = await read();
if (gate) {
gate.captured.resolve();
await gate.resume.promise;
}
return snapshot;
};
return {
...client,
list: (input) => hold("list", () => client.list(input)),
findByHash: (hash) => hold("findByHash", () => client.findByHash(hash), hash),
};
};
});
afterAll(async () => {
pending?.resume.resolve();
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const nextTime = () => new Date(epoch + ++tick);
const row = (id: string) =>
h.db.selectFrom("download_task").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const pauseNext = (method: "list" | "findByHash", hash?: string) => {
const gate = { method, hash, captured: deferred(), resume: deferred() };
pending = gate;
return gate;
};
const seedTorrent = (hash: string) => {
qb.seed({ hash, name: `Observation.${hash}.mkv`, size: 300_000, tags: ["umt"], save_path: cache.root });
qb.setProgress(hash, 0.25);
};
async function task() {
const hash = (++hashIndex).toString(16).padStart(40, "0");
seedTorrent(hash);
nextTime();
await syncDownloads(h.d);
const r = await h.db
.selectFrom("download_task")
.selectAll()
.where("torrent_hash", "=", hash)
.executeTakeFirstOrThrow();
expect(r).toMatchObject({ managed: true, status: "downloading", missing_count: 0 });
return { hash, id: r.id };
}
it("移除标签的新观测先提交时,旧有标签列表晚到不能复活managed", async () => {
const t = await task();
const gate = pauseNext("list");
nextTime();
const older = syncDownloads(h.d);
try {
await gate.captured.promise;
qb.get(t.hash)!.tags = [];
const newerAt = nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({ managed: false, tags: [], last_synced_at: newerAt });
gate.resume.resolve();
await older;
expect(await row(t.id)).toMatchObject({ managed: false, tags: [], removed_at: null, last_synced_at: newerAt });
} finally {
gate.resume.resolve();
await older;
}
});
it("旧缺失响应晚到不能把已经重新观测到的任务判移除或恢复旧missing_count", async () => {
const t = await task();
await h.db
.updateTable("download_task")
.set({ missing_count: MISSING_CONFIRM_ROUNDS - 1, missing_since: new Date(epoch) })
.where("id", "=", t.id)
.execute();
const external = createQbClient({
instanceId: base.instanceId,
baseUrl: qb.url,
username: "admin",
password: "adminadmin",
});
await external.delete([t.hash], { deleteFiles: false });
const gate = pauseNext("findByHash", t.hash);
nextTime();
const older = syncDownloads(h.d);
try {
await gate.captured.promise;
seedTorrent(t.hash);
const newerAt = nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({
managed: true,
status: "downloading",
missing_count: 0,
last_synced_at: newerAt,
});
gate.resume.resolve();
await older;
expect(await row(t.id)).toMatchObject({
managed: true,
status: "downloading",
removed_at: null,
missing_count: 0,
missing_since: null,
last_synced_at: newerAt,
});
} finally {
gate.resume.resolve();
await older;
}
});
it("重新贴标签的新观测可恢复管理,旧无标签响应晚到不能撤销恢复", async () => {
const t = await task();
qb.get(t.hash)!.tags = [];
const gate = pauseNext("findByHash", t.hash);
nextTime();
const older = syncDownloads(h.d);
try {
await gate.captured.promise;
qb.get(t.hash)!.tags = ["umt"];
const newerAt = nextTime();
await syncDownloads(h.d);
gate.resume.resolve();
await older;
expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: newerAt });
qb.get(t.hash)!.tags = [];
nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({ managed: false });
qb.get(t.hash)!.tags = ["umt"];
const restoredAt = nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: restoredAt });
} finally {
gate.resume.resolve();
await older;
}
});
});
@@ -262,6 +262,23 @@ describe.skipIf(!hasPg)("整理卡死回收(真实 PG + mock qB + 临时目录)"
const dec = await decision(`organize_excluded:${plan.recordId}`);
expect(dec).toMatchObject({ status: "open", subject_id: plan.recordId });
expect(JSON.stringify(dec!.evidence)).toContain("excluded:episode_unresolved");
// 终态重放也不能把部分成功当成整包成功,且刮削记录不重复。
const scrapes = () =>
h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", plan.recordId!).execute();
const beforeReplay = await scrapes();
expect(beforeReplay).toHaveLength(1);
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("partial");
expect(existsSync(join(cache.root, name, ani(40)))).toBe(true);
expect(qb.get(hash)).toBeDefined();
expect(qb.callsTo("torrents/delete")).toHaveLength(0);
expect(
await h.db
.selectFrom("download_task")
.select(["managed", "removed_at"])
.where("id", "=", dtId)
.executeTakeFirst(),
).toEqual({ managed: true, removed_at: null });
expect(await scrapes()).toEqual(beforeReplay);
// partial 不在补移除范围内
await h.db
.updateTable("organization_record")
@@ -0,0 +1,318 @@
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 },
});
});
});
@@ -79,7 +79,7 @@ describe.skipIf(!hasPg)("主密钥轮换(临时库)", () => {
const main = fileURLToPath(new URL("../../src/main.ts", import.meta.url));
const url = `${PG_URL}${PG_URL.includes("?") ? "&" : "?"}options=${encodeURIComponent(`-c search_path=${h.schema},public`)}`;
const run = (extra: Record<string, string>) =>
spawnSync("deno", ["run", "-A", main, "--role", "rotate-master-key"], {
spawnSync(process.execPath, [main, "--role", "rotate-master-key"], {
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
@@ -13,7 +13,7 @@ const hasPg = await pgAvailable();
const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url));
function proc(args: string[]) {
const child = spawn(process.execPath, ["run", "-A", "test/fixtures/sse-bus-proc.ts", ...args], {
const child = spawn(process.execPath, ["test/fixtures/sse-bus-proc.ts", ...args], {
cwd: SERVER_DIR,
env: { ...process.env, DATABASE_URL: PG_URL },
stdio: ["ignore", "pipe", "pipe"],
@@ -0,0 +1,130 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { coveringGrant, type PlanBinding, planBinding } from "../../src/authorization/grants.ts";
import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts";
const hasPg = await pgAvailable();
const now = new Date("2026-01-15T12:00:00Z");
const target: PlanBinding = {
payloadHash: "plan-hash",
revision: 1,
mediaEntityId: randomUUID(),
mediaKind: "tv",
bytes: 100,
};
describe.skipIf(!hasPg)("共用计划绑定与持续授权(真实 PG)", () => {
let schema: TestSchema;
beforeAll(async () => {
schema = await createTestSchema(uniqueSchema("server_test_grants"));
});
afterAll(async () => {
await schema?.drop();
});
beforeEach(async () => {
await schema.db.deleteFrom("capability_invocation").execute();
await schema.db.deleteFrom("automation_grant").execute();
await schema.db.deleteFrom("policy_revision").execute();
await schema.db.deleteFrom("artifact").execute();
});
const grant = (over: Record<string, unknown> = {}) =>
schema.db
.insertInto("automation_grant")
.values({ action: "download.submit", granted_by: "user:test", ...over })
.returning("id")
.executeTakeFirstOrThrow();
const check = (plan = target) => coveringGrant(schema.db, "download.submit", plan, now);
it("整理计划绑定读取当前内容与 hash,计划不存在时返回 null", async () => {
const p = await schema.db
.insertInto("artifact")
.values({
kind: "plan",
title: "整理计划",
content_hash: target.payloadHash,
content: JSON.stringify({ mediaEntityId: target.mediaEntityId, mediaKind: target.mediaKind }),
})
.returning("id")
.executeTakeFirstOrThrow();
expect(await planBinding(schema.db, "organize_plan", p.id)).toEqual({ ...target, bytes: null });
await schema.db.updateTable("artifact").set({ content_hash: "new-hash" }).where("id", "=", p.id).execute();
expect((await planBinding(schema.db, "organize_plan", p.id))?.payloadHash).toBe("new-hash");
expect(await planBinding(schema.db, "organize_plan", randomUUID())).toBeNull();
});
it("授权范围同时检查能力、作品和媒体类型,series 范围覆盖 tv", async () => {
const g = await grant({
scope: JSON.stringify({
capabilities: ["download.submit"],
mediaEntityIds: [target.mediaEntityId],
mediaKinds: ["series"],
}),
});
expect(await check()).toEqual({ grantId: g.id });
expect(await check({ ...target, mediaEntityId: randomUUID() })).toMatchObject({ reasons: [expect.any(String)] });
expect(await check({ ...target, mediaKind: "movie" })).toMatchObject({ reasons: [expect.any(String)] });
await schema.db
.updateTable("automation_grant")
.set({ scope: JSON.stringify({ capabilities: ["organize.execute"] }) })
.where("id", "=", g.id)
.execute();
expect(await check()).toMatchObject({ reasons: [expect.any(String)] });
});
it.each(["expired", "revoked"] as const)("%s 授权不放行", async (state) => {
await grant(
state === "expired" ? { expires_at: new Date(now.getTime() - 1) } : { status: "revoked", revoked_at: now },
);
expect(await check()).toEqual({ reasons: [] });
});
it("策略被替代后,绑定该策略的持续授权不再放行", async () => {
const p = await schema.db
.insertInto("policy_revision")
.values({
policy_key: "test-acquisition",
revision: 1,
kind: "acquisition",
title: "测试策略",
status: "published",
})
.returning("id")
.executeTakeFirstOrThrow();
const g = await grant({ action: "policy.acquisition", policy_revision_id: p.id });
expect(await check()).toEqual({ grantId: g.id });
await schema.db.updateTable("policy_revision").set({ status: "superseded" }).where("id", "=", p.id).execute();
expect(await check()).toMatchObject({ reasons: [expect.stringContaining("已不是发布状态")] });
});
it("每日次数只统计成功调用,达到预算后拒绝", async () => {
const g = await grant({ budget: JSON.stringify({ maxInvocationsPerDay: 2 }) });
const invoke = (status: "failed" | "succeeded") =>
schema.db
.insertInto("capability_invocation")
.values({
idempotency_key: randomUUID(),
capability_id: "download.submit",
capability_version: 1,
actor_id: "user:test",
source: "web",
status,
grant_id: g.id,
created_at: now,
})
.execute();
await invoke("succeeded");
await invoke("failed");
expect(await check()).toEqual({ grantId: g.id });
await invoke("succeeded");
expect(await check()).toMatchObject({ reasons: [expect.stringContaining("次数预算已用尽")] });
});
it("提交下载时把本次预留字节计入预算", async () => {
const g = await grant({ budget: JSON.stringify({ maxBytesPerDay: 100 }) });
expect(await check()).toEqual({ grantId: g.id });
expect(await check({ ...target, bytes: 101 })).toMatchObject({
reasons: [expect.stringContaining("字节预算不足")],
});
});
});
+1 -1
View File
@@ -52,7 +52,7 @@ function harness(
status: 200,
headers: { "content-type": "text/html", "www-authenticate": "Basic", "x-frame-options": "DENY" },
});
}) as typeof fetch,
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
});
const root = new OpenAPIHono<AuthEnv>();
root.onError((err, c) => {
@@ -14,7 +14,7 @@ import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./
// 运行(凭证只经环境变量传入,不写进代码与报告):
// UMT_EVAL_REAL=1 UMT_EVAL_API_KEY=… UMT_EVAL_PROVIDER=opencode-go UMT_EVAL_MODEL=deepseek-v4.1-flash \
// UMT_EVAL_LEGACY_PROMPT=/tmp/legacy-system-prompt.ts UMT_EVAL_CASES=health,acquire,why UMT_EVAL_OUT=/tmp/eval.json \
// DATABASE_URL=… deno run -A npm:vitest run test/e2e/agent-prompt-eval.real.test.ts
// DATABASE_URL=… bun --bun x vitest run test/e2e/agent-prompt-eval.real.test.ts
// UMT_EVAL_LEGACY_PROMPT 用 `git show main:packages/agent/src/system-prompt.ts > /tmp/legacy-system-prompt.ts` 生成;
// UMT_EVAL_PROMPTS=legacy,layered 控制跑哪几组(缺省两组都跑)。
+1 -1
View File
@@ -29,7 +29,7 @@ describe.skipIf(!hasPg)("E4 崩溃恢复(worker 子进程 kill -9)", () => {
const children: ChildProcess[] = [];
const spawnWorker = (): Promise<ChildProcess> => {
const child = spawn(process.execPath, ["run", "-A", WORKER], {
const child = spawn(process.execPath, [WORKER], {
cwd: SERVER_DIR,
env: {
...process.env,
+8 -8
View File
@@ -11,17 +11,17 @@ import { startWorker } from "../../../src/worker/index.ts";
import { buildModules } from "./modules.ts";
import { crashScript } from "./scripts.ts";
const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
const biz = Deno.env.get("BIZ_SCHEMA")!;
const ow = Deno.env.get("OW_SCHEMA")!;
const sessionDir = Deno.env.get("SESSION_DIR")!;
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
const biz = process.env.BIZ_SCHEMA!;
const ow = process.env.OW_SCHEMA!;
const sessionDir = process.env.SESSION_DIR!;
await import("@umt/db"); // 注册 int8 解析器
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 8, options: `-c search_path=${biz},public` }),
}),
});
const logger = createLogger({ level: Deno.env.get("E2E_LOG_LEVEL") === "debug" ? "debug" : "warn" });
const logger = createLogger({ level: process.env.E2E_LOG_LEVEL === "debug" ? "debug" : "warn" });
const config = ConfigSchema.parse({
DATABASE_URL: url,
SECRET_MASTER_KEY: "e2e-master-key-0123456789",
@@ -43,10 +43,10 @@ const worker = await startWorker({
agentConcurrency: 2,
},
});
console.log(`worker ready pid=${Deno.pid}`);
Deno.addSignalListener("SIGTERM", async () => {
console.log(`worker ready pid=${process.pid}`);
process.on("SIGTERM", async () => {
await worker.stop().catch(() => {});
await booted.engine.stop().catch(() => {});
await db.destroy().catch(() => {});
Deno.exit(0);
process.exit(0);
});
+1 -1
View File
@@ -52,7 +52,7 @@ const mockFetch = (async (input: string | URL | Request, init?: RequestInit) =>
if (url.href === NOTICE_URL)
return new Response(NOTICE_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
return new Response("not found", { status: 404 });
}) as typeof fetch;
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
const resolveHost = async (host: string) => (host.endsWith("example.org") ? ["93.184.216.34"] : ["203.0.114.10"]);
+5 -5
View File
@@ -1,14 +1,14 @@
// SSE 多进程测试用子进程:sub 模式订阅并逐行输出收到的事件 JSON,收满 n 条退出;pub 模式发布 n 条后退出。
// 用法:deno run -A test/fixtures/sse-bus-proc.ts <sub|pub> <schema> <channel> <n>
// 用法:bun test/fixtures/sse-bus-proc.ts <sub|pub> <schema> <channel> <n>
import type { DB } from "@umt/db";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { PgEventBus } from "../../src/http/sse-pg.ts";
import { createLogger } from "../../src/logger.ts";
const [mode, schema, channel, nRaw] = Deno.args;
const [mode, schema, channel, nRaw] = process.argv.slice(2);
const n = Number(nRaw);
const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }),
@@ -32,12 +32,12 @@ if (mode === "sub") {
console.log("READY");
const timeout = setTimeout(() => {
console.error("timeout");
Deno.exit(3);
process.exit(3);
}, 20_000);
await done;
clearTimeout(timeout);
} else {
for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: Deno.pid });
for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: process.pid });
}
await bus.stop();
await db.destroy();
@@ -1,175 +0,0 @@
import type { ParsedRelease } from "@umt/domain";
import { describe, expect, it, vi } from "vitest";
import { HookRegistry, hasHook, interceptHook, rewriteHook } from "../../src/hooks/index.ts";
// 链式钩子(ADR-006):注册顺序、改写、拦截、抛错隔离、超时、没有注册时零开销。
const warnings = () => {
const warn = vi.fn();
return { logger: { warn } as never, warn };
};
const parsed = (title: string) => ({ title, raw: title, explicitIds: [] }) as unknown as ParsedRelease;
const recognizeCtx = { title: "x", subtitle: null, sourceReleaseId: null, trigger: "observed" };
const completionCtx = {
subscriptionId: "s",
mediaEntityId: null,
mediaKind: "movie",
reason: "slot_satisfied",
progress: {},
};
describe("链式钩子注册表", () => {
it("按注册顺序执行(bootstrap 按模块顺序注册),每个钩子拿到上一个的结果;缺省名 <模块>#<序号>", async () => {
const reg = new HookRegistry();
const seen: string[] = [];
const a = reg.forModule("a");
const b = reg.forModule("b");
a.rewrite("organize.rename", (name) => {
seen.push(`a1:${name}`);
return `${name}-a1`;
});
b.rewrite("organize.rename", (name) => {
seen.push(`b:${name}`);
return `${name}-b`;
});
a.rewrite("organize.rename", (name) => {
seen.push(`a2:${name}`);
return undefined; // 不改
});
const ctx = { hooks: reg };
const out = await rewriteHook(ctx, "organize.rename", "X.mkv", {
targetPath: "/lib/X.mkv",
sourcePath: "/src/X.mkv",
mediaEntityId: "e",
mediaType: "movie",
season: null,
episodes: null,
});
expect(out).toBe("X.mkv-a1-b");
expect(seen).toEqual(["a1:X.mkv", "b:X.mkv-a1", "a2:X.mkv-a1-b"]);
expect(reg.list()).toEqual({ "organize.rename": ["a#1", "b#1", "a#2"] });
});
it("改写生效:异步钩子改写解析结果", async () => {
const reg = new HookRegistry();
reg.forModule("m").rewrite("name.recognize", async (p) => ({ ...p, title: "改写后" }));
const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("原名"), recognizeCtx);
expect(out.title).toBe("改写后");
});
it("拦截生效:第一个返回原因的钩子拒绝,后面的不再执行;都放行返回 null", async () => {
const reg = new HookRegistry();
const later = vi.fn(() => "不该执行");
const m = reg.forModule("m");
m.intercept("subscription.completion.check", () => null);
m.intercept("subscription.completion.check", () => " 还缺特别篇 ", { name: "缺特别篇" });
m.intercept("subscription.completion.check", later);
expect(await interceptHook({ hooks: reg }, "subscription.completion.check", completionCtx)).toEqual({
reason: "还缺特别篇",
by: "缺特别篇",
});
expect(later).not.toHaveBeenCalled();
const pass = new HookRegistry();
pass.forModule("m").intercept("subscription.completion.check", () => undefined);
expect(await interceptHook({ hooks: pass }, "subscription.completion.check", completionCtx)).toBeNull();
});
it("抛错隔离:出错的钩子跳过并记日志,前后的钩子照常生效", async () => {
const { logger, warn } = warnings();
const reg = new HookRegistry(logger);
const m = reg.forModule("m");
m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+1` }));
m.rewrite("name.recognize", () => {
throw new Error("插件坏了");
});
m.rewrite("name.recognize", async () => {
throw new Error("异步也坏了");
});
m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+2` }));
const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("t"), recognizeCtx);
expect(out.title).toBe("t+1+2");
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0]![0]).toMatchObject({
point: "name.recognize",
hook: "m#2",
err: { message: "插件坏了" },
});
// 拦截类抛错按放行处理
const r2 = new HookRegistry(logger);
r2.forModule("m").intercept("organize.intercept", () => {
throw new Error("x");
});
expect(
await interceptHook({ hooks: r2 }, "organize.intercept", {
mediaEntityId: "e",
mediaType: "movie",
action: "copy",
trigger: "auto",
downloadTaskId: null,
files: [],
}),
).toBeNull();
});
it("超时:超过上限的钩子跳过(沿用上一个值),不等它跑完", async () => {
const { logger, warn } = warnings();
const reg = new HookRegistry(logger);
const m = reg.forModule("slow");
m.rewrite("organize.rename", () => new Promise<string>((r) => setTimeout(() => r("迟到.mkv"), 5_000)), {
timeoutMs: 30,
});
m.rewrite("organize.rename", (n) => `${n}.ok`);
const t0 = Date.now();
const out = await reg.runRewrite("organize.rename", "A.mkv", {
targetPath: "/l/A.mkv",
sourcePath: "/s/A.mkv",
mediaEntityId: "e",
mediaType: "movie",
season: null,
episodes: null,
});
expect(out).toBe("A.mkv.ok");
expect(Date.now() - t0).toBeLessThan(2_000);
expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "slow#1", timeoutMs: 30 });
});
it("没有注册时零开销:不构造上下文、原样返回同一个对象;没有注册表(手搭上下文)同样", async () => {
const ctxBuilder = vi.fn(() => recognizeCtx);
const p = parsed("t");
const empty = new HookRegistry();
// 别的挂点有钩子不影响这里
empty.forModule("m").intercept("organize.intercept", () => "x");
expect(hasHook({ hooks: empty }, "name.recognize")).toBe(false);
expect(await rewriteHook({ hooks: empty }, "name.recognize", p, ctxBuilder)).toBe(p);
expect(await rewriteHook({}, "name.recognize", p, ctxBuilder)).toBe(p);
expect(await interceptHook({}, "subscription.completion.check", () => completionCtx)).toBeNull();
expect(ctxBuilder).not.toHaveBeenCalled();
});
it("调用方给了 accept 时,结果不可用的钩子跳过,后面的钩子拿到的是上一个可用值", async () => {
const { logger, warn } = warnings();
const reg = new HookRegistry(logger);
const m = reg.forModule("m");
m.rewrite("organize.rename", (n) => `../${n}`);
m.rewrite("organize.rename", (n) => `${n}.ok`);
const c = {
targetPath: "/a",
sourcePath: "/b",
mediaEntityId: "e",
mediaType: "movie" as const,
season: null,
episodes: null,
};
expect(await rewriteHook({ hooks: reg }, "organize.rename", "A.mkv", c, (v) => !v.includes("/"))).toBe("A.mkv.ok");
expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "m#1" });
});
it("用错挂点类型注册直接报错", () => {
const reg = new HookRegistry();
// biome-ignore lint/suspicious/noExplicitAny: 故意绕过类型检查
const m = reg.forModule("m") as any;
expect(() => m.rewrite("organize.intercept", () => "x")).toThrow(/intercept 类/);
});
});
@@ -1,45 +0,0 @@
import type { SourceReleaseRow } from "@umt/db";
import { describe, expect, it } from "vitest";
import { HookRegistry } from "../../src/hooks/index.ts";
import { selectResources } from "../../src/modules/acquisition-organize/services/acquisition.ts";
// 链式钩子 resource.selection(ADR-006,对照 MP ChainEventType.ResourceSelection):订阅重评时调整候选顺序 / 过滤,
// 只能重排与删减,不能加入新候选。
const rel = (id: string, title: string) => ({ id, title, parsed: null }) as unknown as SourceReleaseRow;
const rels = [
rel("r1", "Show.S01E01.1080p.WEB-DL"),
rel("r2", "Show.S01E01.2160p.WEB-DL"),
rel("r3", "Show.S01E01.720p"),
];
const context = { mediaEntityId: "e", subscriptionId: "s", slotId: null };
const ctxOf = (hooks?: HookRegistry) => ({ d: { ctx: { hooks } } }) as never;
describe("resource.selection 钩子", () => {
it("没有钩子时原样返回同一个数组", async () => {
expect(await selectResources(ctxOf(), rels, context)).toBe(rels);
expect(await selectResources(ctxOf(new HookRegistry()), rels, context)).toBe(rels);
});
it("重排与过滤生效;不认识的 ID 忽略、重复的只取一次", async () => {
const hooks = new HookRegistry();
const seen: unknown[] = [];
hooks.forModule("test").rewrite("resource.selection", (cands, c) => {
seen.push({ ids: cands.map((x) => x.sourceReleaseId), c });
const by = new Map(cands.map((x) => [x.sourceReleaseId, x]));
// 2160p 优先、去掉 720p,夹带一个不存在的候选和一个重复
return [by.get("r2")!, { ...by.get("r1")!, sourceReleaseId: "intruder" }, by.get("r1")!, by.get("r2")!];
});
const out = await selectResources(ctxOf(hooks), rels, context);
expect(out.map((r) => r.id)).toEqual(["r2", "r1"]);
expect(seen).toEqual([{ ids: ["r1", "r2", "r3"], c: context }]);
});
it("钩子出错时沿用原顺序", async () => {
const hooks = new HookRegistry();
hooks.forModule("test").rewrite("resource.selection", () => {
throw new Error("坏了");
});
expect((await selectResources(ctxOf(hooks), rels, context)).map((r) => r.id)).toEqual(["r1", "r2", "r3"]);
});
});
@@ -2,7 +2,6 @@ import { type EventType, isEventType, LIFECYCLE_EVENTS, parseEventPayload } from
import { OutboxRelay } from "@umt/tasks";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { collectOutboxRoutes } from "../../src/context.ts";
import { HookRegistry } from "../../src/hooks/index.ts";
import {
emitDownloadLifecycle,
emitScrapeFinished,
@@ -78,7 +77,7 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
]);
});
it("订阅完成:发已完成(按历史行去重);钩子 subscription.completion.check 拒绝时不完成、不发事件", async () => {
it("订阅完成:发已完成(按历史行去重),重复完成不重复发事件", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "完成判定片", year: 2023 });
const sub = await h.db
.insertInto("subscription")
@@ -86,26 +85,6 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
.returningAll()
.executeTakeFirstOrThrow();
const hooks = new HookRegistry();
const seen: unknown[] = [];
hooks.forModule("test").intercept("subscription.completion.check", (c) => {
seen.push(c);
return "还要等导演剪辑版";
});
h.ctx.hooks = hooks;
try {
const done = await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"));
expect(done).toBe(false);
} finally {
h.ctx.hooks = undefined;
}
expect(seen).toEqual([
{ subscriptionId: sub.id, mediaEntityId: e, mediaKind: "movie", reason: "slot_satisfied", progress: {} },
]);
const still = await h.db.selectFrom("subscription").select("status").where("id", "=", sub.id).executeTakeFirst();
expect(still?.status).toBe("active");
expect((await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id)).toEqual([]);
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(true);
// 已完成的再判一次不会重复发
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(
@@ -1,68 +0,0 @@
import { parseRelease } from "@umt/domain";
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { HookRegistry } from "../../src/hooks/index.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "../sources-identity/harness.ts";
// 链式钩子 name.recognize(ADR-006,对照 MP ChainEventType.NameRecognize):资源标题解析之后、召回之前改写解析结果。
const up = await pgAvailable();
const GARBLED = "Zhuo.Wu.Ming.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT";
describe.skipIf(!up)("name.recognize 钩子改写解析结果", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
const release = async (title: string) =>
h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(910000 + ++n), title })
.returningAll()
.executeTakeFirstOrThrow();
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: TMDB_FIXTURES });
h = await createHarness("name_hook", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://hook.example/" })).site.id;
await h.db
.insertInto("settings")
.values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
.execute();
});
afterEach(() => {
h.ctx.hooks = undefined;
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
it("没有钩子:认不出的标题照常不自动识别", async () => {
const r = await release(GARBLED);
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
expect(res.outcome).not.toBe("auto");
});
it("钩子按站点别名改写解析结果后自动识别;上下文带原标题与来源资源", async () => {
const seen: unknown[] = [];
const hooks = new HookRegistry();
hooks.forModule("test").rewrite("name.recognize", (p, c) => {
seen.push({ title: c.title, sourceReleaseId: c.sourceReleaseId, trigger: c.trigger });
return c.title.startsWith("Zhuo.Wu.Ming") ? parseRelease(c.title.replace("Zhuo.Wu.Ming", "Oppenheimer")) : p;
});
h.ctx.hooks = hooks;
const r = await release(GARBLED);
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
expect(res.outcome).toBe("auto");
const e = await h.db
.selectFrom("external_identity")
.select("external_id")
.where("media_entity_id", "=", res.mediaEntityId!)
.where("provider", "=", "tmdb")
.executeTakeFirstOrThrow();
expect(e.external_id).toBe("872585");
expect(seen).toEqual([{ title: GARBLED, sourceReleaseId: r.id, trigger: "observed" }]);
});
});
@@ -1,112 +0,0 @@
import { existsSync } from "node:fs";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { HookRegistry } from "../../src/hooks/index.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
// 整理上的链式钩子(ADR-006):organize.rename 改写目标文件名(字幕跟着改)、organize.intercept 拒绝整理;
// 钩子出错不影响整理。整理结束发 organization.finished。
const hasPg = await pgAvailable();
const SIZE = 200_000;
const DIR = "Inception.2010.1080p.BluRay.x264-GRP";
const VIDEO = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.mkv`;
const SUB = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.chs.srt`;
describe.skipIf(!hasPg)("整理钩子:改名与拦截(真实 PG + 临时目录)", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let entity = "";
const req = () => ({
sourcePaths: [src.path(DIR)],
mediaEntityId: entity,
action: "copy" as const,
trigger: "manual" as const,
actorType: "user" as const,
});
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-hklib-" });
src = await createTempTree({ [VIDEO]: SIZE, [SUB]: 2_000 }, { prefix: "umt-hksrc-" });
h = await createHarness("organize_hooks");
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
await h.setSettings("organize", { scrapeAfterImport: false });
entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 });
await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
}, 60_000);
afterEach(() => {
h.ctx.hooks = undefined;
});
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
it("拦截:钩子返回原因 → 记录失败(intercepted:<原因>),不动文件;整理结束事件 outcome=failed", async () => {
const hooks = new HookRegistry();
const seen: string[][] = [];
hooks.forModule("test").intercept("organize.intercept", (c) => {
seen.push(c.files.map((f) => `${f.role}:${f.kind}`));
return c.action === "copy" ? "演示:不许复制" : null;
});
h.ctx.hooks = hooks;
const plan = await planImport(h.d, req());
expect(plan).toMatchObject({ status: "failed", reason: "intercepted:演示:不许复制" });
expect(seen).toEqual([["main:video", "sidecar:subtitle"]]);
const rec = await h.db
.selectFrom("organization_record")
.select(["status", "error"])
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(rec).toMatchObject({ status: "failed", error: { hook: "test#1", reason: "演示:不许复制" } });
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
expect(fin.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([["failed", "intercepted:演示:不许复制"]]);
});
it("改名:主视频按钩子返回的文件名入库,同主干的字幕跟着改;成功后发 organization.finished", async () => {
const hooks = new HookRegistry();
const ctxs: unknown[] = [];
hooks.forModule("test").rewrite("organize.rename", (name, c) => {
ctxs.push({ name, mediaType: c.mediaType, mediaEntityId: c.mediaEntityId });
return name.replace(/\.mkv$/, " [自定义].mkv");
});
// 返回带路径分隔符的结果会被忽略(排在后面,拿到的是上一个钩子的结果)
hooks.forModule("bad").rewrite("organize.rename", (name) => `../${name}`);
h.ctx.hooks = hooks;
const plan = await planImport(h.d, req());
expect(plan.status).toBe("running");
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
expect(ctxs).toEqual([{ name: expect.stringMatching(/\.mkv$/), mediaType: "movie", mediaEntityId: entity }]);
const files = await h.db
.selectFrom("organization_file_result")
.select(["target_path", "status"])
.where("organization_record_id", "=", plan.recordId!)
.execute();
const targets = files.map((f) => f.target_path ?? "").sort();
expect(targets).toHaveLength(2);
const video = targets.find((t) => t.endsWith(".mkv"))!;
const sub = targets.find((t) => t.endsWith(".srt"))!;
expect(video).toMatch(/ \[自定义\]\.mkv$/);
const stem = video.slice(0, -".mkv".length);
expect(sub.startsWith(stem)).toBe(true);
expect(existsSync(`${lib.root}/${video}`) || existsSync(video)).toBe(true);
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
expect(fin.map((r) => r.payload.outcome)).toEqual(["succeeded"]);
});
it("钩子出错 / 超时不影响整理:按模板名照常生成计划", async () => {
const hooks = new HookRegistry();
hooks.forModule("broken").rewrite("organize.rename", () => {
throw new Error("坏了");
});
hooks.forModule("broken").intercept("organize.intercept", () => new Promise<string>(() => {}), { timeoutMs: 20 });
h.ctx.hooks = hooks;
const plan = await planImport(h.d, { ...req(), dryRun: true, action: "move" });
expect(plan.status).toBe("queued");
const main = plan.plan!.organize.files.find((f) => f.role === "main")!;
expect(main.dst).not.toContain("自定义");
});
});
+12 -35
View File
@@ -1,8 +1,8 @@
import { createRoute, z } from "@hono/zod-openapi";
import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts";
import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks";
import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS, TaskKind } from "@umt/contracts";
import { defineTask, TaskRegistry, UnknownTaskError } from "@umt/tasks";
import { describe, expect, it } from "vitest";
import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
import { buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
import { createApp } from "../src/http/app.ts";
import { requireScope } from "../src/http/auth.ts";
import { EventBus } from "../src/http/sse.ts";
@@ -12,6 +12,8 @@ import { DEV_KEY, json, memoryLogger } from "./helpers.ts";
// register* 阶段模块只保存 ctx 引用,这里不需要真实依赖
const fakeCtx = { role: "all" } as unknown as ServerContext;
// agentRun 是业务记录类型,对应的执行任务叫 runAgent。
const taskNames = TaskKind.options.map((kind) => (kind === "agentRun" ? "runAgent" : kind));
const pingRoute = createRoute({
method: "get",
@@ -85,22 +87,25 @@ describe("ServerModule 骨架", () => {
expect((await app.request("/api/v1/nope", { headers: { authorization: `Bearer ${DEV_KEY}` } })).status).toBe(404);
});
it("任务注册表:模块任务优先,其余 §3.1-8 任务由空壳兜底;重复注册报错", async () => {
it("任务注册表只收录模块实现;未注册任务和重复注册明确报错", async () => {
const reg = buildTaskRegistry([...SERVER_MODULES, pingModule], fakeCtx);
expect(reg.names()).toEqual(expect.arrayContaining([...TASK_NAMES, "testPing"]));
expect(reg.names()).toEqual(expect.arrayContaining([...taskNames, "testPing"]));
const ingest = reg.latest("testPing");
expect(await ingest.handler({ input: {} } as never)).toBe("real");
const partial = buildTaskRegistry([pingModule], fakeCtx);
expect(partial.names()).toEqual(["testPing"]);
expect(() => partial.latest("submitDownload")).toThrow(UnknownTaskError);
expect(() => buildTaskRegistry([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复注册/);
});
it("生产装配:每个核心任务都由业务模块正式注册,不落到空壳兜底", () => {
it("生产装配:契约中的每个业务任务都有正式处理器", () => {
const owned = new Set<string>();
for (const m of SERVER_MODULES) {
const own = new TaskRegistry();
m.registerTasks(own, fakeCtx);
for (const name of own.names()) owned.add(name);
}
expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]);
expect(taskNames.filter((n) => !owned.has(n))).toEqual([]);
});
it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => {
@@ -171,34 +176,6 @@ describe("ServerModule 骨架", () => {
expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes);
});
it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => {
const order: string[] = [];
const mod = (name: string): ServerModule => ({
name,
registerRoutes() {},
registerTasks() {},
registerHooks(hooks) {
hooks.rewrite("organize.rename", (n) => {
order.push(name);
return `${n}.${name}`;
});
},
});
const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx);
expect(
await hooks.runRewrite("organize.rename", "a", {
targetPath: "/a",
sourcePath: "/b",
mediaEntityId: "e",
mediaType: "movie",
season: null,
episodes: null,
}),
).toBe("a.first.second");
expect(order).toEqual(["first", "second"]);
expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({});
});
it("引擎就绪前调用给出明确错误", async () => {
const e = new DeferredTaskEngine();
expect(e.ready).toBe(false);
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
// 不挂载 HTTP 路由;隔离 Deno 静态文件适配器,以便本单元测试在 Node 下执行。
vi.mock("hono/deno", () => ({ serveStatic: vi.fn() }));
vi.mock("hono/bun", () => ({ serveStatic: vi.fn() }));
import { verifyInPlace } from "../src/modules/acquisition-organize/services/organize.ts";
+2 -2
View File
@@ -62,7 +62,7 @@ describe("存储自检", () => {
mkdirSync(ok);
mkdirSync(ro);
chmodSync(ro, 0o555);
const isRoot = Deno.uid() === 0;
const isRoot = process.getuid?.() === 0;
const { problems, warnings } = await checkStorage({
dataDir,
sessionDir: join(dataDir, "agent-sessions"),
@@ -82,7 +82,7 @@ describe("存储自检", () => {
});
it("数据目录不可写 → problem", async () => {
if (Deno.uid() === 0) return; // root 无视权限位
if (process.getuid?.() === 0) return; // root 无视权限位
const locked = join(dir, "locked");
mkdirSync(locked);
chmodSync(locked, 0o555);
+23 -1
View File
@@ -16,13 +16,34 @@ describe("沙箱子进程", () => {
it("正常返回;子进程里读文件被拒绝", async () => {
expect(await runSandbox("self.test", { mode: "ok" })).toEqual({ ok: true });
await expect(runSandbox("self.test", { mode: "io" })).rejects.toMatchObject({ kind: "job", message: /NotCapable/ });
await expect(runSandbox("self.test", { mode: "io" })).rejects.toMatchObject({
kind: "job",
message: /EPERM|operation not permitted/i,
});
});
it("不继承父进程密钥,也不自动读取 dotenv", async () => {
process.env.UMT_SANDBOX_TEST_SECRET = "private";
try {
const result = await runSandbox("self.test", { mode: "env" });
expect(result).toEqual({ keys: ["PATH"] });
} finally {
delete process.env.UMT_SANDBOX_TEST_SECRET;
}
});
it.each(["async-io", "write", "net", "exec"] as const)("内核拒绝 %s,包括运行时工作线程", async (mode) => {
await expect(runSandbox("self.test", { mode })).rejects.toMatchObject({
kind: "job",
message: /EPERM|operation not permitted/i,
});
});
it("卡死:超时后强杀,不拖住调用方", async () => {
const t0 = Date.now();
const e = await runSandbox("self.test", { mode: "hang" }, { timeoutMs: 800 }).catch((x) => x);
expect(e).toBeInstanceOf(SandboxError);
if (!(e instanceof SandboxError)) throw e;
expect(e.kind).toBe("timeout");
expect(Date.now() - t0).toBeLessThan(5000);
});
@@ -30,6 +51,7 @@ describe("沙箱子进程", () => {
it("吃内存:超过上限强杀", async () => {
const e = await runSandbox("self.test", { mode: "memory" }, { memoryMb: 128, timeoutMs: 20_000 }).catch((x) => x);
expect(e).toBeInstanceOf(SandboxError);
if (!(e instanceof SandboxError)) throw e;
expect(e.kind).toBe("memory");
});
@@ -141,4 +141,4 @@ for name, (target, kind) in SCENARIOS.items():
(HERE / "scrape-layout-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(out, ensure_ascii=False, indent=2))
# 生成后在本仓库跑一次格式化(lint 要求):deno run -A npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json
# 生成后在本仓库跑一次格式化(lint 要求):bun npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json
+1 -1
View File
@@ -78,7 +78,7 @@ describe.skipIf(!up)("发现页接口", () => {
settings: plainSettings(() => db),
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
const metaFetch: typeof fetch = async (input, init) => {
const metaFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = async (input, init) => {
const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
meta.push(`${init?.method ?? "GET"} ${u.host}${u.pathname}${u.search}`);
if (u.pathname.startsWith("/rexxar/api/v2/subject_collection/")) return doubanReply();
+1 -1
View File
@@ -153,7 +153,7 @@ describe.skipIf(!up)("全局搜索", () => {
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
// 挂起的站点:请求一直不返回,直到被中止(单站超时)
const fetchImpl: typeof fetch = (input, init) => {
const fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.hostname !== SLOW_HOST) return fetch(input, init);
slowCalls++;
@@ -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",
@@ -11,15 +11,15 @@ import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from ".
// 真实 OpenList 上跑一次整理往返(环境变量开启,缺省跳过;变量同 packages/adapters/src/alist/alist.real.test.ts):
// 本地下载目录 → 整理(复制)到 OpenList 上的媒体库 → 从 OpenList 整理(移动)回本地媒体库 → OpenList 内移动。
const URL_ = Deno.env.get("UMT_ALIST_REAL_URL");
const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT");
const auth: AlistAuth | null = Deno.env.get("UMT_ALIST_REAL_TOKEN")
? { kind: "token", token: Deno.env.get("UMT_ALIST_REAL_TOKEN")! }
: Deno.env.get("UMT_ALIST_REAL_USER")
const URL_ = process.env.UMT_ALIST_REAL_URL;
const ROOT = process.env.UMT_ALIST_REAL_ROOT;
const auth: AlistAuth | null = process.env.UMT_ALIST_REAL_TOKEN
? { kind: "token", token: process.env.UMT_ALIST_REAL_TOKEN! }
: process.env.UMT_ALIST_REAL_USER
? {
kind: "password",
username: Deno.env.get("UMT_ALIST_REAL_USER")!,
password: Deno.env.get("UMT_ALIST_REAL_PASSWORD") ?? "",
username: process.env.UMT_ALIST_REAL_USER!,
password: process.env.UMT_ALIST_REAL_PASSWORD ?? "",
}
: null;
const enabled = !!URL_ && !!ROOT && !!auth && (await pgAvailable());
@@ -500,6 +500,104 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => {
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", mq).execute()).toHaveLength(0);
});
it("收录规则按版本优先解析,补全继承主版本,概览包含各版本的实际引用", async () => {
const makeGroup = async (name: string, autoFill: boolean) => {
const r = await h.api("POST", "/rules", { name, ruleString: "1080P | 4K" });
expect(r.status, JSON.stringify(r.body)).toBe(201);
await db().updateTable("rule_group").set({ auto_fill: autoFill }).where("id", "=", r.body.data.id).execute();
return r.body.data.id as string;
};
const legacy = await makeGroup("兼容订阅允许补全", true);
const primaryRule = await makeGroup("主版本暂停补全", false);
const e = await seedEntity(db(), { kind: "movie", title: "Converged Rules", year: 2026 });
const s = await sub(e, { kind: "movie", params: { ruleGroupIds: [legacy], include: ["Converged"] } });
const a = await asset(e, { quality: { resolution: "2160p" } });
const primary = await slot(e, s, "4K", {
position: 0,
hard: { resolutions: ["2160p"] },
currentAssetId: a,
status: "satisfied",
});
await db()
.updateTable("acquisition_slot")
.set({ rule_group_ids: JSON.stringify([primaryRule]) })
.where("id", "=", primary)
.execute();
const secondary = await slot(e, s, "1080p", { position: 1, hard: { resolutions: ["1080p"] } });
// 正常 API 保存共同过滤,生成私有组;它参与评估但不混入可见的规则名称。
expect(
(await h.api("PATCH", `/subscriptions/${s}`, { params: { ruleGroupIds: [legacy], include: ["Converged"] } }))
.status,
).toBe(200);
const r = await rel(e, "Converged.Rules.2026.1080p.WEB-DL.x264-GRP");
const deferred = await evaluateAcquisition(h.d, { sourceReleaseId: r });
expect(deferred.deferred).toContainEqual({ sourceReleaseId: r, slotId: secondary, reason: "auto_fill_off" });
const overview = async () => (await h.api("GET", "/subscriptions?q=Converged%20Rules")).body.data.items[0];
const before = await overview();
expect(before.ruleGroups.map((g: { id: string }) => g.id)).toEqual([primaryRule, legacy]);
expect(before.slots.map((x: { ruleGroupIds: string[] }) => x.ruleGroupIds)).toEqual([[primaryRule], [legacy]]);
expect(before.autoFill).toMatchObject({ enabled: false, source: "rule_group" });
await db().updateTable("rule_group").set({ auto_fill: true }).where("id", "=", primaryRule).execute();
await db().updateTable("rule_group").set({ auto_fill: false }).where("id", "=", legacy).execute();
expect((await overview()).autoFill).toMatchObject({ enabled: true, source: "rule_group" });
const enabled = await evaluateAcquisition(h.d, { sourceReleaseId: r });
expect(enabled.plans, JSON.stringify(enabled)).toHaveLength(1);
expect(
await db()
.selectFrom("acquisition_plan")
.select(["slot_id", "evaluation"])
.where("id", "=", enabled.plans[0]!)
.executeTakeFirstOrThrow(),
).toMatchObject({
slot_id: secondary,
evaluation: { autoFill: true, primarySlotId: primary },
});
await h.api("PATCH", `/subscriptions/${s}`, { params: { ruleGroupIds: [legacy], autoFill: "off" } });
expect((await overview()).autoFill).toMatchObject({ enabled: false, source: "subscription" });
});
it("普通订阅保存保留旧质量过滤和每个版本独立的洗版设置", async () => {
const e = await seedEntity(db(), { kind: "series", title: "兼容编辑剧", year: 2026 });
const params = { resolution: "4k", source: "uhd", effect: "atmos" };
const s = await sub(e, { params });
await slot(e, s, "1080p", {
hard: { resolutions: ["1080p"], codecs: ["H.265"] },
upgrade: { enabled: false, mode: "per_unit" },
});
await slot(e, s, "4K", { hard: { resolutions: ["2160p"] }, upgrade: { enabled: true, mode: "full_pack" } });
const slots = () =>
db()
.selectFrom("acquisition_slot")
.select(["id", "revision", "hard_conditions", "upgrade_threshold"])
.where("subscription_id", "=", s)
.orderBy("id")
.execute();
const before = await slots();
const saved = await h.api("PATCH", `/subscriptions/${s}`, { params: { ...params, searchIntervalHours: 6 } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(saved.body.data.params).toEqual({ ...params, searchIntervalHours: 6 });
expect(await slots()).toEqual(before);
const privateGroup = await db()
.selectFrom("rule_group")
.select("hard")
.where("scope", "@>", JSON.stringify({ subscriptionId: s }))
.where("deleted_at", "is", null)
.executeTakeFirstOrThrow();
expect(JSON.stringify(privateGroup.hard)).toContain("Atmos");
expect(JSON.stringify(privateGroup.hard)).toContain("UHD");
const cleared = await h.api("PATCH", `/subscriptions/${s}`, { params: { searchIntervalHours: 6 } });
expect(cleared.status, JSON.stringify(cleared.body)).toBe(200);
expect(await slots()).toEqual(before);
expect(
await db()
.selectFrom("rule_group")
.select("id")
.where("scope", "@>", JSON.stringify({ subscriptionId: s }))
.where("deleted_at", "is", null)
.execute(),
).toEqual([]);
});
it("表单:MP 字段与旧键兼容(规范化后只存规范键);校验 400;同作品同季 409;best_version → 升级策略", async () => {
const e = await seedEntity(db(), { kind: "series", title: "表单剧", year: 2025 });
const site = await db()
+1 -1
View File
@@ -6,7 +6,7 @@ import pg from "pg";
export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
/** 测试库连得上才跑 PG 用例;UMT_SKIP_DB_TESTS=1 显式跳过(deno task test:all 连不上库时直接失败) */
/** 测试库连得上才跑 PG 用例;UMT_SKIP_DB_TESTS=1 显式跳过(bun run test:all 连不上库时直接失败) */
export async function pgAvailable(): Promise<boolean> {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 });
@@ -0,0 +1,65 @@
import { serve, upgradeWebSocket } from "@umt/runtime";
import { Hono } from "hono";
import { expect, it } from "vitest";
it("Bun HTTP 适配通过 Hono 中间件升级 WebSocket,保留协议、文本与二进制消息", async () => {
const app = new Hono();
app.use("*", async (c, next) => {
await next();
c.header("x-test", "middleware");
});
app.get("/ws", (c) => {
const { socket, response } = upgradeWebSocket(c.req.raw, { protocol: "umt-test" });
socket.onmessage = (event) => socket.send(event.data);
return response;
});
const server = serve({ hostname: "127.0.0.1", port: 0 }, app.fetch);
const client = new WebSocket(`ws://127.0.0.1:${server.addr.port}/ws`, ["umt-test"]);
client.binaryType = "arraybuffer";
try {
await new Promise<void>((resolve, reject) => {
client.onopen = () => resolve();
client.onerror = () => reject(new Error("upgrade failed"));
});
expect(client.protocol).toBe("umt-test");
const echo = (data: string | ArrayBuffer) =>
new Promise<unknown>((resolve) => {
client.onmessage = (event) => resolve(event.data);
client.send(data);
});
expect(await echo("hello")).toBe("hello");
expect(new Uint8Array((await echo(new Uint8Array([1, 2, 3]).buffer)) as ArrayBuffer)).toEqual(
new Uint8Array([1, 2, 3]),
);
} finally {
client.close();
await server.shutdown();
}
});
it("Bun WebSocket 上游连接发送自定义认证头", async () => {
let authorization: string | null = null;
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(req, server) {
authorization = req.headers.get("authorization");
if (server.upgrade(req)) return undefined;
return new Response(null, { status: 400 });
},
websocket: { message() {} },
});
const client = new WebSocket(`ws://127.0.0.1:${server.port}`, {
headers: { authorization: "Basic test" },
} as unknown as string[]);
try {
await new Promise<void>((resolve, reject) => {
client.onopen = () => resolve();
client.onerror = () => reject(new Error("connect failed"));
});
expect(authorization).toBe("Basic test");
} finally {
client.close();
await server.stop(true);
}
});
+5 -14
View File
@@ -1,7 +1,8 @@
// 生产权限下读 /proc、/sys:Deno 2 不带 --allow-all 时 readTextFile 一律 NotCapable,
// 编译产物的资源监控与沙箱内存看门狗曾因此静默取不到数(v0.3.34 运行状态页一直无数据)。
// 这里用与 apps/server/deno.json compile 相同的权限起子进程,确认走 cat 兜底能读到。
// 这里用与 apps/server/package.json compile 相同的权限起子进程,确认走 cat 兜底能读到。
import { fileURLToPath } from "node:url";
import { Command } from "@umt/runtime";
import { describe, expect, it } from "vitest";
const here = fileURLToPath(new URL(".", import.meta.url));
@@ -13,20 +14,10 @@ const r = await readResources();
console.log(JSON.stringify({ stat: !!stat && stat.startsWith("cpu"), host: r.host != null, mem: r.meminfo != null }));
`;
describe.skipIf(Deno.build.os !== "linux")("readProcText 在生产权限下", () => {
describe.skipIf(process.platform !== "linux")("readProcText 在生产权限下", () => {
it("走 cat 兜底读到 /proc 与资源读数", async () => {
const out = await new Deno.Command(Deno.execPath(), {
args: [
"run",
"--no-prompt",
"--allow-net",
"--allow-env",
"--allow-sys",
"--allow-read",
"--allow-write",
"--allow-run=ffprobe,sha256sum,cat",
"-",
],
const out = await new Command(process.execPath, {
args: ["run", "-"],
stdin: "piped",
stdout: "piped",
stderr: "piped",
+3 -2
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import type { DiskUsage } from "@umt/adapters";
import { SystemHealth, SystemHealthHistory, SystemResources } from "@umt/contracts";
import { sql } from "@umt/db";
import { runtimeMkdir, runtimeWriteTextFile } from "@umt/runtime";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { inferTriggeredBy, triggeredByFromActor } from "../../src/modules/auth-settings/engine-business.ts";
import { scheduleRegistry } from "../../src/modules/schedules/registry.ts";
@@ -233,8 +234,8 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => {
ctx: h.ctx,
}) as Harness["app"];
admin = bearer(h.app, DEV_KEY);
await Deno.mkdir(libOk, { recursive: true });
await Deno.writeTextFile(join(libOk, ".umt-root"), "x");
await runtimeMkdir(libOk, { recursive: true });
await runtimeWriteTextFile(join(libOk, ".umt-root"), "x");
}, 60_000);
afterAll(async () => {
await h?.close();