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); }); });