Files
UHD_Media_Tool/apps/server/test/lifecycle/organize-hooks.test.ts
T
rootandClaude Opus 5.5 079c3eafe9 feat: 业务节点发生命周期事件并调用链式钩子
- 订阅已建 / 已改 / 已完成 / 已取消,下载完成 / 失败 / 移除,整理结束(统一 organization.changed 终态),
  刮削完成 / 失败,作品进入关注,元数据已刷新(任务调用方),季集结构切换,站点不可达 / 恢复:
  都在调用方事务内写 outbox,带幂等键
- 钩子挂点:name.recognize(resolveRelease / identifyParsed)、organize.rename 与 organize.intercept(planImport)、
  resource.selection(订阅重评)、subscription.completion.check(completeSubscription)
- 测试:各节点事件 payload 与去重、整理改名 / 拦截 / 出错不影响整理、名称识别改写、资源选择、订阅完成拦截

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 19:27:14 +08:00

113 lines
5.5 KiB
TypeScript

import { existsSync } from "node:fs";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { HookRegistry } from "../../src/hooks/index.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
// 整理上的链式钩子(ADR-006):organize.rename 改写目标文件名(字幕跟着改)、organize.intercept 拒绝整理;
// 钩子出错不影响整理。整理结束发 organization.finished。
const hasPg = await pgAvailable();
const SIZE = 200_000;
const DIR = "Inception.2010.1080p.BluRay.x264-GRP";
const VIDEO = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.mkv`;
const SUB = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.chs.srt`;
describe.skipIf(!hasPg)("整理钩子:改名与拦截(真实 PG + 临时目录)", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let entity = "";
const req = () => ({
sourcePaths: [src.path(DIR)],
mediaEntityId: entity,
action: "copy" as const,
trigger: "manual" as const,
actorType: "user" as const,
});
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-hklib-" });
src = await createTempTree({ [VIDEO]: SIZE, [SUB]: 2_000 }, { prefix: "umt-hksrc-" });
h = await createHarness("organize_hooks");
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
await h.setSettings("organize", { scrapeAfterImport: false });
entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 });
await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
}, 60_000);
afterEach(() => {
h.ctx.hooks = undefined;
});
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
it("拦截:钩子返回原因 → 记录失败(intercepted:<原因>),不动文件;整理结束事件 outcome=failed", async () => {
const hooks = new HookRegistry();
const seen: string[][] = [];
hooks.forModule("test").intercept("organize.intercept", (c) => {
seen.push(c.files.map((f) => `${f.role}:${f.kind}`));
return c.action === "copy" ? "演示:不许复制" : null;
});
h.ctx.hooks = hooks;
const plan = await planImport(h.d, req());
expect(plan).toMatchObject({ status: "failed", reason: "intercepted:演示:不许复制" });
expect(seen).toEqual([["main:video", "sidecar:subtitle"]]);
const rec = await h.db
.selectFrom("organization_record")
.select(["status", "error"])
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(rec).toMatchObject({ status: "failed", error: { hook: "test#1", reason: "演示:不许复制" } });
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
expect(fin.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([["failed", "intercepted:演示:不许复制"]]);
});
it("改名:主视频按钩子返回的文件名入库,同主干的字幕跟着改;成功后发 organization.finished", async () => {
const hooks = new HookRegistry();
const ctxs: unknown[] = [];
hooks.forModule("test").rewrite("organize.rename", (name, c) => {
ctxs.push({ name, mediaType: c.mediaType, mediaEntityId: c.mediaEntityId });
return name.replace(/\.mkv$/, " [自定义].mkv");
});
// 返回带路径分隔符的结果会被忽略(排在后面,拿到的是上一个钩子的结果)
hooks.forModule("bad").rewrite("organize.rename", (name) => `../${name}`);
h.ctx.hooks = hooks;
const plan = await planImport(h.d, req());
expect(plan.status).toBe("running");
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
expect(ctxs).toEqual([{ name: expect.stringMatching(/\.mkv$/), mediaType: "movie", mediaEntityId: entity }]);
const files = await h.db
.selectFrom("organization_file_result")
.select(["target_path", "status"])
.where("organization_record_id", "=", plan.recordId!)
.execute();
const targets = files.map((f) => f.target_path ?? "").sort();
expect(targets).toHaveLength(2);
const video = targets.find((t) => t.endsWith(".mkv"))!;
const sub = targets.find((t) => t.endsWith(".srt"))!;
expect(video).toMatch(/ \[自定义\]\.mkv$/);
const stem = video.slice(0, -".mkv".length);
expect(sub.startsWith(stem)).toBe(true);
expect(existsSync(`${lib.root}/${video}`) || existsSync(video)).toBe(true);
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
expect(fin.map((r) => r.payload.outcome)).toEqual(["succeeded"]);
});
it("钩子出错 / 超时不影响整理:按模板名照常生成计划", async () => {
const hooks = new HookRegistry();
hooks.forModule("broken").rewrite("organize.rename", () => {
throw new Error("坏了");
});
hooks.forModule("broken").intercept("organize.intercept", () => new Promise<string>(() => {}), { timeoutMs: 20 });
h.ctx.hooks = hooks;
const plan = await planImport(h.d, { ...req(), dryRun: true, action: "move" });
expect(plan.status).toBe("queued");
const main = plan.plan!.organize.files.find((f) => f.role === "main")!;
expect(main.dst).not.toContain("自定义");
});
});