Files
UHD_Media_Tool/apps/server/test/acquisition-organize/takeover.test.ts
T
rootandClaude Opus 5.5 b61335eaf6 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]>
2026-10-02 16:54:04 +08:00

284 lines
15 KiB
TypeScript

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