test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行

- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀,
  w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名
- vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行);
  pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s
- CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争)
- ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 16:54:04 +08:00
1 parent b343dddbf6
commit b61335eaf6
75 files changed
+159 -115

No files matched your search

@@ -0,0 +1,126 @@
import { sql } from "@umt/db";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createSchedulesModule } from "../../src/modules/schedules/index.ts";
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 {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// W4-D P0:后台循环只编排(W7-C 起由 schedules 调度器驱动模块登记的定时任务)。补入库在巡检里只入队 verifyAndImport;一个 50MB 的入库(复制 + sha256 + 慢 ffprobe)
// 进行中时,qB 同步定时器仍按周期打到 mock qB(用 mock qB 的请求时间戳断言)。
const hasPg = await pgAvailable();
const SIZE = 50 * 1024 * 1024;
const SYNC_MS = 200;
const PROBE_DELAY_MS = 2500;
describe.skipIf(!hasPg)("acquisition-organize 后台循环不被入库阻塞", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10 * 1024 ** 3);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
const schedules = createSchedulesModule({ builtinChecks: false, scheduler: { tickMs: 50, lockRetryMs: 100 } });
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
disk.state.watch = [cache.root];
disk.setPct(40);
h = await createHarness("bgloop", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
// 慢 ffprobe:让一次入库稳定地持续数秒
probe: async () => {
await new Promise((r) => setTimeout(r, PROBE_DELAY_MS));
return { status: "unknown", reason: "ffprobe_unavailable" };
},
options: { backgroundLoops: true, syncIntervalMs: SYNC_MS, planIntervalMs: SYNC_MS, reconcileIntervalMs: 0 },
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
await h.setSettings("retention", { seedRetentionDays: 0 });
}, 60_000);
afterAll(async () => {
await schedules.stop?.();
await h?.module.stop?.();
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
it("巡检只入队 verifyAndImport;50MB 入库进行中 qB 同步仍按时执行", async () => {
const entityId = await seedEntity(h.db, { kind: "movie", title: "长片", year: 2023 });
await seedSlot(h.db, { entityId });
const t = makeTorrent({ name: "Long.Movie.2023.1080p.BluRay.x264-GRP.mkv", length: SIZE, pieceSeed: 7 });
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: "Long.Movie.2023.1080p.BluRay.x264-GRP",
size: SIZE,
hash: t.hash,
});
torrents.set(relId, t.bytes);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
const sub = await submitDownload(h.d, ev.plans[0]!);
expect(sub.status).toBe("submitted");
await qb.complete(t.hash, { writeFiles: true });
expect((await syncDownloads(h.d)).completed).toContain(sub.downloadTaskId);
const dtId = sub.downloadTaskId!;
// 完成超过 5 分钟且没有整理记录 → 巡检对象
await sql`update download_task set completed_at = now() - interval '10 minutes' where id = ${dtId}`.execute(h.db);
await h.module.start?.(h.ctx);
await schedules.start?.(h.ctx);
const deadline = Date.now() + 10_000;
while (!h.engine.enqueued.some((e) => e.task === "verifyAndImport") && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
}
const enq = h.engine.enqueued.find((e) => e.task === "verifyAndImport");
expect(enq?.input).toMatchObject({ downloadTaskId: dtId });
expect(enq?.key).toMatch(new RegExp(`^verifyAndImport:import:${dtId}:\\d+$`));
// 循环没有自己执行入库
const recs = await h.db
.selectFrom("organization_record")
.select("id")
.where("download_task_id", "=", dtId)
.execute();
expect(recs).toHaveLength(0);
// 同一时间槽重复巡检用同一个引擎幂等键(真实引擎去重;假引擎只记录)
await new Promise((r) => setTimeout(r, SYNC_MS * 3));
const keys = new Set(h.engine.enqueued.filter((e) => e.task === "verifyAndImport").map((e) => e.key));
expect(keys.size).toBe(1);
// 模拟 worker 执行这次 verifyAndImport(同进程,与同步循环并发)
const t0 = Date.now();
const plan = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("running");
const ex = await executeRecord(h.d, plan.recordId!);
const t1 = Date.now();
expect(ex).toMatchObject({ status: "succeeded", succeeded: 1 });
expect(t1 - t0).toBeGreaterThanOrEqual(PROBE_DELAY_MS);
const stamps = qb
.callsTo("torrents/info")
.map((c) => c.at)
.filter((at) => at >= t0 && at <= t1);
// 每 200ms 一轮(同步本身几十 ms);入库期间至少有 1/3 的轮次、最大间隔不超过 1s
expect(stamps.length).toBeGreaterThanOrEqual(Math.floor((t1 - t0) / SYNC_MS / 3));
const gaps = [t0, ...stamps, t1].slice(1).map((v, i) => v - [t0, ...stamps][i]!);
expect(Math.max(...gaps)).toBeLessThan(1000);
}, 90_000);
});
@@ -0,0 +1,142 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import type { TmdbClient } from "@umt/adapters";
import { DEFAULT_CLASSIFICATION_YAML } from "@umt/domain";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { AuthEnv } from "../../src/http/auth.ts";
import { createSettingsReader } from "../../src/modules/auth-settings/provided.ts";
import { registerSettingsRoutes } from "../../src/modules/auth-settings/settings-routes.ts";
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
import { classifyEntity } from "../../src/modules/acquisition-organize/services/classification.ts";
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("TMDB classification API and accepted identities (real PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness("classification");
const settingsApp = new OpenAPIHono<AuthEnv>();
registerSettingsRoutes(settingsApp, h.ctx);
h.app.route("/api/v1", settingsApp);
}, 60_000);
afterEach(() => vi.restoreAllMocks());
afterAll(async () => await h?.close());
it("legacy stored organize settings receive default YAML without losing preferences", async () => {
await h.setSettings("organize", { minSizeBytes: 456789, conflictDefault: "skip" });
const reader = createSettingsReader({ db: h.db, logger: h.ctx.logger });
expect(await reader.get("organize")).toMatchObject({
minSizeBytes: 456789,
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
});
it("ordered categories and raw preview do not write settings", async () => {
const view = await h.api("GET", "/classification");
expect(view.status).toBe(200);
expect(view.body.data.yaml).toBe(DEFAULT_CLASSIFICATION_YAML);
expect(view.body.data.categories.movie).toEqual(["动画电影", "演唱会", "华语电影", "亚洲电影", "欧美电影"]);
const preview = await h.api("POST", "/classification/preview", {
yaml: "movie:\n 日本片:\n production_countries: JP\ntv: {}\n",
mediaType: "movie",
tmdb: { production_countries: [{ iso_3166_1: "JP" }] },
});
expect(preview.status).toBe(200);
expect(preview.body.data).toEqual({ category: "日本片", matchedFields: ["production_countries"], fallback: false });
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(DEFAULT_CLASSIFICATION_YAML);
});
it("PUT preserves organize preferences, audits and emits settings change; invalid writes reject", async () => {
await h.setSettings("organize", { ...(await h.ctx.settings.get("organize")), minSizeBytes: 765432 });
const publish = vi.spyOn(h.ctx.events, "publish");
const yaml = "movie:\n 新分类:\n original_language: zh\n 其它: null\ntv: {}\n";
const saved = await h.api("PUT", "/classification", { yaml });
expect(saved.status).toBe(200);
expect(saved.body.data.categories.movie).toEqual(["新分类", "其它"]);
expect(await h.ctx.settings.get("organize")).toMatchObject({ classificationYaml: yaml, minSizeBytes: 765432 });
expect(publish).toHaveBeenCalledWith("settings.changed", { keys: ["organize"] });
expect(
(await h.db.selectFrom("audit_event").selectAll().where("action", "=", "settings.update").execute()).length,
).toBeGreaterThan(0);
for (const invalid of ["movie:\n ../escape: null\ntv: {}", "movie:\n 同名: null\n 同名: null\ntv: {}"]) {
expect((await h.api("PUT", "/classification", { yaml: invalid })).status).toBe(422);
expect((await h.api("PATCH", "/settings", { organize: { classificationYaml: invalid } })).status).toBe(422);
}
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(yaml);
await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML });
});
it("PUT merges current row after waiting rather than overwriting a newer source edit", async () => {
const source = {
id: "33333333-3333-4333-8333-333333333333",
name: "Concurrent source",
enabled: true,
source: { root: "lib:11111111-1111-4111-8111-111111111111", path: "" },
target: { root: "lib:22222222-2222-4222-8222-222222222222", path: "" },
mediaType: "all",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
};
const originalAll = h.ctx.settings.all.bind(h.ctx.settings);
vi.spyOn(h.ctx.settings, "all").mockImplementationOnce(async () => {
const stale = await originalAll();
await h.setSettings("organize", { ...stale.organize, minSizeBytes: 654321, sources: [source] });
return stale;
});
expect((await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML })).status).toBe(200);
const stored = await h.db
.selectFrom("settings")
.select("value")
.where("key", "=", "organize")
.executeTakeFirstOrThrow();
expect(stored.value).toMatchObject({
minSizeBytes: 654321,
sources: [source],
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
});
it("requires accepted movie/tv identity, uses raw detail, and fails without fabricating fallback", async () => {
await h.setSettings("organize", {
...(await h.ctx.settings.get("organize")),
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
const id = await seedEntity(h.db, { kind: "movie", title: "测试片", year: 2020 });
const entity = await h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
const factory = vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
expect((await classifyEntity(h.d, entity)).category).toBeNull();
expect(factory).not.toHaveBeenCalled();
const identity = await h.db
.insertInto("external_identity")
.values({ media_entity_id: id, provider: "tmdb", entity_type: "movie", external_id: "123", status: "proposed" })
.returning("id")
.executeTakeFirstOrThrow();
expect((await classifyEntity(h.d, entity)).category).toBeNull();
await h.db.updateTable("external_identity").set({ status: "accepted" }).where("id", "=", identity.id).execute();
expect(await classifyEntity(h.d, entity)).toEqual({
category: "动画电影",
matchedFields: ["genre_ids"],
fallback: false,
});
expect(getJson).toHaveBeenCalledWith("/movie/123");
getJson.mockRejectedValue(new Error("offline"));
expect(await classifyEntity(h.d, entity)).toEqual({ category: null, matchedFields: [], fallback: false });
getJson.mockResolvedValue({});
expect((await classifyEntity(h.d, entity)).category).toBeNull();
getJson.mockResolvedValue({ genres: [{ id: 10764 }], origin_country: ["CN"] });
const tvId = await seedEntity(h.db, { kind: "series", title: "测试剧", year: 2020 });
const tv = await h.db.selectFrom("media_entity").selectAll().where("id", "=", tvId).executeTakeFirstOrThrow();
await h.db
.insertInto("external_identity")
.values({ media_entity_id: tvId, provider: "tmdb", entity_type: "tv", external_id: "456", status: "accepted" })
.execute();
expect((await classifyEntity(h.d, tv)).category).toBe("综艺");
expect(getJson).toHaveBeenCalledWith("/tv/456");
factory.mockResolvedValue(null);
expect((await classifyEntity(h.d, entity)).category).toBeNull();
});
});
@@ -0,0 +1,314 @@
import { sql } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
// DL-3 下载计划并入下载面板:等批准 / 等磁盘空间的计划作为「进行中」行与下载任务按时间一起排、计入计数;
// 深链按计划 id 取行;「为什么下这个」;取消还没提交的下载;没提交就结束的进作品下载历史;
// 审批被拒 / 过期时草案跟着结束。
const hasPg = await pgAvailable();
const h40 = (n: number) => n.toString(16).padStart(40, "c");
type Row = { kind: "task" | "plan"; id: string; name: string; wait?: string; waitReason?: string | null };
describe.skipIf(!hasPg)("DL-3 下载计划并入下载面板(真实 PG)", () => {
let h: Harness;
let entity: string;
let siteId: string;
const ids = {} as Record<"reserved" | "draft" | "orphan" | "submitted" | "failed" | "agentDraft", string>;
const slots = {} as Record<string, string>;
let taskId: string;
let instId: string;
const at = (min: number) => new Date(Date.UTC(2026, 8, 30, 8, min));
async function plan(
key: keyof typeof ids,
p: { status: string; min: number; purpose?: string; evaluation?: Record<string, unknown>; reason?: string },
) {
const slot = await seedSlot(h.db, { entityId: entity, name: key === "submitted" ? "4K" : `slot-${key}` });
slots[key] = slot;
const rel = await seedRelease(h.db, {
siteId,
entityId: entity,
title: `Severance.S02.${key}.2160p`,
size: 10_000_000_000,
hash: h40(Object.keys(ids).length + 1),
});
await h.db
.updateTable("source_release")
.set({ promotion: JSON.stringify({ kind: "free" }), seeders: 41 })
.where("id", "=", rel)
.execute();
const row = await h.db
.insertInto("acquisition_plan")
.values({
slot_id: slot,
source_release_id: rel,
purpose: (p.purpose ?? "fill") as "fill",
status: p.status as "draft",
evaluation: JSON.stringify(p.evaluation ?? { reasons: ["hard_conditions_met", "pri:90"], instanceId: instId }),
reserved_bytes: 10_000_000_000,
payload_hash: `hash-${key}`,
idempotency_key: `test:${key}`,
status_reason: p.reason ?? null,
created_at: at(p.min),
})
.returning("id")
.executeTakeFirstOrThrow();
ids[key] = row.id;
if (p.status === "reserved")
await h.db
.updateTable("acquisition_slot")
.set({ status: "reserved", reserved_plan_id: row.id })
.where("id", "=", slot)
.execute();
return row.id;
}
async function approval(planId: string, status = "pending", scope: Record<string, unknown> = {}) {
return (
await h.db
.insertInto("approval")
.values({
plan_type: "acquisition_plan",
plan_id: planId,
payload_hash: "hash-x",
expected_revision: 1,
scope: JSON.stringify(scope),
status: status as "pending",
requested_by: "agent:test",
expires_at: new Date(Date.now() + 3600_000),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
beforeAll(async () => {
h = await createHarness("dl3plans");
entity = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
siteId = (
await h.db
.insertInto("site")
.values({ key: "dl3site", name: "测试站", adapter: "nexusphp", base_url: "https://pt.example.test" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 下载器停用:面板不去连 qB,只看数据库
instId = (
await h.db
.insertInto("downloader_instance")
.values({ name: "qb", base_url: "http://127.0.0.1:9", username: "a", enabled: false })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 时间线(新 → 旧):已预留等空间(50)> 下载中的任务(40)> 等批准(30)> 智能体草案(25)> 孤立草案(20)> 失败(10)
await plan("failed", { status: "failed", min: 10, reason: "rejected: torrent is invalid" });
await plan("orphan", { status: "draft", min: 20 });
await plan("agentDraft", { status: "draft", min: 25 });
await plan("draft", { status: "draft", min: 30 });
await plan("submitted", { status: "submitted", min: 35 });
await plan("reserved", { status: "reserved", min: 50 });
await approval(ids.draft);
await approval(ids.agentDraft, "pending", { agentRunId: "x" });
taskId = (
await h.db
.insertInto("download_task")
.values({
instance_id: instId,
torrent_hash: h40(99),
acquisition_plan_id: ids.submitted,
name: "Severance.S02.submitted.2160p",
status: "downloading",
managed: true,
media_entity_id: entity,
created_at: at(40),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 已预留的提交任务被下载盘阈值延后
await h.db
.insertInto("task")
.values({
kind: "submitDownload",
idempotency_key: `plan:${ids.reserved}`,
status: "waiting",
subject_type: "acquisition_plan",
subject_id: ids.reserved,
output: JSON.stringify({ status: "deferred", reason: "cache:projected_over_threshold" }),
})
.execute();
// 智能体会话准备的草案
const run = await h.db
.insertInto("agent_run")
.values({ trigger: "user", goal: "补齐人生切割术第二季的 4K 版本" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("capability_invocation")
.values({
idempotency_key: "inv-1",
capability_id: "download.plan",
capability_version: 1,
agent_run_id: run.id,
actor_id: "agent",
source: "agent",
status: "succeeded",
input: JSON.stringify({}),
output: JSON.stringify({ plan: { id: ids.agentDraft } }),
})
.execute();
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("进行中:等批准、等磁盘空间的计划与下载任务按加入时间一起排,计数包含它们;孤立草案不出现", async () => {
const r = await h.api("GET", "/download-tasks/board");
expect(r.status).toBe(200);
const b = r.body.data;
const rows = b.items as Row[];
expect(rows.map((x) => [x.kind, x.id])).toEqual([
["plan", ids.reserved],
["task", taskId],
["plan", ids.draft],
["plan", ids.agentDraft],
]);
expect(b.counts).toEqual({ active: 4, completed: 0 });
const reserved = b.items[0];
expect(reserved).toMatchObject({
wait: "space",
waitReason: "cache:projected_over_threshold",
status: "reserved",
purpose: "fill",
name: "Severance.S02.reserved.2160p",
sizeBytes: 10_000_000_000,
media: { id: entity, title: "人生切割术", year: 2022 },
approval: null,
});
const draft = b.items[2];
expect(draft).toMatchObject({ wait: "approval", status: "draft", approval: { payloadHash: "hash-x" } });
// 已完成分组不含计划
const done = (await h.api("GET", "/download-tasks/board?state=completed")).body.data;
expect(done.items).toEqual([]);
});
it("翻页:两路按同一游标归并,不重不漏", async () => {
const p1 = (await h.api("GET", "/download-tasks/board?limit=3")).body.data;
expect(p1.items.map((x: Row) => x.id)).toEqual([ids.reserved, taskId, ids.draft]);
expect(p1.nextCursor).toBeTruthy();
const p2 = (await h.api("GET", `/download-tasks/board?limit=3&cursor=${encodeURIComponent(p1.nextCursor)}`)).body
.data;
expect(p2.items.map((x: Row) => x.id)).toEqual([ids.agentDraft]);
expect(p2.nextCursor).toBeNull();
});
it("深链 planId:等待中的计划给计划行,已提交的给它的下载任务,结束的没有行", async () => {
const a = (await h.api("GET", `/download-tasks/board?planId=${ids.reserved}`)).body.data;
expect(a.items.map((x: Row) => [x.kind, x.id])).toEqual([["plan", ids.reserved]]);
const b = (await h.api("GET", `/download-tasks/board?planId=${ids.submitted}&state=completed`)).body.data;
expect(b.items.map((x: Row) => [x.kind, x.id])).toEqual([["task", taskId]]);
const c = (await h.api("GET", `/download-tasks/board?planId=${ids.failed}`)).body.data;
expect(c.items).toEqual([]);
});
it("为什么下这个:订阅 / 智能体会话来源、目的、选中理由原始要点", async () => {
const sub = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data;
expect(sub).toMatchObject({
planId: ids.submitted,
source: { kind: "subscription", name: "人生切割术" },
purpose: "fill",
slotName: "4K",
replaces: null,
reasons: ["hard_conditions_met", "pri:90"],
promotion: "free",
seeders: 41,
siteName: "测试站",
});
expect(sub.evaluation.reasons).toEqual(["hard_conditions_met", "pri:90"]);
const agent = (await h.api("GET", `/acquisition-plans/${ids.agentDraft}/why`)).body.data;
expect(agent.source).toMatchObject({ kind: "agent", name: "补齐人生切割术第二季的 4K 版本" });
expect((await h.api("GET", `/acquisition-plans/${crypto.randomUUID()}/why`)).status).toBe(404);
});
it("升级:被替换的版本取评估时记下的文件规格", async () => {
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: entity,
kind: "video",
delivery: JSON.stringify({ resolution: "1080p", source: "BluRay", hdr: [], releaseGroup: "CMCT" }),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("acquisition_plan")
.set({
purpose: "upgrade",
evaluation: JSON.stringify({ reasons: ["source_tier_improved"], replacesAssetId: asset.id }),
})
.where("id", "=", ids.submitted)
.execute();
const w = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data;
expect(w.purpose).toBe("upgrade");
expect(w.replaces).toEqual({ assetId: asset.id, label: "1080p 蓝光压制 · CMCT" });
});
it("取消:已预留的释放收录版本并进下载历史;已提交的 409", async () => {
const r = await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`);
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ status: "cancelled", statusReason: "cancelled_by_user" });
const slot = await h.db
.selectFrom("acquisition_slot")
.select(["status", "reserved_plan_id"])
.where("id", "=", slots.reserved!)
.executeTakeFirstOrThrow();
expect(slot.status).not.toBe("reserved");
expect((await h.api("POST", `/acquisition-plans/${ids.submitted}/cancel`)).status).toBe(409);
expect((await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`)).status).toBe(409);
const b = (await h.api("GET", "/download-tasks/board")).body.data;
expect(b.counts.active).toBe(3);
expect(b.items.some((x: Row) => x.id === ids.reserved)).toBe(false);
});
it("作品下载历史:没提交就结束的下载带原因码;孤立草案与进行中的不在里面", async () => {
const d = (await h.api("GET", `/library/${entity}`)).body.data;
const dropped = d.droppedPlans.map((x: { plan: { id: string; statusReason: string } }) => [
x.plan.id,
x.plan.statusReason,
]);
expect(dropped).toEqual(
expect.arrayContaining([
[ids.failed, "rejected: torrent is invalid"],
[ids.reserved, "cancelled_by_user"],
]),
);
expect(dropped).toHaveLength(2);
expect(d.droppedPlans.find((x: { plan: { id: string } }) => x.plan.id === ids.failed).releaseTitle).toBe(
"Severance.S02.failed.2160p",
);
});
it("审批被拒 / 过期:草案跟着结束,不再算进行中", async () => {
const { closeDraftPlan } = await import("../../src/modules/agent/interaction.ts");
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.draft }, "rejected");
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.agentDraft }, "expired");
// 不是草案的不动
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.submitted }, "rejected");
const rows = await sql<{ id: string; status: string; status_reason: string | null }>`
select id, status, status_reason from acquisition_plan where id in (${sql.join([ids.draft, ids.agentDraft, ids.submitted])})`.execute(
h.db,
);
const by = new Map(rows.rows.map((r) => [r.id, [r.status, r.status_reason]]));
expect(by.get(ids.draft)).toEqual(["cancelled", "approval_rejected"]);
expect(by.get(ids.agentDraft)).toEqual(["expired", "approval_expired"]);
expect(by.get(ids.submitted)?.[0]).toBe("submitted");
const b = (await h.api("GET", "/download-tasks/board")).body.data;
expect(b.items.map((x: Row) => x.kind)).toEqual(["task"]);
expect(b.counts.active).toBe(1);
});
});
@@ -0,0 +1,197 @@
import { createMockQb, type MockQb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
// DL-2 下载面板:进行中 / 已完成分组与计数、每个种子的实时速度 / 剩余时间 / 连接数、下载器总速度、
// 实时数据短 TTL 缓存(轮询不直打 qB)、下载器不可达时退化为只看数据库。
const hasPg = await pgAvailable();
const TTL = 400;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const h40 = (n: number) => n.toString(16).padStart(40, "b");
describe.skipIf(!hasPg)("DL-2 下载面板(真实 PG + mock qB)", () => {
let h: Harness;
let qb: MockQb;
let instId: string;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
h = await createHarness("dlboard", { options: { liveTtlMs: TTL } });
instId = (
await h.db
.insertInto("downloader_instance")
.values({ name: "qb-main", base_url: qb.url, username: "admin", settings: JSON.stringify({}) })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 进行中:下载中(有速度与 eta)、暂停、排队、出错;已完成:做种、完成暂停;管理标签外的任务不纳入
const dl = qb.seed({ hash: h40(1), name: "Dune.Part.Two.2024.2160p", tags: ["umt"], progress: 0.4 });
Object.assign(dl, {
dlspeed: 12_900_000,
upspeed: 1_200_000,
eta: 620,
num_seeds: 12,
num_leechs: 3,
num_complete: 80,
num_incomplete: 9,
});
qb.seed({ hash: h40(2), name: "Paused.Show.S01", tags: ["umt"], progress: 0.2, state: "pausedDL" });
qb.seed({ hash: h40(3), name: "Queued.Movie.2020", tags: ["umt"], progress: 0, state: "queuedDL" });
qb.seed({ hash: h40(4), name: "Broken.Movie.2019", tags: ["umt"], progress: 0.1, state: "error" });
const seeding = qb.seed({ hash: h40(5), name: "Heat.1995.1080p", tags: ["umt"], progress: 1 });
Object.assign(seeding, { upspeed: 300_000, ratio: 1.25 });
qb.seed({ hash: h40(6), name: "Done.Paused.2018", tags: ["umt"], progress: 1, state: "pausedUP" });
const other = qb.seed({ hash: h40(7), name: "Not.Managed", tags: ["other"], progress: 0.5 });
other.dlspeed = 100_000;
await syncDownloads(h.d);
const heat = await seedEntity(h.db, { kind: "movie", title: "盗火线", year: 1995 });
const t5 = await h.db
.updateTable("download_task")
.set({ media_entity_id: heat })
.where("torrent_hash", "=", h40(5))
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_record")
.values({
trigger: "auto",
actor_type: "system",
action: "copy",
status: "succeeded",
download_task_id: t5.id,
media_entity_id: heat,
})
.execute();
// 完成暂停的那个:最近一次整理失败 + 未关闭的待处理事项(下载任务维度)
const t6 = await h.db
.selectFrom("download_task")
.select("id")
.where("torrent_hash", "=", h40(6))
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_record")
.values({
trigger: "auto",
actor_type: "system",
action: "copy",
status: "failed",
reason: "identity_unknown",
download_task_id: t6.id,
})
.execute();
await h.db
.insertInto("decision")
.values({
kind: "identity_unresolved",
title: "没识别出作品",
subject_type: "download_task",
subject_id: t6.id,
dedupe_key: `test:${t6.id}`,
})
.execute();
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
});
it("默认进行中:只含未完成任务,两组各带计数,附实时速度与剩余时间", async () => {
await sleep(TTL + 50);
const r = await h.api("GET", "/download-tasks/board");
expect(r.status).toBe(200);
const b = r.body.data;
expect(b.counts).toEqual({ active: 4, completed: 2 });
const names = b.items.map((i: { name: string }) => i.name).sort();
expect(names).toEqual(["Broken.Movie.2019", "Dune.Part.Two.2024.2160p", "Paused.Show.S01", "Queued.Movie.2020"]);
const dune = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1));
expect(dune.live).toMatchObject({
dlSpeed: 12_900_000,
upSpeed: 1_200_000,
etaSec: 620,
seeds: 12,
leechs: 3,
seedsTotal: 80,
leechsTotal: 9,
rawState: "downloading",
});
expect(dune.live.progress).toBeCloseTo(0.4);
// eta 8640000(qB 的无穷)记为 null;num_complete -1 记为未知
const queued = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(3));
expect(queued.live.etaSec).toBeNull();
expect(queued.live.seedsTotal).toBeNull();
expect(queued.live.dlSpeed).toBe(0);
// 总速度来自 transfer/info:整个下载器(含管理标签外的任务)
expect(b.transfer.dlSpeed).toBe(12_900_000 + 100_000);
expect(b.transfer.upSpeed).toBe(1_200_000 + 300_000);
expect(b.transfer.instances).toEqual([
{ instanceId: instId, name: "qb-main", reachable: true, dlSpeed: 13_000_000, upSpeed: 1_500_000 },
]);
expect(b.liveAt).toBeTruthy();
});
it("已完成:含做种与完成后暂停,带作品与最近整理记录", async () => {
const b = (await h.api("GET", "/download-tasks/board?state=completed")).body.data;
expect(b.counts).toEqual({ active: 4, completed: 2 });
expect(b.items).toHaveLength(2);
const heat = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(5));
expect(heat.status).toBe("completed");
expect(heat.media).toMatchObject({ title: "盗火线", year: 1995 });
expect(heat.organize).toMatchObject({ status: "succeeded", reason: null, decision: null });
expect(heat.live).toMatchObject({ upSpeed: 300_000, ratio: 1.25, etaSec: null });
expect(heat.live.completedAt).toBeTruthy();
const paused = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(6));
expect(paused.organize).toMatchObject({
status: "failed",
reason: "identity_unknown",
decision: { kind: "identity_unresolved" },
});
expect(paused.media).toBeNull();
});
it("TTL 内的轮询共用一次 qB 查询,并发请求合并;过期后重新查询", async () => {
await sleep(TTL + 50);
const before = qb.callsTo("torrents/info").length;
const beforeT = qb.callsTo("transfer/info").length;
await Promise.all([
h.api("GET", "/download-tasks/board"),
h.api("GET", "/download-tasks/board?state=completed"),
h.api("GET", "/download-tasks/board"),
]);
await h.api("GET", "/download-tasks/board");
expect(qb.callsTo("torrents/info").length - before).toBe(1);
expect(qb.callsTo("transfer/info").length - beforeT).toBe(1);
// 缓存期间速度变化不可见,过期后可见
const dl = qb.get(h40(1));
if (dl) dl.dlspeed = 5_000_000;
const cached = (await h.api("GET", "/download-tasks/board")).body.data;
expect(cached.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(12_900_000);
await sleep(TTL + 50);
const fresh = (await h.api("GET", "/download-tasks/board")).body.data;
expect(fresh.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(5_000_000);
expect(qb.callsTo("torrents/info").length - before).toBe(2);
});
it("下载器不可达:列表照常返回,实时数据为空,实例标记不可达", async () => {
await sleep(TTL + 50);
qb.fault("torrents/info", { status: 500, times: 10 });
const b = (await h.api("GET", "/download-tasks/board")).body.data;
qb.clearFaults();
expect(b.counts.active).toBe(4);
expect(b.items.every((i: { live: unknown }) => i.live === null)).toBe(true);
expect(b.transfer).toMatchObject({ dlSpeed: 0, upSpeed: 0 });
expect(b.transfer.instances[0].reachable).toBe(false);
expect(b.liveAt).toBeNull();
});
it("按实例过滤;非法 state 返回 422", async () => {
const other = await h.api("GET", `/download-tasks/board?instanceId=${crypto.randomUUID()}`);
expect(other.body.data).toMatchObject({ items: [], counts: { active: 0, completed: 0 } });
expect(other.body.data.transfer.instances).toEqual([]);
expect((await h.api("GET", "/download-tasks/board?state=done")).status).toBe(422);
});
});
@@ -0,0 +1,207 @@
import type { TaskRunView } from "@umt/tasks";
import { createTempTree, type TempTree } from "@umt/testkit";
import { sql } from "kysely";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { enqueueSweep, enqueueTracked } from "../../src/modules/acquisition-organize/services/common.ts";
import { type QueueScrapeInput, queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
const runView = (runId: string, input: unknown, status: TaskRunView["status"] = "pending"): TaskRunView => ({
runId,
taskName: "scrapeMedia",
version: 2,
status,
attempts: 0,
idempotencyKey: null,
traceId: null,
priority: 0,
input,
output: null,
error: null,
cancelRequestedAt: null,
cancelReason: null,
availableAt: null,
createdAt: new Date(),
startedAt: null,
finishedAt: null,
});
describe.skipIf(!hasPg)("入队关联与巡检恢复", () => {
let h: Harness;
let lib: TempTree;
let rootId: string;
let entityId: string;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
h = await createHarness("enqueue_recovery");
({ rootId } = await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" }));
entityId = await seedEntity(h.db, { kind: "movie", title: "恢复测试", year: 2020 });
await sql`create function reject_association() returns trigger language plpgsql as $$
begin
if new.engine_run_id is not null and new.engine_run_id is distinct from old.engine_run_id
then raise exception 'injected association failure'; end if;
return new;
end $$`.execute(h.db);
});
async function rejectAssociation(table: "task" | "scrape_record") {
await sql
.raw(`create trigger reject_association before update on ${table}
for each row execute function reject_association()`)
.execute(h.db);
}
async function allowAssociation() {
for (const table of ["task", "scrape_record"])
await sql.raw(`drop trigger if exists reject_association on ${table}`).execute(h.db);
}
afterEach(async () => {
vi.restoreAllMocks();
await allowAssociation();
});
afterAll(async () => {
await h?.close();
await lib?.cleanup();
});
const task = (key: string) => ({ kind: "reconcileLibrary" as const, idempotencyKey: key, input: {} });
const scrape = (path: string, idempotencyKey?: string): QueueScrapeInput => ({
rootId: `lib:${rootId}`,
rootPath: lib.root,
libraryRootId: rootId,
path,
match: {
mediaEntityId: entityId,
provider: null,
externalId: null,
kind: "movie",
title: "恢复测试",
year: 2020,
source: "chosen",
},
trigger: "manual",
mode: "missing",
...(idempotencyKey ? { idempotencyKey } : {}),
});
it("引擎查询异常跳过本轮,下一轮仍查原任务", async () => {
const t = task("query_error");
const q = await enqueueTracked(h.d, t);
const enqueue = vi.spyOn(h.engine, "enqueue");
const get = vi
.spyOn(h.engine, "get")
.mockRejectedValueOnce(new Error("temporary engine outage"))
.mockResolvedValue(runView(q.runId, {}, "running"));
expect(await enqueueSweep(h.d, t, "next")).toBeNull();
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
expect(enqueue).not.toHaveBeenCalled();
expect(get).toHaveBeenNthCalledWith(2, q.runId);
expect(
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
.engine_run_id,
).toBe(q.runId);
});
it.each(["missing", "completed"])("明确 %s 时允许补投", async (state) => {
const t = task(`retry_${state}`);
const q = await enqueueTracked(h.d, t);
vi.spyOn(h.engine, "get").mockResolvedValue(state === "missing" ? null : runView(q.runId, {}, "completed"));
const enqueue = vi.spyOn(h.engine, "enqueue");
expect(await enqueueSweep(h.d, t, "next")).not.toBeNull();
expect(enqueue).toHaveBeenCalledOnce();
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(`${t.kind}:${t.idempotencyKey}:next`);
});
it.each([false, true])("关联写回失败后巡检按原 taskId 找回已接受的 run (旧关联=%s)", async (oldAssociation) => {
const t = task(`association_${oldAssociation}`);
if (oldAssociation) await enqueueTracked(h.d, t);
await rejectAssociation("task");
const q = await enqueueTracked(h.d, t, { engineKey: "new-slot" });
await h.db
.insertInto("tasks_run_meta")
.values({ run_id: q.runId, task_name: t.kind, task_version: 1, idempotency_key: "new-slot", trace_id: q.taskId })
.execute();
await allowAssociation();
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.runId, {}, "running"));
const enqueue = vi.spyOn(h.engine, "enqueue");
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
expect(enqueue).not.toHaveBeenCalled();
expect(
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
.engine_run_id,
).toBe(q.runId);
});
it("刮削已接受而关联失败时保持 queued,重试复用记录与原幂等键", async () => {
const input = scrape("association-retry");
await rejectAssociation("scrape_record");
const q = await queueScrape(h.d, input);
expect(
await h.db
.selectFrom("scrape_record")
.select(["status", "reason", "engine_run_id"])
.where("id", "=", q.recordId)
.executeTakeFirstOrThrow(),
).toMatchObject({ status: "queued", reason: null, engine_run_id: null });
const key = h.engine.enqueued.at(-1)?.key;
await allowAssociation();
const enqueue = vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }));
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(key);
expect((await h.db.selectFrom("scrape_record").select("id").where("path", "=", input.path).execute()).length).toBe(
1,
);
});
it("刮削处理器开始时补关联,不会把已接受的工作误标为入队失败", async () => {
await rejectAssociation("scrape_record");
const q = await queueScrape(h.d, scrape("worker-backfill"));
await allowAssociation();
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
const r = await runScrapeRecord(h.d, q.recordId, undefined, q.taskId!);
expect(r.reason).toBe("scrape_root_read_only");
expect(
(
await h.db
.selectFrom("scrape_record")
.select("engine_run_id")
.where("id", "=", q.recordId)
.executeTakeFirstOrThrow()
).engine_run_id,
).toBe(q.taskId);
});
it("确实入队失败的新刮削记录标失败", async () => {
vi.spyOn(h.engine, "enqueue").mockRejectedValue(new Error("enqueue rejected"));
const input = scrape("enqueue-failed");
await expect(queueScrape(h.d, input)).rejects.toThrow("enqueue rejected");
expect(
await h.db
.selectFrom("scrape_record")
.select(["status", "reason"])
.where("path", "=", input.path)
.executeTakeFirstOrThrow(),
).toMatchObject({ status: "failed", reason: "task_enqueue_failed" });
});
it("自动重放从原 run 输入找回未关联记录,只删除本次多建的记录", async () => {
const input = scrape("auto-replay", "auto-stable-key");
await rejectAssociation("scrape_record");
const q = await queueScrape(h.d, input);
await allowAssociation();
await h.db.updateTable("scrape_record").set({ status: "succeeded" }).where("id", "=", q.recordId).execute();
vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }, "completed"));
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
const recs = await h.db
.selectFrom("scrape_record")
.select(["id", "status", "engine_run_id"])
.where("path", "=", input.path)
.execute();
expect(recs).toEqual([{ id: q.recordId, status: "succeeded", engine_run_id: q.taskId }]);
});
});
@@ -0,0 +1,360 @@
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { DiskUsage } from "@umt/adapters";
import { createQbClient } from "@umt/adapters";
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
import { type DB, migrateToLatest } from "@umt/db";
import type { TaskEngine } from "@umt/tasks";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import { ConfigSchema } from "../../src/config.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { hashApiKey } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts";
// 真实 PG(独立 schema)+ mock qB + 临时目录 的测试装配;引擎用记录入队的假实现,测试直接驱动服务函数。
export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export const DEV_KEY = "dev-admin-ao-test-key";
/** 只读整理记录 / 可发起整理 的 API Key */
export const READ_KEY = "ao-organize-read-key";
export const EXEC_KEY = "ao-organize-exec-key";
const KEYS = [
{ key: READ_KEY, scopes: ["organize.read"] },
{ key: EXEC_KEY, scopes: ["organize.execute"] },
];
export async function pgAvailable() {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: DB_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
export interface Enqueued {
task: string;
input: unknown;
key: string;
}
/** 记录入队、不执行的引擎(处理器由测试直接调服务函数) */
export function fakeEngine(): TaskEngine & { enqueued: Enqueued[] } {
const enqueued: Enqueued[] = [];
let n = 0;
return {
enqueued,
async enqueue(task, input, opts) {
enqueued.push({ task, input, key: opts.idempotencyKey });
n++;
return { runId: `run-${n}`, deduplicated: false };
},
async signal() {
return { delivered: 0 };
},
async cancel() {
return false;
},
async get() {
return null;
},
async list() {
return [];
},
async startWorker() {},
async stop() {},
};
}
/** 可调的磁盘用量:used = base + 目录下文件总大小(清理删文件后自然下降) */
export function fakeDisk(total: number) {
const state = { total, base: 0, watch: [] as string[], fail: false };
const du = (dir: string): number => {
let s = 0;
try {
for (const n of readdirSync(dir)) {
const p = join(dir, n);
const st = statSync(p);
s += st.isDirectory() ? du(p) : st.size;
}
} catch {
// 不存在
}
return s;
};
const fn = async (path: string): Promise<DiskUsage> => {
if (state.fail) return { status: "unknown", path, code: "EIO", message: "fake" };
const used = state.base + state.watch.reduce((a, d) => a + du(d), 0);
return {
status: "ok",
path,
totalBytes: state.total,
freeBytes: state.total - used,
availableBytes: state.total - used,
usedBytes: used,
usedRatio: used / state.total,
};
};
/** 把使用率设为 pct(按当前目录内容反推 base) */
const setPct = (pct: number) => {
const files = state.watch.reduce((a, d) => a + du(d), 0);
state.base = Math.round((pct / 100) * state.total) - files;
};
return { state, fn, setPct };
}
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应/事件 JSON
type Loose = any;
export interface Harness {
module: ReturnType<typeof createAcquisitionOrganizeModule>;
db: Kysely<DB>;
ctx: ServerContext;
d: AoDeps;
engine: ReturnType<typeof fakeEngine>;
app: ReturnType<typeof createApp>;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
setSettings(key: string, value: Record<string, unknown>): Promise<void>;
outbox(type?: string): Promise<{ type: string; payload: Loose; idempotency_key: string | null }[]>;
close(): Promise<void>;
}
export async function createHarness(
name: string,
overrides: AoOverrides = {},
/** withSources:同时装配 sources-identity 模块(待处理中心路由等) */
opts: { withSources?: boolean } = {},
): Promise<Harness> {
const schema = `ao_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new pg.Client({ connectionString: DB_URL });
await admin.connect();
await admin.query(`create schema ${schema}`);
await admin.end();
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: DB_URL, max: 6, options: `-c search_path=${schema},public` }),
}),
});
await migrateToLatest(db, { migrationTableSchema: schema });
const logger = createLogger({ level: "silent" });
const engine = fakeEngine();
const config = ConfigSchema.parse({
DATABASE_URL: DB_URL,
SECRET_MASTER_KEY: "test-master-key-0123456789",
DEV_ADMIN_KEY: DEV_KEY,
});
const outbox = createOutboxPort();
const bus = new EventBus(50);
const ctx: ServerContext = {
role: "all",
db,
config,
logger,
events: bus,
engine,
outbox,
secrets: {
resolve: async () => "adminadmin",
create: async () => "x",
replace: async () => {},
remove: async () => {},
},
// 不缓存的设置读取(测试里改 settings 行立即生效)
settings: {
async all() {
const rows = await db.selectFrom("settings").select(["key", "value"]).execute();
const stored = new Map(rows.map((r) => [r.key, r.value]));
const out: Record<string, unknown> = {};
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
const v = stored.get(k);
// 整理最小体积缺省跟随端口注入的 minVideoBytes(测试用小文件)
const base =
k === "organize"
? { ...DEFAULT_SETTINGS.organize, minSizeBytes: overrides.options?.minVideoBytes ?? 100_000 }
: DEFAULT_SETTINGS[k];
out[k] = GlobalSettings.shape[k].parse({ ...base, ...((v as object) ?? {}) });
}
return out as GlobalSettings;
},
async get(k) {
return (await this.all())[k];
},
},
capabilities: createCapabilityRegistry({ logger }),
};
const module = createAcquisitionOrganizeModule({
downloader: async (inst) =>
createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
username: "admin",
password: "adminadmin",
lookup: { attempts: 3, intervalMs: 50 },
}),
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
...overrides,
options: { backgroundLoops: false, minVideoBytes: 100_000, verifyHash: "sha256", ...(overrides.options ?? {}) },
});
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "test",
db,
modules: opts.withSources ? [module, createSourcesIdentityModule()] : [module],
ctx,
apiKeyStore: {
async findByHash(hash) {
const k = KEYS.find((x) => hashApiKey(x.key) === hash);
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
},
async touch() {},
},
});
const d = module.deps();
return {
module,
db,
ctx,
d,
engine,
app,
async api(method, path, body, key = DEV_KEY) {
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${key}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, body: await res.json() };
},
async setSettings(key, value) {
await sql`insert into settings (key, value) values (${key}, ${JSON.stringify(value)}::jsonb)
on conflict (key) do update set value = excluded.value`.execute(db);
},
async outbox(type) {
const r = await sql<{ type: string; payload: Loose; idempotency_key: string | null }>`
select type, payload, idempotency_key from outbox_event ${type ? sql`where type = ${type}` : sql``} order by at, id`.execute(
db,
);
return r.rows;
},
async close() {
await db.destroy();
const c = new pg.Client({ connectionString: DB_URL });
await c.connect();
await c.query(`drop schema if exists ${schema} cascade`);
await c.end();
},
};
}
// ── 种子数据 ──
export async function seedBase(
db: Kysely<DB>,
opts: { libraryPath: string; cachePath: string; qbUrl: string; mediaKind?: "movie" | "series" },
) {
const root = await db
.insertInto("library_root")
.values({
name: `lib-${Math.random().toString(36).slice(2, 6)}`,
media_kind: "mixed",
local_path: opts.libraryPath,
management_mode: "managed",
})
.returning("id")
.executeTakeFirstOrThrow();
const inst = await db
.insertInto("downloader_instance")
.values({
name: `qb-${Math.random().toString(36).slice(2, 6)}`,
base_url: opts.qbUrl,
username: "admin",
settings: JSON.stringify({ savePath: opts.cachePath }),
})
.returning("id")
.executeTakeFirstOrThrow();
const site = await db
.insertInto("site")
.values({
key: `site${Math.random().toString(36).slice(2, 6)}`,
name: "测试站",
adapter: "nexusphp",
base_url: "https://pt.example.test",
config: JSON.stringify({ hitAndRun: { minSeedingSec: 86_400 } }),
})
.returning("id")
.executeTakeFirstOrThrow();
return { rootId: root.id, instanceId: inst.id, siteId: site.id };
}
export async function seedEntity(db: Kysely<DB>, e: { kind: "movie" | "series"; title: string; year: number }) {
return (
await db
.insertInto("media_entity")
.values({ kind: e.kind, title: e.title, year: e.year, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export async function seedRelease(
db: Kysely<DB>,
r: { siteId: string; entityId: string; title: string; size: number; hash: string; hr?: boolean | "unknown" },
) {
return (
await db
.insertInto("source_release")
.values({
site_id: r.siteId,
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
title: r.title,
size_bytes: r.size,
infohash_v1: r.hash,
media_entity_id: r.entityId,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: r.hr ?? false } }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export async function seedSlot(
db: Kysely<DB>,
s: { entityId: string; name?: string; hard?: Record<string, unknown>; upgrade?: Record<string, unknown> },
) {
const sub = await db
.insertInto("subscription")
.values({ media_entity_id: s.entityId, media_kind: "movie", mode: "continuous" })
.returning("id")
.executeTakeFirstOrThrow();
return (
await db
.insertInto("acquisition_slot")
.values({
media_entity_id: s.entityId,
subscription_id: sub.id,
name: s.name ?? "main",
hard_conditions: JSON.stringify(s.hard ?? {}),
upgrade_threshold: JSON.stringify(s.upgrade ?? {}),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
@@ -0,0 +1,339 @@
import type { DB } from "@umt/db";
import type { Kysely } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { backfillVersionSpecs } from "../../src/modules/acquisition-organize/services/version-backfill.ts";
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
// LIBV-1:入库版本 API(每集多个版本、季汇总、单目标比对)与规格回填(dry-run / apply)。
const hasPg = await pgAvailable();
const GB = 1_000_000_000;
const PROBE_DV = {
status: "ok",
durationSec: 3300,
video: [{ codec: "hevc", width: 3840, height: 2160, pixFmt: "yuv420p10le", hdr: "DV" }],
audio: [{ codec: "eac3", profile: "Dolby Digital Plus + Dolby Atmos", channels: 6, language: "eng" }],
subtitles: [{ language: "chi", title: "简体" }],
};
async function seedAsset(
db: Kysely<DB>,
a: {
entityId: string;
rootId: string;
path: string;
size: number;
units: string[];
mediaInfo?: object;
delivery?: object;
},
) {
const asset = await db
.insertInto("media_asset")
.values({
media_entity_id: a.entityId,
kind: "video",
size_bytes: a.size,
media_info: JSON.stringify(a.mediaInfo ?? {}),
delivery: JSON.stringify(a.delivery ?? {}),
status: "active",
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: a.rootId,
relative_path: a.path,
status: "available",
is_primary: true,
size_bytes: a.size,
})
.execute();
for (const u of a.units)
await db.insertInto("asset_unit_coverage").values({ media_asset_id: asset.id, content_unit_id: u }).execute();
return asset.id;
}
describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
let h: Harness;
let rootId: string;
let series: string;
let movie: string;
let bare: string;
const eps: string[] = [];
const ids: Record<string, string> = {};
beforeAll(async () => {
h = await createHarness("libv1");
rootId = (
await h.db
.insertInto("library_root")
.values({ name: "剧集", media_kind: "mixed", local_path: "/media/tv/", management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
await h.db
.insertInto("content_unit")
.values({ media_entity_id: series, kind: "season", season_number: 1, status: "confirmed", episode_total: 3 })
.execute();
for (const n of [1, 2, 3])
eps.push(
(
await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
kind: "episode",
season_number: 1,
episode_number: n,
status: "confirmed",
})
.returning("id")
.executeTakeFirstOrThrow()
).id,
);
const dir = "人生切割术 (2022)/Season 1";
ids.e1dv = await seedAsset(h.db, {
entityId: series,
rootId,
path: `${dir}/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
size: 14.2 * GB,
units: [eps[0]!],
mediaInfo: { probe: PROBE_DV, _umt: { quality: { resolution: "2160p", source: "WEB-DL" } } },
delivery: { resolution: "2160p", sourceType: "WEB-DL" },
});
ids.e1bd = await seedAsset(h.db, {
entityId: series,
rootId,
path: `${dir}/Severance.S01E01.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD.mkv`,
size: 8 * GB,
units: [eps[0]!],
});
ids.e2 = await seedAsset(h.db, {
entityId: series,
rootId,
path: `${dir}/Severance.S01E02.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
size: 13 * GB,
units: [eps[1]!],
});
// 附属外挂字幕
const sub = await h.db
.insertInto("media_asset")
.values({ media_entity_id: series, kind: "subtitle", parent_asset_id: ids.e2, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({
media_asset_id: sub.id,
library_root_id: rootId,
relative_path: `${dir}/Severance.S01E02.chs.srt`,
status: "available",
})
.execute();
await seedSlot(h.db, { entityId: series, name: "4K", hard: { resolutions: ["2160p"] } });
movie = await seedEntity(h.db, { kind: "movie", title: "奥本海默", year: 2023 });
ids.movie = await seedAsset(h.db, {
entityId: movie,
rootId,
path: "奥本海默 (2023)/Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR10.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
size: 80 * GB,
units: [],
});
bare = await seedEntity(h.db, { kind: "movie", title: "没有版本目标", year: 2020 });
await seedAsset(h.db, {
entityId: bare,
rootId,
path: "x/Bare.2020.1080p.WEB-DL.H264-GRP.mkv",
size: GB,
units: [],
});
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("剧集:一集两个版本、季汇总、按版本目标逐集比对", async () => {
const r = await h.api("GET", `/library/${series}/versions`);
expect(r.status).toBe(200);
const v = r.body.data;
expect(v.kind).toBe("series");
expect(v.targets).toEqual([{ slotId: expect.any(String), name: "4K" }]);
expect(v.summary.versionCount).toBe(3);
const s1 = v.seasons[0];
expect(s1).toMatchObject({ seasonNumber: 1, episodeTotal: 3, episodesWithVersion: 2 });
expect(s1.summary.sizeBytes).toBe(35.2 * GB);
expect(s1.summary.specs[0]).toEqual({ resolution: "2160p", source: "WEB-DL", hdr: ["DV"], count: 2 });
expect(s1.matchCounts).toEqual({ satisfied: 2, missing: 1, upgradeable: 0 });
const [e1, e2, e3] = s1.episodes;
expect(e1.versions.map((x: { assetId: string }) => x.assetId).sort()).toEqual([ids.e1dv, ids.e1bd].sort());
expect(e1.matches).toEqual([
{ slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [], codes: [] },
]);
const dv = e1.versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
expect(dv).toMatchObject({
resolution: "2160p",
source: "WEB-DL",
videoCodec: "H.265",
hdr: ["DV", "HDR10"],
releaseGroup: "FLUX",
audio: [{ codec: "DDP Atmos", channels: "5.1", language: "en" }],
subtitleLanguages: ["zh-Hans"],
durationSec: 3300,
sizeBytes: 14.2 * GB,
specFrom: ["name", "probe"],
specStored: false,
locationStatus: "available",
unitIds: [eps[0]],
});
expect(dv.absolutePath).toBe(
"/media/tv/人生切割术 (2022)/Season 1/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv",
);
expect(dv.probe.status).toBe("ok");
expect(e2.versions[0].subtitleLanguages).toEqual(["zh-Hans"]);
expect(e3).toMatchObject({ episodeNumber: 3, versions: [] });
expect(e3.matches[0]).toMatchObject({ state: "missing", assetId: null });
expect(v.unplaced).toEqual([]);
});
it("指定一个作品没有的版本目标 = 不比对;电影给出版本与比对", async () => {
const r = await h.api("GET", `/library/${series}/versions?slotId=00000000-0000-4000-8000-000000000000`);
expect(r.status).toBe(200);
expect(r.body.data.targets).toEqual([]);
expect(r.body.data.seasons[0].episodes[0].matches).toBeUndefined();
expect(r.body.data.seasons[0].matchCounts).toBeUndefined();
const m = await h.api("GET", `/library/${bare}/versions`);
expect(m.body.data.targets).toEqual([]);
expect(m.body.data.movie.versions).toHaveLength(1);
expect(m.body.data.movie.matches).toBeUndefined();
await seedSlot(h.db, { entityId: movie, name: "1080p", hard: { resolutions: ["1080p"] } });
const mv = await h.api("GET", `/library/${movie}/versions`);
expect(mv.body.data.movie.versions[0]).toMatchObject({ source: "REMUX", resolution: "2160p", edition: null });
expect(mv.body.data.movie.matches[0]).toMatchObject({ state: "upgradeable", assetId: ids.movie });
const nf = await h.api("GET", "/library/00000000-0000-4000-8000-000000000000/versions");
expect(nf.status).toBe(404);
});
it("回填:dry-run 只出清单不写库;apply 后再跑没有待更新", async () => {
const before = await h.db
.selectFrom("media_asset")
.select(["id", "delivery"])
.where("kind", "=", "video")
.execute();
const dry = await backfillVersionSpecs(h.db, { entityId: series });
expect(dry).toMatchObject({ apply: false, scanned: 3, upToDate: 0, changed: 3, probed: 0, noLocation: 0 });
const item = dry.items.find((i) => i.assetId === ids.e1dv)!;
expect(item).toMatchObject({ reason: "no_spec", entityTitle: "人生切割术", before: "2160p · WEB-DL" });
expect(item.after).toBe("2160p · WEB-DL · H.265 · DV/HDR10 · DDP Atmos · 字幕 zh-Hans · FLUX");
const after = await h.db.selectFrom("media_asset").select(["id", "delivery"]).where("kind", "=", "video").execute();
expect(after).toEqual(before);
// 可选探测:只对缺探测结果的本地文件调用端口
const probed: string[] = [];
const withProbe = await backfillVersionSpecs(h.db, {
entityId: series,
probe: async (p) => {
probed.push(p);
return { status: "unknown" };
},
});
expect(withProbe.probed).toBe(0);
expect(probed).toHaveLength(2);
expect(probed.every((p) => p.startsWith("/media/tv/人生切割术 (2022)/Season 1/"))).toBe(true);
const applied = await backfillVersionSpecs(h.db, { apply: true });
expect(applied.changed).toBe(5);
const row = await h.db
.selectFrom("media_asset")
.select("delivery")
.where("id", "=", ids.e1dv!)
.executeTakeFirstOrThrow();
expect(row.delivery).toMatchObject({
specVersion: 1,
sourceType: "WEB-DL",
releaseGroup: "FLUX",
durationSec: 3300,
});
const again = await backfillVersionSpecs(h.db, {});
expect(again).toMatchObject({ changed: 0, upToDate: 5 });
const r = await h.api("GET", `/library/${series}/versions`);
const dv = r.body.data.seasons[0].episodes[0].versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
expect(dv).toMatchObject({ specStored: true, hdr: ["DV", "HDR10"], releaseGroup: "FLUX" });
});
it("作品详情保留跨过旧条数上限的整理、下载和失败计划供分页查看", async () => {
const entityId = await seedEntity(h.db, { kind: "movie", title: "历史分页测试", year: 2026 });
const instance = await h.db
.insertInto("downloader_instance")
.values({ name: "历史下载器", base_url: "http://127.0.0.1:1" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("download_task")
.values(
Array.from({ length: 105 }, (_, i) => ({
instance_id: instance.id,
media_entity_id: entityId,
torrent_hash: String(i + 1).padStart(40, "0"),
name: `历史下载 ${i + 1}`,
})),
)
.execute();
const slotId = await seedSlot(h.db, { entityId, name: "历史版本" });
const site = await h.db
.insertInto("site")
.values({ key: "history-source", name: "历史来源", base_url: "http://127.0.0.1:1", adapter: "nexusphp" })
.returning("id")
.executeTakeFirstOrThrow();
const releaseId = await seedRelease(h.db, {
siteId: site.id,
entityId,
title: "历史分页测试",
size: 1000,
hash: "a".repeat(40),
});
await h.db
.insertInto("acquisition_plan")
.values(
Array.from({ length: 51 }, (_, i) => ({
slot_id: slotId,
source_release_id: releaseId,
status: "failed" as const,
payload_hash: `history-${i}`,
idempotency_key: `history:${i}`,
})),
)
.execute();
await h.db
.insertInto("organization_record")
.values(
Array.from({ length: 105 }, (_, i) => ({
media_entity_id: entityId,
trigger: "manual" as const,
actor_type: "user" as const,
action: "copy" as const,
status: "succeeded" as const,
source_path: `/history/${i}.mkv`,
target_path: `/library/history/${i}.mkv`,
})),
)
.execute();
const response = await h.api("GET", `/library/${entityId}`);
expect(response.status).toBe(200);
expect(response.body.data.organizationRecords).toHaveLength(105);
expect(response.body.data.downloads).toHaveLength(105);
expect(response.body.data.droppedPlans).toHaveLength(51);
});
});
@@ -0,0 +1,153 @@
import { ManualAcquisitionRequest } from "@umt/contracts";
import { createMockQb, createTempTree, makeTorrent } from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { submitDownload } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { planManualAcquisition } from "../../src/modules/acquisition-organize/services/manual-acquisition.ts";
import { createHarness, fakeDisk, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("独立手动下载", () => {
it("无订阅下载,未知资源确认,同hash沿用原路径", async () => {
const qb = await createMockQb({ addVisibleAfterPolls: 0 });
const cache = await createTempTree({});
const lib = await createTempTree({}, { sentinel: true });
const torrents = new Map<string, Uint8Array>();
const disk = fakeDisk(10_000_000);
disk.setPct(20);
const h = await createHarness("manual_acquisition", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
try {
const base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Manual Movie", year: 2026 });
const torrent = makeTorrent({ name: "Manual.Movie.2026.1080p.mkv", length: 300_000 });
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: torrent.name,
size: 300_000,
hash: torrent.hash,
});
torrents.set(relId, torrent.bytes);
const request = (id: string, opts = {}) => ManualAcquisitionRequest.parse({ sourceReleaseId: id, ...opts });
const first = await planManualAcquisition(h.ctx, request(relId));
expect(first).toMatchObject({ submitted: true, slotId: null });
expect(await h.db.selectFrom("subscription").selectAll().execute()).toHaveLength(0);
expect(await h.db.selectFrom("acquisition_slot").selectAll().execute()).toHaveLength(0);
const sub = await submitDownload(h.d, first.plan!.id);
expect(sub.status).toBe("submitted");
const rel2 = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: "Other.Release.2160p.mkv",
size: 300_000,
hash: torrent.hash,
});
torrents.set(rel2, torrent.bytes);
const second = await planManualAcquisition(h.ctx, request(rel2, { savePath: `${cache.root}/other` }));
const reused = await submitDownload(h.d, second.plan!.id);
expect(reused).toMatchObject({ reason: "already_exists", downloadTaskId: sub.downloadTaskId });
const dt = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.save_path).toBe(cache.root);
expect(dt.media_entity_id).toBe(entityId);
const unknown = makeTorrent({ name: "Unknown.1080p.mkv", length: 300_000, pieceSeed: 42 });
const uid = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: unknown.name,
size: 300_000,
hash: unknown.hash,
});
await h.db.updateTable("source_release").set({ media_entity_id: null }).where("id", "=", uid).execute();
torrents.set(uid, unknown.bytes);
expect(await planManualAcquisition(h.ctx, request(uid))).toMatchObject({
submitted: false,
requiresConfirmation: true,
});
const confirmed = await planManualAcquisition(h.ctx, request(uid, { allowUnrecognized: true }));
const submitted = await submitDownload(h.d, confirmed.plan!.id);
expect(submitted.status).toBe("submitted");
expect(
(
await h.db
.selectFrom("download_task")
.select("media_entity_id")
.where("id", "=", submitted.downloadTaskId!)
.executeTakeFirstOrThrow()
).media_entity_id,
).toBeNull();
const autoEntity = await seedEntity(h.db, { kind: "movie", title: "Auto Movie", year: 2026 });
const slotId = await seedSlot(h.db, { entityId: autoEntity });
const autoTorrent = makeTorrent({
name: "Auto.Movie.2026.1080p.BluRay.x264-GRP.mkv",
length: 300_000,
pieceSeed: 100,
});
const autoRelease = await seedRelease(h.db, {
siteId: base.siteId,
entityId: autoEntity,
title: autoTorrent.name,
size: 300_000,
hash: autoTorrent.hash,
});
torrents.set(autoRelease, autoTorrent.bytes);
const auto = await evaluateAcquisition(h.d, { sourceReleaseId: autoRelease });
expect(auto.plans).toHaveLength(1);
const autoTask = await submitDownload(h.d, auto.plans[0]!);
const oldAsset = await h.db
.insertInto("media_asset")
.values({ media_entity_id: autoEntity, kind: "video", size_bytes: 300_000, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("download_task")
.set({ status: "imported" })
.where("id", "=", autoTask.downloadTaskId!)
.execute();
const unchanged = await planManualAcquisition(h.ctx, request(autoRelease));
expect(unchanged.plan!.id).toBe(auto.plans[0]);
const wash = await planManualAcquisition(
h.ctx,
request(autoRelease, { importMode: "replace", replacementAssetIds: [oldAsset.id] }),
);
const washed = await submitDownload(h.d, wash.plan!.id);
expect(washed.downloadTaskId).toBe(autoTask.downloadTaskId);
const linked = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", autoTask.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(linked).toMatchObject({
acquisition_plan_id: auto.plans[0],
import_plan_id: wash.plan!.id,
status: "completed",
});
expect(
(
await h.db
.selectFrom("acquisition_plan")
.select("slot_id")
.where("id", "=", auto.plans[0]!)
.executeTakeFirstOrThrow()
).slot_id,
).toBe(slotId);
expect(h.engine.enqueued).toContainEqual(
expect.objectContaining({
task: "verifyAndImport",
input: expect.objectContaining({ downloadTaskId: autoTask.downloadTaskId }),
}),
);
} finally {
await h.close();
await qb.close();
await cache.cleanup();
await lib.cleanup();
}
});
});
@@ -0,0 +1,175 @@
import { createHash } from "node:crypto";
import { DEFAULT_NAMING_TEMPLATES } from "@umt/contracts";
import { LEGACY_V1_NAMING, MP_DEFAULT_NAMING, namingCanonicalJson } from "@umt/domain";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureNamingVersion, namingByVersion } from "../../src/modules/acquisition-organize/services/naming.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// W9-C 命名模板:变量表、预览(样本/错误/路径绑定)、三级模板读写与保存前校验、模板版本登记。
const hasPg = await pgAvailable();
it("contracts 默认模板 = domain MP 默认", () => {
expect(DEFAULT_NAMING_TEMPLATES).toEqual(MP_DEFAULT_NAMING);
});
describe.skipIf(!hasPg)("命名模板 API(真实 PG)", () => {
let h: Harness;
let rootId: string;
beforeAll(async () => {
h = await createHarness("naming");
({ rootId } = await seedBase(h.db, {
libraryPath: "/media/lib",
cachePath: "/cache",
qbUrl: "http://127.0.0.1:1",
}));
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("迁移种子:版本 1 = v1 旧模板,2 = MP 默认,指纹与 domain 同算法;新模板集按内容登记版本", async () => {
const rows = await h.db.selectFrom("naming_template_version").selectAll().orderBy("version").execute();
const fp = (t: typeof MP_DEFAULT_NAMING) => createHash("sha256").update(namingCanonicalJson(t)).digest("hex");
expect(rows.map((r) => [r.version, r.fingerprint])).toEqual([
[1, fp(LEGACY_V1_NAMING)],
[2, fp(MP_DEFAULT_NAMING)],
]);
expect((await ensureNamingVersion(h.db, { ...MP_DEFAULT_NAMING })).version).toBe(2);
const custom = { ...MP_DEFAULT_NAMING, movie: "{{title}}/{{title}}{{fileExt}}" };
const v = (await ensureNamingVersion(h.db, custom)).version;
expect(v).toBeGreaterThanOrEqual(3);
expect((await ensureNamingVersion(h.db, custom)).version).toBe(v);
expect(await namingByVersion(h.db, v)).toEqual({ ...custom, version: v });
expect(await namingByVersion(h.db, 1)).toEqual({ ...LEGACY_V1_NAMING, version: 1 });
expect(await namingByVersion(h.db, 9999)).toBeUndefined();
});
it("GET /naming/variables", async () => {
const r = await h.api("GET", "/naming/variables");
expect(r.status).toBe(200);
const names = r.body.data.items.map((i: { name: string }) => i.name);
expect(names).toEqual(expect.arrayContaining(["title", "videoFormat", "season_episode", "fileExt", "slotName"]));
expect(r.body.data.items.find((i: { name: string }) => i.name === "airDate")).toMatchObject({ ours: true });
expect(r.body.data.filters.map((f: { name: string }) => f.name)).toContain("pad");
expect(r.body.data.samples).toHaveLength(6);
});
it("POST /naming/preview:内置样本 / 手填文件名 / 编译错误行列号 / 提示", async () => {
const a = await h.api("POST", "/naming/preview", { kind: "tv", sample: { builtin: 3 } });
expect(a.status).toBe(200);
expect(a.body.data).toMatchObject({
ok: true,
relativePath: "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv",
usesBinding: false,
templateSource: "global",
segments: [
{ text: "绝命毒师 (2008)", role: "root" },
{ text: "Season 1", role: "season" },
{ text: "绝命毒师 - S01E01 - 第 1 集.mkv", role: "file" },
],
});
const b = await h.api("POST", "/naming/preview", {
kind: "movie",
template: "{{title}} ({{year}})/{{title}} - {{videoFormat}}{{fileExt}}",
sample: { fileName: "Inception.2010.1080p.BluRay.x264-CMCT.mkv" },
});
expect(b.body.data).toMatchObject({
ok: true,
relativePath: "Inception (2010)/Inception - 1080p.mkv",
templateSource: "input",
});
expect(b.body.data.warnings).toEqual(expect.arrayContaining([expect.stringContaining("year 可能为空")]));
const c = await h.api("POST", "/naming/preview", {
kind: "movie",
template: "{{title}}/\n{{videoformat}}{{fileExt}}",
});
expect(c.body.data).toMatchObject({ ok: false, relativePath: "" });
expect(c.body.data.errors[0]).toMatchObject({ code: "unknown_variable", line: 2, col: 3 });
expect(c.body.data.errors[0].message).toContain("videoFormat");
});
it("三级覆盖:全局保存校验、库根与作品覆盖、GET /naming/templates 生效来源", async () => {
const bad = await h.api("PUT", "/naming/templates", { movie: "{{title}}/{{title}}" });
expect(bad.status).toBe(422);
const ok = await h.api("PUT", "/naming/templates", {
movie: "{{title_year}}/{{title}}{{fileExt}}",
lockFileNameForBound: true,
});
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({ movie: "{{title_year}}/{{title}}{{fileExt}}", lockFileNameForBound: true });
expect((await h.ctx.settings.get("naming")).lockFileNameForBound).toBe(true);
const e = await seedEntity(h.db, { kind: "series", title: "绝命毒师", year: 2008 });
expect((await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: { tv: "{{title}}" } })).status).toBe(422);
expect(
(
await h.api("PUT", `/library-roots/${rootId}/naming`, {
naming: { tv: "{{title}}/{{title}} {{season_episode}}{{fileExt}}" },
})
).status,
).toBe(200);
const put = await h.api("PUT", `/media-entities/${e}/naming-override`, {
namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" },
});
expect(put.body.data).toEqual({ namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" } });
const view = await h.api("GET", `/naming/templates?libraryRootId=${rootId}&entityId=${e}`);
expect(view.body.data.sources).toEqual({ movie: "global", tv: "root", variety: "entity" });
expect(view.body.data.effective.tv).toBe("{{title}}/{{title}} {{season_episode}}{{fileExt}}");
const pv = await h.api("POST", "/naming/preview", {
kind: "tv",
entityId: e,
libraryRootId: rootId,
sample: { fileName: "Breaking.Bad.S01E02.1080p.WEB-DL.H264-NTb.mkv" },
});
expect(pv.body.data).toMatchObject({
ok: true,
relativePath: "绝命毒师/绝命毒师 S01E02.mkv",
templateSource: "root",
});
await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: null });
await h.api("PUT", "/naming/templates", { ...MP_DEFAULT_NAMING, lockFileNameForBound: false });
});
it("预览:已有路径绑定时目录用绑定路径,文件名按模板", async () => {
const e = await seedEntity(h.db, { kind: "series", title: "新译名", year: 2008 });
await h.db
.insertInto("path_binding")
.values([
{
media_entity_id: e,
binding_kind: "entity",
library_root_id: rootId,
relative_path: "旧名 (2008)",
template_version: "1",
},
{
media_entity_id: e,
binding_kind: "season",
season_number: 1,
library_root_id: rootId,
relative_path: "旧名 (2008)/S1",
template_version: "1",
},
])
.execute();
const r = await h.api("POST", "/naming/preview", {
kind: "tv",
entityId: e,
sample: { fileName: "Show.S01E03.1080p.WEB-DL.H264-NTb.mkv" },
});
expect(r.body.data).toMatchObject({
ok: true,
usesBinding: true,
relativePath: "旧名 (2008)/S1/新译名 - S01E03 - 第 3 集.mkv",
segments: [
{ text: "旧名 (2008)", role: "root" },
{ text: "S1", role: "season" },
{ text: "新译名 - S01E03 - 第 3 集.mkv", role: "file" },
],
});
});
});
@@ -0,0 +1,189 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord, type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// W4-E4 执行侧:replace_with_verify 接线(写临时→校验→原子替换,旧文件进回收目录)、写前清 partial、
// 对账比对挂死不误报 EEXIST 而是 TIMEOUT → 有限次重试 → 超限建待处理(不删记录)。
const hasPg = await pgAvailable();
const SIZE = 200_000;
describe.skipIf(!hasPg)("整理执行:onExisting / partial 清理 / TIMEOUT 重试", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-fslib-" });
src = await createTempTree(
{ "Replace.Me.2020.1080p.BluRay.x264-GRP.mkv": SIZE, "Hang.Case.2019.1080p.BluRay.x264-GRP.mkv": SIZE },
{ prefix: "umt-fssrc-" },
);
h = await createHarness("fsexec");
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
}, 60_000);
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
const plannedDst = async (entity: string, file: string) => {
const dry = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
});
return dry.plan!.organize.files.find((f) => f.role === "main")!.dst;
};
it("replace_with_verify + 授权:原子替换旧文件,旧文件进 .umt-trash;写前清走 >24h 的 partial", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "替换片", year: 2020 });
const file = "Replace.Me.2020.1080p.BluRay.x264-GRP.mkv";
const dst = await plannedDst(e, file);
await writeFile(dst, "OLD-VERSION").catch(async () => {
execFileSync("mkdir", ["-p", dirname(dst)]);
await writeFile(dst, "OLD-VERSION");
});
const stale = join(dirname(dst), ".x.mkv.1.abc.uhd-partial");
await writeFile(stale, "half");
const old = new Date(Date.now() - 48 * 3600_000);
await utimes(stale, old, old);
// 无授权:阻塞待处理,不执行
const blocked = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: e,
action: "copy",
trigger: "manual",
actorType: "user",
onExisting: "replace_with_verify",
});
expect(blocked.status).toBe("failed");
expect(blocked.reason).toContain("replace_requires_grant");
const plan = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: e,
action: "copy",
trigger: "manual",
actorType: "user",
onExisting: "replace_with_verify",
grantReplace: true,
});
expect(plan.status).toBe("running");
const rec = await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect((rec.plan as unknown as FrozenPlan).organize.files[0]!.action).toBe("replace");
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
expect((await readFile(dst)).equals(await readFile(src.path(file)))).toBe(true);
const op = await h.db
.selectFrom("asset_operation")
.select("result")
.where("operation_id", "=", `org:${plan.recordId}:0`)
.executeTakeFirstOrThrow();
const r = op.result as { replaced?: { disposition: string; path: string; trashItemId?: string } };
expect(r.replaced?.disposition).toBe("trash");
expect(r.replaced!.path.startsWith(join(lib.root, ".umt-trash"))).toBe(true);
expect(await readFile(r.replaced!.path, "utf8")).toBe("OLD-VERSION");
// 旧文件登记进文件管理回收站:布局 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,可还原/清除
const ti = await h.db
.selectFrom("trash_item")
.selectAll()
.where("id", "=", r.replaced!.trashItemId!)
.executeTakeFirstOrThrow();
expect(ti.trash_path).toBe(r.replaced!.path);
expect(ti.root_id).toBe(`lib:${ti.library_root_id}`);
expect(ti.trash_path.endsWith(`/${ti.original_path}`)).toBe(true);
expect(join(ti.root_host_path, ti.original_path)).toBe(dst);
expect(Number(ti.size_bytes)).toBe("OLD-VERSION".length);
expect((await readdir(join(lib.root, ".umt-trash"))).filter((n) => n.startsWith("replaced-"))).toEqual([]);
expect(existsSync(stale)).toBe(false);
expect((await readdir(dirname(dst))).filter((n) => n.startsWith("."))).toEqual([]);
}, 60_000);
it("对账比对挂死 → TIMEOUT(不是 EEXIST)→ 有限次重试 → 超限建待处理,记录保留", async () => {
const ctx = h.d.ctx as { config: Record<string, unknown> };
const saved = ctx.config;
ctx.config = { ...saved, FS_HANG_TIMEOUT_MS: 1000, FS_KILL_GRACE_MS: 300, FS_TRANSFER_MAX_ATTEMPTS: 3 };
try {
const e = await seedEntity(h.db, { kind: "movie", title: "挂死片", year: 2019 });
const file = "Hang.Case.2019.1080p.BluRay.x264-GRP.mkv";
const plan = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: e,
action: "copy",
trigger: "manual",
actorType: "user",
});
expect(plan.status).toBe("running");
const f = (plan.plan ??
((
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow()
).plan as unknown as FrozenPlan))!.organize.files[0]!;
// 源换成 FIFO(读会卡在 open)、目标放一个同尺寸(0)的文件:对账走 sha256 比对并挂死
await rm(f.src);
execFileSync("mkfifo", [f.src]);
execFileSync("mkdir", ["-p", dirname(f.dst)]);
await writeFile(f.dst, "");
for (const attempt of [1, 2]) {
const err = await executeRecord(h.d, plan.recordId!).catch((x) => x);
expect(err).toBeInstanceOf(Error);
expect(String(err.message)).toContain(`TIMEOUT@${f.src}(第 ${attempt} 次)`);
const fr = await h.db
.selectFrom("organization_file_result")
.select(["status"])
.where("organization_record_id", "=", plan.recordId!)
.where("role", "=", "main")
.executeTakeFirstOrThrow();
expect(fr.status).toBe("running");
}
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("failed");
const fr = await h.db
.selectFrom("organization_file_result")
.select(["status", "error"])
.where("organization_record_id", "=", plan.recordId!)
.where("role", "=", "main")
.executeTakeFirstOrThrow();
expect(fr.status).toBe("failed");
expect((fr.error as { code: string }).code).toBe("TIMEOUT");
const dec = await h.db
.selectFrom("decision")
.select(["kind", "title", "evidence"])
.where("dedupe_key", "=", `fs_retry_exhausted:org:${plan.recordId}:0`)
.executeTakeFirstOrThrow();
expect(dec.title).toContain("挂死");
expect((dec.evidence as { attempts: number }).attempts).toBe(3);
// 记录保留,目标未被覆盖/删除
const rec = await h.db
.selectFrom("organization_record")
.select("status")
.where("id", "=", plan.recordId!)
.executeTakeFirst();
expect(rec?.status).toBe("failed");
expect(existsSync(f.dst)).toBe(true);
await rm(f.src);
} finally {
ctx.config = saved;
}
}, 90_000);
});
@@ -0,0 +1,275 @@
import { existsSync } from "node:fs";
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { sql } from "@umt/db";
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 {
createHarness,
EXEC_KEY,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// 验收 R4:POST /organization-records 的三道闸。
// 路径守卫(400):根外路径 / `..` / 符号链接逃逸(顶层与目录内)/ 不存在;dryRun 同样受限。
// Move 授权(403):API Key 无授权、授权范围不含目标作品 → 拒绝且不留记录;覆盖作品的持续授权、
// 与本次请求一致的一次性审批(用后 consumed)、管理员 → 放行;Copy 与 dryRun 不要求授权。
// 做种义务(409):来源是纳管下载任务(按 ID 或按路径命中)且 H&R 未满足 → 冲突 + 待处理,不改 Copy、不动 qB。
const hasPg = await pgAvailable();
const SIZE = 150_000;
describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let outside: TempTree;
const disk = fakeDisk(100_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
let movie: string;
const post = (body: Record<string, unknown>, key?: string) => h.api("POST", "/organization-records", body, key);
const records = async (entityId: string) =>
h.db.selectFrom("organization_record").select(["id", "action"]).where("media_entity_id", "=", entityId).execute();
const file = async (rel: string) => cache.write(rel, SIZE);
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-glib-" });
cache = await createTempTree({}, { prefix: "umt-gcache-" });
outside = await createTempTree(
{ "Secret.2020.1080p.BluRay-X.mkv": SIZE, "etc/passwd": 100 },
{ prefix: "umt-gout-" },
);
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("guard", {
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 });
movie = await seedEntity(h.db, { kind: "movie", title: "守卫片", year: 2020 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
for (const t of [lib, cache, outside]) await t?.cleanup();
});
it("路径守卫:根外 / `..` / 符号链接逃逸 / 不存在 → 400,dryRun 也不例外,且不留记录", async () => {
const outFile = outside.path("Secret.2020.1080p.BluRay-X.mkv");
for (const dryRun of [true, false]) {
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("不在已登记的媒体库目录或下载器路径映射之下");
}
// `..` 即使规范化后仍在根内也拒绝
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
const dotted = `${cache.root}/sub/../Dots.2020.1080p.BluRay-X.mkv`;
expect((await post({ sourcePaths: [dotted], mediaEntityId: movie, action: "copy" })).status).toBe(400);
// 顶层符号链接:根内的链接指向根外文件
const link = join(cache.root, "Link.2020.1080p.BluRay-X.mkv");
await symlink(outFile, link);
const r1 = await post({ sourcePaths: [link], mediaEntityId: movie, action: "copy", dryRun: true });
expect(r1.status).toBe(400);
// 目录内的符号链接:遍历用 lstat,不跟随链接(文件链接、目录链接都不进计划),根外内容不会被列出
const dir = join(cache.root, "Pack.2020.1080p.BluRay-X");
await mkdir(dir);
await writeFile(join(dir, "Pack.2020.1080p.BluRay-X.mkv"), Buffer.alloc(SIZE, 1));
await symlink(outside.root, join(dir, "escape"));
await symlink(outFile, join(dir, "Pack.2020.1080p.BluRay-X.sample.mkv"));
const r2 = await post({ sourcePaths: [dir], mediaEntityId: movie, action: "copy", dryRun: true });
expect(r2.status).toBe(201);
const planArt = await h.db
.selectFrom("artifact")
.select("content")
.where("id", "=", r2.body.data.planId)
.executeTakeFirstOrThrow();
const srcs = (planArt.content as { plan: { organize: { files: { src: string }[] } } }).plan.organize.files.map(
(f) => f.src,
);
expect(srcs).toEqual([join(dir, "Pack.2020.1080p.BluRay-X.mkv")]);
expect(JSON.stringify(planArt.content)).not.toContain(outside.root);
// 不存在
expect(
(await post({ sourcePaths: [join(cache.root, "nope.mkv")], mediaEntityId: movie, action: "copy" })).status,
).toBe(400);
expect(await records(movie)).toHaveLength(0);
expect(existsSync(outFile)).toBe(true);
// 根内正常路径:dryRun 通过
const ok = await post({ sourcePaths: [inside], mediaEntityId: movie, action: "copy", dryRun: true }, EXEC_KEY);
expect(ok.status).toBe(201);
expect(ok.body.data.planId).toBeTruthy();
});
it("Move 授权:API Key 无授权 / 范围不含作品 → 403 不留记录;dryRun 与 Copy 不要求授权", async () => {
const src = await file("Auth.2020.1080p.BluRay-X.mkv");
const denied = await post({ sourcePaths: [src], mediaEntityId: movie, action: "move" }, EXEC_KEY);
expect(denied.status).toBe(403);
expect(denied.body.error.message).toContain("需要");
expect(await records(movie)).toHaveLength(0);
expect(existsSync(src)).toBe(true);
// 授权范围只含别的作品
const other = await seedEntity(h.db, { kind: "movie", title: "别的片", year: 2019 });
const g = await h.db
.insertInto("automation_grant")
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [other] }), granted_by: "test" })
.returning("id")
.executeTakeFirstOrThrow();
const scoped = await post({ sourcePaths: [src], mediaEntityId: movie, action: "move" }, EXEC_KEY);
expect(scoped.status).toBe(403);
expect(scoped.body.error.message).toContain("对象范围不含目标作品");
// 没有目标作品时持续授权无从核对
expect((await post({ sourcePaths: [src], action: "move" }, EXEC_KEY)).status).toBe(403);
// dryRun Move / Copy:只要 organize.execute
expect(
(await post({ sourcePaths: [src], mediaEntityId: movie, action: "move", dryRun: true }, EXEC_KEY)).status,
).toBe(201);
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", g.id).execute();
const audits = await sql<{ n: number }>`
select count(*)::int as n from audit_event where action = 'organize.move_denied'`.execute(h.db);
expect(audits.rows[0]!.n).toBe(3);
});
it("Move 授权:覆盖作品的持续授权放行;策略版本非发布状态不算", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "授权片", year: 2021 });
const src = await file("Granted.2021.1080p.BluRay-X.mkv");
const pol = await h.db
.insertInto("policy_revision")
.values({ policy_key: "g", revision: 1, kind: "acquisition", title: "g", status: "draft" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("automation_grant")
.values({
action: "policy.acquisition",
policy_revision_id: pol.id,
scope: JSON.stringify({ mediaKinds: ["movie"] }),
granted_by: "policy:g",
})
.execute();
const draft = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" }, EXEC_KEY);
expect(draft.status).toBe(403);
expect(draft.body.error.message).toContain("不是发布状态");
const g = await h.db
.insertInto("automation_grant")
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e] }), granted_by: "test" })
.returning("id")
.executeTakeFirstOrThrow();
const ok = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" }, EXEC_KEY);
expect(ok.status).toBe(201);
expect((await records(e)).map((r) => r.action)).toEqual(["move"]);
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", g.id).execute();
});
it("Move 授权:一次性审批绑定 dryRun 计划,请求一致才放行,用后 consumed 不可复用", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "审批片", year: 2022 });
const src = await file("Approved.2022.1080p.BluRay-X.mkv");
const req = { sourcePaths: [src], mediaEntityId: e, action: "move" as const };
const dry = await post({ ...req, dryRun: true }, EXEC_KEY);
expect(dry.status).toBe(201);
const art = await h.db
.selectFrom("artifact")
.select(["id", "content_hash", "content"])
.where("id", "=", dry.body.data.planId)
.executeTakeFirstOrThrow();
expect(art.content).toMatchObject({ mediaEntityId: e, mediaKind: "movie" });
const approve = (hash: string) =>
h.db
.insertInto("approval")
.values({
plan_type: "organize_plan",
plan_id: art.id,
payload_hash: hash,
expected_revision: 1,
status: "approved",
requested_by: "api_key:x",
decided_by: "admin_session:a",
})
.returning("id")
.executeTakeFirstOrThrow();
// 请求与计划不一致(换了目标库根字段)→ 不算
const a = await approve(art.content_hash);
const mismatch = await post({ ...req, action: "move", targetLibraryRootId: base.rootId }, EXEC_KEY);
expect(mismatch.status).toBe(403);
const ok = await post(req, EXEC_KEY);
expect(ok.status).toBe(201);
const used = await h.db.selectFrom("approval").select("status").where("id", "=", a.id).executeTakeFirstOrThrow();
expect(used.status).toBe("consumed");
// 用过的审批不能再放行第二次
const again = await post(req, EXEC_KEY);
expect(again.status).toBe(403);
// payloadHash 与计划内容不符的审批无效
await approve("tampered");
expect((await post(req, EXEC_KEY)).status).toBe(403);
});
it("做种义务:纳管下载任务 H&R 未满足 → 409 + 待处理,不改 Copy、不动 qB;管理员 Copy 可以", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "做种片", year: 2023 });
await seedSlot(h.db, { entityId: e });
const t = makeTorrent({ name: "Seeding.2023.1080p.BluRay-HR.mkv", length: SIZE, pieceSeed: 11 });
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId: e,
title: "Seeding.2023.1080p.BluRay-HR",
size: SIZE,
hash: t.hash,
hr: true,
});
torrents.set(relId, t.bytes);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
const sub = await submitDownload(h.d, ev.plans[0]!);
await qb.complete(t.hash, { writeFiles: true });
await syncDownloads(h.d);
const dtId = sub.downloadTaskId!;
const deletesBefore = qb.callsTo("torrents/delete").length;
// 按下载任务 ID(管理员也一样被做种义务拦下)
const byId = await post({ downloadTaskId: dtId, action: "move" });
expect(byId.status).toBe(409);
expect(byId.body.error.message).toContain("未改为复制");
// 按路径命中纳管任务的内容
const content = join(cache.root, "Seeding.2023.1080p.BluRay-HR.mkv");
expect(existsSync(content)).toBe(true);
const byPath = await post({ sourcePaths: [content], mediaEntityId: e, action: "move" });
expect(byPath.status).toBe(409);
const decisions = await h.db
.selectFrom("decision")
.select(["kind", "status", "attempts"])
.where("dedupe_key", "=", `manual_move_hnr:${dtId}`)
.execute();
expect(decisions).toMatchObject([{ kind: "approval_required", status: "open" }]);
expect(Number(decisions[0]!.attempts)).toBeGreaterThanOrEqual(1);
expect(await records(e)).toHaveLength(0);
expect(qb.callsTo("torrents/delete")).toHaveLength(deletesBefore);
expect(qb.get(t.hash)).toBeDefined();
expect(existsSync(content)).toBe(true);
// 同一来源 Copy 不受做种义务限制
const copy = await post({ downloadTaskId: dtId, action: "copy" });
expect(copy.status).toBe(201);
expect((await records(e)).map((r) => r.action)).toEqual(["copy"]);
}, 60_000);
it("管理员会话(dev admin)按路径 Move:放行并记审计", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "管理员片", year: 2024 });
const src = await file("Admin.2024.1080p.BluRay-X.mkv");
const r = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" });
expect(r.status).toBe(201);
const a = await sql<{ payload: { via: string } }>`
select payload from audit_event where action = 'organize.move_authorized' and target_id = ${e}`.execute(h.db);
expect(a.rows.map((x) => x.payload.via)).toEqual(["admin_session"]);
});
});
@@ -0,0 +1,249 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { copyFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { sql } from "@umt/db";
import {
createMockQb,
createTempTree,
findCrossDeviceBase,
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, type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import {
createHarness,
EXEC_KEY,
fakeDisk,
type Harness,
pgAvailable,
READ_KEY,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// 跨卷 Move 分步可恢复(kill 后 resumed 先对账目标)、路径绑定不因译名变化改变、手动整理 API 与 scope。
const hasPg = await pgAvailable();
const xdev = findCrossDeviceBase();
const SIZE = 250_000;
describe.skipIf(!hasPg)("整理:跨卷 Move 恢复 / 路径绑定固定 / 手动整理", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let src: TempTree;
const disk = fakeDisk(100_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
// 库根放到另一个设备(/dev/shm)上,缓存在 tmpdir:入库 Move 必然跨卷
lib = await createTempTree({}, { sentinel: true, prefix: "umt-xlib-", ...(xdev ? { base: xdev } : {}) });
cache = await createTempTree({}, { prefix: "umt-xcache-" });
src = await createTempTree(
{
"Show.S01E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
"Show.S01E02.1080p.WEB-DL.x264-GRP.mkv": SIZE,
"Show.S02E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
"Show.S01E01.1080p.WEB-DL.x264-GRP.chs.srt": 2_000,
},
// 手动整理的源路径必须在已登记的根下(验收 R4):放在下载器保存路径里
{ prefix: "umt-src-", base: cache.root },
);
disk.state.watch = [cache.root];
disk.setPct(30);
h = await createHarness("move", {
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 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
for (const t of [lib, cache, src]) await t?.cleanup();
});
it.skipIf(!xdev)(
"跨卷 Move:copy 已完成后进程被 kill → 续跑时 resumed 先对账目标,不重复复制,核验后删源并移除 qB 任务记录",
async () => {
await h.setSettings("organize", { defaultMode: "move", allowCrossVolumeMove: true });
const e = await seedEntity(h.db, { kind: "movie", title: "跨卷片", year: 2021 });
await seedSlot(h.db, { entityId: e });
const t = makeTorrent({ name: "Cross.Volume.2021.1080p.BluRay-XV.mkv", length: SIZE, pieceSeed: 7 });
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId: e,
title: "Cross.Volume.2021.1080p.BluRay-XV",
size: SIZE,
hash: t.hash,
hr: false,
});
torrents.set(relId, t.bytes);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
const sub = await submitDownload(h.d, ev.plans[0]!);
await qb.complete(t.hash, { writeFiles: true });
await syncDownloads(h.d);
const plan = await planImport(h.d, { downloadTaskId: sub.downloadTaskId, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("running");
const rec = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
const frozen = rec.plan as unknown as FrozenPlan;
const f = frozen.organize.files[0]!;
expect(f.crossDevice).toBe(true);
// 模拟上一次 attempt:copy 步骤已把目标写完并校验,删源前 worker 被 kill(账本停在 started)
await mkdir(join(lib.root, "跨卷片 (2021)"), { recursive: true });
await copyFile(f.src, f.dst);
await sql`insert into tasks_op_ledger (op_key, attempt_token, attempts, status) values (${`org:${plan.recordId}:0`}, ${randomUUID()}, 1, 'started')`.execute(
h.db,
);
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
expect(existsSync(f.src)).toBe(false);
expect(existsSync(f.dst)).toBe(true);
const op = await h.db
.selectFrom("asset_operation")
.selectAll()
.where("operation_id", "=", `org:${plan.recordId}:0`)
.executeTakeFirstOrThrow();
const result = op.result as { resumed?: boolean; steps?: { step: string; status: string }[] };
expect(result.resumed).toBe(true);
expect(result.steps?.find((s) => s.step === "copy")?.status).toBe("skipped");
expect(result.steps?.find((s) => s.step === "delete_source")?.status).toBe("done");
// Move 成功:移除 qB 任务记录(deleteFiles=false),结束下载侧管理
expect(qb.get(t.hash)).toBeUndefined();
const del = qb.callsTo("torrents/delete").at(-1)!;
expect(del.form.deleteFiles).toBe("false");
const dt = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.removed_at).not.toBeNull();
expect(dt.status).toBe("imported");
await h.setSettings("organize", { defaultMode: "copy" });
},
60_000,
);
it("路径绑定固定:译名变化后新集仍进旧目录;新季在旧作品目录下建季绑定;手动整理与 scope", async () => {
const e = await seedEntity(h.db, { kind: "series", title: "旧译名", year: 2020 });
// 只读 key 不能发起整理
const denied = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
READ_KEY,
);
expect(denied.status).toBe(403);
// dryRun:只出计划,不写记录
const dry = await h.api(
"POST",
"/organization-records",
{
sourcePaths: [src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv")],
mediaEntityId: e,
action: "copy",
dryRun: true,
},
EXEC_KEY,
);
expect(dry.status).toBe(201);
expect(dry.body.data).toMatchObject({ recordId: null });
expect(dry.body.data.planId).toBeTruthy();
expect(
await h.db.selectFrom("organization_record").select("id").where("media_entity_id", "=", e).execute(),
).toHaveLength(0);
const r1 = await h.api(
"POST",
"/organization-records",
{
sourcePaths: [
src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv"),
src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.chs.srt"),
],
mediaEntityId: e,
action: "copy",
},
EXEC_KEY,
);
expect(r1.status).toBe(201);
expect(h.engine.enqueued.at(-1)).toMatchObject({
task: "verifyAndImport",
input: { recordId: r1.body.data.recordId },
});
await executeRecord(h.d, r1.body.data.recordId);
const ep1 = join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01 - 第 1 集.mkv");
expect(existsSync(ep1)).toBe(true);
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01 - 第 1 集.chi.zh-cn.srt"))).toBe(
true,
);
const rec1 = await h.api("GET", `/organization-records/${r1.body.data.recordId}`, undefined, READ_KEY);
expect(rec1.body.data).toMatchObject({ origin: "manual", status: "succeeded", counts: { total: 2, succeeded: 2 } });
expect(rec1.body.data.actor.type).toBe("api_key");
// TMDB 改名:只改展示,不改目录
await h.db.updateTable("media_entity").set({ title: "新译名" }).where("id", "=", e).execute();
const r2 = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S01E02.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
EXEC_KEY,
);
await executeRecord(h.d, r2.body.data.recordId);
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "新译名 - S01E02 - 第 2 集.mkv"))).toBe(true);
expect(existsSync(join(lib.root, "新译名 (2020)"))).toBe(false);
const r3 = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S02E01.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
EXEC_KEY,
);
await executeRecord(h.d, r3.body.data.recordId);
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 2", "新译名 - S02E01 - 第 1 集.mkv"))).toBe(true);
const bindings = await h.db
.selectFrom("path_binding")
.select(["binding_kind", "season_number", "relative_path", "status"])
.where("media_entity_id", "=", e)
.execute();
bindings.sort((a, b) => (a.season_number ?? -1) - (b.season_number ?? -1));
expect(bindings).toEqual([
{ binding_kind: "entity", season_number: null, relative_path: "旧译名 (2020)", status: "active" },
{ binding_kind: "season", season_number: 1, relative_path: "旧译名 (2020)/Season 1", status: "active" },
{ binding_kind: "season", season_number: 2, relative_path: "旧译名 (2020)/Season 2", status: "active" },
]);
// 同目标已存在且内容相同:重复整理判 already_in_library 跳过,不覆盖
const r4 = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S01E02.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
EXEC_KEY,
);
const rec4 = await h.db
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", r4.body.data.recordId)
.executeTakeFirstOrThrow();
expect(rec4).toMatchObject({ status: "skipped", skip_reason: "already_in_library" });
}, 60_000);
});
@@ -0,0 +1,248 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { TmdbClient } from "@umt/adapters";
import { DEFAULT_SETTINGS, type OrganizeSourceConfig } from "@umt/contracts";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
const FILE = "Example.2020.1080p.BluRay.x264-GRP.mkv";
describe.skipIf(!hasPg)("configured organize sources (real PG, files, mock qB)", () => {
let h: Harness;
let lib: TempTree;
let cache: TempTree;
let qb: MockQb;
let otherQb: MockQb;
let base: Awaited<ReturnType<typeof seedBase>>;
let otherInstance: string;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
cache = await createTempTree({ [`a/${FILE}`]: 200_000, [`b/${FILE}`]: 200_000, [`tagged/${FILE}`]: 200_000 });
qb = await createMockQb();
otherQb = await createMockQb();
h = await createHarness("source_config");
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
otherInstance = (
await h.db
.insertInto("downloader_instance")
.values({
name: "other-qb",
base_url: otherQb.url,
username: "admin",
settings: JSON.stringify({ savePath: cache.root }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}, 60_000);
afterEach(() => vi.restoreAllMocks());
afterAll(async () => {
await h?.close();
await qb?.close();
await otherQb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const profile = (patch: Partial<OrganizeSourceConfig> = {}): OrganizeSourceConfig => ({
id: randomUUID(),
name: "整理来源",
enabled: true,
source: { root: `dl:${base.instanceId}:0`, path: "a" },
target: { root: `lib:${base.rootId}`, path: "华语电影" },
mediaType: "movie",
category: "华语电影",
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
...patch,
});
const settings = (
sources: OrganizeSourceConfig[],
classificationYaml = DEFAULT_SETTINGS.organize.classificationYaml,
) => h.setSettings("organize", { ...DEFAULT_SETTINGS.organize, minSizeBytes: 100_000, sources, classificationYaml });
const request = (entityId: string, source: OrganizeSourceConfig, folder = "a") => ({
sourcePaths: [cache.path(`${folder}/${FILE}`)],
mediaEntityId: entityId,
sourceConfigId: source.id,
trigger: "manual" as const,
actorType: "user" as const,
dryRun: true,
});
it("fixed category adds no duplicate; explicit same-category source selects own target and rejects wrong source", async () => {
const a = profile();
const b = profile({
source: { root: `dl:${base.instanceId}:0`, path: "b" },
target: { root: `lib:${base.rootId}`, path: "另一个目录/华语电影" },
});
await settings([a, b]);
const movie = await seedEntity(h.db, { kind: "movie", title: "固定片", year: 2020 });
const first = await planImport(h.d, request(movie, a));
expect(first.plan?.sourceConfigId).toBe(a.id);
expect(first.plan?.newBindings[0]?.relativePath).toBe("华语电影/固定片 (2020)");
const explicit = await planImport(h.d, { ...request(movie, a), targetDir: join(lib.root, "华语电影") });
expect(explicit.plan?.newBindings).toEqual([]);
const explicitDst = explicit.plan?.organize.files.find((f) => f.role === "main")?.dst;
expect(explicitDst?.startsWith(`${join(lib.root, "华语电影")}/`)).toBe(true);
expect(explicitDst?.includes("华语电影/华语电影")).toBe(false);
const second = await planImport(h.d, request(movie, b, "b"));
expect(second.plan?.sourceConfigId).toBe(b.id);
expect(second.plan?.newBindings[0]?.relativePath).toBe("另一个目录/华语电影/固定片 (2020)");
const mismatch = await planImport(h.d, request(movie, a, "b"));
expect(mismatch.reason).toBe("source_config_mismatch");
expect(mismatch.plan).toBeUndefined();
});
it("automatic category prefixes new binding; changed YAML/target preserves existing binding", async () => {
const automatic = profile({ category: null, target: { root: `lib:${base.rootId}`, path: "自动" } });
await settings([automatic]);
const movie = await seedEntity(h.db, { kind: "movie", title: "动画片", year: 2020 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: movie,
provider: "tmdb",
entity_type: "movie",
external_id: "987",
status: "accepted",
})
.execute();
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
const plan = await planImport(h.d, { ...request(movie, automatic), dryRun: false });
expect(plan.status).toBe("running");
const record = await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(record.plan).toMatchObject({
classification: { category: "动画电影" },
newBindings: [{ relativePath: "自动/动画电影/动画片 (2020)" }],
});
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
await settings(
[{ ...automatic, target: { ...automatic.target, path: "新目录" } }],
"movie:\n 新分类: null\ntv: {}\n",
);
const updated = await planImport(h.d, request(movie, automatic));
expect(updated.plan?.classification?.category).toBe("新分类");
expect(updated.plan?.newBindings).toEqual([]);
expect(updated.plan?.organize.files.find((f) => f.role === "main")?.dst).toContain(
join(lib.root, "自动", "动画电影", "动画片 (2020)"),
);
expect(
(
await h.db
.selectFrom("path_binding")
.select("relative_path")
.where("media_entity_id", "=", movie)
.executeTakeFirstOrThrow()
).relative_path,
).toBe("自动/动画电影/动画片 (2020)");
});
it("explicit root creates an unbound path there and refuses to relocate an existing binding", async () => {
const alternate = await createTempTree({}, { sentinel: true });
const alternateRoot = await h.db
.insertInto("library_root")
.values({ name: "alternate", media_kind: "mixed", local_path: alternate.root, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
try {
const source = profile();
await settings([source]);
const fresh = await seedEntity(h.db, { kind: "movie", title: "新根片", year: 2020 });
const override = await planImport(h.d, { ...request(fresh, source), targetLibraryRootId: alternateRoot.id });
expect(override.plan?.libraryRootId).toBe(alternateRoot.id);
expect(override.plan?.newBindings[0]?.relativePath).toBe("新根片 (2020)");
const existing = await seedEntity(h.db, { kind: "movie", title: "已绑定片", year: 2020 });
const first = await planImport(h.d, { ...request(existing, source), dryRun: false });
expect((await executeRecord(h.d, first.recordId!)).status).toBe("succeeded");
const attempted = await planImport(h.d, { ...request(existing, source), targetLibraryRootId: alternateRoot.id });
expect(attempted.reason).toBe("binding_on_other_root");
expect(
await h.db
.selectFrom("path_binding")
.select(["library_root_id", "relative_path"])
.where("media_entity_id", "=", existing)
.executeTakeFirstOrThrow(),
).toMatchObject({ library_root_id: base.rootId, relative_path: "华语电影/已绑定片 (2020)" });
} finally {
await h.db.updateTable("library_root").set({ enabled: false }).where("id", "=", alternateRoot.id).execute();
await alternate.cleanup();
}
});
it("tag and source path adopt only matching instance; removing tag stops management", async () => {
const tag = "source-only-tag";
await settings([
profile({
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
trigger: "qb_tag",
downloaderInstanceId: base.instanceId,
tag,
}),
]);
const hash = (n: number) => n.toString(16).padStart(40, "0");
qb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
qb.seed({ hash: hash(2), name: "No.Tag.mkv", save_path: cache.path("tagged"), tags: [] });
qb.seed({ hash: hash(3), name: "Wrong.Path.mkv", save_path: cache.path("b"), tags: [tag] });
otherQb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
const result = await syncDownloads(h.d);
expect(result.adopted).toBe(1);
const rows = await h.db.selectFrom("download_task").selectAll().execute();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ instance_id: base.instanceId, torrent_hash: hash(1), managed: true });
expect(rows.some((r) => r.instance_id === otherInstance)).toBe(false);
qb.get(hash(1))!.tags = [];
await syncDownloads(h.d);
expect(
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
.managed,
).toBe(false);
qb.get(hash(1))!.tags = [tag];
await syncDownloads(h.d);
expect(
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
.managed,
).toBe(true);
});
it("automatic source with movie-only media type refuses a TV entity instead of generic organization", async () => {
const tag = "movie-source";
await settings([
profile({
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
trigger: "qb_tag",
downloaderInstanceId: base.instanceId,
tag,
}),
]);
const hash = "4".padStart(40, "0");
const name = "Only.TV.S01E01.1080p.WEB-DL.x264-GRP.mkv";
qb.seed({ hash, name, save_path: cache.path("tagged"), tags: [tag] });
await qb.complete(hash, { files: [{ path: name, size: 200_000 }] });
await syncDownloads(h.d);
const row = await h.db
.selectFrom("download_task")
.selectAll()
.where("torrent_hash", "=", hash)
.executeTakeFirstOrThrow();
const entity = await seedEntity(h.db, { kind: "series", title: "Only TV", year: 2020 });
await h.db.updateTable("download_task").set({ media_entity_id: entity }).where("id", "=", row.id).execute();
const planned = await planImport(h.d, {
downloadTaskId: row.id,
trigger: "auto",
actorType: "system",
dryRun: true,
});
expect(planned.reason).toBe("source_config_mismatch");
expect(planned.plan).toBeUndefined();
});
});
@@ -0,0 +1,148 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
// HIS-1 整理记录列表筛选与统计:类型(电影 / 剧集 / 未识别)、时间、关键字(标题 / 路径 / 文件名 / 种子 hash,支持 * ?)、
// 只看整理类、状态计数与列表同口径;列表带海报、大小、下载器与 hash。
const skip = !(await pgAvailable());
describe.skipIf(skip)("整理记录筛选与统计", () => {
let h: Harness;
const ids: Record<string, string> = {};
beforeAll(async () => {
h = await createHarness("recfilter");
const film = await seedEntity(h.db, { kind: "movie", title: "Arrival", year: 2016 });
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ posterPath: { value: "/arrival.jpg" } }) })
.where("id", "=", film)
.execute();
const show = await seedEntity(h.db, { kind: "series", title: "Severance", year: 2022 });
const inst = await h.db
.insertInto("downloader_instance")
.values({ name: "家里的 qB", base_url: "http://127.0.0.1:1" })
.returning("id")
.executeTakeFirstOrThrow();
const task = await h.db
.insertInto("download_task")
.values({ instance_id: inst.id, torrent_hash: "abcdef0123456789abcdef0123456789abcdef01", name: "Arrival.2016" })
.returning("id")
.executeTakeFirstOrThrow();
const old = new Date(Date.now() - 40 * 86_400_000);
const rows = [
{ k: "film", entity: film, status: "succeeded", action: "copy", src: "/dl/Arrival.2016.mkv", task: task.id },
{ k: "show", entity: show, status: "failed", action: "move", src: "/dl/Severance.S01E01.mkv", at: old },
{ k: "unknown", entity: null, status: "failed", action: "copy", src: "/dl/random.mp4" },
{ k: "rename", entity: film, status: "succeeded", action: "rename", src: "/lib/Arrival/a.mkv" },
] as const;
for (const r of rows) {
const rec = await h.db
.insertInto("organization_record")
.values({
trigger: "auto",
actor_type: "system",
media_entity_id: r.entity,
download_task_id: "task" in r ? r.task : null,
source_path: r.src,
action: r.action,
status: r.status,
...("at" in r ? { created_at: r.at } : {}),
})
.returning("id")
.executeTakeFirstOrThrow();
ids[r.k] = rec.id;
await h.db
.insertInto("organization_file_result")
.values({
organization_record_id: rec.id,
mode: r.action,
source_path: `${r.src}`,
source_size_bytes: 1000,
status: r.status === "failed" ? "failed" : "succeeded",
})
.execute();
}
});
afterAll(async () => h?.close());
const list = async (q: string) => {
const r = await h.api("GET", `/organization-records?${q}`);
expect(r.status).toBe(200);
return r.body.data.items as { id: string }[];
};
const idsOf = (items: { id: string }[]) => items.map((x) => x.id).sort();
it("类型:电影 / 剧集 / 未识别", async () => {
expect(idsOf(await list("kind=movie"))).toEqual([ids.film, ids.rename].sort());
expect(idsOf(await list("kind=series"))).toEqual([ids.show]);
expect(idsOf(await list("kind=none"))).toEqual([ids.unknown]);
});
it("时间:since 之后创建的", async () => {
const since = new Date(Date.now() - 7 * 86_400_000).toISOString();
const items = await list(`since=${encodeURIComponent(since)}`);
expect(items.map((x) => x.id)).not.toContain(ids.show);
expect(items).toHaveLength(3);
});
it("关键字:标题、文件名、种子 hash、通配", async () => {
expect(idsOf(await list("q=Severance"))).toEqual([ids.show]);
expect(idsOf(await list(`q=${encodeURIComponent("abcdef0123")}`))).toEqual([ids.film]);
expect(idsOf(await list(`q=${encodeURIComponent("*.mp4")}`))).toEqual([ids.unknown]);
expect(idsOf(await list(`q=${encodeURIComponent("/dl/?rrival*")}`))).toEqual([ids.film]);
// 通配里的 % 与 _ 按字面匹配
expect(await list(`q=${encodeURIComponent("100%")}`)).toEqual([]);
});
it("只看整理类:不含改名 / 删除 / 还原", async () => {
const items = await list("organizeOnly=true");
expect(items.map((x) => x.id)).not.toContain(ids.rename);
expect(items).toHaveLength(3);
});
it("统计与列表同口径,不受状态筛选影响", async () => {
const all = await h.api("GET", "/organization-records/stats");
expect(all.status).toBe(200);
expect(all.body.data).toEqual({ total: 4, succeeded: 2, partial: 0, failed: 2, running: 0, cancelled: 0 });
const movies = await h.api("GET", "/organization-records/stats?kind=movie&organizeOnly=true");
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
});
it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => {
const inserted = await h.db
.insertInto("organization_record")
.values([
{ trigger: "manual", actor_type: "user", action: "copy", status: "queued", source_path: "/queued.mkv" },
{ trigger: "manual", actor_type: "user", action: "move", status: "running", source_path: "/running.mkv" },
])
.returning("id")
.execute();
try {
expect(await list("phase=history")).toHaveLength(4);
expect(idsOf(await list("phase=active"))).toEqual(inserted.map((item) => item.id).sort());
expect(await list("phase=history&status=queued")).toHaveLength(0);
const stats = await h.api("GET", "/organization-records/stats?phase=history");
expect(stats.body.data).toMatchObject({ total: 4, running: 0 });
} finally {
await h.db
.deleteFrom("organization_record")
.where(
"id",
"in",
inserted.map((item) => item.id),
)
.execute();
}
});
it("列表项带海报、大小、下载器名称与 hash", async () => {
const [film] = await list("q=Arrival.2016");
expect(film).toMatchObject({
media: { title: "Arrival", posterPath: "/arrival.jpg" },
sizeBytes: 1000,
download: { name: "Arrival.2016", hash: "abcdef0123456789abcdef0123456789abcdef01", downloaderName: "家里的 qB" },
});
const [unknown] = await list("kind=none");
expect(unknown).toMatchObject({ media: null, download: null, sizeBytes: 1000 });
});
});
@@ -0,0 +1,440 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { DEFAULT_SETTINGS } from "@umt/contracts";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("one-time replacement (real PG and library files)", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let rootId: string;
let instanceId: string;
let siteId: string;
let qb: MockQb;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
src = await createTempTree({
"Movie.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
"Same.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
...Object.fromEntries(
Array.from({ length: 6 }, (_, i) => [
`Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
200_000,
]),
),
});
qb = await createMockQb();
h = await createHarness("replacement");
({ rootId, instanceId, siteId } = await seedBase(h.db, {
libraryPath: lib.root,
cachePath: src.root,
qbUrl: qb.url,
}));
await h.setSettings("naming", {
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
});
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await src?.cleanup();
});
const old = async (entityId: string, path: string, parent?: string) => {
await mkdir(dirname(lib.path(path)), { recursive: true });
await writeFile(lib.path(path), Buffer.alloc(200_001, 7));
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: entityId,
kind: parent ? "subtitle" : "video",
parent_asset_id: parent ?? null,
status: "active",
size_bytes: 200_001,
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: rootId,
relative_path: path,
status: "available",
is_primary: true,
})
.execute();
return asset.id;
};
const request = (entityId: string, paths: string[], assetIds: string[] = []) => ({
mediaEntityId: entityId,
sourcePaths: paths.map((p) => src.path(p)),
trigger: "manual" as const,
actorType: "user" as const,
action: "copy" as const,
importMode: "replace" as const,
replacementAssetIds: assetIds,
});
const status = async (id: string) =>
(await h.db.selectFrom("media_asset").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
it("new movie registers first, retires only selected old and its private subtitle, preserving unselected/shared/cache", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Movie",
year: 2020,
});
const path = "Movie (2020)/Movie (2020) - 720p.mkv";
const selected = await old(e, path);
const subtitle = await old(e, path.replace(".mkv", ".en.srt"), selected);
const kept = await old(e, "Movie (2020)/Movie (2020) - 2160p.mkv");
await writeFile(lib.path("Movie (2020)/movie.nfo"), "shared");
const planned = await planImport(h.d, request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect(existsSync(lib.path(path))).toBe(true);
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(selected)).toBe("retired");
expect(await status(subtitle)).toBe("retired");
expect(await status(kept)).toBe("active");
expect(existsSync(lib.path(path))).toBe(false);
expect(existsSync(lib.path("Movie (2020)/movie.nfo"))).toBe(true);
expect(existsSync(src.path("Movie.2020.1080p.BluRay.x264-GRP.mkv"))).toBe(true);
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
await executeRecord(h.d, planned.recordId!);
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
});
it("same-path replaces selected old asset with a fresh asset, but normal/unselected cannot overwrite", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Same",
year: 2020,
});
const selected = await old(e, "Same (2020)/Same (2020) - 1080p.mkv");
const normal = await planImport(h.d, {
...request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"]),
importMode: "normal",
dryRun: true,
});
expect(normal.plan?.organize.files[0]?.action).toBe("hold");
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(selected)).toBe("retired");
const active = await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", e)
.where("status", "=", "active")
.execute();
expect(active).toHaveLength(1);
expect(active[0]!.id).not.toBe(selected);
});
it("selected grants cannot overwrite an unselected path, and changed old files survive cleanup", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Protected",
year: 2020,
});
const lower = await old(e, "Protected (2020)/Protected (2020) - 720p.mkv");
const occupied = await old(e, "Protected (2020)/Protected (2020) - 1080p.mkv");
const blocked = await planImport(h.d, {
...request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [lower]),
dryRun: true,
});
expect(blocked.plan?.organize.files[0]?.action).toBe("hold");
expect(await status(occupied)).toBe("active");
const other = await seedEntity(h.db, {
kind: "movie",
title: "Stale",
year: 2020,
});
const oldPath = "Stale (2020)/Stale (2020) - 720p.mkv";
const stale = await old(other, oldPath);
const planned = await planImport(h.d, request(other, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [stale]));
await writeFile(lib.path(oldPath), "changed-after-plan");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(stale)).toBe("active");
expect((await readFile(lib.path(oldPath))).toString()).toBe("changed-after-plan");
});
it("same-path failed final probe restores old bytes and asset, even with delete policy", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Same",
year: 2021,
});
const path = "Same (2021)/Same (2021) - 1080p.mkv";
const selected = await old(e, path);
const savedProbe = h.d.ports.probe;
const savedConfig = h.ctx.config;
h.ctx.config = { ...savedConfig, FS_REPLACE_OLD: "delete" };
h.d.ports.probe = async () => ({
status: "ok",
video: [],
audio: [],
subtitles: [],
});
try {
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("failed");
expect((await readFile(lib.path(path))).equals(Buffer.alloc(200_001, 7))).toBe(true);
expect(await status(selected)).toBe("active");
expect(
(
await h.db
.selectFrom("asset_location")
.select("status")
.where("media_asset_id", "=", selected)
.executeTakeFirstOrThrow()
).status,
).toBe("available");
await executeRecord(h.d, planned.recordId!);
expect((await readFile(lib.path(path))).length).toBe(200_001);
} finally {
h.d.ports.probe = savedProbe;
h.ctx.config = savedConfig;
}
});
it("old E01-E10 only loses successful new E01-E06; failed E04 and old merged range survive", async () => {
const e = await seedEntity(h.db, {
kind: "series",
title: "Show",
year: 2020,
});
const ids: string[] = [];
for (let i = 1; i <= 10; i++) {
ids.push(await old(e, `Show (2020)/Season 1/Show - S01E${String(i).padStart(2, "0")} - 720p.mkv`));
}
const merged = await old(e, "Show (2020)/Season 1/Show - S01E01-E10 - 480p.mkv");
const saved = h.d.ports.probe;
h.d.ports.probe = async (path) =>
path.includes("E04")
? { status: "ok", video: [], audio: [], subtitles: [] }
: { status: "unknown", reason: "ffprobe_unavailable" };
try {
const paths = Array.from(
{ length: 6 },
(_, i) => `Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
);
const planned = await planImport(h.d, request(e, paths, [...ids, merged]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("partial");
for (let i = 1; i <= 10; i++) {
expect(await status(ids[i - 1]!)).toBe(i <= 6 && i !== 4 ? "retired" : "active");
}
expect(await status(merged)).toBe("active");
} finally {
h.d.ports.probe = saved;
}
});
it("unknown manual task resolves Local, classifies and registers in the same import, then explicit reused-hash replacement uses its new plan", async () => {
const name = "Unknown.2022.1080p.WEB-DL.x264-GRP.mkv";
const hash = "a".repeat(40);
qb.seed({ hash, name, tags: ["umt"], save_path: src.root });
await qb.complete(hash, {
writeFiles: true,
files: [{ path: name, size: 200_000 }],
});
const release = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: randomUUID(),
title: name,
size_bytes: 200_000,
raw: JSON.stringify({}),
resolution_status: "unresolved",
})
.returning("id")
.executeTakeFirstOrThrow();
const makePlan = async (evaluation: object) =>
(
await h.db
.insertInto("acquisition_plan")
.values({
slot_id: null,
source_release_id: release.id,
status: "submitted",
payload_hash: randomUUID(),
idempotency_key: randomUUID(),
evaluation: JSON.stringify(evaluation),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const initialPlan = await makePlan({
manual: true,
mediaEntityId: null,
importMode: "normal",
replacementAssetIds: [],
});
const dt = await h.db
.insertInto("download_task")
.values({
instance_id: instanceId,
torrent_hash: hash,
name,
save_path: src.root,
content_path: src.path(name),
managed: true,
tags: ["umt"],
status: "completed",
source_release_id: release.id,
acquisition_plan_id: initialPlan,
import_plan_id: initialPlan,
})
.returning("id")
.executeTakeFirstOrThrow();
const local = await seedEntity(h.db, {
kind: "movie",
title: "本地作品",
year: 2022,
});
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
title: {
value: "本地作品",
source: "web",
evidence: { sourceUrl: "https://example.test/detail" },
},
originalLanguage: { value: "zh", source: "web" },
genres: { value: ["剧情"], source: "web" },
}),
})
.where("id", "=", local)
.execute();
const sourceId = randomUUID();
await h.setSettings("organize", {
...DEFAULT_SETTINGS.organize,
minSizeBytes: 100_000,
scrapeAfterImport: false,
sources: [
{
id: sourceId,
name: "Local来源",
enabled: true,
source: { root: `dl:${instanceId}:0`, path: "" },
target: { root: `lib:${rootId}`, path: "" },
mediaType: "movie",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
},
],
});
const has = vi.spyOn(h.ctx.capabilities, "has").mockReturnValue(true);
const invoke = vi.spyOn(h.ctx.capabilities, "invoke").mockResolvedValue({
mediaEntityId: local,
mode: "local",
enrichmentId: randomUUID(),
reason: null,
});
try {
const planned = await planImport(h.d, {
downloadTaskId: dt.id,
sourceConfigId: sourceId,
trigger: "auto",
actorType: "system",
});
expect(planned.status).toBe("running");
expect(
(
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", planned.recordId!)
.executeTakeFirstOrThrow()
).plan,
).toMatchObject({ metadataMode: "local" });
expect(invoke).toHaveBeenCalledWith(
"metadata.resolve_organize",
{ sourceReleaseId: release.id },
expect.objectContaining({ source: "task" }),
);
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(
await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", local)
.where("status", "=", "active")
.execute(),
).toHaveLength(1);
expect(
(
await h.db
.selectFrom("path_binding")
.select("relative_path")
.where("media_entity_id", "=", local)
.executeTakeFirstOrThrow()
).relative_path,
).toBe("华语电影/本地作品 (2022)");
expect(h.engine.enqueued.some((j) => j.task === "scrapeMedia")).toBe(true);
const selected = await old(local, "华语电影/本地作品 (2022)/本地作品 (2022) - 720p.mkv");
const secondPlan = await makePlan({
manual: true,
mediaEntityId: local,
importMode: "replace",
replacementAssetIds: [selected],
});
await h.db
.updateTable("download_task")
.set({ import_plan_id: secondPlan, status: "completed" })
.where("id", "=", dt.id)
.execute();
const rerun = await planImport(h.d, {
downloadTaskId: dt.id,
sourceConfigId: sourceId,
trigger: "auto",
actorType: "system",
});
expect(rerun.status).toBe("running");
expect(
(
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", rerun.recordId!)
.executeTakeFirstOrThrow()
).plan,
).toMatchObject({ metadataMode: "local" });
expect(invoke).toHaveBeenCalledTimes(1);
await executeRecord(h.d, rerun.recordId!);
expect(await status(selected)).toBe("retired");
expect(
(
await h.db
.selectFrom("acquisition_plan")
.select("status")
.where("id", "=", secondPlan)
.executeTakeFirstOrThrow()
).status,
).toBe("completed");
expect(
(
await h.db
.selectFrom("download_task")
.select("acquisition_plan_id")
.where("id", "=", dt.id)
.executeTakeFirstOrThrow()
).acquisition_plan_id,
).toBe(initialPlan);
} finally {
has.mockRestore();
invoke.mockRestore();
}
});
});
@@ -0,0 +1,254 @@
import { makeTorrent } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// 规则 API:订阅/Slot/策略版本(发布=持续授权、回滚)/偏好标签抑制;新作品收录走已发布策略;outbox 路由。
const hasPg = await pgAvailable();
describe("outbox 路由", () => {
const routes = createAcquisitionOrganizeModule().outboxRoutes!({} as never);
it("release.resolved → evaluateAcquisition;只有下载完成才建 verifyAndImport", () => {
const ev = (type: string, payload: unknown) => ({ id: "1", type, payload, at: new Date() });
expect(
routes["release.resolved"]!(ev("release.resolved", { sourceReleaseId: "a", contentUnitIds: ["u"] })),
).toEqual({
task: "evaluateAcquisition",
input: { sourceReleaseId: "a", contentUnitIds: ["u"] },
});
expect(
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "completed" })),
).toEqual({
task: "verifyAndImport",
input: { downloadTaskId: "d" },
});
expect(
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "paused" })),
).toBeNull();
});
});
describe.skipIf(!hasPg)("规则 API(真实 PG)", () => {
let h: Harness;
const disk = fakeDisk(1_000_000_000);
beforeAll(async () => {
disk.setPct(10);
h = await createHarness("rules", { diskUsage: disk.fn });
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("订阅与 Slot:缺省季范围、同名冲突、revision 乐观锁", async () => {
const e = await seedEntity(h.db, { kind: "series", title: "追更剧", year: 2025 });
const sub = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series" });
expect(sub.status).toBe(201);
expect(sub.body.data).toMatchObject({
mode: "continuous",
seasonScope: "following_seasons",
origin: "user",
// 剧集没有已接受的 TMDB 身份 → 总集数无来源:pending(照常搜索/评估,不能完成)
status: "pending",
statusReason: "metadata_pending",
});
const once = await h.api("PATCH", `/subscriptions/${sub.body.data.id}`, {
seasonScope: "current_season",
params: { stopAfter: "2030-01-01T00:00:00Z" },
});
expect(once.body.data.seasonScope).toBe("current_season");
expect((await h.api("POST", "/subscriptions", { mediaKind: "movie" })).status).toBe(422);
const s = await h.api("POST", "/acquisition-slots", {
mediaEntityId: e,
subscriptionId: sub.body.data.id,
name: "1080p",
hardConditions: { resolutions: ["1080p"] },
});
expect(s.status).toBe(201);
expect(s.body.data).toMatchObject({ status: "empty", revision: 1 });
expect((await h.api("POST", "/acquisition-slots", { mediaEntityId: e, name: "1080p" })).status).toBe(409);
expect(
(
await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
expectedRevision: 9,
softPreferences: { hdr: ["DV"] },
})
).status,
).toBe(409);
const ok = await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
expectedRevision: 1,
softPreferences: { hdr: ["DV"] },
});
expect(ok.body.data).toMatchObject({ revision: 2, softPreferences: { hdr: ["DV"] } });
const list = await h.api("GET", `/acquisition-slots?mediaEntityId=${e}`);
expect(list.body.data.items).toHaveLength(1);
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
});
it("策略版本:草案 → 发布(持续授权)→ 新版本发布(旧授权吊销)→ 回滚(新版本号,内容同目标)", async () => {
const bad = await h.api("POST", "/policy-revisions", {
policyKey: "acq",
kind: "acquisition",
title: "x",
rules: { newTitle: { enabled: "yes" } },
});
expect(bad.status).toBe(422);
const rules = {
defaultSlots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }],
newTitle: {
enabled: true,
windowDays: 60,
mediaKinds: ["movie"],
slots: [{ name: "新片", hardConditions: { resolutions: ["1080p", "2160p"] } }],
},
};
const r1 = await h.api("POST", "/policy-revisions", {
policyKey: "acq",
kind: "acquisition",
title: "默认收录",
rules,
});
expect(r1.body.data).toMatchObject({ revision: 1, status: "draft" });
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/preview`)).body.data.preview).toMatchObject({
valid: true,
templates: 2,
});
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 2 })).status).toBe(
409,
);
const p1 = await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 1 });
expect(p1.body.data.status).toBe("published");
expect((await h.api("PATCH", `/policy-revisions/${r1.body.data.id}`, { title: "改" })).status).toBe(409);
const g1 = await h.db
.selectFrom("automation_grant")
.selectAll()
.where("policy_revision_id", "=", r1.body.data.id)
.executeTakeFirstOrThrow();
expect(g1).toMatchObject({ status: "active", action: "policy.acquisition" });
const r2 = await h.api("POST", "/policy-revisions", {
policyKey: "acq",
kind: "acquisition",
title: "只要 4K",
rules: { defaultSlots: [{ name: "4k", hardConditions: { resolutions: ["2160p"] } }] },
});
expect(r2.body.data.revision).toBe(2);
await h.api("POST", `/policy-revisions/${r2.body.data.id}/publish`, { expectedRevision: 2 });
expect((await h.api("GET", `/policy-revisions/${r1.body.data.id}`)).body.data.status).toBe("superseded");
expect(
(await h.db.selectFrom("automation_grant").select("status").where("id", "=", g1.id).executeTakeFirstOrThrow())
.status,
).toBe("revoked");
const rb = await h.api("POST", `/policy-revisions/${r1.body.data.id}/rollback`);
expect(rb.body.data).toMatchObject({ revision: 3, status: "published", basedOnRevisionId: r1.body.data.id, rules });
expect((await h.api("GET", `/policy-revisions/${r2.body.data.id}`)).body.data.status).toBe("rolled_back");
const active = await h.db
.selectFrom("automation_grant")
.select("policy_revision_id")
.where("status", "=", "active")
.execute();
expect(active.map((a) => a.policy_revision_id)).toEqual([rb.body.data.id]);
// 订阅没有 Slot 时套用已发布策略的缺省 Slot
const e = await seedEntity(h.db, { kind: "movie", title: "套模板", year: 2025 });
await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" });
const slots = await h.db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", e).execute();
expect(slots.map((s) => s.name)).toEqual(["1080p"]);
});
it("新作品收录:库里没有、首映在窗口内 → 按已发布策略建订阅与 Slot 并预留计划;旧片不算新片", async () => {
const base = await seedBase(h.db, {
libraryPath: "/tmp/nonexistent-lib",
cachePath: "/tmp",
qbUrl: "http://127.0.0.1:9",
});
const recent = new Date(Date.now() - 5 * 86_400_000).toISOString().slice(0, 10);
const fresh = (
await h.db
.insertInto("media_entity")
.values({
kind: "movie",
title: "新片",
year: 2026,
status: "active",
metadata: JSON.stringify({ releaseDate: recent }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const t = makeTorrent({ name: "Fresh.2026.2160p.WEB-DL.mkv", length: 1000, pieceSeed: 3 });
const rel = await seedRelease(h.db, {
siteId: base.siteId,
entityId: fresh,
title: "Fresh.2026.2160p.WEB-DL.H265-NEW",
size: 1000,
hash: t.hash,
});
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel });
expect(ev.plans).toHaveLength(1);
const sub = await h.db
.selectFrom("subscription")
.selectAll()
.where("media_entity_id", "=", fresh)
.executeTakeFirstOrThrow();
expect(sub).toMatchObject({ origin: "policy", mode: "once", priority: "new_work" });
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", ev.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({ status: "reserved", priority: "new_work", purpose: "fill" });
expect((plan.evaluation as { mode: string }).mode).toBe("new_title");
const old = await seedEntity(h.db, { kind: "movie", title: "老片", year: 1999 });
const t2 = makeTorrent({ name: "Old.1999.1080p.mkv", length: 1000, pieceSeed: 4 });
const rel2 = await seedRelease(h.db, {
siteId: base.siteId,
entityId: old,
title: "Old.1999.1080p.BluRay-OLD",
size: 1000,
hash: t2.hash,
});
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 });
expect(ev2.plans).toHaveLength(0);
expect(ev2.skipped[0]?.reason).toBe("new_title:not_new_release");
// 总开关关闭:不建计划
await h.setSettings("automation", { enabled: false });
const t3 = makeTorrent({ name: "Fresh2.mkv", length: 1000, pieceSeed: 5 });
const rel3 = await seedRelease(h.db, {
siteId: base.siteId,
entityId: fresh,
title: "Fresh.2026.1080p.WEB-DL.H264-NEW",
size: 1000,
hash: t3.hash,
});
const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: rel3 });
expect(ev3.plans).toHaveLength(0);
await h.setSettings("automation", { enabled: true });
});
it("偏好标签:删除/停用写抑制记录;Agent 不能据旧证据加回;用户重新添加解除抑制", async () => {
const tag = { label: "不收恐怖片", kind: "hard", scope: { genres: ["horror"] }, rule: { exclude: true } };
const c = await h.api("POST", "/preference-tags", tag);
expect(c.status).toBe(201);
expect((await h.api("POST", "/preference-tags", tag)).status).toBe(409);
expect((await h.api("DELETE", `/preference-tags/${c.body.data.id}`)).status).toBe(200);
const sup = await h.api("GET", "/preference-suppressions?active=true");
expect(sup.body.data.items).toHaveLength(1);
expect(sup.body.data.items[0].fingerprint).toBe(c.body.data.fingerprint);
expect((await h.api("POST", "/preference-tags", { ...tag, origin: "agent_inferred" })).status).toBe(409);
const again = await h.api("POST", "/preference-tags", { ...tag, origin: "manual" });
expect(again.status).toBe(201);
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(0);
const off = await h.api("PATCH", `/preference-tags/${again.body.data.id}`, { status: "disabled" });
expect(off.body.data).toMatchObject({ status: "disabled", revision: 2 });
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(1);
const list = await h.api("GET", "/preference-tags");
expect(list.body.data.items.map((t: { id: string }) => t.id)).toEqual([again.body.data.id]);
});
});
@@ -0,0 +1,336 @@
import { chmod, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { entityScrapeLocations, runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
// 图片失败不阻塞 NFO;只登记库根不写;作品详情「重新刮削」接口与刮削状态(最近一条刮削记录)。
const hasPg = await pgAvailable();
const SIZE = 200_000;
/** 最小 JPEG 头(按文件头认图) */
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]);
const at = new Date().toISOString();
const f = (value: unknown, language: string | null = null) => ({ value, source: "tmdb", language, at, locked: false });
async function tree(root: string): Promise<string[]> {
const out: string[] = [];
const walk = async (d: string) => {
for (const n of (await readdir(d)).sort()) {
const p = join(d, n);
if ((await stat(p)).isDirectory()) await walk(p);
else out.push(relative(root, p));
}
};
await walk(root);
return out.filter((p) => !p.startsWith(".")).sort();
}
describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
const fetched: string[] = [];
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-scrlib-" });
src = await createTempTree(
{
"Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv": SIZE,
"Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv": SIZE,
"Interstellar.2014.1080p.BluRay.x264-GRP.mkv": SIZE,
},
{ prefix: "umt-scrsrc-" },
);
h = await createHarness("scrape", {
async scrapeImage(p) {
fetched.push(p);
if (p === "/broken.jpg") throw new Error("TMDB 图片返回 HTTP 404");
return { content: JPEG, mime: "image/jpeg" };
},
});
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
await h.setSettings("organize", { scrapeAfterImport: true });
}, 60_000);
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
async function organize(entity: string, file: string) {
const plan = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
});
expect(plan.status).toBe("running");
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
return plan.recordId!;
}
/** 按引擎记录的入队项跑刮削(假引擎不执行):该作品最近一条刮削记录 */
async function runQueued(entity: string) {
const recs = await h.db
.selectFrom("scrape_record")
.select(["id"])
.where("media_entity_id", "=", entity)
.where("status", "=", "queued")
.orderBy("requested_at", "desc")
.execute();
expect(recs.length).toBeGreaterThan(0);
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
expect(q.some((x) => (x.input as { recordId: string }).recordId === recs[0]!.id)).toBe(true);
return runScrapeRecord(h.d, recs[0]!.id);
}
/** 作品详情「重新刮削」的服务端做法:对作品目录排刮削并执行 */
async function rescrape(entity: string, mode?: "missing" | "overwrite") {
const q = await scrapeEntity(h.d, entity, { trigger: "manual", mode });
expect(q.length).toBe(1);
return runScrapeRecord(h.d, q[0]!.recordId);
}
let series = "";
let movie = "";
it("剧集:入库后自动排刮削,写 tvshow / 季 / 集 NFO、海报、季海报、剧照", async () => {
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
await h.db
.updateTable("media_entity")
.set({
original_title: "Severance",
metadata: JSON.stringify({
title: f("人生切割术", "zh-CN"),
originalTitle: f("Severance", "en"),
year: f(2022),
releaseDate: f("2022-02-17"),
overview: f("马克领导着一个办公室团队。", "zh-CN"),
genres: f(["剧情", "悬疑"]),
voteAverage: f(8.4),
originCountry: f(["US"]),
platform: f("Apple TV+"),
posterPath: f("/sev.jpg"),
backdropPath: f("/broken.jpg"),
cast: f([
{ id: 4495, name: "亚当·斯科特", originalName: null, role: "Mark Scout", profilePath: "/adam.jpg" },
]),
crew: [],
externalIds: f({ imdb: "tt11280740", tvdb: "371980", wikidata: null }),
}),
})
.where("id", "=", series)
.execute();
await h.db
.insertInto("external_identity")
.values({
media_entity_id: series,
provider: "tmdb",
entity_type: "tv",
external_id: "95396",
status: "accepted",
})
.execute();
const season = await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
kind: "season",
season_number: 1,
status: "confirmed",
title: "第 1 季",
metadata: JSON.stringify({ name: f("第 1 季", "zh-CN"), airDate: f("2022-02-17"), posterPath: f("/s1.jpg") }),
})
.returning("id")
.executeTakeFirstOrThrow();
for (const [n, name, still] of [
[1, "有关地狱的好消息", "/e1.jpg"],
[2, "半循环", null],
] as const)
await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: n,
status: "confirmed",
title: name,
air_date: "2022-02-17",
metadata: JSON.stringify({
name: f(name, "zh-CN"),
overview: f(`第 ${n} 集简介`, "zh-CN"),
airDate: f("2022-02-17"),
runtime: f(55),
voteAverage: f(8),
...(still ? { stillPath: f(still) } : {}),
}),
})
.execute();
const rec = await organize(series, "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv");
await organize(series, "Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv");
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
expect(q[0]!.key).toBe(`${SCRAPE_TASK}:record:${rec}:0`);
const first = await h.db
.selectFrom("scrape_record")
.selectAll()
.where("id", "=", (q[0]!.input as { recordId: string }).recordId)
.executeTakeFirstOrThrow();
expect(first).toMatchObject({
media_entity_id: series,
trigger: "auto",
mode: "missing",
organization_record_id: rec,
});
const r = await runQueued(series);
expect(r.status).toBe("partial"); // 背景图下载失败
const files = await tree(lib.root);
const show = files.find((p) => p.endsWith("tvshow.nfo"))!;
const dir = show.slice(0, -"/tvshow.nfo".length);
const ep1 = files.find((p) => p.includes("S01E01") && p.endsWith(".mkv"))!;
const stem1 = ep1.slice(0, -4);
expect(files).toEqual(
expect.arrayContaining([
`${dir}/tvshow.nfo`,
`${dir}/poster.jpg`,
`${dir}/season01-poster.jpg`,
`${dir}/Season 1/season.nfo`,
`${dir}/Season 1/poster.jpg`,
`${stem1}.nfo`,
`${stem1}-thumb.jpg`,
]),
);
expect(files.some((p) => p.endsWith("fanart.jpg"))).toBe(false);
expect(files.filter((p) => p.endsWith(".nfo"))).toHaveLength(4);
expect(files.some((p) => p.includes(".umt-scrape.tmp"))).toBe(false);
const nfo = await readFile(join(lib.root, show), "utf8");
expect(nfo.startsWith('<?xml version="1.0" encoding="utf-8"?>\n<tvshow>')).toBe(true);
expect(nfo).toContain('<uniqueid type="imdb" default="true">tt11280740</uniqueid>');
expect(nfo).toContain("<country>美国</country>");
const epNfo = await readFile(join(lib.root, `${stem1}.nfo`), "utf8");
expect(epNfo).toContain("<title>有关地狱的好消息</title>");
expect(epNfo).toMatch(/<season>1<\/season>\n {2}<episode>1<\/episode>/);
// 同一张季海报只取一次
expect(fetched.filter((p) => p === "/s1.jpg")).toHaveLength(1);
const st = await h.api("GET", `/library/${series}`);
expect(st.status).toBe(200);
expect(st.body.data.scrape).toMatchObject({
status: "partial",
trigger: "auto",
failed: 1,
reason: "scrape_image_failed",
});
expect(st.body.data.scrape.files.find((x: { role: string }) => x.role === "fanart")).toMatchObject({
action: "failed",
reason: "scrape_image_failed",
});
const audit = await h.db
.selectFrom("audit_event")
.select(["action", "target_id", "target_type"])
.where("action", "=", "organize.scrape")
.execute();
expect(audit.some((a) => a.target_type === "scrape_record" && a.target_id === st.body.data.scrape.id)).toBe(true);
}, 60_000);
it("只补缺失:已有文件(包括别人写的)不动;全部覆盖:改写", async () => {
const files = await tree(lib.root);
const show = join(lib.root, files.find((p) => p.endsWith("tvshow.nfo"))!);
await writeFile(show, "<tvshow>别的工具写的</tvshow>");
const again = await rescrape(series, "missing");
expect(await readFile(show, "utf8")).toBe("<tvshow>别的工具写的</tvshow>");
expect(again.files.find((x) => x.role === "tvshow_nfo")).toMatchObject({
action: "skipped",
reason: "scrape_exists",
});
const over = await rescrape(series, "overwrite");
expect(over.written).toBeGreaterThan(0);
expect(await readFile(show, "utf8")).toContain("<title>人生切割术</title>");
}, 60_000);
it("电影:<视频名>.nfo + 作品目录 poster / fanart;手动「重新刮削」接口排任务", async () => {
await h.setSettings("organize", { scrapeAfterImport: false });
movie = await seedEntity(h.db, { kind: "movie", title: "星际穿越", year: 2014 });
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
title: f("星际穿越", "zh-CN"),
overview: f("穿越虫洞。", "zh-CN"),
releaseDate: f("2014-11-05"),
runtime: f(169),
posterPath: f("/inter.jpg"),
backdropPath: f("/inter-bg.jpg"),
crew: f([{ id: 525, name: "克里斯托弗·诺兰", originalName: null, role: "Director", profilePath: null }]),
}),
})
.where("id", "=", movie)
.execute();
const before = h.engine.enqueued.length;
await organize(movie, "Interstellar.2014.1080p.BluRay.x264-GRP.mkv");
expect(h.engine.enqueued.slice(before).some((x) => x.task === SCRAPE_TASK)).toBe(false); // 关了自动刮削
const res = await h.api("POST", `/library/${movie}/scrape`, {});
expect(res.status).toBe(202);
expect(res.body.data).toMatchObject({ mediaEntityId: movie, deduplicated: false });
const dup = await h.api("POST", `/library/${movie}/scrape`, {});
expect(dup.body.data.deduplicated).toBe(true);
expect((await h.api("GET", `/library/${movie}`)).body.data.scrape.status).toBe("queued");
const r = await runQueued(movie);
expect(r.status).toBe("succeeded");
const files = await tree(lib.root);
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
const dir = video.slice(0, video.lastIndexOf("/"));
expect(files.filter((p) => p.startsWith(`${dir}/`))).toEqual(
[video, `${video.slice(0, -4)}.nfo`, `${dir}/fanart.jpg`, `${dir}/poster.jpg`].sort(),
);
const nfo = await readFile(join(lib.root, `${video.slice(0, -4)}.nfo`), "utf8");
expect(nfo).toContain('<director tmdbid="525">克里斯托弗·诺兰</director>');
expect(nfo).toContain("<runtime>169</runtime>");
}, 60_000);
it("蓝光原盘资产:原盘目录本身就是刮削目录,不取上一级", async () => {
const disc = await seedEntity(h.db, { kind: "movie", title: "原盘", year: 2020 });
const root = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
const asset = await h.db
.insertInto("media_asset")
.values({ media_entity_id: disc, kind: "bluray_dir" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({ media_asset_id: asset.id, library_root_id: root.id, relative_path: "Movies/原盘 (2020)" })
.execute();
expect((await entityScrapeLocations(h.d, disc, "movie")).map((l) => l.path)).toEqual(["Movies/原盘 (2020)"]);
});
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", async () => {
const files = await tree(lib.root);
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
const dirAbs = join(lib.root, video.slice(0, video.lastIndexOf("/")));
if (process.getuid?.() !== 0) {
await chmod(dirAbs, 0o555);
const r = await rescrape(movie, "overwrite");
await chmod(dirAbs, 0o755);
expect(r.files.some((x) => x.reason === "scrape_write_failed")).toBe(true);
}
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).execute();
const ro = await rescrape(movie, "overwrite");
expect(ro).toMatchObject({ status: "failed", reason: "scrape_root_read_only", written: 0 });
await h.db.updateTable("library_root").set({ management_mode: "managed" }).execute();
const empty = await seedEntity(h.db, { kind: "movie", title: "没入库", year: 2020 });
expect((await h.api("POST", `/library/${empty}/scrape`, {})).status).toBe(412);
expect(await scrapeEntity(h.d, empty, { trigger: "manual" })).toEqual([]);
}, 60_000);
});
@@ -0,0 +1,346 @@
import { createQbClient } from "@umt/adapters";
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, subscriptionProgress, sweepDueSlots } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// W5-C 订阅增强集成:Slot×单元多在途 + 忙时延后重评 + 移除释放;季包按缺集选文件(mock qB files/filePrio);
// 总集数未知不结束(疑似缺集);once 完成写历史;pending 不完成;分集洗版只替换能提升的单元。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("subscription", {
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 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const db = () => h.db;
async function seriesSlot(
title: string,
opts: { mode?: "once" | "continuous"; status?: "active" | "pending"; hard?: object; upgrade?: object } = {},
) {
const entityId = await seedEntity(h.db, { kind: "series", title, year: 2024 });
const sub = await db()
.insertInto("subscription")
.values({
media_entity_id: entityId,
media_kind: "series",
mode: opts.mode ?? "continuous",
status: opts.status ?? "active",
})
.returning("id")
.executeTakeFirstOrThrow();
const slot = await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: "1080p",
hard_conditions: JSON.stringify(opts.hard ?? { resolutions: ["1080p"] }),
upgrade_threshold: JSON.stringify(opts.upgrade ?? {}),
})
.returning("id")
.executeTakeFirstOrThrow();
return { entityId, subId: sub.id, slotId: slot.id };
}
async function release(entityId: string, title: string, files?: { path: string; length: number }[]) {
const t = files
? makeTorrent({ name: title, files, pieceSeed: Math.floor(Math.random() * 1e6) })
: makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
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, name: t.name };
}
const slotRow = (id: string) =>
db().selectFrom("acquisition_slot").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const planRow = (id: string) =>
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const subRow = (id: string) =>
db().selectFrom("subscription").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
async function planFor(relId: string) {
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
return ev.plans[0]!;
}
async function submit(planId: string) {
const s = await submitDownload(h.d, planId);
expect(s.status).toBe("submitted");
return s.downloadTaskId!;
}
async function completeAndImport(hash: string, dtId: string) {
await qb.complete(hash, { writeFiles: true });
const sync = await syncDownloads(h.d);
expect(sync.completed).toContain(dtId);
const p = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(p.status, JSON.stringify(p)).toBe("running");
const ex = await executeRecord(h.d, p.recordId!);
expect(ex.status).toBe("succeeded");
}
it("Slot×单元多在途:两集同时在途;重叠单元的发布延后;下载被移除即释放预留,重评后补上", async () => {
const { entityId, slotId } = await seriesSlot("熊家餐馆");
const e1 = await release(entityId, "The.Bear.S01E01.1080p.WEB-DL.x264-GRP");
const e2 = await release(entityId, "The.Bear.S01E02.1080p.WEB-DL.x264-GRP");
const p1 = await planFor(e1.id);
const p2 = await planFor(e2.id); // Slot 已 reserved,单元不相交 → 第二个在途计划
expect((await planRow(p1)).unit_keys).toEqual(["s1e1"]);
expect((await planRow(p2)).unit_keys).toEqual(["s1e2"]);
expect((await slotRow(slotId)).status).toBe("reserved");
// 同一集的另一个版本:单元在途 → 延后(deferred),不丢
const e2b = await release(entityId, "The.Bear.S01E02.1080p.WEB-DL.x265-OTHER");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: e2b.id });
expect(ev.plans).toHaveLength(0);
expect(ev.deferred).toEqual([{ sourceReleaseId: e2b.id, slotId, reason: "units_inflight" }]);
expect(
await db().selectFrom("slot_deferral").select(["unit_keys"]).where("slot_id", "=", slotId).execute(),
).toEqual([{ unit_keys: ["s1e2"] }]);
const dt1 = await submit(p1);
await submit(p2);
expect((await slotRow(slotId)).status).toBe("downloading");
await completeAndImport(e1.hash, dt1);
// E01 入库,但 E02 仍在途:Slot 显示在途阶段而不是静止态;分集账本登记 E01
const s1 = await slotRow(slotId);
expect(s1.status).toBe("downloading");
expect(s1.current_asset_id).not.toBeNull();
expect(s1.next_evaluate_at).not.toBeNull();
const ledger = await db()
.selectFrom("slot_unit as su")
.innerJoin("content_unit as u", "u.id", "su.content_unit_id")
.select(["u.episode_number", "su.plan_id"])
.where("su.slot_id", "=", slotId)
.execute();
expect(ledger).toEqual([{ episode_number: 1, plan_id: p1 }]);
// 用户在 qB 里删掉 E02:确认移除 → 计划失败释放单元 → Slot 到期重评 → 延后的 E02 另一版本得到计划
const qc = createQbClient({ instanceId: "t", baseUrl: qb.url, username: "admin", password: "adminadmin" });
await qc.delete([e2.hash], { deleteFiles: false });
await syncDownloads(h.d);
expect(await planRow(p2)).toMatchObject({ status: "failed", status_reason: "download_removed" });
expect((await slotRow(slotId)).status).toBe("attention");
h.engine.enqueued.length = 0;
expect(await sweepDueSlots(h.d)).toBeGreaterThanOrEqual(1);
expect(h.engine.enqueued).toContainEqual(
expect.objectContaining({ task: "evaluateAcquisition", input: { acquisitionSlotId: slotId } }),
);
const re = await evaluateAcquisition(h.d, { acquisitionSlotId: slotId });
expect(re.plans).toHaveLength(1);
expect(await planRow(re.plans[0]!)).toMatchObject({ source_release_id: e2b.id, unit_keys: ["s1e2"] });
// e2b 的延后已消化;被移除的原 E02 此时单元在途(e2b)→ 转为延后,等 e2b 结束后再判
const left = await db()
.selectFrom("slot_deferral")
.select("source_release_id")
.where("slot_id", "=", slotId)
.execute();
expect(left.map((x) => x.source_release_id)).toEqual([e2.id]);
// 候选评估记录带 trace / unitKeys
const art = await db()
.selectFrom("artifact")
.select("content")
.where("subject_id", "=", slotId)
.where("title", "=", "candidate_evaluation")
.execute();
const byRel = new Map(art.map((a) => [(a.content as { sourceReleaseId: string }).sourceReleaseId, a.content]));
expect(byRel.get(e2b.id)).toMatchObject({ decision: "accept", unitKeys: ["s1e2"], pri: 100 });
}, 60_000);
it("季包:总集数已知 → 只预留缺集;提交后读文件清单,已覆盖/在途的集文件优先级置 0", async () => {
const { entityId, slotId } = await seriesSlot("选集剧");
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 4, now: new Date() }));
const e1 = await release(entityId, "Pick.Show.S01E01.1080p.WEB-DL.x264-GRP");
await completeAndImport(e1.hash, await submit(await planFor(e1.id)));
const e2 = await release(entityId, "Pick.Show.S01E02.1080p.WEB-DL.x264-GRP");
const p2 = await planFor(e2.id); // E02 在途(未提交)
const packTitle = "Pick.Show.S01.1080p.WEB-DL.x264-GRP";
const files = [1, 2, 3, 4].map((e) => ({ path: `Pick.Show.S01E0${e}.1080p.WEB-DL.x264-GRP.mkv`, length: SIZE }));
const pack = await release(entityId, packTitle, [
...files,
{ path: "Pick.Show.S01E01.chs.srt", length: 100 },
{ path: "Pick.Show.S01E03.chs.srt", length: 100 },
]);
const pp = await planFor(pack.id);
expect((await planRow(pp)).unit_keys).toEqual(["s1e3", "s1e4"]);
const before = qb.callsTo("torrents/filePrio").length;
await submit(pp);
const prio = qb.callsTo("torrents/filePrio").slice(before);
expect(prio).toHaveLength(1);
const t = qb.get(pack.hash)!;
const off = t.files.filter((f) => f.priority === 0).map((f) => f.name.split("/").pop());
expect(off.sort()).toEqual(
[
"Pick.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv",
"Pick.Show.S01E01.chs.srt",
"Pick.Show.S01E02.1080p.WEB-DL.x264-GRP.mkv",
].sort(),
);
expect((await planRow(pp)).file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3", "s1e4"] });
// 进度:E01 已覆盖,E02–E04 在途
const prog = await subscriptionProgress(db(), (await slotRow(slotId)).subscription_id!);
expect(prog!.seasons[0]).toMatchObject({ seasonNumber: 1, episodeTotal: 4, totalKnown: true, missing: [] });
expect(prog!.seasons[0]!.units.map((u) => u.state)).toEqual(["covered", "inflight", "inflight", "inflight"]);
expect(prog!.slots[0]!.inflightPlans.map((x) => x.planId).sort()).toEqual([p2, pp].sort());
// 认不出集号的整包:整包下载并记理由
const { entityId: e2Id } = await seriesSlot("整包剧");
const odd = await release(e2Id, "Odd.Show.S01.1080p.WEB-DL.x264-GRP", [
{ path: "disc1.mkv", length: SIZE },
{ path: "disc2.mkv", length: SIZE },
]);
const po = await planFor(odd.id);
expect((await planRow(po)).unit_keys).toEqual(["s1*"]);
const n0 = qb.callsTo("torrents/filePrio").length;
await submit(po);
expect(qb.callsTo("torrents/filePrio").length).toBe(n0);
expect((await planRow(po)).file_selection).toMatchObject({ mode: "whole", reason: "unmapped_video_file" });
}, 60_000);
it("总集数未知:入库后不结束、记疑似缺集;总集数就绪后补齐 → once 订阅完成并写历史", async () => {
const { entityId, slotId, subId } = await seriesSlot("未知集数剧", { mode: "once" });
for (const ep of [1, 3]) {
const r = await release(entityId, `Unknown.Total.S01E0${ep}.1080p.WEB-DL.x264-GRP`);
await completeAndImport(r.hash, await submit(await planFor(r.id)));
}
expect((await slotRow(slotId)).status).toBe("empty");
expect(await subRow(subId)).toMatchObject({ status: "active" });
const dec = await db()
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", `suspected_missing:${slotId}`)
.executeTakeFirstOrThrow();
expect(dec.evidence).toMatchObject({ suspectedMissing: [{ season: 1, episodes: [2] }] });
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 3, now: new Date() }));
const r2 = await release(entityId, "Unknown.Total.S01E02.1080p.WEB-DL.x264-GRP");
const acceptedTask = await submit(await planFor(r2.id));
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
expect((await slotRow(slotId)).status).toBe("downloading");
await completeAndImport(r2.hash, acceptedTask);
expect((await slotRow(slotId)).status).toBe("satisfied");
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
const hist = await db()
.selectFrom("subscription_history")
.selectAll()
.where("subscription_id", "=", subId)
.execute();
expect(hist).toHaveLength(1);
expect(hist[0]).toMatchObject({ reason: "download_accepted", media_entity_id: entityId, mode: "once" });
expect((hist[0]!.progress as { seasons: { complete: boolean }[] }).seasons[0]!.complete).toBe(true);
// 历史 API + 重新订阅(改回 active)
const api = await h.api("GET", `/subscription-history?subscriptionId=${subId}`);
expect(api.body.data.items).toHaveLength(1);
const re = await h.api("PATCH", `/subscriptions/${subId}`, { status: "active" });
expect(re.body.data.status).toBe("active");
}, 90_000);
it("多个目标版本按各自已接收单元完成,单版本入库不提前完成订阅", async () => {
const { entityId, subId } = await seriesSlot("双版本剧", { mode: "once" });
await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: subId,
name: "720p",
hard_conditions: JSON.stringify({ resolutions: ["720p"] }),
})
.execute();
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
const first = await release(entityId, "Two.Versions.S01E01.1080p.WEB-DL.x264-GRP");
const firstTask = await submit(await planFor(first.id));
expect((await subRow(subId)).status).toBe("active");
await completeAndImport(first.hash, firstTask);
expect((await subRow(subId)).status).toBe("active");
const second = await release(entityId, "Two.Versions.S01E01.720p.WEB-DL.x264-GRP");
await submit(await planFor(second.id));
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
await qb.complete(second.hash, { writeFiles: true });
await syncDownloads(h.d);
}, 60_000);
it("pending(TMDB 未就绪)照常下载但不完成;暂停的订阅不评估", async () => {
const { entityId, slotId, subId } = await seriesSlot("待定剧", { mode: "once", status: "pending" });
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
const r = await release(entityId, "Pending.Show.S01E01.1080p.WEB-DL.x264-GRP");
await completeAndImport(r.hash, await submit(await planFor(r.id)));
expect((await slotRow(slotId)).status).toBe("empty");
expect((await subRow(subId)).status).toBe("pending");
const paused = await seriesSlot("暂停剧", { status: "active" });
await db().updateTable("subscription").set({ status: "paused" }).where("id", "=", paused.subId).execute();
const pr = await release(paused.entityId, "Paused.Show.S01E01.1080p.WEB-DL.x264-GRP");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: pr.id });
expect(ev).toMatchObject({ evaluated: 0, plans: [] });
}, 60_000);
it("分集洗版:只替换能提升的单元;同档不换(trace 可读)", async () => {
const { entityId, slotId } = await seriesSlot("洗版剧", {
hard: { resolutions: ["720p", "1080p"] },
upgrade: { enabled: true, allowResolutionUpgrade: true },
});
const r = await release(entityId, "Wash.Show.S01E01.720p.WEB-DL.x264-GRP");
await completeAndImport(r.hash, await submit(await planFor(r.id)));
const same = await release(entityId, "Wash.Show.S01E01.720p.WEB-DL.x264-OTHER");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: same.id });
expect(ev.plans).toHaveLength(0);
const up = await release(entityId, "Wash.Show.S01E01-E02.1080p.WEB-DL.x264-GRP");
// E02 还缺 → 追更(fill);只有 E01 覆盖时的纯升级场景见下一条
const fill = await planFor(up.id);
expect(await planRow(fill)).toMatchObject({ purpose: "fill", unit_keys: ["s1e2"] });
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", fill).execute();
await db().updateTable("acquisition_slot").set({ status: "empty" }).where("id", "=", slotId).execute();
const up1 = await release(entityId, "Wash.Show.S01E01.1080p.WEB-DL.x264-GRP");
const pu = await planFor(up1.id);
expect(await planRow(pu)).toMatchObject({ purpose: "upgrade", unit_keys: ["s1e1"] });
const art = await db()
.selectFrom("artifact")
.select("content")
.where("subject_id", "=", slotId)
.where("title", "=", "candidate_evaluation")
.execute();
const sameEval = art
.map((a) => a.content as { sourceReleaseId: string; trace?: string[] })
.find((a) => a.sourceReleaseId === same.id);
expect(sameEval?.trace?.[0]).toBe("与当前版本同档,不反复更换");
}, 60_000);
});
@@ -0,0 +1,283 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { addAliasIfAbsent } from "../../src/modules/sources-identity/service/entities.ts";
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { organizeDecisionHook, pickMainVideo, takeoverParsed } from "../../src/modules/acquisition-organize/services/takeover.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// QB-1:接管任务(用户手动加进 qB、贴管理标签,没有站点资源)完成后按 种子名 + 主视频文件名 识别 → 直接整理;
// 识别失败建带选项的事项(候选 / 选择作品 / 临时作品 / 忽略),处理后执行并闭环;目标已存在 → 替换 / 保留两者 / 跳过;
// 手动按路径整理也关闭同一下载任务的整理事项;作品详情下载历史列出接管任务与整理记录。
const hasPg = await pgAvailable();
const SIZE = 300_000;
function walkFiles(dir: string): string[] {
const out: string[] = [];
for (const n of readdirSync(dir)) {
const p = join(dir, n);
if (statSync(p).isDirectory()) out.push(...walkFiles(p));
else out.push(p);
}
return out;
}
describe("接管识别:文件名解析", () => {
it("主视频取要下载的最大视频,跳过 sample;文件名 + 目录 + 种子名合并", () => {
expect(
pickMainVideo([
{ name: "Movie.2021/Sample/sample.mkv", size: 9e9 },
{ name: "Movie.2021/Movie.2021.1080p.mkv", size: 5e9 },
{ name: "Movie.2021/Extra.mkv", size: 6e9, priority: 0 },
{ name: "Movie.2021/Movie.2021.nfo", size: 1e3 },
]),
).toBe("Movie.2021/Movie.2021.1080p.mkv");
expect(pickMainVideo([{ name: "a.nfo", size: 1 }])).toBeNull();
// 文件名只有集号:标题 / 季从目录名(=种子根目录)补
const p = takeoverParsed("The.Bear.S02.1080p.WEB-DL", "The.Bear.S02.1080p.WEB-DL/E03.mkv");
expect(p.title?.toLowerCase()).toContain("bear");
expect(p.season).toBe(2);
expect(p.episodes).toEqual({ start: 3, end: 3 });
// 单文件种子:种子名即文件名
const m = takeoverParsed("Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer.2023.2160p.UHD.BluRay.mkv");
expect(m.year).toBe(2023);
// 文件名解析不出标题:用种子名
expect(takeoverParsed("Heat.1995.1080p.BluRay", "Heat.1995.1080p.BluRay/0001.mkv").year).toBe(1995);
});
});
describe.skipIf(!hasPg)("QB-1 接管任务识别与事项闭环(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let n = 0;
const actor = { type: "user", id: "tester" };
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
h = await createHarness("takeover", {}, { withSources: true });
await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
await h.setSettings("retention", { seedRetentionDays: 0 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
/** 用户在 qB 里手动加的任务(贴管理标签)→ 同步接管 → 完成落盘 */
async function adoptCompleted(name: string, file: string, size = SIZE) {
n++;
const hash = n.toString(16).padStart(40, "a");
qb.seed({ hash, name, tags: ["umt"], save_path: cache.root });
await qb.complete(hash, { files: [{ path: `${name}/${file}`, size }] });
const sync = await syncDownloads(h.d);
expect(sync.adopted).toBeGreaterThanOrEqual(1);
const dt = await h.db
.selectFrom("download_task")
.selectAll()
.where("torrent_hash", "=", hash)
.executeTakeFirstOrThrow();
expect(dt).toMatchObject({ origin: "adopted", source_release_id: null, status: "completed" });
return dt;
}
const decisionOf = (key: string) =>
h.db
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", key)
.orderBy("created_at", "desc")
.executeTakeFirst();
let movieId: string;
let firstDtId: string;
it("识别成功(本地别名召回):不建事项,直接整理入库并回填下载任务的作品", async () => {
movieId = await seedEntity(h.db, { kind: "movie", title: "Quiet Harbor", year: 2021 });
await addAliasIfAbsent(h.db, movieId, "Quiet Harbor", "original", "user");
const dt = await adoptCompleted(
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
);
firstDtId = dt.id;
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("running");
const done = await executeRecord(h.d, plan.recordId!);
expect(done.status).toBe("succeeded");
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
expect(row).toMatchObject({ media_entity_id: movieId, status: "imported" });
expect(await decisionOf(`organize_identity:${dt.id}`)).toBeUndefined();
});
let unknownDtId: string;
it("识别失败:事项带 选择作品(本地 / TMDB)/ 临时作品 / 忽略;选临时作品 → 建临时作品并整理,事项闭环", async () => {
const dt = await adoptCompleted("Zzqx.Vorlanth.2019.1080p.WEB-DL", "Zzqx.Vorlanth.2019.1080p.WEB-DL.mkv");
unknownDtId = dt.id;
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" });
const dec = (await decisionOf(`organize_identity:${dt.id}`))!;
expect(dec).toBeTruthy();
const keys = (dec.options as { key: string }[]).map((o) => o.key);
expect(keys).toEqual(expect.arrayContaining(["select_entity", "select_tmdb", "provisional", "dismiss"]));
expect(dec.evidence).toMatchObject({ downloadTaskId: dt.id, outcome: "no_match" });
expect((dec.evidence as { parsed: { year: number } }).parsed.year).toBe(2019);
const hook = organizeDecisionHook(h.d);
expect(hook.match(dec)).toBe(true);
// 选择作品(本地)但没给作品 → 错误,事项保持打开
await expect(hook.execute(dec, { optionKey: "select_entity" }, actor)).rejects.toThrow(/mediaEntityId/);
const out = await hook.execute(dec, { optionKey: "provisional" }, actor);
expect(out).toMatchObject({ kind: "organize", status: "running" });
const ent = await h.db
.selectFrom("media_entity")
.selectAll()
.where("id", "=", out.mediaEntityId as string)
.executeTakeFirstOrThrow();
expect(ent).toMatchObject({ status: "provisional", year: 2019 });
expect(h.engine.enqueued.some((e) => (e.input as { recordId?: string }).recordId === out.recordId)).toBe(true);
const done = await executeRecord(h.d, out.recordId as string);
expect(done.status).toBe("succeeded");
const closed = (await decisionOf(`organize_identity:${dt.id}`))!;
expect(closed.status).toBe("resolved");
expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: out.recordId });
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
expect(row.media_entity_id).toBe(ent.id);
});
it("待处理中心 API:选候选 / 本地作品 → 执行并关闭;缺作品 → 400 且事项保持打开", async () => {
const target = await seedEntity(h.db, { kind: "movie", title: "Amber Coast", year: 2017 });
const dt = await adoptCompleted("Qwzz.Nomatch.2017.1080p.WEB", "Qwzz.Nomatch.2017.1080p.WEB.mkv");
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
const dec = (await decisionOf(`organize_identity:${dt.id}`))!;
const bad = await h.api("POST", `/decisions/${dec.id}/resolve`, { optionKey: "select_entity" });
expect(bad.status).toBe(400);
expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open");
const ok = await h.api("POST", `/decisions/${dec.id}/resolve`, {
optionKey: "select_entity",
identity: { mediaEntityId: target },
});
expect(ok.status).toBe(200);
expect(ok.body.data.status).toBe("resolved");
expect(ok.body.data.resolution.execution).toMatchObject({ kind: "organize", mediaEntityId: target });
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
expect(row.media_entity_id).toBe(target);
const recId = ok.body.data.resolution.execution.recordId as string;
expect((await executeRecord(h.d, recId)).status).toBe("succeeded");
});
it("识别失败后在文件管理按路径手动整理:同一下载任务的整理事项关闭,回填作品", async () => {
const other = await seedEntity(h.db, { kind: "movie", title: "Glass Orchard", year: 2018 });
const dt = await adoptCompleted("Xqqz.Unmatched.2018.720p.HDTV", "Xqqz.Unmatched.2018.720p.HDTV.mkv");
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open");
const src = join(cache.root, "Xqqz.Unmatched.2018.720p.HDTV");
const plan = await planImport(h.d, {
sourcePaths: [src],
mediaEntityId: other,
trigger: "manual",
actorType: "user",
actorId: "tester",
});
expect(plan.status).toBe("running");
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
const closed = (await decisionOf(`organize_identity:${dt.id}`))!;
expect(closed).toMatchObject({ status: "resolved" });
expect(closed.resolution).toMatchObject({ key: "manual_organize" });
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
expect(row.media_entity_id).toBe(other);
});
it("目标已存在:事项给 替换 / 保留两者 / 跳过;保留两者 → 新文件加序号入库,旧文件不动,事项闭环", async () => {
// 同一作品的另一份(不同内容、同名)→ 目标文件已存在
const dt = await adoptCompleted(
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
SIZE + 1000,
);
// 同名种子在 qB 里是新 hash;接管识别仍命中同一作品
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("failed");
expect(plan.reason).toContain("target_exists");
const dec = (await decisionOf(`organize_blocked:${dt.id}`))!;
expect((dec.options as { key: string }[]).map((o) => o.key)).toEqual(["replace", "keep_both", "skip"]);
const before = walkFiles(lib.root).filter((p) => p.endsWith(".mkv"));
expect(before.filter((p) => p.includes("Quiet"))).toHaveLength(1);
const hook = organizeDecisionHook(h.d);
const out = await hook.execute(dec, { optionKey: "keep_both" }, actor);
expect(out).toMatchObject({ kind: "organize", choice: "keep_both", status: "running" });
expect((await executeRecord(h.d, out.recordId as string)).status).toBe("succeeded");
const after = walkFiles(lib.root).filter((p) => p.endsWith(".mkv"));
expect(after).toHaveLength(before.length + 1);
const renamed = after.find((p) => p.includes(" (2)."));
expect(renamed && statSync(renamed).size).toBe(SIZE + 1000);
for (const p of before) expect(existsSync(p)).toBe(true);
expect((await decisionOf(`organize_blocked:${dt.id}`))?.status).toBe("resolved");
});
it("目标已存在 → 跳过:其余照常,已存在的不整理;替换:核验后替换", async () => {
const dt = await adoptCompleted(
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
SIZE + 2000,
);
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
const dec = (await decisionOf(`organize_blocked:${dt.id}`))!;
const hook = organizeDecisionHook(h.d);
const skip = await hook.execute(dec, { optionKey: "skip" }, actor);
const rec = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", skip.recordId as string)
.executeTakeFirstOrThrow();
expect(rec.file_skipped).toBeGreaterThanOrEqual(1);
const dt2 = await adoptCompleted(
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
SIZE + 3000,
);
await planImport(h.d, { downloadTaskId: dt2.id, trigger: "auto", actorType: "system" });
const dec2 = (await decisionOf(`organize_blocked:${dt2.id}`))!;
const rep = await hook.execute(dec2, { optionKey: "replace" }, actor);
expect(rep.status).toBe("running");
expect((await executeRecord(h.d, rep.recordId as string)).status).toBe("succeeded");
// 被替换的旧文件进库根回收站(.umt-trash),不在库内
const inLib = walkFiles(lib.root).filter(
(p) => p.endsWith(".mkv") && p.includes("Quiet") && !p.includes("/.umt-trash/"),
);
const sizes = inLib.map((p) => statSync(p).size);
expect(sizes).toContain(SIZE + 3000);
expect(sizes).not.toContain(SIZE);
expect(walkFiles(lib.root).some((p) => p.includes("/.umt-trash/") && statSync(p).size === SIZE)).toBe(true);
});
it("作品详情下载历史:列出接管的下载任务与整理记录", async () => {
const r = await h.api("GET", `/library/${movieId}`);
expect(r.status).toBe(200);
const ids = r.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id);
expect(ids).toContain(firstDtId);
const first = r.body.data.downloads.find((d: { downloadTask: { id: string } }) => d.downloadTask.id === firstDtId);
expect(first.downloadTask).toMatchObject({ origin: "adopted", mediaEntityId: movieId });
expect(first.lastOrganizationStatus).toBe("succeeded");
expect(r.body.data.organizationRecords.length).toBeGreaterThanOrEqual(3);
const prov = await h.db
.selectFrom("download_task")
.select("media_entity_id")
.where("id", "=", unknownDtId)
.executeTakeFirstOrThrow();
const r2 = await h.api("GET", `/library/${prov.media_entity_id}`);
expect(r2.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id)).toContain(
unknownDtId,
);
});
});
@@ -0,0 +1,523 @@
import { existsSync, readFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { sql } from "@umt/db";
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 { cleanupDownloadCache, submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { reconcileLibrary } from "../../src/modules/acquisition-organize/services/reconcile.ts";
import {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// 纵切:release.resolved → 计划 → 提交 mock qB → 完成(落盘)→ Copy 入库 → 整理记录 API(含 changes 游标);
// 以及硬阈值暂停/恢复、already_exists 接管、缓存清理安全边界、资产对账。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
disk.state.watch = [cache.root];
disk.setPct(40);
h = await createHarness("vertical", {
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("retention", { seedRetentionDays: 0 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
async function release(entityId: string, title: string, opts: { hr?: boolean } = {}) {
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title,
size: SIZE,
hash: t.hash,
...(opts.hr !== undefined ? { hr: opts.hr } : {}),
});
torrents.set(id, t.bytes);
return { id, hash: t.hash, name: t.name };
}
async function runToCompleted(entityId: string, title: string, opts: { hr?: boolean } = {}) {
const rel = await release(entityId, title, opts);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans).toHaveLength(1);
const sub = await submitDownload(h.d, ev.plans[0]!);
expect(sub.status).toBe("submitted");
await qb.complete(rel.hash, { writeFiles: true });
const sync = await syncDownloads(h.d);
expect(sync.completed).toContain(sub.downloadTaskId);
return { rel, planId: ev.plans[0]!, dtId: sub.downloadTaskId! };
}
let movieId: string;
let slotId: string;
let firstRecord: string;
it("release → 计划(预留 Slot)→ 提交 qB(管理标签)→ 完成 → Copy 入库 → 登记资产与绑定", async () => {
movieId = await seedEntity(h.db, { kind: "movie", title: "沙丘2", year: 2024 });
slotId = await seedSlot(h.db, { entityId: movieId, hard: { resolutions: ["1080p"] } });
// 不满足硬条件的候选:写拒绝理由,不建计划
const bad = await release(movieId, "Dune.Part.Two.2024.720p.WEB-DL.x264-AAA");
const r0 = await evaluateAcquisition(h.d, { sourceReleaseId: bad.id });
expect(r0.plans).toHaveLength(0);
const rel = await release(movieId, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans).toHaveLength(1);
const slot1 = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", slotId)
.executeTakeFirstOrThrow();
expect(slot1.status).toBe("reserved");
expect(h.engine.enqueued.map((e) => e.task)).toContain("submitDownload");
const sub = await submitDownload(h.d, ev.plans[0]!);
expect(sub).toMatchObject({ status: "submitted", reason: "added" });
const t = qb.get(rel.hash)!;
expect(t.tags).toContain("umt");
expect(t.save_path).toBe(cache.root);
// 重放:账本已有结果,不重复提交
expect((await submitDownload(h.d, ev.plans[0]!)).status).toBe("skipped");
expect(qb.callsTo("torrents/add")).toHaveLength(1);
expect((await h.outbox("download.submitted")).length).toBe(1);
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
.status,
).toBe("downloading");
// 未完成:同步只更新进度
qb.setProgress(rel.hash, 0.5);
const s1 = await syncDownloads(h.d);
expect(s1.completed).toHaveLength(0);
await qb.complete(rel.hash, { writeFiles: true });
const s2 = await syncDownloads(h.d);
expect(s2.completed).toEqual([sub.downloadTaskId]);
const done = await h.outbox("download.status_changed");
expect(done.filter((e) => e.payload.to === "completed")).toHaveLength(1);
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
.status,
).toBe("verifying");
const plan = await planImport(h.d, { downloadTaskId: sub.downloadTaskId, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("running");
firstRecord = plan.recordId!;
const ex = await executeRecord(h.d, firstRecord);
expect(ex).toMatchObject({ status: "succeeded", succeeded: 1, failed: 0 });
const target = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv");
expect(existsSync(target)).toBe(true);
// Copy:源继续做种
expect(existsSync(join(cache.root, `${rel.name}`))).toBe(true);
const slot = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", slotId)
.executeTakeFirstOrThrow();
expect(slot.status).toBe("satisfied");
expect(slot.current_asset_id).not.toBeNull();
const binding = await h.db.selectFrom("path_binding").selectAll().where("media_entity_id", "=", movieId).execute();
expect(binding.map((b) => b.relative_path)).toEqual(["沙丘2 (2024)"]);
const loc = await h.db
.selectFrom("asset_location")
.selectAll()
.where("media_asset_id", "=", slot.current_asset_id!)
.executeTakeFirstOrThrow();
expect(loc.relative_path).toBe("沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv");
const dt = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.status).toBe("imported");
expect(
(
await h.db
.selectFrom("acquisition_plan")
.select("status")
.where("id", "=", ev.plans[0]!)
.executeTakeFirstOrThrow()
).status,
).toBe("completed");
// 重跑执行:幂等,不重复登记
await executeRecord(h.d, firstRecord);
expect(
await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", movieId).execute(),
).toHaveLength(1);
}, 60_000);
it("整理记录 API:详情含文件明细;changes 游标增量(含既有记录状态更新);changes 先于 {id}", async () => {
const det = await h.api("GET", `/organization-records/${firstRecord}`);
expect(det.status).toBe(200);
expect(det.body.data).toMatchObject({
id: firstRecord,
status: "succeeded",
action: "copy",
origin: "auto",
counts: { total: 1, succeeded: 1 },
});
expect(det.body.data.files[0]).toMatchObject({
role: "main",
status: "succeeded",
target: { path: "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv", view: "library" },
});
expect(det.body.data.media).toMatchObject({ entityId: movieId, title: "沙丘2" });
const poll = async (after: string) => {
for (let i = 0; i < 50; i++) {
const r = await h.api("GET", `/organization-records/changes?after=${after}&limit=100`);
if (r.body.data.items.length > 0) return r;
await new Promise((res) => setTimeout(res, 100));
}
return h.api("GET", `/organization-records/changes?after=${after}&limit=100`);
};
const first = await poll("0");
expect(first.status).toBe(200);
expect(first.body.data.items.map((i: { id: string }) => i.id)).toContain(firstRecord);
const cursor = first.body.data.nextCursor as string;
const again = await h.api("GET", `/organization-records/changes?after=${cursor}`);
expect(again.body.data).toMatchObject({ items: [], nextCursor: cursor, hasMore: false });
// 既有记录状态更新 → 以更大的 changeSeq 再次出现
await sql`update organization_record set reason = 'touched' where id = ${firstRecord}`.execute(h.db);
const upd = await poll(cursor);
expect(upd.body.data.items.map((i: { id: string }) => i.id)).toEqual([firstRecord]);
expect(BigInt(upd.body.data.nextCursor)).toBeGreaterThan(BigInt(cursor));
const list = await h.api("GET", "/organization-records?limit=10");
expect(list.body.data.items[0].files).toBeNull();
expect((await h.api("GET", "/organization-records/changes?after=abc")).status).toBe(422);
});
it("硬阈值:达到时暂停纳管下载中任务并阻止新计划;低于恢复水位只恢复阈值暂停项", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "阈值片", year: 2024 });
await seedSlot(h.db, { entityId: e, hard: {} });
// 一个下载中的纳管任务 + 一个用户手动暂停的纳管任务(贴标签接管)
const a = makeTorrent({ name: "busy.mkv", length: SIZE, pieceSeed: 11 });
const b = makeTorrent({ name: "user-paused.mkv", length: SIZE, pieceSeed: 12 });
qb.seed({ hash: a.hash, name: "busy.mkv", tags: ["umt"], progress: 0.2, save_path: cache.root, size: SIZE });
qb.seed({
hash: b.hash,
name: "user-paused.mkv",
tags: ["umt"],
progress: 0.2,
save_path: cache.root,
size: SIZE,
state: "pausedDL",
});
const s0 = await syncDownloads(h.d);
expect(s0.adopted).toBe(2);
const rowB = await h.db
.selectFrom("download_task")
.selectAll()
.where("torrent_hash", "=", b.hash)
.executeTakeFirstOrThrow();
expect(rowB).toMatchObject({ status: "paused", pause_reason: "user", origin: "adopted" });
disk.setPct(95);
const s1 = await syncDownloads(h.d);
expect(s1.thresholdPaused).toBe(1);
expect(qb.get(a.hash)!.state).toMatch(/^paused|^stopped/i);
expect(
(await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", a.hash).executeTakeFirstOrThrow())
.pause_reason,
).toBe("threshold");
const tripped = await h.outbox("download.threshold");
expect(tripped.at(-1)?.payload.state).toBe("tripped");
const rel = await release(e, "Threshold.Movie.2024.1080p.WEB-DL-X");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans).toHaveLength(0);
expect(ev.skipped[0]?.reason).toMatch(/^cache:/);
// 手动恢复阈值暂停项被拒绝(不绕过阈值)
const rowA = await h.db
.selectFrom("download_task")
.select("id")
.where("torrent_hash", "=", a.hash)
.executeTakeFirstOrThrow();
expect((await h.api("POST", `/download-tasks/${rowA.id}/resume`)).status).toBe(412);
// 下一轮观察:暂停中不计入无进展,原因保持 threshold
await syncDownloads(h.d);
expect(
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rowA.id).executeTakeFirstOrThrow())
.pause_reason,
).toBe("threshold");
disk.setPct(88); // 高于恢复水位:保持暂停
expect((await syncDownloads(h.d)).thresholdResumed).toBe(0);
disk.setPct(70);
const s3 = await syncDownloads(h.d);
expect(s3.thresholdResumed).toBe(1);
expect(qb.get(a.hash)!.state).not.toMatch(/^paused|^stopped/i);
expect(qb.get(b.hash)!.state).toMatch(/^paused|^stopped/i);
expect(
(await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", b.hash).executeTakeFirstOrThrow())
.pause_reason,
).toBe("user");
// 读不到磁盘:保守阻止
disk.state.fail = true;
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev2.plans).toHaveLength(0);
disk.state.fail = false;
disk.setPct(40);
qb.setProgress(a.hash, 0);
}, 60_000);
it("already_exists:种子原本在 qB 里 → 接管,只补管理标签,不改保存路径和其它标签", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "旧种", year: 2023 });
await seedSlot(h.db, { entityId: e });
const rel = await release(e, "Old.Seed.2023.1080p.BluRay-OLD");
qb.seed({ hash: rel.hash, name: rel.name, tags: ["keep"], save_path: "/elsewhere", progress: 0.1, size: SIZE });
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
const sub = await submitDownload(h.d, ev.plans[0]!);
expect(sub).toMatchObject({ status: "submitted", reason: "already_exists" });
const t = qb.get(rel.hash)!;
expect(t.save_path).toBe("/elsewhere");
expect(t.tags.sort()).toEqual(["keep", "umt"]);
const row = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(row).toMatchObject({
origin: "adopted",
managed: true,
acquisition_plan_id: ev.plans[0],
media_entity_id: e,
});
expect(qb.callsTo("torrents/add").length).toBe(1);
}, 30_000);
it("H&R 未满足时 Move 变冲突:记录失败 + 待处理,不搬源、不静默改 Copy", async () => {
await h.setSettings("organize", { defaultMode: "move" });
const e = await seedEntity(h.db, { kind: "movie", title: "做种片", year: 2022 });
const slot = await seedSlot(h.db, { entityId: e });
const { dtId, rel } = await runToCompleted(e, "Seeding.Movie.2022.1080p.BluRay-HR", { hr: true });
const plan = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("failed");
expect(plan.reason).toContain("hnr_unsatisfied");
const rec = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(rec.action).toBe("move");
expect(existsSync(join(cache.root, rel.name))).toBe(true);
expect(existsSync(join(lib.root, "做种片 (2022)"))).toBe(false);
const dec = await h.db
.selectFrom("decision")
.selectAll()
.where("subject_id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(dec).toMatchObject({ kind: "approval_required", status: "open" });
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slot).executeTakeFirstOrThrow())
.status,
).toBe("attention");
await h.setSettings("organize", { defaultMode: "copy" });
}, 30_000);
it("缓存清理:低于阈值不动;超阈值只删已验证入库且满足保留/H&R 的任务,删数据必经安全检查", async () => {
// 已有:第一部片(已入库、hr=false、保留 0 天)→ 可清理;做种片(hr 未满足,未入库)→ 锁定
disk.setPct(50);
const r0 = await cleanupDownloadCache(h.d);
expect(r0.volumes[0]!.action).toBe("none");
const firstDt = await h.db.selectFrom("download_task").selectAll().where("status", "=", "imported").execute();
expect(firstDt.length).toBeGreaterThanOrEqual(1);
// 另造一个"已入库"但 content_path 落在媒体库内的任务:安全检查必须拒绝删数据
const trap = await h.db
.insertInto("download_task")
.values({
instance_id: base.instanceId,
torrent_hash: "f".repeat(40),
name: "trap",
source_release_id: firstDt[0]!.source_release_id,
save_path: lib.root,
content_path: join(lib.root, "沙丘2 (2024)"),
status: "imported",
managed: true,
completed_at: new Date(0),
size_bytes: 1,
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_record")
.values({ trigger: "auto", actor_type: "system", action: "copy", status: "succeeded", download_task_id: trap.id })
.execute();
qb.seed({ hash: "f".repeat(40), name: "trap", tags: ["umt"], progress: 1, save_path: lib.root, size: 1 });
disk.setPct(95);
const r = await cleanupDownloadCache(h.d);
const v = r.volumes[0]!;
expect(v.removed).toContain(firstDt[0]!.id);
expect(v.removed).not.toContain(trap.id);
expect(v.skipped.find((s) => s.downloadTaskId === trap.id)?.reason).toMatch(/拒绝删除|媒体库/);
expect(qb.get(firstDt[0]!.torrent_hash)).toBeUndefined();
// 库内副本不受影响
expect(existsSync(join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"))).toBe(true);
const locked = v.skipped.map((s) => s.reason);
expect(locked.some((x) => x === "hnr_unsatisfied" || x === "import_not_verified")).toBe(false); // 做种片未入库,不在候选里
const op = await h.db
.selectFrom("asset_operation")
.selectAll()
.where("operation_id", "=", `cleanup:${firstDt[0]!.id}`)
.executeTakeFirstOrThrow();
expect(op).toMatchObject({ kind: "qb_remove", status: "succeeded" });
disk.setPct(40);
}, 30_000);
it("资产对账:库根离线只标 unavailable;缺失按 suspected → confirmed 推进,不删记录不补下载", async () => {
const file = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv");
const content = readFileSync(file);
const loc = await h.db
.selectFrom("asset_location")
.selectAll()
.where("relative_path", "=", "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv")
.executeTakeFirstOrThrow();
const { rm } = await import("node:fs/promises");
await rm(join(lib.root, ".uhd-root"));
const off = await reconcileLibrary(h.d);
expect(off.stats.rootsOffline).toHaveLength(1);
expect(
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
.status,
).toBe("unavailable");
await writeFile(join(lib.root, ".uhd-root"), "x");
await rm(file);
for (const want of ["suspected_missing", "suspected_missing", "confirmed_missing"]) {
await reconcileLibrary(h.d);
expect(
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
.status,
).toBe(want);
}
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
.status,
).toBe("attention");
expect(
await h.db.selectFrom("media_asset").select("id").where("id", "=", loc.media_asset_id).executeTakeFirst(),
).toBeDefined();
await writeFile(file, content);
await reconcileLibrary(h.d);
expect(
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
.status,
).toBe("available");
const runs = await h.db.selectFrom("reconcile_run").select(["kind", "status"]).execute();
expect(runs.every((r) => r.kind === "asset")).toBe(true);
}, 30_000);
it("首页、媒体库、库存叠加 API", async () => {
const dash = await h.api("GET", "/dashboard");
expect(dash.status).toBe(200);
expect(dash.body.data.recentOutput.length).toBeGreaterThan(0);
expect(dash.body.data.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 });
expect(dash.body.data.needsAttention.total).toBeGreaterThan(0);
const auto = dash.body.data.automation;
expect(auto.library.entities).toBeGreaterThanOrEqual(1);
expect(auto.library.movies).toBeGreaterThanOrEqual(1);
expect(auto.library.assets).toBeGreaterThanOrEqual(1);
const slotTotal = Object.values(auto.slotCounts as Record<string, number>).reduce((a, b) => a + b, 0);
expect(auto.slotSatisfiedRate).toBe(slotTotal ? auto.slotCounts.satisfied / slotTotal : null);
const libList = await h.api("GET", "/library?q=沙丘");
expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 });
const det = await h.api("GET", `/library/${movieId}`);
expect(det.status).toBe(200);
expect(det.body.data.assets[0].locations[0].absolutePath).toBe(
join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"),
);
expect(det.body.data.candidates.some((c: { decision: string }) => c.decision === "reject")).toBe(true);
expect(det.body.data.downloads[0].organizationRecordIds.length).toBeGreaterThan(0);
// QB-1:下载任务直接记作品;整理记录按作品列出
const dune = det.body.data.downloads.find((x: { downloadTask: { name: string } }) =>
x.downloadTask.name.startsWith("Dune.Part.Two"),
);
expect(dune.downloadTask.mediaEntityId).toBe(movieId);
expect(det.body.data.organizationRecords.map((r: { id: string }) => r.id)).toEqual(
expect.arrayContaining(dune.organizationRecordIds),
);
const inv = await h.api("GET", `/inventory?entityIds=${movieId}`);
expect(inv.body.data.items[0].versions[0]).toMatchObject({ resolution: "1080p", available: true });
const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`);
expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [], partial: false });
});
it("自动整理被拦(attention)后手动整理成功:Slot 经 verifying 落 satisfied,整理事项自动关闭(QA U1)", async () => {
await h.setSettings("organize", { defaultMode: "move" });
const e = await seedEntity(h.db, { kind: "movie", title: "被拦片", year: 2021 });
const slot = await seedSlot(h.db, { entityId: e });
const { dtId, planId } = await runToCompleted(e, "Blocked.Movie.2021.1080p.BluRay-HR", { hr: true });
// 新作品首次下载:计划用途是补缺,不是升级(QA U7)
expect(
(await h.db.selectFrom("acquisition_plan").select("purpose").where("id", "=", planId).executeTakeFirstOrThrow())
.purpose,
).toBe("fill");
const auto = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(auto.status).toBe("failed");
const slotRow = () =>
h.db.selectFrom("acquisition_slot").selectAll().where("id", "=", slot).executeTakeFirstOrThrow();
expect((await slotRow()).status).toBe("attention");
const dec = await h.db
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", `organize_blocked:${dtId}`)
.executeTakeFirstOrThrow();
expect(dec.status).toBe("open");
await h.setSettings("organize", { defaultMode: "copy" });
const manual = await planImport(h.d, { downloadTaskId: dtId, trigger: "manual", actorType: "user", actorId: null });
expect(manual.status).toBe("running");
const done = await executeRecord(h.d, manual.recordId!);
expect(done.status).toBe("succeeded");
const after = await slotRow();
expect(after.status).toBe("satisfied");
expect(after.current_asset_id).not.toBeNull();
const asset = await h.db
.selectFrom("media_asset")
.select("media_entity_id")
.where("id", "=", after.current_asset_id!)
.executeTakeFirstOrThrow();
expect(asset.media_entity_id).toBe(e);
const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow();
expect(closed.status).toBe("resolved");
expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: manual.recordId });
expect(closed.resolved_by).toBe("user");
}, 30_000);
});
@@ -0,0 +1,276 @@
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { MOCK_ROW_H, MOCK_ROW_Y0, MOCK_VIEWPORT } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { registerBrowserCapabilities } from "../../src/modules/browser/capabilities.ts";
import { createBrowserModule } from "../../src/modules/browser/index.ts";
import {
ACCESS_KEY,
type BrowserHarness,
createBrowserHarness,
type Loose,
ORIGIN,
OTHER_KEY,
PASSKEY,
PASSWORD,
pgAvailable,
READ_KEY,
} from "./harness.ts";
// C1-E 浏览器 HTTP 端点(对 testkit 交互式 mock camofox 验证,未连真实实例):
// 浏览器服务列表与探测、会话开关(数据库为准)、接管租约、坐标点击与凭证注入、截图、下载暂存核验与提交、异常路径。
const up = await pgAvailable();
const row = (i: number) => ({ x: 200, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + MOCK_ROW_H / 2 });
describe.skipIf(!up)("浏览器 HTTP 端点", () => {
let h: BrowserHarness;
let sid: string;
beforeAll(async () => {
h = await createBrowserHarness("api");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("GET /browser/providers:凭证只给引用;probe 写回能力与健康,verified 只来自人工核验配置", async () => {
const r = await h.req("/browser/providers");
expect(r.status).toBe(200);
expect(r.body.data.items).toHaveLength(1);
expect(r.body.data.items[0]).toMatchObject({ kind: "camofox", enabled: true, capabilities: null, health: null });
expect(JSON.stringify(r.body)).not.toContain(ACCESS_KEY);
const p = await h.req("/browser/providers?probe=true");
const item = p.body.data.items[0];
expect(item.health.status).toBe("ok");
expect(item.healthStatus).toBe("healthy");
// mock 的版本号不在真机核验表里:verified 只来自配置
expect(item.capabilities.upstreamVersion).toBe("1.17.0-mock");
expect(item.capabilities.snapshot).toMatchObject({ supported: true, verified: true, source: "probed" });
expect(item.capabilities.interact).toMatchObject({ supported: true, verified: false });
expect(item.capabilities.takeover).toMatchObject({ supported: true, verified: false });
// 探测带 Bearer 访问凭据
expect(h.mock.callsTo("/openapi.json")[0]?.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
expect((await h.req("/browser/providers", { key: READ_KEY })).status).toBe(403);
});
it("打开会话:profile 作 camofox userId,会话/标签页落库;同站点账号再开返回同一会话", async () => {
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/torrents.php` } });
expect(r.status).toBe(200);
const s = r.body.data;
sid = s.id;
expect(s).toMatchObject({ siteId: h.siteId, accountId: h.accountId, controller: "automation", lease: null });
expect(s.profileKey).toBe(`umt-site-${h.siteId}-acct-${h.accountId}`);
// 登录失效:落到登录页
expect(s.currentUrl).toContain("/login.php");
expect(s.tabs).toHaveLength(1);
expect([...h.mock.tabs.values()][0]?.userId).toBe(s.profileKey);
const dbRow = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
expect(dbRow.controller).toBe("automation");
const again = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
expect(again.body.data.id).toBe(sid);
const list = await h.req(`/browser/sessions?siteId=${h.siteId}`);
expect(list.body.data.items.map((x: Loose) => x.id)).toEqual([sid]);
});
it("输入类动作要先接管;他人接管中 409;读(快照/截图)随时可用", async () => {
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/` } });
expect(nav.status).toBe(409);
expect(nav.body.error.message).toContain("先接管");
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
expect(shot.status).toBe(200);
expect(shot.body.data).toMatchObject({
mimeType: "image/png",
width: MOCK_VIEWPORT.width,
height: MOCK_VIEWPORT.height,
modelSafe: false,
controller: "automation",
});
const png = Buffer.from(shot.body.data.dataBase64, "base64");
expect(png.subarray(1, 4).toString()).toBe("PNG");
const a = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "登录" } });
expect(a.status).toBe(200);
expect(a.body.data.lease).toMatchObject({ holderKind: "human", status: "active" });
expect(a.body.data.session.controller).toBe("human");
const other = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {}, key: OTHER_KEY });
expect(other.status).toBe(409);
const otherClick = await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) }, key: OTHER_KEY });
expect(otherClick.status).toBe(409);
});
it("人工:导航限站点域名;坐标点击 + 凭证注入登录;快照遮蔽密码与 passkey", async () => {
const evil = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: "https://evil.example/" } });
expect(evil.status).toBe(400);
expect(evil.body.error.message).toContain("白名单");
expect((await h.req(`/browser/sessions/${sid}/navigate`, { body: {} })).status).toBe(400);
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/torrents.php` } });
expect(nav.body.data).toMatchObject({ loginRequired: true, ok: true });
expect((await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) } })).status).toBe(200);
await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "username" } });
await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(2) } });
const typed = await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "password" } });
expect(typed.status).toBe(200);
expect(JSON.stringify(typed.body)).not.toContain(PASSWORD);
// 登录前快照:密码框有值,返回前已遮蔽
const snap = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(snap.body.data.text).toContain("登录");
expect(snap.body.data.text).not.toContain(PASSWORD);
// 凭证确实下发到了浏览器(mock 记录),点击按钮完成登录
expect(h.mock.actions.some((x) => x.action === "type" && x.body.text === PASSWORD)).toBe(true);
await h.req(`/browser/sessions/${sid}/type`, { body: { key: "Enter" } });
const after = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(after.body.data.text).toContain("种子列表");
expect(after.body.data.text).not.toContain(PASSKEY);
expect(after.body.data.url).toContain("/torrents.php");
// 后退 / 滚动
expect((await h.req(`/browser/sessions/${sid}/scroll`, { body: { direction: "down" } })).status).toBe(200);
const back = await h.req(`/browser/sessions/${sid}/navigate`, { body: { action: "reload" } });
expect(back.status).toBe(200);
});
it("下载:点击捕获 → 暂存 0600 文件、sha256/infohash 核验、按 infohash 匹配资源,经 /acquisition-plans 提交;同一文件不重复提交", async () => {
await h.req(`/browser/sessions/${sid}/snapshot`);
const r = await h.req(`/browser/sessions/${sid}/download`, {
body: { target: { ref: "e11" }, submit: { note: "browser" } },
});
expect(r.status, JSON.stringify(r.body)).toBe(200);
const d = r.body.data;
expect(d.artifact.sha256).toBe(createHash("sha256").update(h.torrent.bytes).digest("hex"));
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
expect(d.sourceReleaseId).toBe(h.releaseId);
// 资源未识别:与 POST /acquisition-plans 相同的确认结果,产物照常返回
expect(d.submission).toMatchObject({ submitted: false, requiresConfirmation: true });
const files = await readdir(h.stagingDir);
expect(files).toHaveLength(1);
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
// 识别到作品后再下载:同一文件第二次捕获标 duplicate、不提交
const entity = await h.db
.insertInto("media_entity")
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("source_release")
.set({ media_entity_id: entity.id })
.where("id", "=", h.releaseId)
.execute();
await h.req(`/browser/sessions/${sid}/snapshot`);
const dup = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" }, submit: {} } });
expect(dup.body.data).toMatchObject({ duplicate: true, submission: null });
expect(dup.body.data.note).toContain("未重复提交");
// 未接管时不能点击触发下载
await h.req(`/browser/sessions/${sid}/control/release`, { body: {} });
const denied = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" } } });
expect(denied.status).toBe(409);
});
it("交还后需重新读取页面;租约到期 → 等待处理 + 待处理事项,自动化被拒", async () => {
const v = await h.req(`/browser/sessions/${sid}`);
expect(v.body.data).toMatchObject({ controller: "automation", needsSnapshot: true, lease: null });
await h.grant("browser.interact");
await expect(
h.registry.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
).rejects.toMatchObject({ code: "conflict" });
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
await h.db
.updateTable("browser_control_lease")
.set({ acquired_at: new Date(Date.now() - 120_000), expires_at: new Date(Date.now() - 1_000) })
.where("session_id", "=", sid)
.where("status", "=", "active")
.execute();
const w = await h.req(`/browser/sessions/${sid}`);
expect(w.body.data).toMatchObject({ controller: "waiting", waitingReason: "lease_expired", lease: null });
const dec = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", sid).execute();
expect(dec).toHaveLength(1);
expect(dec[0]).toMatchObject({ status: "awaiting_user", subject_type: "browser_session" });
await expect(
h.registry.invoke("browser.interact", { sessionId: sid, action: "scroll" }, h.agentCtx()),
).rejects.toMatchObject({ code: "conflict" });
// 人工重新接管并交还 → 自动化恢复,待处理事项关闭
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "处理完" } });
const d2 = await h.db
.selectFrom("decision")
.select("status")
.where("subject_id", "=", sid)
.executeTakeFirstOrThrow();
expect(d2.status).toBe("resolved");
});
it("服务重启:按数据库恢复会话,旧引用失效;上游回收的标签页标 lost 并自动重开", async () => {
// 新模块实例 = 进程重启(适配器内存为空)
const fresh = createBrowserModule({ stagingDir: h.stagingDir });
const reg = (await import("../../src/modules/agent/capability-registry.ts")).createCapabilityRegistry({
logger: h.ctx.logger,
db: h.db,
settings: h.ctx.settings,
});
registerBrowserCapabilities(reg, () => fresh.service(h.ctx));
await expect(
reg.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
).rejects.toMatchObject({ code: "conflict" });
const snap = (await reg.invoke("browser.snapshot", { siteId: h.siteId }, h.agentCtx())) as Loose;
expect(snap.snapshot.text).toContain("种子列表");
expect(snap).toMatchObject({ controller: "automation", loginRequired: false });
expect((await fresh.service(h.ctx).view(sid)).needsSnapshot).toBe(false);
const tab = (
await h.db.selectFrom("browser_tab").selectAll().where("session_id", "=", sid).executeTakeFirstOrThrow()
).upstream_tab_id;
h.mock.dropTab(tab);
// BR-3:上游回收后自愈 —— 同一会话新开标签页回到原地址,重试一次
const healed = (await reg.invoke("browser.snapshot", { sessionId: sid }, h.agentCtx())) as Loose;
expect(healed.snapshot.text).toContain("种子列表");
expect(healed.snapshot.tabId).not.toBe(tab);
const t = await h.db
.selectFrom("browser_tab")
.select("status")
.where("upstream_tab_id", "=", tab)
.executeTakeFirstOrThrow();
expect(t.status).toBe("lost");
});
it("站点停用 → 会话关闭、自动访问停止;关闭会话撤销租约并清上游标签页", async () => {
const s2 = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null } });
expect(s2.status).toBe(200);
const id2 = s2.body.data.id;
expect(id2).not.toBe(sid);
await h.req(`/browser/sessions/${id2}/control/acquire`, { body: {} });
const closed = await h.req(`/browser/sessions/${id2}`, { method: "DELETE" });
expect(closed.body.data).toMatchObject({ controller: "closed", closeReason: "closed_by_user", lease: null });
const lease = await h.db.selectFrom("browser_control_lease").selectAll().where("session_id", "=", id2).execute();
expect(lease.every((l) => l.status === "revoked")).toBe(true);
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute();
const r = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(r.status).toBe(412);
const s = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
expect(s).toMatchObject({ controller: "closed", close_reason: "site_disabled" });
expect((await h.req("/browser/sessions", { body: { siteId: h.siteId } })).status).toBe(412);
await h.db.updateTable("site").set({ enabled: true }).where("id", "=", h.siteId).execute();
});
it("浏览器服务认证失败(401)与超时:报上游不可用,不无限重试", async () => {
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
const id = r.body.data.id;
await h.req(`/browser/sessions/${id}/control/acquire`, { body: {} });
h.mock.fault(/\/navigate$/, { status: 401, times: 5 });
const a = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
expect(a.status).toBe(412);
expect(JSON.stringify(a.body)).not.toContain(ACCESS_KEY);
h.mock.clearFaults();
h.mock.fault(/\/navigate$/, { delayMs: 2_500, times: 5 });
const before = h.mock.callsTo(/\/navigate$/).length;
const t = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
expect(t.status).toBe(502);
// maxRetries = 1:共 2 次
expect(h.mock.callsTo(/\/navigate$/).length - before).toBe(2);
h.mock.clearFaults();
}, 30_000);
});
@@ -0,0 +1,228 @@
import { siteCloudflareState } from "@umt/contracts";
import { MOCK_CAMOFOX_IDENTITY, type MockCamofoxSite } from "@umt/testkit";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { browserServiceFor } from "../../src/modules/browser/index.ts";
import { type BrowserService, registerChallengeVision } from "../../src/modules/browser/service.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// CF-2 浏览器一侧(对 testkit mock camofox 的 Cloudflare 模式):
// ① 自动放行(轮询页面)② 复选框 / Turnstile 派视觉子流程,用插件真实鼠标点击 ③ 选图验证码 / 点不过 → 会话转等待 + 待处理事项;
// 人在原站视图里过完交还 → 读回 cf_clearance / UA 通知站点请求继续;浏览器代抓页面受 profile 锁与站点限流约束。
const up = await pgAvailable();
const GOOD = "cf2-session-value";
const CLEARANCE = "clr-from-browser";
const LIST_HTML =
'<html><head><title>种子</title></head><body><table class="torrents"><tr><td>Oppenheimer</td></tr></table></body></html>';
describe.skipIf(!up)("CF-2 浏览器过挑战 / 代抓", () => {
let h: BrowserHarness;
let svc: BrowserService;
const cfCfg: NonNullable<MockCamofoxSite["cloudflare"]> = { mode: "auto", autoPolls: 2, clearance: CLEARANCE };
const visionCalls: string[] = [];
let visionClick = true;
const url = `${ORIGIN}/torrents.php`;
const sessions = async () => (await h.req("/browser/sessions")).body.data.items as Loose[];
const closeAll = async () => {
for (const s of await sessions()) await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
const profileKey = () => `umt-site-${h.siteId}-acct-${h.accountId}`;
beforeAll(async () => {
h = await createBrowserHarness("cf2", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: GOOD },
cloudflare: cfCfg,
html: (u) =>
u.includes("torrents.php") ? LIST_HTML : "<html><head><title>首页</title></head><body></body></html>",
browserOptions: { challengePollMs: 5, challengeAutoWaitMs: 200, settleMs: 0 },
});
await sessions(); // 第一次请求时浏览器模块才为这个上下文建服务
svc = browserServiceFor(h.ctx)!;
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_uid=1; c_secure_pass=${GOOD}` });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active" })
.where("id", "=", h.accountId)
.execute();
// 视觉子流程(假):点一下复选框所在的位置(第 2 行)
registerChallengeVision(h.ctx, async (req) => {
visionCalls.push(req.sessionId);
if (visionClick)
await svc.visionAct(
req.sessionId,
{ kind: "automation", holder: "system:cloudflare" },
(p, ref) => p.click(ref, { x: 100, y: 70 }),
{ wantImage: false, sensitiveAllowed: false },
);
return { status: visionClick ? "solved" : "needs_human", reason: "", childRunId: null };
});
}, 120_000);
afterAll(async () => {
await h?.close();
});
beforeEach(async () => {
await closeAll();
h.mock.cfPassed.clear();
h.mock.contentReads.clear();
h.mock.actions.length = 0;
for (const jar of h.mock.cookieJars.values())
jar.splice(0, jar.length, ...jar.filter((c) => c.name !== "cf_clearance"));
cfCfg.mode = "auto";
visionCalls.length = 0;
visionClick = true;
await h.db
.updateTable("site")
.set({ config: JSON.stringify({}), enabled: true })
.where("id", "=", h.siteId)
.execute();
await h.db
.updateTable("settings")
.set({ value: JSON.stringify({ enabled: true }) })
.where("key", "=", "automation")
.execute();
});
it("① 自动放行:轮询到挑战页消失,读回 cf_clearance + navigator.userAgent + 出口", async () => {
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toEqual({
status: "passed",
by: "auto",
clearance: CLEARANCE,
userAgent: MOCK_CAMOFOX_IDENTITY.userAgent,
browserEgress: "direct",
});
expect(visionCalls).toEqual([]);
// 会话仍归自动化,没有待处理事项
const [s] = await sessions();
expect(s.controller).toBe("automation");
});
it("② 复选框 / Turnstile:等不过 → 视觉子流程用插件真实鼠标点击 → 通过(by vision)", async () => {
cfCfg.mode = "interactive";
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toMatchObject({ status: "passed", by: "vision", clearance: CLEARANCE });
expect(visionCalls).toHaveLength(1);
expect(h.mock.actions.some((a) => a.action === "mouse-click")).toBe(true);
});
it("③ 视觉子流程点不过(最多 2 次)→ 会话转等待,待处理事项「观众需要你在原站视图里过一次验证」", async () => {
cfCfg.mode = "interactive";
visionClick = false;
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toMatchObject({ status: "needs_human" });
expect(visionCalls).toHaveLength(2);
const s = (await h.req(`/browser/sessions/${(r as Loose).sessionId}`)).body.data as Loose;
expect(s.controller).toBe("waiting");
expect(s.waitingReason).toBe("cf_challenge");
const d = await h.db
.selectFrom("decision")
.selectAll()
.where("id", "=", (r as Loose).decisionId)
.executeTakeFirstOrThrow();
expect(d.title).toBe("观众需要你在原站视图里过一次验证");
expect(d.status).toBe("awaiting_user");
expect((d.evidence as Loose).cloudflare).toMatchObject({ siteId: h.siteId });
// 已在等人:再来一次不打扰,直接返回同一个等待
const again = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(again).toMatchObject({ status: "needs_human", sessionId: (r as Loose).sessionId });
expect(visionCalls).toHaveLength(2);
});
it("自动化总开关关闭:不派视觉子流程,直接转人工", async () => {
cfCfg.mode = "interactive";
await h.db
.insertInto("settings")
.values({ key: "automation", value: JSON.stringify({ enabled: false }) })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ enabled: false }) }))
.execute();
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toMatchObject({ status: "needs_human" });
expect(visionCalls).toEqual([]);
});
it("③ 选图 / 输码类:不点、不等,直接转人工;人过完交还 → 读回 cf_clearance,站点请求继续", async () => {
cfCfg.mode = "captcha";
const r = (await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url })) as Loose;
expect(r.status).toBe("needs_human");
expect(r.reason).toContain("选图");
expect(visionCalls).toEqual([]);
// 站点请求一侧记下"等人处理"(级联在 sources-identity 里做,这里直接写状态)
await h.db
.updateTable("site")
.set({ config: JSON.stringify({ cloudflare: { pending: "human", sessionId: r.sessionId } }) })
.where("id", "=", h.siteId)
.execute();
// 人接管,在桌面里过了验证(桌面操作不经 API:直接改 mock 状态),交还
expect(
(await h.req(`/browser/sessions/${r.sessionId}/control/acquire`, { body: { reason: "过验证" } })).status,
).toBe(200);
h.mock.cfPassed.add(profileKey());
h.mock.cookieJars.get(profileKey())!.push({
name: "cf_clearance",
value: "clr-human",
domain: ".audiences.example",
path: "/",
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: true,
secure: true,
});
expect((await h.req(`/browser/sessions/${r.sessionId}/control/release`, { body: {} })).status).toBe(200);
const site = await h.db.selectFrom("site").select("config").where("id", "=", h.siteId).executeTakeFirstOrThrow();
expect(siteCloudflareState(site.config)).toMatchObject({ pending: null, lastSolvedBy: "human" });
const acc = await h.db
.selectFrom("site_account")
.select("user_info")
.where("id", "=", h.accountId)
.executeTakeFirstOrThrow();
const info = (acc.user_info as Loose).cloudflare;
expect(info).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, egress: "direct" });
expect(h.secrets.plain.get(info.secretRef)).toBe("clr-human");
// 待处理事项已关闭
const d = await h.db
.selectFrom("decision")
.select("status")
.where("id", "=", r.decisionId)
.executeTakeFirstOrThrow();
expect(d.status).toBe("resolved");
});
it("浏览器代抓:过了挑战的 profile 导航取 outerHTML;同一 profile 并发串行(只开一个会话);每次导航取站点令牌", async () => {
h.mock.cfPassed.add(profileKey());
const [a, b] = await Promise.all([
svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }),
svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }),
]);
expect(a).toMatchObject({ status: "ok", html: LIST_HTML });
expect(b).toMatchObject({ status: "ok", html: LIST_HTML });
expect(await sessions()).toHaveLength(1);
const navs = h.mock.calls.filter(
(c) => c.path.endsWith("/navigate") || (c.path === "/tabs" && c.method === "POST"),
);
expect(navs.length).toBeGreaterThanOrEqual(2);
const count = await h.db
.selectFrom("site_request_count")
.select((eb) => eb.fn.sum<number>("count").as("n"))
.where("site_id", "=", h.siteId)
.executeTakeFirst();
expect(Number(count?.n ?? 0)).toBeGreaterThanOrEqual(2);
});
it("浏览器代抓:仍是挑战页 → challenge;会话在人工接管中 → 不打扰;站点停用 → 不可用", async () => {
cfCfg.mode = "captcha";
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
status: "challenge",
});
const [s] = await sessions();
await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "看看" } });
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
status: "unavailable",
});
await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute();
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
status: "unavailable",
reason: "站点已停用",
});
});
});
@@ -0,0 +1,203 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readAccountCookie } from "../../src/modules/sources-identity/service/credentials.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// BR-2 凭据统一(对 testkit mock camofox + mock NexusPHP 登录态判定):
// 打开会话注入站点账号 Cookie → 首页 / 受保护页是登录态;Cookie 过期 → 需要人工登录;
// 人工在画面里登录后交还 → 读回站点域名 Cookie 写进账号(来源 browser);关闭时也回写;没有插件时降级说明。
const up = await pgAvailable();
const GOOD = "good-session-value-7f3a";
const STALE = "stale-session-value-11";
const FRESH = "fresh-browser-login-9c2";
describe.skipIf(!up)("BR-2 站点账号 Cookie ⇄ 浏览器 profile", () => {
let h: BrowserHarness;
let cookieAcc: string;
const setCookieAccount = async (cookie: string) => {
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
.where("id", "=", cookieAcc)
.execute();
};
const open = async (path = "/torrents.php") =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: cookieAcc, url: `${ORIGIN}${path}` } }))
.body.data as Loose;
const closeAll = async () => {
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
beforeAll(async () => {
h = await createBrowserHarness("cred", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
});
cookieAcc = h.accountId;
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("注入:Cookie 账号打开会话前写进该 profile,受保护页直接是登录态;响应里没有 Cookie 值", async () => {
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
const s = await open();
expect(s.controller).toBe("automation");
expect(s.currentUrl).toBe(`${ORIGIN}/torrents.php`);
expect(s.credential).toMatchObject({ status: "injected", source: "manual", count: 2 });
expect(JSON.stringify(s)).not.toContain(GOOD);
// 注入走 umt-cookies 插件,早于打开标签页;Cookie 按站点主机 host-only、https → secure
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
const inj = calls.indexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`);
expect(inj).toBeGreaterThanOrEqual(0);
expect(inj).toBeLessThan(calls.lastIndexOf("POST /tabs"));
const jar = h.mock.cookieJars.get(s.profileKey)!;
expect(jar.find((c) => c.name === "c_secure_pass")).toMatchObject({
value: GOOD,
domain: "audiences.example",
secure: true,
});
await closeAll();
});
it("过期:注入后仍落在登录页 → 需要人工登录;人工登录后交还 → 回写账号 Cookie(来源浏览器),下次抓取用新值", async () => {
h.mock.loggedIn.clear();
h.mock.cookieJars.clear();
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${STALE}`);
const s = await open();
expect(s.controller).toBe("waiting");
expect(s.waitingReason).toBe("login_required");
expect(s.credential).toMatchObject({ status: "expired" });
expect(s.credential.message).toContain("登录一次");
const d = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", s.id).executeTakeFirstOrThrow();
expect(d.summary).toContain("交还后全站生效");
// 人工接管,在画面里登录(桌面模式里的导航不经 API:直接改 mock 的登录态与当前地址)
expect((await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "登录" } })).status).toBe(200);
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
h.mock.loggedIn.add(s.profileKey);
h.mock.cookieJars.get(s.profileKey)!.push(
{
name: "c_secure_pass",
value: FRESH,
domain: "audiences.example",
path: "/",
expires: -1,
httpOnly: true,
secure: true,
},
// 别的域名的 Cookie 不回写
{
name: "_ga",
value: "tracker",
domain: ".analytics.example",
path: "/",
expires: -1,
httpOnly: false,
secure: true,
},
);
tab.url = `${ORIGIN}/torrents.php`;
const r = await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
expect(r.status).toBe(200);
const acc = await readAccountCookie(h.ctx, cookieAcc);
expect(acc).toMatchObject({ source: "browser", applicable: true });
expect(acc!.header).toContain(`c_secure_pass=${FRESH}`);
expect(acc!.header).toContain("c_secure_uid=1001");
expect(acc!.header).not.toContain("_ga");
const row = await h.db.selectFrom("site_account").selectAll().where("id", "=", cookieAcc).executeTakeFirstOrThrow();
expect((row.user_info as Loose).cookieOrigin).toMatchObject({ source: "browser", by: "browser" });
const view = (await h.req(`/browser/sessions/${s.id}`)).body.data;
expect(view.credential).toMatchObject({ status: "injected", source: "browser" });
expect(view.credential.syncedAt).toBeTruthy();
expect(JSON.stringify(view)).not.toContain(FRESH);
await closeAll();
});
it("关闭会话时回写;停在登录页不回写(未登录的 Cookie 不覆盖存储)", async () => {
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
h.mock.cookieJars.clear();
const s = await open();
expect(s.credential.status).toBe("injected");
// 站点在浏览器里轮换了会话 Cookie
const jar = h.mock.cookieJars.get(s.profileKey)!;
jar.find((c) => c.name === "c_secure_pass")!.value = "rotated-by-site-44";
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain("c_secure_pass=rotated-by-site-44");
// 登录页:不回写
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
const s2 = await open("/login.php");
h.mock.cookieJars.get(s2.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "anonymous-junk";
await h.req(`/browser/sessions/${s2.id}`, { method: "DELETE" });
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
});
it("有活动时每 10 分钟读回一次", async () => {
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
h.mock.cookieJars.clear();
const s = await open();
h.mock.cookieJars.get(s.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "periodic-rotated-5";
// 刚打开:不读回
await h.req(`/browser/sessions/${s.id}/snapshot`);
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
// 会话创建时间拨到 11 分钟前:下一次活动触发后台读回
await h.db
.updateTable("browser_session")
.set({ created_at: new Date(Date.now() - 11 * 60_000) })
.where("id", "=", s.id)
.execute();
await h.req(`/browser/sessions/${s.id}/snapshot`);
await expect
.poll(async () => (await readAccountCookie(h.ctx, cookieAcc))!.header, { timeout: 3000 })
.toContain("c_secure_pass=periodic-rotated-5");
await closeAll();
});
it("匿名会话与 API Key 账号不注入", async () => {
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/` } });
expect(r.body.data.credential).toMatchObject({ status: "not_applicable" });
await closeAll();
const ref = await h.secrets.create({ purpose: "site_api_key", plaintext: "api-key-xyz" });
await h.db
.updateTable("site_account")
.set({ auth_kind: "api_key", secret_ref: ref })
.where("id", "=", cookieAcc)
.execute();
const before = h.mock.callsTo(/\/cookies$/).length;
const s = await open("/");
expect(s.credential).toMatchObject({ status: "not_applicable" });
expect(h.mock.callsTo(/\/cookies$/).length).toBe(before);
await closeAll();
});
});
describe.skipIf(!up)("BR-2 浏览器服务没有 umt-cookies 插件", () => {
let h: BrowserHarness;
beforeAll(async () => {
h = await createBrowserHarness("cred_noplugin", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
cookieApi: "none",
});
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("注入报不支持,会话照常打开并说明原因", async () => {
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_pass=${GOOD}` });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref })
.where("id", "=", h.accountId)
.execute();
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/` } });
expect(r.status).toBe(200);
expect(r.body.data.credential).toMatchObject({ status: "unsupported", count: 0 });
expect(r.body.data.credential.message).toContain("umt-cookies");
});
});
@@ -0,0 +1,117 @@
import { MOCK_ROW_H, MOCK_ROW_Y0 } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
type BrowserHarness,
createBrowserHarness,
type Loose,
ORIGIN,
PASSKEY,
PASSWORD,
pgAvailable,
} from "./harness.ts";
// C1-E e2e(mock 站点,未连真实 camofox):
// Agent 打开站点 → 登录失效 + 验证码 → 会话转等待处理(待处理事项)→ 自动化被拒 →
// 人在原站视图接管(截图坐标点击 + 输入,密码由服务端注入)完成登录 → 交还 →
// Agent 的旧引用被拒,重新读取页面后继续:捕获种子下载、核验并匹配到本地资源。
const up = await pgAvailable();
const at = (i: number) => ({ x: 320, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + 10 });
describe.skipIf(!up)("e2e:登录失效 → 人工接管 → 交还 → Agent 重新读取并继续", () => {
let h: BrowserHarness;
beforeAll(async () => {
h = await createBrowserHarness("e2e", { captcha: true });
await h.grant("browser.navigate", "browser.interact", "browser.download");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("全流程", async () => {
const agent = h.agentCtx();
const invoke = (id: string, input: unknown) => h.registry.invoke(id, input, agent) as Promise<Loose>;
// 1. Agent 打开种子列表:登录已失效,落到登录页
const nav = await invoke("browser.navigate", { siteId: h.siteId, url: `${ORIGIN}/torrents.php` });
expect(nav).toMatchObject({ loginRequired: true, controller: "automation" });
const sid = nav.sessionId as string;
// 2. Agent 读页面:有验证码 → 会话转等待处理,生成待处理事项
const snap = await invoke("browser.snapshot", { sessionId: sid });
expect(snap).toMatchObject({ controller: "waiting", waitingReason: "captcha", loginRequired: true });
expect(snap.snapshot.text).toContain("验证码");
const decision = await h.db
.selectFrom("decision")
.selectAll()
.where("subject_id", "=", sid)
.executeTakeFirstOrThrow();
expect(decision).toMatchObject({ status: "awaiting_user", kind: "other" });
expect(decision.title).toContain("观众");
// 3. 等待处理期间 Agent 不能输入(不无限重试)
await expect(
invoke("browser.interact", { sessionId: sid, action: "type", target: { ref: "e3" }, credential: "password" }),
).rejects.toMatchObject({ code: "conflict" });
// 4. 人在原站视图接管:截图 → 坐标点击 → 输入(密码走凭证注入)→ 验证码 → 登录
const view = await h.req(`/browser/sessions/${sid}`);
expect(view.body.data).toMatchObject({ controller: "waiting", waitingReason: "captcha" });
const acq = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "验证码" } });
expect(acq.body.data.session.controller).toBe("human");
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
expect(shot.body.data).toMatchObject({ controller: "human", modelSafe: false });
const human = async (path: string, body: unknown) => {
const r = await h.req(`/browser/sessions/${sid}/${path}`, { body });
expect(r.status, JSON.stringify(r.body)).toBe(200);
};
await human("click", { target: at(1) });
await human("type", { text: "fixture-user" });
await human("click", { target: at(2) });
await human("type", { credential: "password" });
await human("click", { target: at(4) });
await human("type", { text: "4321" });
await human("click", { target: at(5) });
const loggedIn = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(loggedIn.body.data.text).toContain("种子列表");
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSWORD);
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSKEY);
// 5. 交还:Agent 恢复前必须重新读页面;待处理事项关闭
const rel = await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "已登录" } });
expect(rel.body.data.session).toMatchObject({ controller: "automation", needsSnapshot: true, waitingReason: null });
const d2 = await h.db
.selectFrom("decision")
.select("status")
.where("id", "=", decision.id)
.executeTakeFirstOrThrow();
expect(d2.status).toBe("resolved");
// 6. 旧引用(交接前拿到的 eN)被拒,不重放
await expect(
invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e6" } }),
).rejects.toMatchObject({ code: "conflict", message: expect.stringContaining("重新读取页面") });
// 7. 重新读取:人工登录后的状态;继续捕获下载 → 暂存核验 → 匹配本地资源(提交仍走 download.plan)
const fresh = await invoke("browser.snapshot", { siteId: h.siteId });
expect(fresh).toMatchObject({ controller: "automation", loginRequired: false, waitingReason: null });
expect(fresh.snapshot.text).toContain("种子列表");
const afterAgent = (await h.req(`/browser/sessions/${sid}`)).body.data;
expect(afterAgent.needsSnapshot).toBe(false);
// 原站视图据此显示「Agent 正在操作」;人工动作不刷新它
expect(Date.now() - Date.parse(afterAgent.lastAutomationAt)).toBeLessThan(60_000);
const dl = await invoke("browser.download", { sessionId: sid, target: { ref: "e11" }, timeoutMs: 2_000 });
expect(dl).toMatchObject({
sourceReleaseId: h.releaseId,
duplicate: false,
verification: { valid: true, infohashV1: h.torrent.infoHash },
});
expect(dl.note).toContain("download.plan");
expect(JSON.stringify(dl)).not.toContain(PASSKEY);
// 审计:能力调用留痕,输入里没有凭证
const audits = await h.db.selectFrom("audit_event").select(["action", "target_id", "payload"]).execute();
expect(audits.some((a) => a.target_id === "browser.download" && a.action === "capability.invoke")).toBe(true);
expect(JSON.stringify(audits)).not.toContain(PASSWORD);
}, 60_000);
});
@@ -0,0 +1,79 @@
import { MOCK_CAMOFOX_IDENTITY } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { sourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import { readBrowserUserAgent } from "../../src/modules/sources-identity/service/user-agent.ts";
import { egressProxy } from "../../src/modules/browser/service.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// BR-4:打开会话前按站点下发出口(站点开代理 = 与 HTTP 同一代理,否则直连);浏览器固定身份写进 browser_provider.identity,
// 站点请求 UA 经钩子跟随它。
const up = await pgAvailable();
describe("egressProxy", () => {
it("URL → 上下文代理(口令拆出);非法为 null", () => {
expect(egressProxy("http://u:p%[email protected]:7891")).toEqual({
server: "http://172.17.0.1:7891",
username: "u",
password: "p@ss",
});
expect(egressProxy("socks5://127.0.0.1:1080")).toEqual({ server: "socks5://127.0.0.1:1080" });
expect(egressProxy("not a url")).toBeNull();
});
});
describe.skipIf(!up)("BR-4 浏览器身份与出口", () => {
let h: BrowserHarness;
const open = async () =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/torrents.php` } }))
.body.data as Loose;
const closeAll = async () => {
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
beforeAll(async () => {
h = await createBrowserHarness("br4id");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("站点不开代理:该 profile 直连;打开时读浏览器身份存库,站点请求 UA 跟随", async () => {
const s = await open();
expect(h.mock.egress.get(s.profileKey)).toBeNull();
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
expect(calls.indexOf(`PUT /umt/sessions/${encodeURIComponent(s.profileKey)}/proxy`)).toBeLessThan(
calls.lastIndexOf("POST /tabs"),
);
const row = await h.db
.selectFrom("browser_provider")
.select("identity")
.where("id", "=", h.providerId)
.executeTakeFirstOrThrow();
expect(row.identity).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" });
expect(await readBrowserUserAgent(h.ctx)).toBe(MOCK_CAMOFOX_IDENTITY.userAgent);
expect(await sourcesIdentityModule.runtime(h.ctx).defaultUserAgent()).toBe(MOCK_CAMOFOX_IDENTITY.userAgent);
await closeAll();
});
it("站点开代理且设了代理地址:该 profile 走同一代理(口令单独传)", async () => {
await h.db.updateTable("site").set({ proxy: true }).where("id", "=", h.siteId).execute();
await h.db
.insertInto("settings")
.values({ key: "sites", value: JSON.stringify({ proxyUrl: "http://user:[email protected]:7891" }) })
.onConflict((oc) =>
oc.column("key").doUpdateSet({ value: JSON.stringify({ proxyUrl: "http://user:[email protected]:7891" }) }),
)
.execute();
const s = await open();
expect(h.mock.egress.get(s.profileKey)).toEqual({
server: "http://172.17.0.1:7891",
username: "user",
password: "pw",
});
await closeAll();
await h.db.updateTable("site").set({ proxy: false }).where("id", "=", h.siteId).execute();
});
});
@@ -0,0 +1,183 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { browserServiceFor } from "../../src/modules/browser/index.ts";
import { BrowserKeepalive } from "../../src/modules/browser/keepalive.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// AG-4 浏览器会话保活(对 testkit mock camofox):桌面连接 / 人工接管 / 智能体会话正在用的会话定期做一次
// GET /tabs/{id}/downloads(不带 includeData / consume);用完不再保活;保活时标签页已被回收 → BR-3 自愈重连。
describe("BrowserKeepalive(登记与复查)", () => {
const fake = () => {
const calls: string[] = [];
const alive = new Set<string>(["s1", "s2"]);
return {
calls,
alive,
target: {
keepAlive: async (id: string) => {
calls.push(id);
return alive.has(id);
},
},
};
};
it("引用计数:同一会话多个桌面连接,全部断开才停;撤销可重复调用", async () => {
const f = fake();
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
const a = k.hold("s1", "desktop", "desktop");
const b = k.hold("s1", "desktop", "desktop");
await k.tick();
expect(f.calls).toEqual(["s1"]);
a();
a();
expect(k.sessions()).toEqual([{ sessionId: "s1", reasons: ["desktop"] }]);
b();
expect(k.sessions()).toEqual([]);
await k.tick();
expect(f.calls).toEqual(["s1"]);
});
it("复查不通过的登记撤销;会话已关闭(keepAlive 返回 false)整条撤销;ensure 不加引用计数", async () => {
const f = fake();
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
let running = true;
k.ensure("s1", "agent:r1", "agent", async () => running);
k.ensure("s1", "agent:r1", "agent", async () => running);
k.hold("s2", "desktop", "desktop");
await k.tick();
expect(f.calls.sort()).toEqual(["s1", "s2"]);
running = false;
f.alive.delete("s2");
await k.tick();
expect(f.calls).toHaveLength(3); // s1 复查不通过不再保活;s2 保活一次后发现已关闭
expect(k.sessions()).toEqual([]);
});
it("保活失败只记日志,下一轮继续;intervalMs = 0 时不登记", async () => {
const logs: string[] = [];
let fail = true;
const k = new BrowserKeepalive(
{
keepAlive: async () => {
if (fail) throw new Error("浏览器服务不可用");
return true;
},
},
{ intervalMs: 60_000, log: (_l, msg, extra) => logs.push(`${msg} ${String(extra?.err)}`) },
);
k.hold("s1", "takeover", "takeover");
await k.tick();
expect(logs).toEqual(["浏览器会话保活失败 浏览器服务不可用"]);
fail = false;
await k.tick();
expect(k.sessions()).toHaveLength(1);
k.stop();
const off = new BrowserKeepalive(fake().target, { intervalMs: 0 });
off.hold("s1", "desktop", "desktop");
expect(off.sessions()).toEqual([]);
});
});
const up = await pgAvailable();
describe.skipIf(!up)("AG-4 浏览器会话保活(mock camofox)", () => {
let h: BrowserHarness;
const svc = () => browserServiceFor(h.ctx)!;
const open = async (path: string) =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } }))
.body.data as Loose;
const pings = (tabId: string) =>
h.mock.callsTo(`/tabs/${encodeURIComponent(tabId)}/downloads`).filter((c) => c.method === "GET");
const view = async (id: string) => (await h.req(`/browser/sessions/${id}`)).body.data as Loose;
beforeAll(async () => {
h = await createBrowserHarness("ag4ka");
await h.grant("browser.navigate");
}, 120_000);
afterAll(async () => {
svc()?.keepalive.stop();
await h?.close();
});
it("人工接管期间保活:只读调用、不记自动化活动;交还后停止", async () => {
const s = await open("/torrents.php");
const before = (await view(s.id)).lastAutomationAt;
const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } });
expect(acq.status).toBe(200);
expect(svc().keepalive.sessions()).toEqual([{ sessionId: s.id, reasons: ["takeover"] }]);
await svc().keepalive.tick();
const p = pings(s.currentTabId);
expect(p).toHaveLength(1);
// 不带 includeData / consume:不读下载内容、不清下载记录
expect(p[0]!.query).toEqual({ userId: s.profileKey });
const v = await view(s.id);
expect(v.controller).toBe("human");
expect(v.lastAutomationAt).toBe(before);
await h.req(`/browser/sessions/${s.id}/control/release`, { body: { to: "automation", reason: "done" } });
expect(svc().keepalive.sessions()).toEqual([]);
await svc().keepalive.tick();
expect(pings(s.currentTabId)).toHaveLength(1);
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
});
it("桌面连接期间标签页被空闲回收 → 保活时按 BR-3 自愈重连回到原地址;断开后停止", async () => {
const s = await open("/details.php?id=42");
const release = svc().keepalive.hold(s.id, "desktop", "desktop");
// 上游空闲回收了标签页
h.mock.tabs.delete(s.currentTabId);
await svc().keepalive.tick();
const v = await view(s.id);
expect(v.currentTabId).not.toBe(s.currentTabId);
// 回到会话记录的地址(测试账号没有登录态,mock 站点跳登录页并带 returnto)
expect(decodeURIComponent(v.currentUrl)).toContain("/details.php?id=42");
expect(v.reconnect).toMatchObject({ status: "ok", trigger: "tab_gone" });
// 新标签页上重试了保活
expect(pings(v.currentTabId)).toHaveLength(1);
release();
expect(svc().keepalive.sessions()).toEqual([]);
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
});
it("智能体会话在用:运行中保活;会话结束后停止;浏览器会话关闭也停止", async () => {
const run = await h.db
.insertInto("agent_run")
.values({
trigger: "user",
goal: "看看种子列表",
status: "running",
mode: "execute",
scope: JSON.stringify({}),
budget: JSON.stringify({}),
created_by: "user:admin",
})
.returning("id")
.executeTakeFirstOrThrow();
const nav = (await h.registry.invoke(
"browser.navigate",
{ siteId: h.siteId, url: `${ORIGIN}/torrents.php` },
{ ...h.agentCtx(), agentRunId: run.id },
)) as Loose;
const sid = nav.sessionId as string;
expect(svc().keepalive.sessions()).toEqual([{ sessionId: sid, reasons: ["agent"] }]);
const tab = (await view(sid)).currentTabId as string;
await svc().keepalive.tick();
expect(pings(tab)).toHaveLength(1);
await h.db.updateTable("agent_run").set({ status: "succeeded" }).where("id", "=", run.id).execute();
await svc().keepalive.tick();
expect(pings(tab)).toHaveLength(1);
expect(svc().keepalive.sessions()).toEqual([]);
// 再用一次(新会话在跑)→ 登记;关闭浏览器会话 → 撤销
await h.db.updateTable("agent_run").set({ status: "running" }).where("id", "=", run.id).execute();
await h.registry.invoke("browser.snapshot", { sessionId: sid }, { ...h.agentCtx(), agentRunId: run.id });
expect(svc().keepalive.sessions()).toHaveLength(1);
await h.req(`/browser/sessions/${sid}`, { method: "DELETE" });
expect(svc().keepalive.sessions()).toEqual([]);
});
});
@@ -0,0 +1,92 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { ServerContext } from "../../src/modules/types.ts";
import { BrowserService, BrowserServiceError, ENV_PROVIDER_NAME } from "../../src/modules/browser/service.ts";
import { ACCESS_KEY, type BrowserHarness, createBrowserHarness, type Loose, pgAvailable } from "./harness.ts";
// BR-1:浏览器服务登记 —— 环境变量(BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → camofox-env)
// 与设置页端点(POST / PATCH /browser/providers、POST /browser/providers/{id}/probe)。
const up = await pgAvailable();
describe.skipIf(!up)("浏览器服务登记", () => {
let h: BrowserHarness;
const withEnv = (env: Record<string, unknown>) =>
new BrowserService({ ...h.ctx, config: { ...(h.ctx.config as object), ...env } } as ServerContext, {});
beforeAll(async () => {
h = await createBrowserHarness("provcfg");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("BROWSER_PROVIDER_URL:登记 camofox-env,凭据只在进程内;探测带 Bearer;地址/凭据不能在设置页改", async () => {
const svc = withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ACCESS_KEY: ACCESS_KEY, BROWSER_ENABLED: true });
const list = await svc.listProviders(true);
const env = list.find((p) => p.name === ENV_PROVIDER_NAME)!;
expect(env).toMatchObject({ managedBy: "env", hasAccessKey: true, secretRef: null, enabled: true });
expect(env.health?.status).toBe("ok");
const row = await h.db
.selectFrom("browser_provider")
.selectAll()
.where("name", "=", ENV_PROVIDER_NAME)
.executeTakeFirstOrThrow();
expect(JSON.stringify(row)).not.toContain(ACCESS_KEY);
expect(h.mock.calls.filter((c) => c.path === "/openapi.json").at(-1)?.headers.authorization).toBe(
`Bearer ${ACCESS_KEY}`,
);
await expect(svc.updateProvider(env.id, { baseUrl: "http://x:1" })).rejects.toBeInstanceOf(BrowserServiceError);
// 启用可以改
expect((await svc.updateProvider(env.id, { enabled: false })).enabled).toBe(false);
});
it("BROWSER_ENABLED=false / 去掉地址:camofox-env 停用,其它服务不受影响", async () => {
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: false }).syncEnvProvider();
const off = await h.db
.selectFrom("browser_provider")
.selectAll()
.where("name", "=", ENV_PROVIDER_NAME)
.executeTakeFirstOrThrow();
expect(off.enabled).toBe(false);
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: true }).syncEnvProvider();
await withEnv({}).syncEnvProvider();
const rows = await h.db.selectFrom("browser_provider").select(["name", "enabled"]).orderBy("name").execute();
expect(rows).toEqual([
{ name: "camofox", enabled: true },
{ name: ENV_PROVIDER_NAME, enabled: false },
]);
});
it("设置页:登记 / 改凭据 / 测试连通;凭据明文不出 API,名称 camofox-env 保留", async () => {
const bad = await h.req("/browser/providers", { body: { name: ENV_PROVIDER_NAME, baseUrl: h.mock.url } });
expect(bad.status).toBe(400);
const c = await h.req("/browser/providers", {
body: { name: "my-camofox", baseUrl: h.mock.url, accessKey: "wrong-key", enabled: true },
});
expect(c.status).toBe(200);
const id = c.body.data.id as string;
expect(c.body.data).toMatchObject({ managedBy: null, hasAccessKey: true, enabled: true });
expect(JSON.stringify(c.body)).not.toContain("wrong-key");
const dup = await h.req("/browser/providers", { body: { name: "my-camofox", baseUrl: h.mock.url } });
expect(dup.status).toBe(409);
// 错误凭据:/health 免认证是好的,但 /openapi.json 401 → 标离线并注明 auth
const p1 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
expect(p1.body.data).toMatchObject({
healthStatus: "offline",
health: { status: "down", detail: { error: "auth" } },
});
const u = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: ACCESS_KEY } });
expect(u.status).toBe(200);
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
expect(p2.status).toBe(200);
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
// 空串 = 保持原凭据(FIX-8 只写凭证统一语义)
const kept = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: "", enabled: true } });
expect(kept.body.data).toMatchObject({ hasAccessKey: true, secretRef: u.body.data.secretRef });
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
const env = (await h.req("/browser/providers")).body.data.items.find((p: Loose) => p.name === ENV_PROVIDER_NAME);
expect((await h.req(`/browser/providers/${env.id}`, { method: "DELETE" })).status).toBe(409);
});
});
@@ -0,0 +1,143 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// BR-3 浏览器服务重启后自愈(对 testkit mock camofox):容器重启 → 旧标签页 410、GET /tabs 为空、实例标识换新、
// profile 登录态丢失(SIGKILL 未落盘)。任一会话操作 → 注入账号 Cookie → 同一会话新开标签页回到原地址 → 重试原操作;
// 失败 503 browser_reconnect_failed;POST .../reconnect 幂等;人工接管中重连控制权不变并提示核对。
const up = await pgAvailable();
const GOOD = "good-session-value-br3";
describe.skipIf(!up)("BR-3 浏览器服务重启后自动重连", () => {
let h: BrowserHarness;
const setCookieAccount = async (cookie: string) => {
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
.where("id", "=", h.accountId)
.execute();
};
const open = async (path: string) =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } }))
.body.data as Loose;
const closeAll = async () => {
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
const tabRow = (upstream: string) =>
h.db.selectFrom("browser_tab").selectAll().where("upstream_tab_id", "=", upstream).executeTakeFirstOrThrow();
const cookiePosts = (profileKey: string) =>
h.mock.callsTo(`/umt/sessions/${encodeURIComponent(profileKey)}/cookies`).filter((c) => c.method === "POST").length;
beforeAll(async () => {
h = await createBrowserHarness("br3", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: "fresh-br3" },
});
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("重启后截图:标签页 410 → 注入 Cookie、新开标签页回到原地址、重试成功;旧标签页标 lost", async () => {
const s = await open("/details.php?id=42");
expect(s.currentUrl).toBe(`${ORIGIN}/details.php?id=42`);
const old = s.currentTabId as string;
const injected = cookiePosts(s.profileKey);
h.mock.restart({ loseProfiles: true });
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(r.status).toBe(200);
expect(r.body.data.url).toBe(`${ORIGIN}/details.php?id=42`);
// Cookie 注入先于新标签页;profile 丢了登录态也能回到受保护页(不是登录页)
expect(cookiePosts(s.profileKey)).toBe(injected + 1);
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
expect(calls.lastIndexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`)).toBeLessThan(
calls.lastIndexOf("POST /tabs"),
);
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
expect(tab.sessionKey).toBe(s.id);
expect(tab.url).toBe(`${ORIGIN}/details.php?id=42`);
expect(h.mock.loggedIn.has(s.profileKey)).toBe(true);
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
expect(v.currentTabId).toBe(tab.tabId);
expect(v.currentTabId).not.toBe(old);
expect(v).toMatchObject({ controller: "automation", needsReconnect: false, needsSnapshot: true });
expect(v.reconnect).toMatchObject({ status: "ok", humanNotice: false, reason: null });
expect((await tabRow(old)).status).toBe("lost");
expect(JSON.stringify(v)).not.toContain(GOOD);
await closeAll();
});
it("重启检测:实例标识变了 → 会话标需要重连;POST .../reconnect 幂等", async () => {
const s = await open("/torrents.php");
h.mock.restart();
// 会话详情轮询发现重启(实例标识缓存 5 秒:用显式 reconnect 强制刷新)
const r1 = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
expect(r1.status).toBe(200);
expect(r1.body.data).toMatchObject({
needsReconnect: false,
reconnect: { status: "ok", trigger: "provider_restarted" },
});
expect(r1.body.data.currentUrl).toBe(`${ORIGIN}/torrents.php`);
const opened = h.mock.callsTo(/^\/tabs$/).filter((c) => c.method === "POST").length;
const r2 = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
expect(r2.status).toBe(200);
expect(r2.body.data.currentTabId).toBe(r1.body.data.currentTabId);
expect(h.mock.callsTo(/^\/tabs$/).filter((c) => c.method === "POST").length).toBe(opened);
// Agent 快照也走同一条自愈
h.mock.restart();
const snap = await h.req(`/browser/sessions/${s.id}/snapshot`);
expect(snap.status).toBe(200);
expect(snap.body.data.url).toBe(`${ORIGIN}/torrents.php`);
await closeAll();
});
it("人工接管中重连:控制者与租约不变,提示页面已重新加载", async () => {
const s = await open("/torrents.php");
const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } });
const leaseId = acq.body.data.lease.id;
h.mock.restart();
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(r.status).toBe(200);
expect(r.body.data.controller).toBe("human");
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
expect(v.lease).toMatchObject({ id: leaseId, status: "active" });
expect(v.reconnect).toMatchObject({ status: "ok", humanNotice: true });
// 接管者的输入照常可用
const nav = await h.req(`/browser/sessions/${s.id}/navigate`, { body: { url: `${ORIGIN}/index.php` } });
expect(nav.status).toBe(200);
await closeAll();
});
it("重连失败:503 browser_reconnect_failed 带原因;短时间内不反复重连;恢复后手动重连成功", async () => {
const s = await open("/torrents.php");
h.mock.restart();
h.mock.fault(/^\/tabs$/, { status: 500, times: 10 });
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(r.status).toBe(503);
expect(r.body.error.code).toBe("browser_reconnect_failed");
expect(r.body.error.message).toContain("HTTP 500");
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
expect(v).toMatchObject({ needsReconnect: true, reconnect: { status: "failed", trigger: "tab_gone" } });
// 退避期内的截图直接报上次原因,不再打上游
const before = h.mock.calls.length;
const again = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(again.status).toBe(503);
expect(h.mock.calls.slice(before).some((c) => c.path === "/tabs")).toBe(false);
h.mock.clearFaults();
const ok = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({ needsReconnect: false, reconnect: { status: "ok" } });
expect((await h.req(`/browser/sessions/${s.id}/screenshot`)).status).toBe(200);
await closeAll();
});
});
@@ -0,0 +1,74 @@
import { getSiteDefinition } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resourceIdFromUrl } from "../../src/modules/browser/resource.ts";
import { type BrowserHarness, createBrowserHarness, ORIGIN, pgAvailable, READ_KEY } from "./harness.ts";
const def = getSiteDefinition("audiences")!;
describe("原站资源详情定位", () => {
it("仅按本站详情路径定位,不把列表、登录或别站同 id 当作资源", () => {
expect(resourceIdFromUrl(`${ORIGIN}/details.php?id=73`, `${ORIGIN}/`, def)).toBe("73");
expect(resourceIdFromUrl(`${ORIGIN}/torrents.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl(`${ORIGIN}/login.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl("https://else.example/details.php?id=73", `${ORIGIN}/`, def)).toBeNull();
});
});
const up = await pgAvailable();
describe.skipIf(!up)("原站详情页交接(隔离 PG 与浏览器 fixture)", () => {
let h: BrowserHarness;
let sid: string;
beforeAll(async () => {
h = await createBrowserHarness("resource", {
html: () => `<!doctype html><html><head><title>资源详情</title></head><body>
<h1 id="top">Example.Movie.2024.2160p.WEB-DL.DV</h1>
<table><tr><td class="rowhead">副标题</td><td class="rowfollow">测试译名与简介</td></tr>
<tr><td class="rowhead">基本信息</td><td class="rowfollow">大小: 2 GB 类型: 电影</td></tr></table>
<div id="kdescr">详情页提供的影片简介 <a href="https://www.imdb.com/title/tt1234567/">IMDb</a></div>
</body></html>`,
});
h.mock.loggedIn.add(`umt-site-${h.siteId}-acct-${h.accountId}`);
const session = await h.req("/browser/sessions", {
body: { siteId: h.siteId, url: `${ORIGIN}/details.php?id=73` },
});
expect(session.status).toBe(200);
sid = session.body.data.id;
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("未在快照/搜索中的详情可直接登记,缓存PT内容供元数据兜底,不点击原站下载按钮", async () => {
const before = h.mock.actions.length;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.release).toMatchObject({
siteId: h.siteId,
siteReleaseId: "73",
title: "Example.Movie.2024.2160p.WEB-DL.DV",
});
expect(r.body.data.detailUrl).toBe(`${ORIGIN}/details.php?id=73`);
const row = await h.db
.selectFrom("source_release")
.selectAll()
.where("id", "=", r.body.data.release.id)
.executeTakeFirstOrThrow();
expect(row.detail_fetched_at).not.toBeNull();
expect(row.raw).toMatchObject({ _detail: { imdbId: "tt1234567", sourceUrl: `${ORIGIN}/details.php?id=73` } });
expect(h.mock.actions.slice(before).some((a) => a.action === "click")).toBe(false);
const again = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(again.body.data.release.id).toBe(row.id);
});
it("使用当前页面而非旧会话地址;列表页明确提示进入详情", async () => {
const tab = [...h.mock.tabs.values()][0]!;
tab.url = `${ORIGIN}/torrents.php`;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("详情页");
});
it("只读账号不能通过该入口登记下载资源", async () => {
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {}, key: READ_KEY });
expect(r.status).toBe(403);
});
});
+240
View File
@@ -0,0 +1,240 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CapabilityRegistry, InvokeContext } from "@umt/agent";
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
import {
buildFakeTorrent,
createMockCamofox,
type FakeTorrent,
type MockCamofox,
type MockCamofoxSite,
} from "@umt/testkit";
import { Kysely as K, PostgresDialect } from "kysely";
import pg from "pg";
import { withSchemaLock } from "../auth-settings/harness.ts";
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
import { fakeEngine, fakeSecrets, PG_URL, plainSettings } from "../sources-identity/harness.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { registerBrowserCapabilities } from "../../src/modules/browser/capabilities.ts";
import { createBrowserModule } from "../../src/modules/browser/index.ts";
import type { BrowserModuleOptions } from "../../src/modules/browser/service.ts";
// C1-E 浏览器模块测试装配:独立 PG schema + 真实迁移 + 交互式 mock camofox(观众风格站点)+ 真实能力注册中心。
export { pgAvailable } from "../sources-identity/harness.ts";
export const ORIGIN = "https://audiences.example";
export const PASSWORD = "brw-s3cret-login-pw";
export const PASSKEY = "0f0e0d0c0b0a09080706050403020100";
export const ACCESS_KEY = "camofox-access-key-c1e";
/** 第二位管理员(接管冲突) */
export const OTHER_KEY = "brw-other-admin-key";
export const READ_KEY = "brw-read-key";
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
export type Loose = any;
export interface BrowserHarness {
db: Kysely<DB>;
ctx: ServerContext;
mock: MockCamofox;
torrent: FakeTorrent;
registry: CapabilityRegistry;
siteId: string;
accountId: string;
providerId: string;
releaseId: string;
stagingDir: string;
secrets: ReturnType<typeof fakeSecrets>;
req(path: string, init?: { method?: string; body?: unknown; key?: string }): Promise<{ status: number; body: Loose }>;
agentCtx(): InvokeContext;
grant(...actions: string[]): Promise<void>;
close(): Promise<void>;
}
export async function createBrowserHarness(
name: string,
opts: {
captcha?: boolean;
/** BR-2:mock 站点按这个 Cookie 判定登录态 */
sessionCookie?: { name: string; valid: string[]; fresh: string };
cookieApi?: "plugin" | "upstream" | "none";
cookieImportKey?: string;
/** CF-2:mock 站点的 Cloudflare 挑战与页面 HTML */
cloudflare?: MockCamofoxSite["cloudflare"];
html?: MockCamofoxSite["html"];
/** 浏览器模块的额外选项(挑战轮询间隔等) */
browserOptions?: Partial<BrowserModuleOptions>;
} = {},
): Promise<BrowserHarness> {
const torrent = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.x265-GRP" });
const mock = await createMockCamofox({
accessKey: ACCESS_KEY,
...(opts.cookieApi ? { cookieApi: opts.cookieApi } : {}),
...(opts.cookieImportKey ? { cookieImportKey: opts.cookieImportKey } : {}),
site: {
...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}),
...(opts.cloudflare ? { cloudflare: opts.cloudflare } : {}),
...(opts.html ? { html: opts.html } : {}),
origin: ORIGIN,
username: "fixture-user",
password: PASSWORD,
passkey: PASSKEY,
...(opts.captcha ? { captcha: true, captchaAnswer: "4321" } : {}),
releases: [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }],
torrent: () => torrent.bytes,
},
});
const stagingDir = await mkdtemp(join(tmpdir(), "umt-browser-"));
const schema = `c1e_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new K<unknown>({
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
});
await sql.raw(`create schema ${schema}`).execute(admin);
await import("@umt/db");
const db = new K<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
}),
}) as unknown as Kysely<DB>;
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
const { logger } = memoryLogger();
const secrets = fakeSecrets(() => db);
const settings = plainSettings(() => db);
const registry = createCapabilityRegistry({ logger, db, settings });
const ctx = {
role: "all",
db,
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
logger,
events: new EventBus(5),
engine: fakeEngine(),
outbox: createOutboxPort(),
secrets,
settings,
capabilities: registry,
} as ServerContext;
const sources = createSourcesIdentityModule({ tmdb: () => null });
const browser = createBrowserModule({
runtime: (c) => sources.runtime(c),
stagingDir,
downloadPollMs: 20,
...opts.browserOptions,
});
registerBrowserCapabilities(registry, () => browser.service(ctx));
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus: new EventBus(5),
role: "all",
version: "0.0.0-test",
// POST /acquisition-plans 在 agent 模块
modules: [sources, createAcquisitionOrganizeModule(), createAgentModule(), browser],
ctx,
apiKeyStore: memoryStore({
[OTHER_KEY]: { id: "other-admin", scopes: ["admin"] },
[READ_KEY]: { id: "reader", scopes: ["read"] },
}),
});
const siteId = (
await db
.insertInto("site")
.values({
key: "audiences",
name: "观众",
adapter: "nexusphp",
base_url: `${ORIGIN}/`,
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const pwRef = await secrets.create({ purpose: "site_password", plaintext: PASSWORD });
const accountId = (
await db
.insertInto("site_account")
.values({ site_id: siteId, label: "main", auth_kind: "password", secret_ref: pwRef, login_name: "fixture-user" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
const keyRef = await secrets.create({ purpose: "other", plaintext: ACCESS_KEY });
const providerId = (
await db
.insertInto("browser_provider")
.values({
name: "camofox",
kind: "camofox",
base_url: mock.url,
secret_ref: keyRef,
enabled: true,
config: JSON.stringify({ verified: { snapshot: true }, navigationTimeoutMs: 2_000, maxRetries: 1 }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const releaseId = (
await db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: "42",
title: "Oppenheimer 2023 2160p UHD BluRay",
infohash_v1: torrent.infoHash,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
return {
db,
ctx,
mock,
torrent,
registry,
siteId,
accountId,
providerId,
releaseId,
stagingDir,
secrets,
async req(path, init = {}) {
const res = await app.request(`/api/v1${path}`, {
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
headers: {
authorization: `Bearer ${init.key ?? DEV_KEY}`,
...(init.body !== undefined ? { "content-type": "application/json" } : {}),
},
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
});
return { status: res.status, body: (await res.json()) as Loose };
},
agentCtx: () => ({
actorId: "agent:c1e",
taskId: crypto.randomUUID(),
scopeIds: [],
signal: new AbortController().signal,
source: "agent",
}),
async grant(...actions) {
for (const action of actions)
await db.insertInto("automation_grant").values({ action, granted_by: "test" }).execute();
},
async close() {
await db.destroy();
await withSchemaLock(() => sql.raw(`drop schema if exists ${schema} cascade`).execute(admin));
await admin.destroy();
await mock.close();
await rm(stagingDir, { recursive: true, force: true });
},
};
}
+147
View File
@@ -0,0 +1,147 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { describe, expect, it } from "vitest";
import type { AuthEnv, Principal, SessionVar } from "../../src/http/auth.ts";
import { ApiException, errorBody } from "../../src/http/errors.ts";
import {
basicAuth,
type DesktopDeps,
type DesktopRole,
deriveDesktopPasswords,
desktopUpstream,
registerDesktopRoutes,
sameOrigin,
} from "../../src/modules/desktop/desktop.ts";
// 桌面模式反代:口令派生与容器 init 脚本一致、角色 → 口令注入、只接受控制台会话、响应头与路径约束。
const SID = "7d3c1a52-9b0e-4b7a-8a51-0c2f7f7f0a11";
const KEY = "p0key";
function harness(
opts: { principal?: Partial<Principal>; role?: DesktopRole; check?: DesktopDeps["checkSession"] } = {},
) {
const calls: { url: string; headers: Headers; method: string }[] = [];
const app = new OpenAPIHono<AuthEnv>();
app.onError((err, c) => {
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
throw err;
});
app.use("*", async (c, next) => {
const p: Principal = { kind: "admin_session", id: "u1", name: "admin", scopes: ["admin"], ...opts.principal };
c.set("principal", p);
c.set("session", p.kind === "admin_session" ? ({ id: "s1" } as SessionVar) : undefined);
await next();
});
registerDesktopRoutes(app, {
upstream: "http://camofox:3000",
accessKey: KEY,
checkSession:
opts.check ??
(async (_id, holder) => {
expect(holder).toBe("admin_session:admin");
return { ok: true, role: opts.role ?? "viewer", leaseExpiresAt: null };
}),
consoleSessionValid: async () => true,
probeTtlMs: 0,
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
calls.push({ url, headers: new Headers(init?.headers), method: init?.method ?? "GET" });
if (url.endsWith("/api/secret-401"))
return new Response("no", { status: 401, headers: { "www-authenticate": "Basic" } });
return new Response("ok", {
status: 200,
headers: { "content-type": "text/html", "www-authenticate": "Basic", "x-frame-options": "DENY" },
});
}) as typeof fetch,
});
const root = new OpenAPIHono<AuthEnv>();
root.onError((err, c) => {
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
throw err;
});
root.route("/api/v1", app);
return { app: root, calls };
}
describe("桌面口令与上游地址", () => {
it("口令与容器 init-umt-desktop 的 openssl HMAC 派生一致", () => {
// printf '%s' umt-desktop/control | openssl dgst -sha256 -hmac p0key -r | cut -c1-48
const p = deriveDesktopPasswords(KEY);
expect(p.controller).toBe("0b29251f95456b1e4612e3f4c45c2e353fd24e5ecbdf4959");
expect(p.viewer).toHaveLength(48);
expect(p.viewer).not.toBe(p.controller);
});
it("缺省由浏览器服务地址推出(端口换 3000),可显式覆盖", () => {
expect(desktopUpstream({ providerUrl: "http://camofox:9377" })).toBe("http://camofox:3000");
expect(desktopUpstream({ providerUrl: "http://camofox:9377", desktopUrl: "http://desk:3000/" })).toBe(
"http://desk:3000",
);
expect(desktopUpstream({})).toBeNull();
});
it("WebSocket 只接受同源页面", () => {
const req = (origin: string | null, host = "media.example.com") =>
new Request("https://media.example.com/x", { headers: { host, ...(origin ? { origin } : {}) } });
expect(sameOrigin(req("https://media.example.com"))).toBe(true);
expect(sameOrigin(req("https://evil.example"))).toBe(false);
expect(sameOrigin(req(null))).toBe(false);
});
});
describe("桌面反代", () => {
it("外部 API Key 不能用桌面模式", async () => {
const { app } = harness({ principal: { kind: "api_key" } });
const r = await app.request(`/api/v1/browser/desktop/${SID}/`);
expect(r.status).toBe(403);
});
it("状态:按租约给角色,并返回 iframe 地址", async () => {
const { app } = harness({ role: "controller" });
const r = await app.request(`/api/v1/browser/desktop/${SID}/_status`);
const body = (await r.json()) as { data: { available: boolean; role: string; path: string } };
expect(body.data).toMatchObject({ available: true, role: "controller", path: `/api/v1/browser/desktop/${SID}/` });
});
it("静态资源:不带口令、不转发 cookie;响应只允许本站嵌入,不外露认证质询", async () => {
const { app, calls } = harness();
const r = await app.request(`/api/v1/browser/desktop/${SID}/assets/index.js?v=1`, {
headers: { cookie: "umt_session=secret" },
});
expect(r.status).toBe(200);
expect(calls.at(-1)?.url).toBe("http://camofox:3000/assets/index.js?v=1");
expect(calls.at(-1)?.headers.get("authorization")).toBeNull();
expect(calls.at(-1)?.headers.get("cookie")).toBeNull();
expect(r.headers.get("content-security-policy")).toBe("frame-ancestors 'self'");
expect(r.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(r.headers.get("www-authenticate")).toBeNull();
});
it("Selkies 接口:只读 / 控制口令按角色注入", async () => {
const pw = deriveDesktopPasswords(KEY);
for (const role of ["viewer", "controller"] as const) {
const { app, calls } = harness({ role });
await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`);
expect(calls.at(-1)?.headers.get("authorization")).toBe(basicAuth(pw[role]));
}
});
it("上游 401 不透传质询,报口令不匹配", async () => {
const { app } = harness();
const r = await app.request(`/api/v1/browser/desktop/${SID}/api/secret-401`);
expect(r.status).toBe(502);
expect(r.headers.get("www-authenticate")).toBeNull();
});
it("会话不属于自带浏览器服务 → 409;非法会话 id → 404;WebSocket 跨源 → 403", async () => {
const { app } = harness({
check: async () => ({ ok: false, code: "unsupported", message: "只有部署自带的浏览器服务支持桌面模式" }),
});
expect((await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`)).status).toBe(409);
expect((await app.request("/api/v1/browser/desktop/not-a-uuid/")).status).toBe(404);
const ws = await harness().app.request(`/api/v1/browser/desktop/${SID}/api/websockets`, {
headers: { upgrade: "websocket", origin: "https://evil.example", host: "media.example.com" },
});
expect(ws.status).toBe(403);
});
});
@@ -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/e16-agent-prompt-eval.real.test.ts
// DATABASE_URL=… deno run -A npm: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 控制跑哪几组(缺省两组都跑)。
@@ -9,7 +9,7 @@ import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./
// - fake 模型按"合格模型"的路线调用能力:断言调用顺序、只读调用都成功、需要批准的被拒绝(不向用户要 ID:
// download.inspect 的 ID 来自 download.list);
// - 汇报格式约束:合格汇报(只用工具结果里的数据写)通过 lintReport(无 UUID / 原始字节 / 内部字段,≤8 行,结论先行)。
// 真实模型的前后对比见 e16-agent-prompt-eval.real.test.ts 与 docs/design/agent-prompt-eval.md。
// 真实模型的前后对比见 agent-prompt-eval.real.test.ts 与 docs/design/agent-prompt-eval.md。
const hasPg = await pgAvailable();
File renamed without changes.
+1 -1
View File
@@ -20,7 +20,7 @@ import { createApp } from "../../src/http/app.ts";
import { deriveCsrfToken, hashApiKey, hashSessionToken } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import { fakeEngine } from "../acquisition-organize/harness.ts";
import { executeRecord, type FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
import { runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
@@ -4,7 +4,7 @@ import {
type Harness,
pgAvailable,
seedEntity,
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
} from "../acquisition-organize/harness.ts";
import { MEDIA_SERVER_LIMITS } from "../../src/modules/acquisition-organize/routes/library.ts";
// C1-D:GET /inventory/media-servers 按实例并行(allSettled)+ 实例内并发上限 + 整实例超时;部分失败 partial=true。
+1 -1
View File
@@ -9,7 +9,7 @@ import {
seedEntity,
seedRelease,
seedSlot,
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
} from "../acquisition-organize/harness.ts";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
// W9-D 规则组:关键字表、规则串解析/序列化、CRUD + 修订、抑制记录、试算、默认链、评估接入、旧端点别名。
@@ -10,7 +10,7 @@ import {
seedBase,
seedEntity,
seedRelease,
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
} from "../acquisition-organize/harness.ts";
import {
evaluateAcquisition,
subscriptionSavePath,
@@ -10,7 +10,7 @@ import {
seedBase,
seedEntity,
seedRelease,
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
} from "../acquisition-organize/harness.ts";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
// W9-E 订阅重做后端:总览分组/排序/计数、覆盖矩阵(单集/季包/多版本/在途/临时单元/电影版本)、