// Test-only helpers — co-located with the orchestrator tests so the // recovery test and the conformance test can share them. Excluded // from production builds via the vitest config's // `*.types.ts`/`*.test.ts`-only barrel rules. import { Database } from "bun:sqlite"; import type { Principal, RequestContext } from "@suaveplan/api-types"; import { EventBus } from "@suaveplan/events"; import { IdempotencyGuard, MemoryIdempotencyStore, } from "@suaveplan/idempotency"; import type { SagaEventMap } from "../../src/events/events.types.js"; import { createSagaInstruments } from "../../src/instruments/instruments.js"; import type { SagaInstruments } from "../../src/instruments/instruments.types.js"; import { createSagaOrchestrator } from "../../src/orchestrator/orchestrator.js"; import type { SagaOrchestrator, SagaRequestContextFactory, } from "../../src/orchestrator/orchestrator.types.js"; import type { LedgerDb } from "../../src/persistence/persistence.types.js"; import { defineSagaParticipant, SagaParticipantRegistry, } from "../../src/registry/participant-registry.js"; import type { SagaActionHandler, SagaCompensateHandler, SagaOwnerDeclaration, } from "../../src/registry/participant-registry.types.js"; import { applySagaSchema } from "./saga-schema.js"; function buildNoopMeter(): SagaInstruments["meter"] { return { createCounter() { return { add() {} } as ReturnType< SagaInstruments["meter"]["createCounter"] >; }, createHistogram() { return { record() {} } as ReturnType< SagaInstruments["meter"]["createHistogram"] >; }, createObservableGauge() { return { addCallback() {}, removeCallback() {}, } as unknown as ReturnType< SagaInstruments["meter"]["createObservableGauge"] >; }, } as unknown as SagaInstruments["meter"]; } function buildNoopTracer(): SagaInstruments["tracer"] { return { startSpan() { return { end() {}, setAttribute() { return this; }, setAttributes() { return this; }, setStatus() { return this; }, recordException() {}, isRecording() { return false; }, updateName() { return this; }, addEvent() { return this; }, spanContext() { return { traceId: "t", spanId: "s", traceFlags: 0 }; }, } as ReturnType; }, } as unknown as SagaInstruments["tracer"]; } export function buildTestRequestContextFactory(): SagaRequestContextFactory { return ({ instance, stepName, principal, targetTenantId }) => ({ instance, stepName, principal, tenantId: targetTenantId, tenant: { id: targetTenantId, slug: targetTenantId }, request: new Request("http://saga.local/"), requestId: `req-${instance.id}-${stepName}`, span: { setAttribute() {}, setStatus() {}, recordException() {}, end() {}, }, db: null, secrets: { require() { throw new Error("no secrets"); }, }, capabilities: {}, log: () => undefined, now: () => Date.now(), requireSession: () => null, }) as unknown as RequestContext; } export interface TestStepSpec { readonly plugin: string; readonly action: string; readonly compensate: string; readonly handler: SagaActionHandler; readonly compensateHandler?: SagaCompensateHandler; readonly idempotency?: "auto" | "manual"; readonly timeoutMs?: number; } export interface TestHarness { readonly orchestrator: SagaOrchestrator; readonly registry: SagaParticipantRegistry; readonly bus: EventBus; readonly events: Map; readonly ledgerDb: LedgerDb; readonly instruments: SagaInstruments; } export function makeHarness(args: { readonly owner: SagaOwnerDeclaration; readonly steps: readonly TestStepSpec[]; readonly principal?: Principal; }): TestHarness { const db = new Database(":memory:") as unknown as LedgerDb; applySagaSchema(db); const registry = new SagaParticipantRegistry(); for (const s of args.steps) { registry.registerParticipant( defineSagaParticipant({ plugin: s.plugin, action: s.action, handler: s.handler, ...(s.compensateHandler !== undefined ? { compensate: s.compensateHandler } : {}), compensateName: s.compensate, ...(s.idempotency !== undefined ? { idempotency: s.idempotency } : {}), ...(s.timeoutMs !== undefined ? { timeoutMs: s.timeoutMs } : {}), }) ); } registry.registerOwner("owner-plugin", args.owner); const bus = new EventBus(); const events = new Map(); const eventNames: ReadonlyArray = [ "saga.step.requested", "saga.step.completed", "saga.step.failed", "saga.step.compensated", "saga.instance.started", "saga.instance.completed", "saga.instance.compensated", "saga.instance.compensation_failed", ]; for (const n of eventNames) { events.set(n, []); bus.on(n, (payload) => { events.get(n)?.push(payload); }); } const idempotency = new IdempotencyGuard({ store: new MemoryIdempotencyStore(), }); const instruments = createSagaInstruments( buildNoopMeter(), buildNoopTracer() ); const orchestrator = createSagaOrchestrator({ ledgerDb: db, registry, bus, idempotency, instruments, requestContextFactory: buildTestRequestContextFactory(), idGenerator: makeSequentialIdGen(), }); return { orchestrator, registry, bus, events, ledgerDb: db, instruments }; } function makeSequentialIdGen(): () => string { let n = 0; return () => { n += 1; return `saga-${n}`; }; }