import { createRoute, z } from "@hono/zod-openapi"; import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts"; import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks"; import { describe, expect, it } from "vitest"; import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts"; import { createApp } from "../src/http/app.ts"; import { requireScope } from "../src/http/auth.ts"; import { EventBus } from "../src/http/sse.ts"; import { SERVER_MODULES } from "../src/modules/index.ts"; import type { ServerContext, ServerModule } from "../src/modules/types.ts"; import { DEV_KEY, json, memoryLogger } from "./helpers.ts"; // register* 阶段模块只保存 ctx 引用,这里不需要真实依赖 const fakeCtx = { role: "all" } as unknown as ServerContext; const pingRoute = createRoute({ method: "get", path: "/test-ping", tags: ["test"], responses: { 200: { description: "ok", content: { "application/json": { schema: apiResponse(z.object({ who: z.string() })) } } }, }, }); const pingModule: ServerModule = { name: "test-ping", registerRoutes(app) { app.use("/test-ping", requireScope("admin")); app.openapi(pingRoute, (c) => c.json({ ok: true as const, data: { who: c.get("principal").name } }, 200)); }, registerTasks(registry) { registry.register( defineTask({ name: "testPing", version: 1, inputSchema: z.object({ feedSourceId: z.string() }), handler: async () => "real", }), ); }, // 测试专用事件类型:真实事件(如 release.resolved)已由业务模块认领 outboxRoutes: () => ({ "test.ping": () => ({ task: "evaluateAcquisition", input: {} }) }), }; describe("ServerModule 骨架", () => { it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + 桌面反代 + MCP + 网页搜索 + 缓存 + 进程与健康 + 日志(末尾),名字唯一", () => { expect(SERVER_MODULES.map((m) => m.name)).toEqual([ "auth-settings", "sources-identity", "acquisition-organize", "agent", "file-manager", "pi", "schedules", "search", "subtitles", "browser", "desktop", "mcp", "websearch", "cache", "system", "logs", ]); }); it("模块路由挂在 /api/v1 下,经过鉴权与 scope 检查,并进入 OpenAPI 文档", async () => { const { logger } = memoryLogger(); const app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger, bus: new EventBus(5), role: "all", version: "0.0.0-test", modules: [pingModule], ctx: fakeCtx, }); expect((await app.request("/api/v1/test-ping")).status).toBe(401); const ok = await app.request("/api/v1/test-ping", { headers: { authorization: `Bearer ${DEV_KEY}` } }); expect(ok.status).toBe(200); expect((await json(ok)).data.who).toBe("dev-admin"); const doc = await json(await app.request("/api/openapi.json")); expect(doc.paths["/api/v1/test-ping"]).toBeDefined(); // 未知路径仍是 JSON 404 expect((await app.request("/api/v1/nope", { headers: { authorization: `Bearer ${DEV_KEY}` } })).status).toBe(404); }); it("任务注册表:模块任务优先,其余 §3.1-8 任务由空壳兜底;重复注册报错", async () => { const reg = buildTaskRegistry([...SERVER_MODULES, pingModule], fakeCtx); expect(reg.names()).toEqual(expect.arrayContaining([...TASK_NAMES, "testPing"])); const ingest = reg.latest("testPing"); expect(await ingest.handler({ input: {} } as never)).toBe("real"); expect(() => buildTaskRegistry([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复注册/); }); it("生产装配:每个核心任务都由业务模块正式注册,不落到空壳兜底", () => { const owned = new Set(); for (const m of SERVER_MODULES) { const own = new TaskRegistry(); m.registerTasks(own, fakeCtx); for (const name of own.names()) owned.add(name); } expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]); }); it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => { const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx)); expect(merged).toContain("test.ping"); expect(merged).toContain("release.resolved"); expect(merged).toEqual(expect.arrayContaining(["feed.due", "release.observed", "feed.gap_suspected"])); expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/); }); it("生命周期广播事件可以被多个模块订阅:按模块顺序合成,各自的任务都投递,某个订阅方出错不连累别人", () => { const { logger } = memoryLogger(); const ctx = { role: "all", logger } as unknown as ServerContext; const sub = (name: string, fn: () => unknown): ServerModule => ({ name, registerRoutes() {}, registerTasks() {}, outboxRoutes: () => ({ "organization.finished": fn as never }), }); const routes = collectOutboxRoutes( [ ...SERVER_MODULES, sub("a", () => ({ task: "taskA", input: { n: 1 } })), sub("broken", () => { throw new Error("坏了"); }), sub("b", () => [{ task: "taskB", input: {} }]), sub("none", () => null), ], ctx, ); const ev = { id: "e1", type: "organization.finished", at: new Date(), payload: { recordId: "00000000-0000-4000-8000-000000000001", outcome: "succeeded", action: "copy", trigger: "auto", mediaEntityId: "00000000-0000-4000-8000-000000000002", downloadTaskId: null, reason: null, changeSeq: "7", }, }; // 补字幕(样板)订阅了整理结束,返回 null(自动补字幕还没做) expect(routes["organization.finished"]!(ev)).toEqual([ { task: "taskA", input: { n: 1 } }, { task: "taskB", input: {} }, ]); // 只有补字幕一个订阅方时它自己的路由不建任务 expect(collectOutboxRoutes(SERVER_MODULES, ctx)["organization.finished"]!(ev)).toBeNull(); // 非广播事件仍只能一个模块认领(release.observed 由 sources-identity 认领) expect(() => collectOutboxRoutes([...SERVER_MODULES, sub("x", () => null)], ctx)).not.toThrow(); const dup: ServerModule = { ...sub("y", () => null), outboxRoutes: () => ({ "release.observed": () => null }) }; expect(() => collectOutboxRoutes([...SERVER_MODULES, dup], ctx)).toThrow(/重复认领/); // 广播事件都登记了 payload schema expect(LIFECYCLE_EVENTS.length).toBeGreaterThanOrEqual(17); }); it("功能清单:外围功能模块都声明 manifest,与 contracts FEATURES 一一对应", () => { const declared = SERVER_MODULES.filter((m) => m.manifest).map((m) => [m.name, m.manifest!.id]); for (const [name, id] of declared) expect(id).toBe(name); expect(declared.map(([n]) => n).sort()).toEqual(FEATURES.map((f) => f.id).sort()); const subtitles = SERVER_MODULES.find((m) => m.name === "subtitles")!; expect(subtitles.manifest?.subscribes).toEqual(["organization.finished"]); expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes); }); it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => { const order: string[] = []; const mod = (name: string): ServerModule => ({ name, registerRoutes() {}, registerTasks() {}, registerHooks(hooks) { hooks.rewrite("organize.rename", (n) => { order.push(name); return `${n}.${name}`; }); }, }); const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx); expect( await hooks.runRewrite("organize.rename", "a", { targetPath: "/a", sourcePath: "/b", mediaEntityId: "e", mediaType: "movie", season: null, episodes: null, }), ).toBe("a.first.second"); expect(order).toEqual(["first", "second"]); expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({}); }); it("引擎就绪前调用给出明确错误", async () => { const e = new DeferredTaskEngine(); expect(e.ready).toBe(false); expect(() => e.get("x")).toThrow(/尚未就绪/); await e.stop(); }); it("能力规格取自 contracts,可按模块注册", () => { expect(CAPABILITY_IDS.map((id) => capabilitySpec(id).id)).toEqual([...CAPABILITY_IDS]); }); });