feat(server): ServerModule skeleton with shared ServerContext, worker runs engine + outbox relay (B6)
modules/{auth-settings,sources-identity,acquisition-organize,agent} placeholders listed in
SERVER_MODULES; ServerModule registers routes (under /api/v1, authenticated), tasks,
outbox routes, capabilities and background loops. bootstrap() builds ServerContext (db,
config, logger, events, engine, outbox writer, secrets placeholder, settings reader,
capability registry), fills unregistered tasks with stubs and rejects duplicate task or
outbox ownership. worker starts the OpenWorkflow worker, OutboxRelay and module start().
--role migrate also migrates the engine schema; api/worker connect without migrating.
tasks: TaskRegistry.has, migrateEngineSchema.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ef3998767c
commit
9858b8494d
23 files changed
+829
-22
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { type DB, migrateToLatest } from "@umt/db";
|
||||
import { defineTask } from "@umt/tasks";
|
||||
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||
import pg from "pg";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { ConfigSchema } from "../src/config.ts";
|
||||
import { type Bootstrapped, bootstrap } from "../src/context.ts";
|
||||
import { EventBus } from "../src/http/sse.ts";
|
||||
import type { ServerModule } from "../src/modules/types.ts";
|
||||
import { type RunningWorker, startWorker } from "../src/worker/index.ts";
|
||||
import { memoryLogger } from "./helpers.ts";
|
||||
|
||||
// 真实 PG:独立业务 schema + 独立引擎 schema,验证 bootstrap → worker → outbox → 模块任务 的整条装配。
|
||||
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
|
||||
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const biz = `server_test_${uniq}`;
|
||||
const ow = `${biz}_ow`;
|
||||
|
||||
async function pgAvailable() {
|
||||
const c = new pg.Client({ connectionString: url, connectionTimeoutMillis: 2000 });
|
||||
try {
|
||||
await c.connect();
|
||||
await c.end();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const hasPg = process.env.UMT_SKIP_DB_TESTS !== "1" && (await pgAvailable());
|
||||
|
||||
describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
|
||||
let db: Kysely<DB>;
|
||||
let booted: Bootstrapped;
|
||||
let worker: RunningWorker;
|
||||
const seen: string[] = [];
|
||||
|
||||
const probe: ServerModule = {
|
||||
name: "probe",
|
||||
registerRoutes() {},
|
||||
registerTasks(registry, ctx) {
|
||||
registry.register(
|
||||
defineTask({
|
||||
name: "evaluateAcquisition",
|
||||
version: 1,
|
||||
inputSchema: z.object({ sourceReleaseId: z.string().optional() }),
|
||||
handler: async (t) => {
|
||||
const s = await ctx.settings.get("cache");
|
||||
seen.push(`${t.input.sourceReleaseId}:${s.hardThresholdPercent}`);
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
outboxRoutes: () => ({
|
||||
[EventType.releaseResolved]: (ev) => ({
|
||||
task: "evaluateAcquisition",
|
||||
input: { sourceReleaseId: (ev.payload as { sourceReleaseId: string }).sourceReleaseId },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const admin = new pg.Client({ connectionString: url });
|
||||
await admin.connect();
|
||||
await admin.query(`create schema ${biz}`);
|
||||
await admin.end();
|
||||
db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: url, max: 5, options: `-c search_path=${biz},public` }),
|
||||
}),
|
||||
});
|
||||
await migrateToLatest(db, { migrationTableSchema: biz });
|
||||
await db
|
||||
.insertInto("settings")
|
||||
.values({ key: "cache", value: JSON.stringify({ hardThresholdPercent: 95, resumePercent: 90 }) })
|
||||
.execute();
|
||||
const config = ConfigSchema.parse({ DATABASE_URL: url, SECRET_MASTER_KEY: "test-master-key-0123456789" });
|
||||
const { logger } = memoryLogger();
|
||||
booted = await bootstrap({
|
||||
role: "all",
|
||||
config,
|
||||
logger,
|
||||
db,
|
||||
events: new EventBus(10),
|
||||
modules: [probe],
|
||||
engineSchema: ow,
|
||||
});
|
||||
worker = await startWorker({
|
||||
logger,
|
||||
heartbeatMs: 60_000,
|
||||
runtime: { ctx: booted.ctx, modules: [probe], setRelay: booted.outbox.setRelay, outboxIntervalMs: 100 },
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await worker?.stop();
|
||||
await booted?.engine.stop();
|
||||
await db?.destroy();
|
||||
const admin = new pg.Client({ connectionString: url });
|
||||
await admin.connect();
|
||||
await admin.query(`drop schema if exists ${biz} cascade`);
|
||||
await admin.query(`drop schema if exists ${ow} cascade`);
|
||||
await admin.end();
|
||||
}, 60_000);
|
||||
|
||||
it("settings 读取合并缺省值", async () => {
|
||||
const cache = await booted.ctx.settings.get("cache");
|
||||
expect(cache).toMatchObject({ hardThresholdPercent: 95, resumePercent: 90, checkIntervalSec: 60 });
|
||||
expect((await booted.ctx.settings.all()).automation.enabled).toBe(true);
|
||||
await expect(booted.ctx.secrets.resolve("x")).rejects.toThrow(/W2-A/);
|
||||
});
|
||||
|
||||
it("业务事务内 emit → 中继路由 → worker 执行模块任务;同幂等键只投一次;非法 payload 拒写", async () => {
|
||||
const payload = {
|
||||
sourceReleaseId: "11111111-1111-4111-8111-111111111111",
|
||||
observationId: null,
|
||||
mediaEntityId: "22222222-2222-4222-8222-222222222222",
|
||||
contentUnitIds: [],
|
||||
evidenceLevel: "rule_confirmed" as const,
|
||||
};
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await booted.ctx.outbox.emit(trx, EventType.releaseResolved, payload, { idempotencyKey: "rr:1" });
|
||||
await booted.ctx.outbox.emit(trx, EventType.releaseResolved, payload, { idempotencyKey: "rr:1" });
|
||||
});
|
||||
await expect(
|
||||
booted.ctx.outbox.emit(db, EventType.releaseResolved, { ...payload, evidenceLevel: "bogus" } as never),
|
||||
).rejects.toThrow(/payload/);
|
||||
expect(booted.ctx.outbox.relay).not.toBeNull();
|
||||
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (seen.length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
|
||||
expect(seen).toEqual([`${payload.sourceReleaseId}:95`]);
|
||||
const rows = await sql<{ status: string }>`select status from outbox_event`.execute(db);
|
||||
expect(rows.rows).toEqual([{ status: "dispatched" }]);
|
||||
}, 40_000);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DownloaderClient, SiteRelease } from "@umt/adapters";
|
||||
import type { DomainDownloadStatus, SiteReleaseLike } from "@umt/mapping";
|
||||
import { toDomainDownloadStatus } from "@umt/mapping";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// @umt/mapping 不依赖 adapters;server 同时依赖两者,在这里做类型层兼容校验。
|
||||
type Assert<T extends true> = T;
|
||||
export type _SiteReleaseCompatible = Assert<SiteRelease extends SiteReleaseLike ? true : false>;
|
||||
// 删数据接口必须带安全检查输入(B7)
|
||||
export type _DeleteRequiresSafety = Assert<
|
||||
{ deleteFiles: true } extends Parameters<DownloaderClient["delete"]>[1] ? false : true
|
||||
>;
|
||||
|
||||
describe("mapping ↔ adapters 兼容", () => {
|
||||
it("adapters 的 SiteRelease 可直接交给 mapping", () => {
|
||||
const status: DomainDownloadStatus = toDomainDownloadStatus("paused", "threshold");
|
||||
expect(status).toBe("paused_by_threshold");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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",
|
||||
}),
|
||||
);
|
||||
},
|
||||
outboxRoutes: () => ({ "release.resolved": () => ({ 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 路由合并;同一事件被两个模块认领报错", () => {
|
||||
expect(Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx))).toEqual(["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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user