- Rules API: subscriptions, acquisition slots (revision lock), policy revisions (draft -> publish = automation_grant, rollback as new revision), preference tags with suppression records (agent cannot re-add suppressed fingerprints). - Tasks: evaluateAcquisition (mode/evaluate/budget/cache volume -> frozen plan with slot reservation, candidate reasons as evidence artifacts), submitDownload (withAttemptToken fencing, already_exists adoption), verifyAndImport (frozen organization_record + file results, resumable copy/move/cross-volume steps, assets/locations/bindings in one tx), cleanupDownloadCache (verified+retention+H&R only, assertSafeToDeleteData), reconcileLibrary (root sentinel, suspected -> confirmed missing). - Worker loop: qB sync with tag adoption, no-progress evidence, hard threshold pause / resume-watermark resume, sweepers for deferred plans and imports. - Public organization records API incl. changes cursor (change_xid guard), media library list/detail, inventory overlay, Emby inventory, dashboard. - Contracts: SlotTemplate, AcquisitionPolicyRules, CandidateEvaluation, library/inventory/dashboard read models. Co-Authored-By: Claude Opus 5.5 <[email protected]>
102 lines
4.1 KiB
TypeScript
102 lines
4.1 KiB
TypeScript
import { createRoute, z } from "@hono/zod-openapi";
|
|
import { apiResponse, CAPABILITY_IDS, capabilitySpec } from "@umt/contracts";
|
|
import { defineTask, TASK_NAMES } from "@umt/tasks";
|
|
import { describe, expect, it } from "vitest";
|
|
import { 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: "ingestFeed",
|
|
version: 1,
|
|
inputSchema: z.object({ feedSourceId: z.string() }),
|
|
handler: async () => "real",
|
|
}),
|
|
);
|
|
},
|
|
// 测试专用事件类型:真实事件(如 release.resolved)已由业务模块认领
|
|
outboxRoutes: () => ({ "test.ping": () => ({ task: "evaluateAcquisition", input: {} }) }),
|
|
};
|
|
|
|
describe("ServerModule 骨架", () => {
|
|
it("清单里是四个 W2 模块,名字唯一", () => {
|
|
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
|
|
"auth-settings",
|
|
"sources-identity",
|
|
"acquisition-organize",
|
|
"agent",
|
|
]);
|
|
});
|
|
|
|
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([...TASK_NAMES].sort());
|
|
const ingest = reg.latest("ingestFeed");
|
|
expect(await ingest.handler({ input: {} } as never)).toBe("real");
|
|
expect(() => buildTaskRegistry([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复注册/);
|
|
});
|
|
|
|
it("outbox 路由合并;同一事件被两个模块认领报错", () => {
|
|
const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx));
|
|
expect(merged).toContain("test.ping");
|
|
expect(merged).toContain("release.resolved");
|
|
expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/);
|
|
});
|
|
|
|
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]);
|
|
});
|
|
});
|