Files
suaveplan/packages/api-stack/api/api-saga/tests/setup/test-helpers.ts
2026-05-27 23:29:53 +01:00

213 lines
6.7 KiB
TypeScript

// 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<SagaInstruments["tracer"]["startSpan"]>;
},
} 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<SagaEventMap>;
readonly events: Map<keyof SagaEventMap, unknown[]>;
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<SagaEventMap>();
const events = new Map<keyof SagaEventMap, unknown[]>();
const eventNames: ReadonlyArray<keyof SagaEventMap> = [
"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}`;
};
}