Files
suaveplan/packages/api-stack/api/api-host/src/pipeline-events-bus/in-memory-bus.test.ts
2026-05-27 23:29:53 +01:00

106 lines
4.0 KiB
TypeScript

import type { PipelineStageEvent } from "@suaveplan/api-types/pipeline-events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { InMemoryPipelineEventsBus } from "./in-memory-bus.js";
function ev(
requestId: string,
stage: PipelineStageEvent["stage"],
outcome: PipelineStageEvent["outcome"],
at: number
): PipelineStageEvent {
return { requestId, stage, outcome, at };
}
describe("InMemoryPipelineEventsBus — FIFO + fan-out semantics", () => {
it("emit appends to the internal FIFO in order", { timeout: 5000 }, () => {
const bus = new InMemoryPipelineEventsBus();
bus.emit(ev("r1", "tenant", "entered", 1));
bus.emit(ev("r1", "tenant", "completed", 2));
expect(bus.events.map((e) => e.outcome)).toEqual([
"entered",
"completed",
]);
expect(bus.length).toBe(2);
});
it("subscribe receives events in emission order", { timeout: 5000 }, () => {
const bus = new InMemoryPipelineEventsBus();
const seen: string[] = [];
bus.subscribe((e) => seen.push(`${e.stage}/${e.outcome}`));
bus.emit(ev("r1", "auth", "entered", 1));
bus.emit(ev("r1", "auth", "rejected", 2));
expect(seen).toEqual(["auth/entered", "auth/rejected"]);
});
it("subscriber throws are isolated — FIFO + remaining subscribers unaffected", {
timeout: 5000,
}, () => {
const bus = new InMemoryPipelineEventsBus();
const survivor: PipelineStageEvent[] = [];
bus.subscribe(() => {
throw new Error("boom");
});
bus.subscribe((e) => survivor.push(e));
const event = ev("r1", "csrf", "completed", 1);
expect(() => bus.emit(event)).not.toThrow();
expect(survivor).toEqual([event]);
expect(bus.events).toEqual([event]);
});
it("unsubscribe is idempotent — calling twice does not double-detach", {
timeout: 5000,
}, () => {
const bus = new InMemoryPipelineEventsBus();
const seen: PipelineStageEvent[] = [];
const off = bus.subscribe((e) => seen.push(e));
expect(bus.subscriberCount).toBe(1);
off();
expect(bus.subscriberCount).toBe(0);
// Second call is a no-op.
expect(() => off()).not.toThrow();
expect(bus.subscriberCount).toBe(0);
// Re-subscribe to confirm the set still works.
const off2 = bus.subscribe((e) => seen.push(e));
bus.emit(ev("r1", "audit", "completed", 1));
expect(seen).toHaveLength(1);
off2();
});
it("drain returns + clears the FIFO atomically", { timeout: 5000 }, () => {
const bus = new InMemoryPipelineEventsBus();
bus.emit(ev("r1", "tenant", "entered", 1));
bus.emit(ev("r2", "tenant", "entered", 2));
const out = bus.drain();
expect(out).toHaveLength(2);
expect(bus.length).toBe(0);
expect(bus.events).toEqual([]);
});
it("byRequest + byStage slice the FIFO correctly", {
timeout: 5000,
}, () => {
const bus = new InMemoryPipelineEventsBus();
bus.emit(ev("r1", "tenant", "entered", 1));
bus.emit(ev("r2", "tenant", "entered", 2));
bus.emit(ev("r1", "auth", "entered", 3));
expect(bus.byRequest("r1")).toHaveLength(2);
expect(bus.byRequest("r2")).toHaveLength(1);
expect(bus.byStage("tenant")).toHaveLength(2);
expect(bus.byStage("auth")).toHaveLength(1);
expect(bus.byStage("response-validate")).toHaveLength(0);
});
it("events getter returns a fresh array snapshot each call", {
timeout: 5000,
}, () => {
const bus = new InMemoryPipelineEventsBus();
bus.emit(ev("r1", "audit", "completed", 1));
const snap1 = bus.events;
// Mutating the snapshot MUST NOT poison the internal FIFO.
(snap1 as PipelineStageEvent[]).pop();
expect(bus.length).toBe(1);
const snap2 = bus.events;
expect(snap2).toHaveLength(1);
});
});