Files
UHD_Media_Tool/apps/server/test/hooks/hook-registry.test.ts
T
rootandClaude Opus 5.5 1db689d9a7 feat: 生命周期广播事件与链式钩子设施(ADR-006)
- contracts:补齐生命周期广播事件(订阅、下载、整理结束、刮削、字幕、作品关注 / 元数据 / 季集结构、站点可达性)
  的类型与 payload schema,LIFECYCLE_EVENTS 清单与 lifecycleKey 幂等键;HOOK_POINTS 链式钩子挂点;
  FEATURES 功能清单与 SLOT_POSITIONS 前端插槽位置
- outbox 中继支持一条事件路由给多个订阅方(其余订阅方用 <键>#<任务名>);生命周期事件允许多模块订阅,
  没有订阅方时也被中继认领并镜像到 SSE
- 服务端 HookRegistry:按注册顺序执行、rewrite / intercept 两类、单钩子超时与异常隔离、无注册零开销;
  ServerModule 增加 manifest 与 registerHooks,bootstrap 装配 ctx.hooks
- ADR-006 与 server README「如何写一个功能模块」

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

176 lines
6.9 KiB
TypeScript

import type { ParsedRelease } from "@umt/domain";
import { describe, expect, it, vi } from "vitest";
import { HookRegistry, hasHook, interceptHook, rewriteHook } from "../../src/hooks/index.ts";
// 链式钩子(ADR-006):注册顺序、改写、拦截、抛错隔离、超时、没有注册时零开销。
const warnings = () => {
const warn = vi.fn();
return { logger: { warn } as never, warn };
};
const parsed = (title: string) => ({ title, raw: title, explicitIds: [] }) as unknown as ParsedRelease;
const recognizeCtx = { title: "x", subtitle: null, sourceReleaseId: null, trigger: "observed" };
const completionCtx = {
subscriptionId: "s",
mediaEntityId: null,
mediaKind: "movie",
reason: "slot_satisfied",
progress: {},
};
describe("链式钩子注册表", () => {
it("按注册顺序执行(bootstrap 按模块顺序注册),每个钩子拿到上一个的结果;缺省名 <模块>#<序号>", async () => {
const reg = new HookRegistry();
const seen: string[] = [];
const a = reg.forModule("a");
const b = reg.forModule("b");
a.rewrite("organize.rename", (name) => {
seen.push(`a1:${name}`);
return `${name}-a1`;
});
b.rewrite("organize.rename", (name) => {
seen.push(`b:${name}`);
return `${name}-b`;
});
a.rewrite("organize.rename", (name) => {
seen.push(`a2:${name}`);
return undefined; // 不改
});
const ctx = { hooks: reg };
const out = await rewriteHook(ctx, "organize.rename", "X.mkv", {
targetPath: "/lib/X.mkv",
sourcePath: "/src/X.mkv",
mediaEntityId: "e",
mediaType: "movie",
season: null,
episodes: null,
});
expect(out).toBe("X.mkv-a1-b");
expect(seen).toEqual(["a1:X.mkv", "b:X.mkv-a1", "a2:X.mkv-a1-b"]);
expect(reg.list()).toEqual({ "organize.rename": ["a#1", "b#1", "a#2"] });
});
it("改写生效:异步钩子改写解析结果", async () => {
const reg = new HookRegistry();
reg.forModule("m").rewrite("name.recognize", async (p) => ({ ...p, title: "改写后" }));
const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("原名"), recognizeCtx);
expect(out.title).toBe("改写后");
});
it("拦截生效:第一个返回原因的钩子拒绝,后面的不再执行;都放行返回 null", async () => {
const reg = new HookRegistry();
const later = vi.fn(() => "不该执行");
const m = reg.forModule("m");
m.intercept("subscription.completion.check", () => null);
m.intercept("subscription.completion.check", () => " 还缺特别篇 ", { name: "缺特别篇" });
m.intercept("subscription.completion.check", later);
expect(await interceptHook({ hooks: reg }, "subscription.completion.check", completionCtx)).toEqual({
reason: "还缺特别篇",
by: "缺特别篇",
});
expect(later).not.toHaveBeenCalled();
const pass = new HookRegistry();
pass.forModule("m").intercept("subscription.completion.check", () => undefined);
expect(await interceptHook({ hooks: pass }, "subscription.completion.check", completionCtx)).toBeNull();
});
it("抛错隔离:出错的钩子跳过并记日志,前后的钩子照常生效", async () => {
const { logger, warn } = warnings();
const reg = new HookRegistry(logger);
const m = reg.forModule("m");
m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+1` }));
m.rewrite("name.recognize", () => {
throw new Error("插件坏了");
});
m.rewrite("name.recognize", async () => {
throw new Error("异步也坏了");
});
m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+2` }));
const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("t"), recognizeCtx);
expect(out.title).toBe("t+1+2");
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0]![0]).toMatchObject({
point: "name.recognize",
hook: "m#2",
err: { message: "插件坏了" },
});
// 拦截类抛错按放行处理
const r2 = new HookRegistry(logger);
r2.forModule("m").intercept("organize.intercept", () => {
throw new Error("x");
});
expect(
await interceptHook({ hooks: r2 }, "organize.intercept", {
mediaEntityId: "e",
mediaType: "movie",
action: "copy",
trigger: "auto",
downloadTaskId: null,
files: [],
}),
).toBeNull();
});
it("超时:超过上限的钩子跳过(沿用上一个值),不等它跑完", async () => {
const { logger, warn } = warnings();
const reg = new HookRegistry(logger);
const m = reg.forModule("slow");
m.rewrite("organize.rename", () => new Promise<string>((r) => setTimeout(() => r("迟到.mkv"), 5_000)), {
timeoutMs: 30,
});
m.rewrite("organize.rename", (n) => `${n}.ok`);
const t0 = Date.now();
const out = await reg.runRewrite("organize.rename", "A.mkv", {
targetPath: "/l/A.mkv",
sourcePath: "/s/A.mkv",
mediaEntityId: "e",
mediaType: "movie",
season: null,
episodes: null,
});
expect(out).toBe("A.mkv.ok");
expect(Date.now() - t0).toBeLessThan(2_000);
expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "slow#1", timeoutMs: 30 });
});
it("没有注册时零开销:不构造上下文、原样返回同一个对象;没有注册表(手搭上下文)同样", async () => {
const ctxBuilder = vi.fn(() => recognizeCtx);
const p = parsed("t");
const empty = new HookRegistry();
// 别的挂点有钩子不影响这里
empty.forModule("m").intercept("organize.intercept", () => "x");
expect(hasHook({ hooks: empty }, "name.recognize")).toBe(false);
expect(await rewriteHook({ hooks: empty }, "name.recognize", p, ctxBuilder)).toBe(p);
expect(await rewriteHook({}, "name.recognize", p, ctxBuilder)).toBe(p);
expect(await interceptHook({}, "subscription.completion.check", () => completionCtx)).toBeNull();
expect(ctxBuilder).not.toHaveBeenCalled();
});
it("调用方给了 accept 时,结果不可用的钩子跳过,后面的钩子拿到的是上一个可用值", async () => {
const { logger, warn } = warnings();
const reg = new HookRegistry(logger);
const m = reg.forModule("m");
m.rewrite("organize.rename", (n) => `../${n}`);
m.rewrite("organize.rename", (n) => `${n}.ok`);
const c = {
targetPath: "/a",
sourcePath: "/b",
mediaEntityId: "e",
mediaType: "movie" as const,
season: null,
episodes: null,
};
expect(await rewriteHook({ hooks: reg }, "organize.rename", "A.mkv", c, (v) => !v.includes("/"))).toBe("A.mkv.ok");
expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "m#1" });
});
it("用错挂点类型注册直接报错", () => {
const reg = new HookRegistry();
// biome-ignore lint/suspicious/noExplicitAny: 故意绕过类型检查
const m = reg.forModule("m") as any;
expect(() => m.rewrite("organize.intercept", () => "x")).toThrow(/intercept 类/);
});
});