Files
UHD_Media_Tool/packages/tasks/test/outbox-broadcast.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

89 lines
3.4 KiB
TypeScript

import { sql } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { type EnqueueOptions, OutboxRelay, type TaskEngine } from "../src/index.ts";
import {
connectDb,
dropSchemas,
migrateBizSchema,
pgAvailable,
resetSchemas,
type TestDb,
testSchemas,
} from "./helpers.ts";
// 生命周期广播(ADR-006):一条 outbox 事件路由给多个订阅方任务。第一个任务用事件的幂等键,
// 其余用 <键>#<任务名>;失败重投时已入队的由引擎幂等键去重,没入队的补上。
const schemas = testSchemas("outbox_bc");
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("OutboxRelay 广播给多个订阅方", () => {
let db: TestDb;
beforeAll(async () => {
await resetSchemas(schemas);
db = connectDb(schemas.biz);
await migrateBizSchema(db, schemas.biz);
}, 30_000);
afterAll(async () => {
await db?.destroy();
await dropSchemas(schemas);
}, 30_000);
it("每个订阅方各入队一次,幂等键按订阅方区分;中途失败重投时同键去重", async () => {
await sql`INSERT INTO outbox_event (type, payload, idempotency_key)
VALUES ('thing.done', '{"id":"t1"}'::jsonb, 'thing.done/t1')`.execute(db);
const keys = new Map<string, number>();
let failB = true;
const engine: Pick<TaskEngine, "enqueue"> = {
enqueue: async (task: string, _input: unknown, opts: EnqueueOptions) => {
if (task === "b" && failB) {
failB = false;
throw new Error("引擎暂时不可用");
}
keys.set(opts.idempotencyKey, (keys.get(opts.idempotencyKey) ?? 0) + 1);
return { runId: opts.idempotencyKey, deduplicated: false };
},
};
const relay = new OutboxRelay({
db,
engine,
routes: {
"thing.done": (ev) => [
{ task: "a", input: ev.payload },
{ task: "b", input: ev.payload },
],
},
});
const first = await relay.pollOnce();
expect(first).toEqual({ published: 0, failed: 1 });
// 退避期过了再投:a 用同一把键再入一次(真实引擎按幂等键返回已有 run),b 补上
await sql`UPDATE outbox_event SET next_attempt_at = now()`.execute(db);
expect(await relay.pollOnce()).toEqual({ published: 1, failed: 0 });
expect([...keys.keys()].sort()).toEqual(["thing.done/t1", "thing.done/t1#b"]);
expect(keys.get("thing.done/t1")).toBe(2);
expect(keys.get("thing.done/t1#b")).toBe(1);
const row = await sql<{ status: string }>`SELECT status FROM outbox_event`.execute(db);
expect(row.rows).toEqual([{ status: "dispatched" }]);
});
it("单个订阅方保持原来的键;返回空数组 / null 直接标记已投递", async () => {
await sql`DELETE FROM outbox_event`.execute(db);
await sql`INSERT INTO outbox_event (type, payload, idempotency_key) VALUES
('one.sub', '{}'::jsonb, 'k-one'), ('no.sub', '{}'::jsonb, 'k-none')`.execute(db);
const keys: string[] = [];
const relay = new OutboxRelay({
db,
engine: {
enqueue: async (_t: string, _i: unknown, o: EnqueueOptions) => {
keys.push(o.idempotencyKey);
return { runId: "x", deduplicated: false };
},
},
routes: { "one.sub": () => [{ task: "a", input: {} }], "no.sub": () => [] },
});
expect(await relay.pollOnce()).toEqual({ published: 2, failed: 0 });
expect(keys).toEqual(["k-one"]);
});
});