213 lines
6.7 KiB
TypeScript
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}`;
|
|
};
|
|
}
|