@beignet/core
Version:
Core framework primitives for Beignet
1,637 lines (1,544 loc) • 42.4 kB
text/typescript
import {
createMemoryErrorReporter,
type MemoryErrorReporterPort,
} from "../error-reporting/index.js";
import { createMemoryFlags, type MemoryFlagsPort } from "../flags/index.js";
import {
createMemoryIdempotencyStore,
type MemoryIdempotencyStore,
} from "../idempotency/index.js";
import { createMemoryLocks, type MemoryLocksPort } from "../locks/index.js";
import { createMemoryMailer, type MemoryMailerPort } from "../mail/index.js";
import {
createMemoryNotificationPort,
type MemoryNotificationPort,
} from "../notifications/index.js";
import {
createMemoryOutbox,
createOutboxEventRecorder,
type MemoryOutboxPort,
} from "../outbox/index.js";
import {
createMemoryPayments,
type MemoryPaymentsPort,
} from "../payments/index.js";
import {
type ActivityActor,
type ActivityTenant,
type AuditLogPort,
createMemoryAuditLog,
type MemoryAuditLogPort,
} from "../ports/audit.js";
import type {
BestEffortWork,
BestEffortWorkPort,
} from "../ports/best-effort-work.js";
import { type CachePort, createMemoryCache } from "../ports/cache.js";
import {
type ClockPort,
createFrozenClock,
createSystemClock,
} from "../ports/clock.js";
import type { EventBusPort, JobDispatcherPort } from "../ports/events.js";
import {
createUuidIdGenerator,
type IdGeneratorPort,
} from "../ports/id-generator.js";
import {
type AnyPorts,
type BoundGate,
type BufferedDomainEventRecorder,
createGate,
createMemoryRateLimiter,
createMemoryStorage,
createNoopLogger,
createNoopUnitOfWork,
type GatePort,
type LoggerPort,
type RateLimitPort,
type StoragePort,
type UnitOfWorkPort,
} from "../ports/index.js";
import {
createRecordingBestEffortWork,
createRecordingEventBus,
createRecordingJobDispatcher,
createTestActivityContext,
createTestSystemActor,
createTestUserActor,
type RecordedEvent,
type RecordedJobDispatch,
} from "../ports/testing.js";
import type {
AnyServiceProvider,
ProviderSetupResult,
} from "../providers/provider.js";
import { createMemorySearch, type MemorySearchPort } from "../search/index.js";
import { createAmbientAuditLog } from "../server/audit-context.js";
import { loadProviderConfig } from "../server/providers/loadProviderConfig.js";
import {
clearActiveRequestContext,
enterActiveRequestContext,
} from "../server/request-context.js";
export * from "../ports/testing.js";
/**
* Value that may be returned synchronously or asynchronously.
*/
export type MaybePromise<T> = T | Promise<T>;
/**
* Common Beignet ports created by `createTestPorts(...)`.
*/
export interface CommonTestPorts<TxPorts = AnyPorts> {
[]: unknown;
/**
* Ambient-enriched in-memory audit log. Entries recorded through this port
* inherit actor, tenant, request ID, and trace ID from the active request
* context, matching production server behavior. Use the fixture's `audit`
* for entry assertions.
*/
audit: AuditLogPort;
/**
* In-memory string cache.
*/
cache: CachePort;
/**
* Mutable deterministic test clock.
*/
clock: ClockPort;
/**
* Recording event bus.
*/
eventBus: EventBusPort;
/**
* Recording non-durable best-effort work.
*/
bestEffortWork: BestEffortWorkPort;
/**
* In-memory error reporter.
*/
errorReporter: MemoryErrorReporterPort;
/**
* Authorization gate. Apps with real policies should provide their own gate
* through `overrides`.
*/
gate: GatePort<unknown, []>;
/**
* In-memory feature flags.
*/
flags: MemoryFlagsPort;
/**
* In-memory idempotency store.
*/
idempotency: MemoryIdempotencyStore;
/**
* Sequence or UUID generator used by tests.
*/
ids: IdGeneratorPort;
/**
* Recording job dispatcher.
*/
jobs: JobDispatcherPort;
/**
* No-op logger.
*/
logger: LoggerPort;
/**
* In-memory lease-backed locks.
*/
locks: MemoryLocksPort;
/**
* In-memory mailer.
*/
mailer: MemoryMailerPort;
/**
* In-memory notification port.
*/
notifications: MemoryNotificationPort;
/**
* In-memory outbox.
*/
outbox: MemoryOutboxPort;
/**
* In-memory payments port.
*/
payments: MemoryPaymentsPort;
/**
* In-memory search port.
*/
search: MemorySearchPort;
/**
* In-memory rate limiter.
*/
rateLimit: RateLimitPort;
/**
* In-memory object storage.
*/
storage: StoragePort;
/**
* No-op Unit of Work over the configured transaction ports.
*/
uow: UnitOfWorkPort<TxPorts>;
}
/**
* Captured state and ports returned by `createTestPorts(...)`.
*/
export interface TestPortsFixture<
Ports extends AnyPorts = CommonTestPorts,
TxPorts = Ports,
> {
/**
* App ports to pass into a use-case or route test context.
*/
ports: Ports;
/**
* Memory audit port, exposed for assertions.
*/
audit: MemoryAuditLogPort;
/**
* Recording event bus port.
*/
eventBus: EventBusPort;
/**
* Recording best-effort-work port.
*/
bestEffortWork: BestEffortWorkPort;
/**
* Work currently waiting in the recording best-effort-work adapter.
*/
pendingBestEffortWork: BestEffortWork[];
/**
* Run the currently pending best-effort-work batch in FIFO order.
*/
flushBestEffortWork(): Promise<void>;
/**
* Recorded events published through `eventBus`.
*/
events: RecordedEvent[];
/**
* Memory error reporter, exposed for assertions.
*/
errorReporter: MemoryErrorReporterPort;
/**
* Recording job dispatcher port.
*/
jobs: JobDispatcherPort;
/**
* Jobs dispatched through `jobs`.
*/
dispatchedJobs: RecordedJobDispatch[];
/**
* Memory mailer, exposed for delivery assertions.
*/
mailer: MemoryMailerPort;
/**
* Memory notification port, exposed for delivery assertions.
*/
notifications: MemoryNotificationPort;
/**
* Memory outbox, exposed for durable workflow assertions.
*/
outbox: MemoryOutboxPort;
/**
* Memory payments port.
*/
payments: MemoryPaymentsPort;
/**
* Memory storage port.
*/
storage: StoragePort;
/**
* Memory idempotency store.
*/
idempotency: MemoryIdempotencyStore;
/**
* Test cache port.
*/
cache: CachePort;
/**
* Test feature flags port.
*/
flags: MemoryFlagsPort;
/**
* Test rate-limit port.
*/
rateLimit: RateLimitPort;
/**
* Test logger port.
*/
logger: LoggerPort;
/**
* Test locks port.
*/
locks: MemoryLocksPort;
/**
* Test search port.
*/
search: MemorySearchPort;
/**
* Test clock port.
*/
clock: ClockPort;
/**
* Test ID generator.
*/
ids: IdGeneratorPort;
/**
* Unit of Work port installed on `ports`.
*/
uow: UnitOfWorkPort<TxPorts>;
}
/**
* Unit of Work behavior for `createTestPorts(...)`.
*/
export interface CreateTestPortsTransactionOptions<
Ports extends AnyPorts,
TxPorts,
> {
/**
* Transaction-scoped ports or a function that derives them from final ports.
*
* Defaults to the final `ports` object.
*/
ports?: TxPorts | ((ports: Ports) => TxPorts);
/**
* Hook run after the transaction callback resolves.
*/
afterCommit?: (tx: TxPorts) => MaybePromise<void>;
/**
* Hook run after the transaction callback throws.
*/
afterRollback?: (error: unknown, tx: TxPorts) => MaybePromise<void>;
/**
* Enqueue `tx.events` (a buffered domain event recorder) to `ports.outbox`
* after the transaction commits, and clear it after a rollback.
*
* Requires `transaction.ports` to include an `events` recorder created by
* `createDomainEventRecorder()`.
*/
outbox?: boolean;
}
/**
* Override shape for one test port.
*
* Object-valued ports may be supplied one level deep as partials: the missing
* members are completed behind a proxy that throws a named error on use.
* Function-valued ports must be supplied whole, so the check for functions
* happens before the object branch.
*/
export type TestPortOverride<Port> = Port extends (...args: never[]) => unknown
? Port
: Port extends object
? Partial<Port>
: Port;
/**
* Typed partial port overrides accepted by `createTestPorts(...)` and
* `createTestContext(...)`.
*/
export type TestPortsOverrides<Ports extends AnyPorts> = {
[]?: TestPortOverride<Ports[K]>;
};
/**
* Options for `createTestPorts(...)`.
*/
export interface CreateTestPortsOptions<
Ports extends AnyPorts,
TxPorts = Ports,
> {
/**
* App-owned default ports, usually imported from `infra/port-wiring`. Common
* Beignet test defaults replace matching keys from `base`; use `overrides`
* for app ports that should win.
*
* Values are intentionally loose: boot-time app ports may bind wider types
* (or deferred placeholders) than the provider-contributed runtime ports.
*/
base?: { [K in keyof Ports]?: unknown };
/**
* Test-specific ports that should replace generated defaults or `base`.
*
* Plain-object ports may be supplied as one-level-deep partials; missing
* members throw a named error when called. Function-valued ports, class
* instances, and other exotic objects are passed through whole.
*/
overrides?: TestPortsOverrides<Ports>;
/**
* Clock implementation. Defaults to a frozen clock at the Unix epoch.
*/
clock?: ClockPort;
/**
* ID generator. Defaults to UUIDs.
*/
ids?: IdGeneratorPort;
/**
* Unit of Work configuration. A no-op UOW is installed by default.
*/
transaction?: CreateTestPortsTransactionOptions<Ports, TxPorts>;
}
/**
* Create standard memory/fake Beignet ports for tests.
*
* Use this as the starting point for use-case and route tests, then layer
* app-owned repositories or provider fakes through `base` and `overrides`.
*
* @param options - Optional app ports, overrides, and Unit of Work behavior.
* @returns Ports plus captured state for assertions.
*/
export function createTestPorts<
Ports extends AnyPorts = CommonTestPorts,
TxPorts = Ports,
>(
options: CreateTestPortsOptions<Ports, TxPorts> = {},
): TestPortsFixture<Ports, TxPorts> {
const audit = createMemoryAuditLog();
const cache = createMemoryCache();
const clock = options.clock ?? createFrozenClock();
const {
bestEffortWork,
pending: pendingBestEffortWork,
flush: flushBestEffortWork,
} = createRecordingBestEffortWork();
const { bus: eventBus, events } = createRecordingEventBus();
const errorReporter = createMemoryErrorReporter();
const flags = createMemoryFlags();
const { jobs, dispatchedJobs } = createRecordingJobDispatcher();
const ids = options.ids ?? createUuidIdGenerator();
const idempotency = createMemoryIdempotencyStore({
now: () => clock.now(),
});
const logger = createNoopLogger();
const locks = createMemoryLocks({
now: () => clock.now(),
sleep: createClockAwareSleep(clock),
});
const mailer = createMemoryMailer();
const notifications = createMemoryNotificationPort();
const outbox = createMemoryOutbox({
id: () => ids.nextId(),
now: () => clock.now(),
});
const payments = createMemoryPayments();
const rateLimit = createMemoryRateLimiter();
const search = createMemorySearch();
const storage = createMemoryStorage();
const defaultPorts = {
audit: createAmbientAuditLog(audit),
cache,
clock,
bestEffortWork,
eventBus,
errorReporter,
flags,
gate: createGate({ policies: [] }),
idempotency,
ids,
jobs,
logger,
locks,
mailer,
notifications,
outbox,
payments,
rateLimit,
search,
storage,
};
const overrides = completeTestPortOverrides(options.overrides);
// The kit's one sanctioned cast: boot-time bases and completed partial
// overrides are widened to the app's full Ports shape.
const portsWithoutUow = {
...options.base,
...defaultPorts,
...overrides,
} as unknown as Ports;
const transactionOptions = options.transaction;
const flushEventsToOutbox = transactionOptions?.outbox === true;
const resolveTransactionPorts = (): TxPorts => {
const txPorts = transactionOptions?.ports;
if (typeof txPorts === "function") {
return (txPorts as (ports: Ports) => TxPorts)(portsWithoutUow);
}
return txPorts ?? (portsWithoutUow as unknown as TxPorts);
};
const generatedUow = createNoopUnitOfWork<TxPorts>(
() => {
const tx = resolveTransactionPorts();
if (flushEventsToOutbox) {
// Validate up front so both commit and rollback paths fail loudly.
resolveBufferedTransactionEvents(tx);
}
return tx;
},
{
afterCommit: async (tx) => {
if (flushEventsToOutbox) {
const events = resolveBufferedTransactionEvents(tx);
const outbox = (
portsWithoutUow as unknown as {
outbox: MemoryOutboxPort;
}
).outbox;
const outboxRecorder = createOutboxEventRecorder(outbox);
await events.flush({
publish(event, payload, options) {
return outboxRecorder.record(event, payload, options);
},
subscribe() {
throw new Error(
"The test outbox event publisher does not support subscriptions.",
);
},
});
}
await transactionOptions?.afterCommit?.(tx);
},
afterRollback: async (error, tx) => {
if (flushEventsToOutbox) {
resolveBufferedTransactionEvents(tx).clear();
}
await transactionOptions?.afterRollback?.(error, tx);
},
},
);
// The kit consumes `uow` directly, so keep the supplied port's identity
// instead of the completed override.
const uow =
(options.overrides as { uow?: UnitOfWorkPort<TxPorts> } | undefined)?.uow ??
generatedUow;
const ports = {
...portsWithoutUow,
uow,
} as Ports;
return {
ports,
audit,
bestEffortWork,
pendingBestEffortWork,
flushBestEffortWork,
eventBus,
events,
errorReporter,
jobs,
dispatchedJobs,
mailer,
notifications,
outbox,
payments,
storage,
idempotency,
cache,
flags,
rateLimit,
logger,
locks,
search,
clock,
ids,
uow,
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object") return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function createClockAwareSleep(
clock: ClockPort,
): (ms: number) => Promise<void> {
const maybeFrozenClock = clock as Partial<{ advance(ms: number): void }>;
if (typeof maybeFrozenClock.advance === "function") {
return async (ms) => {
maybeFrozenClock.advance?.(ms);
};
}
return (ms) => new Promise((resolve) => setTimeout(resolve, ms));
}
function createMissingTestPortMember(
portKey: string,
member: string,
): () => never {
const memberName = `${portKey}.${member}`;
const throwMissingMember = () => {
throw new Error(
`Test port "${memberName}" was called but not provided. Pass it through createTestPorts overrides.`,
);
};
Object.defineProperty(throwMissingMember, "name", {
value: memberName,
configurable: true,
});
return throwMissingMember;
}
function completeTestPortOverride(portKey: string, value: unknown): unknown {
// Functions are objects: the function check must run first so
// function-valued ports pass through whole.
if (typeof value === "function") return value;
// Class instances (repositories, Maps, gates built from classes) rely on
// internal slots or prototype identity, so only plain objects are completed.
if (!isPlainObject(value)) return value;
return new Proxy(value, {
get(target, member, receiver) {
if (
typeof member === "symbol" ||
member === "then" ||
member === "constructor" ||
member in target
) {
return Reflect.get(target, member, receiver);
}
return createMissingTestPortMember(portKey, member);
},
});
}
function completeTestPortOverrides<Ports extends AnyPorts>(
overrides: TestPortsOverrides<Ports> | undefined,
): Record<string, unknown> {
const completed: Record<string, unknown> = {};
for (const [key, value] of Object.entries(overrides ?? {})) {
if (value === undefined) continue;
completed[key] = completeTestPortOverride(key, value);
}
return completed;
}
function resolveBufferedTransactionEvents(
tx: unknown,
): Pick<BufferedDomainEventRecorder, "entries" | "clear" | "flush"> {
const events =
tx && typeof tx === "object"
? (tx as { events?: unknown }).events
: undefined;
const recorder = events as
| Partial<Pick<BufferedDomainEventRecorder, "entries" | "clear" | "flush">>
| undefined;
if (
!recorder ||
typeof recorder.entries !== "function" ||
typeof recorder.clear !== "function" ||
typeof recorder.flush !== "function"
) {
throw new Error(
"createTestPorts transaction.outbox requires tx.events to be a buffered domain event recorder. " +
"Add events: createDomainEventRecorder() to transaction.ports.",
);
}
return recorder as Pick<
BufferedDomainEventRecorder,
"entries" | "clear" | "flush"
>;
}
/**
* Bound gate type inferred from a ports object.
*/
export type TestBoundGateForPorts<Ports extends AnyPorts> = Ports extends {
gate?: { bind: (ctx: never) => infer Bound };
}
? Bound
: BoundGate<readonly []>;
/**
* Minimal gate shape used by `createTestContextFactory(...)`.
*/
type TestGatePort<BoundGateValue = BoundGate<readonly []>> = {
/**
* Bind the gate to a context.
*/
bind: (ctx: never) => BoundGateValue;
};
/**
* Common app context fields built by `createTestContextFactory(...)`.
*/
export type TestContextFields<Ports extends AnyPorts> = {
/**
* Actor under test.
*/
actor: ActivityActor;
/**
* Optional tenant under test.
*/
tenant?: ActivityTenant;
/**
* Stable request ID.
*/
requestId: string;
/**
* Stable trace ID.
*/
traceId?: string;
/**
* Optional auth/session value.
*/
auth?: unknown;
/**
* App ports under test.
*/
ports: Ports;
/**
* Bound authorization gate when `ports.gate` is available.
*/
gate?: TestBoundGateForPorts<Ports>;
};
/**
* Options for `createTestContextFactory(...)`.
*/
export interface CreateTestContextFactoryOptions<Ctx, Ports extends AnyPorts> {
/**
* Ports or a callback returning ports for each created context.
*/
ports: Ports | (() => Ports);
/**
* Actor used by default.
*
* @default createTestUserActor()
*/
actor?: ActivityActor;
/**
* Tenant used by default. Pass `null` to omit tenant context.
*/
tenant?: ActivityTenant | null;
/**
* Request ID used by default.
*/
requestId?: string;
/**
* Trace ID used by default.
*/
traceId?: string;
/**
* Auth/session value added to the context.
*/
auth?: unknown;
/**
* Additional app-specific context fields.
*/
extra?: Partial<Ctx> | ((args: TestContextFields<Ports>) => Partial<Ctx>);
}
/**
* Per-call overrides for a test context factory.
*/
export type TestContextFactoryOverrides<Ctx, Ports extends AnyPorts> = Partial<
Pick<
CreateTestContextFactoryOptions<Ctx, Ports>,
"actor" | "tenant" | "requestId" | "traceId" | "auth" | "extra"
>
> & {
/**
* Ports for this context call.
*/
ports?: Ports;
};
/**
* Create a repeatable app context factory for use-case and route tests.
*
* The factory adds actor, tenant, request ID, trace ID, ports, and auth fields,
* and attaches a live `ctx.gate` automatically when `ports.gate` exposes
* `bind(...)`. The gate is attached after all `extra` and override fields are
* merged, so it always authorizes against the final context identity.
*
* @param options - Default context values.
* @returns A function that creates one app context.
*/
export function createTestContextFactory<
Ctx extends object,
Ports extends AnyPorts,
>(
options: CreateTestContextFactoryOptions<Ctx, Ports>,
): (overrides?: TestContextFactoryOverrides<Ctx, Ports>) => Ctx {
return (overrides = {}) => {
const ports = overrides.ports ?? resolvePorts(options.ports);
const activity = createTestActivityContext({
actor: overrides.actor ?? options.actor ?? createTestUserActor(),
tenant:
overrides.tenant !== undefined ? overrides.tenant : options.tenant,
requestId: overrides.requestId ?? options.requestId,
traceId: overrides.traceId ?? options.traceId,
});
const auth =
overrides.auth !== undefined ? overrides.auth : (options.auth ?? null);
const base = {
...activity,
auth,
ports,
} as TestContextFields<Ports>;
const fields = attachTestGate(ports, base);
const extra = resolveExtra(options.extra, fields);
const overrideExtra = resolveExtra(overrides.extra, fields);
const merged = {
...fields,
...extra,
...overrideExtra,
};
if (Object.hasOwn(merged, "gate")) {
return merged as Ctx;
}
return attachTestGate(ports, merged) as Ctx;
};
}
function resolvePorts<Ports extends AnyPorts>(
ports: Ports | (() => Ports),
): Ports {
return typeof ports === "function" ? (ports as () => Ports)() : ports;
}
function resolveExtra<Ctx, Ports extends AnyPorts>(
extra:
| Partial<Ctx>
| ((args: TestContextFields<Ports>) => Partial<Ctx>)
| undefined,
fields: TestContextFields<Ports>,
): Partial<Ctx> {
return typeof extra === "function" ? extra(fields) : (extra ?? {});
}
function attachTestGate<Ports extends AnyPorts, Ctx extends object>(
ports: Ports,
ctx: Ctx,
): Ctx {
const maybeGate = (
ports as { gate?: TestGatePort<TestBoundGateForPorts<Ports>> }
).gate;
if (!maybeGate) return ctx;
// Mirror GatePort.attach: a live, non-enumerable getter that re-binds
// against the receiver so in-place identity changes are never stale and
// spread copies drop the gate loudly.
Object.defineProperty(ctx, "gate", {
configurable: true,
enumerable: false,
get() {
return maybeGate.bind(this as never);
},
});
return ctx;
}
const disposeSymbol: typeof Symbol.dispose =
Symbol.dispose ?? (Symbol.for("Symbol.dispose") as typeof Symbol.dispose);
/**
* Options for `createTestContext(...)`.
*/
export interface CreateTestContextOptions<Ctx, Ports extends AnyPorts> {
/**
* Actor under test.
*
* @default createTestSystemActor("test-system")
*/
actor?: ActivityActor;
/**
* Tenant under test. Pass `null` to omit tenant context.
*
* @default createTestTenant()
*/
tenant?: ActivityTenant | null;
/**
* Request ID exposed on the context.
*
* @default "test-request"
*/
requestId?: string;
/**
* Trace ID exposed on the context.
*
* @default "test-trace"
*/
traceId?: string;
/**
* Auth/session value added to the context.
*
* @default null
*/
auth?: unknown;
/**
* App-owned default ports, usually imported from `infra/port-wiring`. Common
* Beignet test defaults replace matching keys from `base`; use `ports` for
* app ports that should win.
*/
base?: { [K in keyof Ports]?: unknown };
/**
* Typed partial port overrides that replace generated defaults or `base`.
*/
ports?: TestPortsOverrides<Ports>;
/**
* Clock implementation. Defaults to a frozen clock at the Unix epoch.
*/
clock?: ClockPort;
/**
* ID generator. Defaults to UUIDs.
*/
ids?: IdGeneratorPort;
/**
* Unit of Work configuration. A no-op UOW is installed by default.
*/
transaction?: CreateTestPortsTransactionOptions<Ports, unknown>;
/**
* Additional app-specific context fields. An explicit `gate` here wins over
* the kit-attached gate.
*/
extra?: Partial<Ctx>;
/**
* Enter the ambient request context with the test actor, tenant, request
* ID, and trace ID so ambient enrichment (such as the default audit port)
* works like production. Call `dispose()` (or use `using`) to clear it.
*
* @default true
*/
ambient?: boolean;
}
/**
* Test context fixture returned by `createTestContext(...)`.
*/
export interface TestContextFixture<Ctx, Ports extends AnyPorts>
extends TestPortsFixture<Ports, unknown> {
/**
* Assembled app context under test.
*/
ctx: Ctx;
/**
* Clear the ambient request context entered by this fixture.
*/
dispose(): void;
/**
* Explicit-resource-management alias for `dispose()`, so fixtures work with
* `using`.
*/
[](): void;
}
/**
* Create a one-call test context fixture for jobs, listeners, schedules,
* notifications, payments, tasks, and use-case tests.
*
* The fixture builds common memory ports through `createTestPorts(...)`,
* assembles an app context with actor, tenant, request ID, trace ID, auth,
* and a live bound gate, and enters the ambient request context so ambient
* enrichment matches production. Reading an app port that is neither a kit
* default nor supplied throws a named error on use.
*
* @example
* ```ts
* const makeContext = createTestContext<AppContext>();
*
* using fixture = makeContext({
* ports: { issues: { create: async (input) => issueRecord(input) } },
* });
* await runCreateIssue(fixture.ctx);
* ```
*
* @returns A factory that creates one disposable test context fixture.
*/
export function createTestContext<Ctx extends { ports: AnyPorts }>(): (
options?: CreateTestContextOptions<Ctx, Ctx["ports"]>,
) => TestContextFixture<Ctx, Ctx["ports"]> {
return (options = {}) => {
const fixture = createTestPorts<Ctx["ports"], unknown>({
base: options.base,
overrides: options.ports,
clock: options.clock,
ids: options.ids,
transaction: options.transaction,
});
const ports = createUnboundPortGuard(fixture.ports);
const activity = createTestActivityContext({
actor: options.actor ?? createTestSystemActor("test-system"),
tenant: options.tenant,
requestId: options.requestId,
traceId: options.traceId,
});
const auth = options.auth !== undefined ? options.auth : null;
const base = {
...activity,
auth,
ports,
} as TestContextFields<Ctx["ports"]>;
const fields = attachTestGate(ports, base);
const merged = {
...fields,
...(options.extra ?? {}),
};
const ctx = (Object.hasOwn(merged, "gate")
? merged
: attachTestGate(ports, merged)) as unknown as Ctx;
const ambient = options.ambient ?? true;
if (ambient) {
enterActiveRequestContext({
requestId: activity.requestId,
traceId: activity.traceId,
actor: activity.actor,
tenant: activity.tenant,
});
}
let disposed = false;
const dispose = () => {
if (!ambient || disposed) return;
disposed = true;
clearActiveRequestContext();
};
return {
...fixture,
ports,
ctx,
dispose,
[]: dispose,
};
};
}
function createUnboundPortGuard<Ports extends AnyPorts>(ports: Ports): Ports {
return new Proxy(ports, {
get(target, key, receiver) {
if (
typeof key === "symbol" ||
key === "then" ||
key === "constructor" ||
key in target
) {
return Reflect.get(target, key, receiver);
}
throw new Error(
`App port "${key}" is not bound in this test context. Pass it through createTestContext ports.`,
);
},
});
}
/**
* Options for `installProviderForTest(...)`.
*/
export interface InstallProviderForTestOptions {
/**
* Environment variables used to resolve the provider's config through the
* same loader the server uses (including the provider's `overrides`).
* Ignored when `config` is passed explicitly.
*/
env?: Record<string, string | undefined>;
/**
* Base app ports visible to provider setup, such as `{ devtools }`.
*/
ports?: Record<string, unknown>;
/**
* Config passed to provider setup as-is. The provider config schema is not
* run, matching server startup where config is validated before setup.
*/
config?: unknown;
/**
* Service-context factory passed to setup and lifecycle hooks. The default
* factory rejects, mirroring the late-bound runtime factory before all
* providers have started.
*/
createServiceContext?: (input?: unknown) => Promise<unknown>;
}
/**
* Installed provider fixture returned by `installProviderForTest(...)`.
*/
export interface InstalledProviderFixture {
/**
* Base ports merged with the ports contributed by provider setup.
*/
ports: Record<string, unknown>;
/**
* Raw provider setup result, exposed for assertions on contributed ports
* and optional lifecycle hooks.
*/
result: ProviderSetupResult<Record<string, unknown>, Record<string, unknown>>;
/**
* Run the provider `start` hook when the provider declares one.
*/
start(): Promise<void>;
/**
* Run the provider `stop` hook when the provider declares one.
*/
stop(): Promise<void>;
}
/**
* Run provider setup against test ports and return the merged ports plus
* lifecycle runners.
*
* Use this in provider tests instead of hand-rolling the setup, port merge,
* and lifecycle plumbing around `provider.setup(...)`.
*
* @param provider - Provider under test.
* @param options - Base ports, raw config, and service-context factory.
* @returns Merged ports, the raw setup result, and start/stop runners.
*/
export async function installProviderForTest(
provider: AnyServiceProvider,
options: InstallProviderForTestOptions = {},
): Promise<InstalledProviderFixture> {
const basePorts: Record<string, unknown> = { ...options.ports };
const createServiceContext =
options.createServiceContext ??
(async () => {
throw new Error(
`Provider "${provider.name}" called createServiceContext during a test install. ` +
"Pass createServiceContext to installProviderForTest(...) when the test needs a service context.",
);
});
const config =
options.config !== undefined
? options.config
: await loadProviderConfig(provider, options.env ?? {}, {});
const result = await provider.setup({
ports: basePorts,
config,
createServiceContext,
});
const ports: Record<string, unknown> = { ...basePorts, ...result.ports };
const lifecycleCtx = { ports, createServiceContext };
return {
ports,
result,
async start() {
await result.start?.(lifecycleCtx);
},
async stop() {
await result.stop?.(lifecycleCtx);
},
};
}
/**
* Arguments passed to a factory default builder.
*/
export interface FactoryBuildArgs<Name extends string = string> {
/**
* Factory name.
*/
readonly name: Name;
/**
* Current sequence value.
*/
readonly sequence: number;
/**
* ID generator available to the factory.
*/
readonly ids: IdGeneratorPort;
/**
* Clock available to the factory.
*/
readonly clock: ClockPort;
}
/**
* Arguments passed to a factory persistence function.
*/
export type FactoryPersistArgs<Name extends string = string> =
FactoryBuildArgs<Name>;
/**
* Overrides accepted by factory build and create methods.
*/
export type FactoryOverrides<
Value extends object,
Name extends string = string,
> =
| Partial<Value>
| ((value: Value, args: FactoryBuildArgs<Name>) => Partial<Value>);
/**
* Options for declaring a test data factory.
*/
export interface CreateFactoryOptions<
Name extends string,
Value extends object,
Ctx = unknown,
Created = Value,
> {
/**
* Build the default value for the current sequence.
*/
defaults(args: FactoryBuildArgs<Name>): Value;
/**
* Persist a built value and return the created record.
*/
persist?(
ctx: Ctx,
value: Value,
args: FactoryPersistArgs<Name>,
): MaybePromise<Created>;
/**
* ID generator to expose to the factory.
*/
ids?: IdGeneratorPort;
/**
* Clock to expose to the factory.
*/
clock?: ClockPort;
/**
* Initial sequence number. Defaults to `1`.
*/
start?: number;
}
/**
* Declared test data factory.
*/
export interface FactoryDef<
Name extends string = string,
Value extends object = object,
Ctx = unknown,
Created = Value,
> {
/**
* Discriminator for factory definitions.
*/
readonly kind: "factory";
/**
* Factory name.
*/
readonly name: Name;
/**
* Build an in-memory value.
*/
build(overrides?: FactoryOverrides<Value, Name>): Value;
/**
* Build multiple in-memory values.
*/
buildList(count: number, overrides?: FactoryOverrides<Value, Name>): Value[];
/**
* Build and persist a value.
*/
create(ctx: Ctx, overrides?: FactoryOverrides<Value, Name>): Promise<Created>;
/**
* Build and persist multiple values.
*/
createList(
ctx: Ctx,
count: number,
overrides?: FactoryOverrides<Value, Name>,
): Promise<Created[]>;
/**
* Reset the factory sequence.
*/
resetSequence(next?: number): void;
}
/**
* Options for declaring a seed.
*/
export interface DefineSeedOptions<Ctx> {
/**
* Optional human-readable seed description.
*/
description?: string;
/**
* Execute the seed.
*/
run(ctx: Ctx): MaybePromise<void>;
}
/**
* Declared seed that can be run with `runSeeds(...)`.
*/
export interface SeedDef<Ctx = unknown, Name extends string = string> {
/**
* Discriminator for seed definitions.
*/
readonly kind: "seed";
/**
* Seed name.
*/
readonly name: Name;
/**
* Optional human-readable seed description.
*/
readonly description?: string;
/**
* Execute the seed.
*/
run(ctx: Ctx): MaybePromise<void>;
}
/**
* Options for running a list of seeds.
*/
export interface RunSeedsOptions<Ctx> {
/**
* Context passed to every seed.
*/
ctx: Ctx;
/**
* Seeds to run in order.
*/
seeds: readonly SeedDef<Ctx>[];
}
/**
* Options for creating a database test harness.
*/
export interface CreateDatabaseTestHarnessOptions<Database, Ctx> {
/**
* Create an isolated app-owned database fixture.
*/
create(): MaybePromise<Database>;
/**
* Build the context passed to factories and seeds from the database fixture.
*/
ctx(database: Database): Ctx;
/**
* Reset the database fixture, when supported by the app.
*/
reset?(database: Database): MaybePromise<void>;
/**
* Close and dispose the database fixture.
*/
close?(database: Database): MaybePromise<void>;
/**
* Factories whose sequences should reset before each setup and reset.
*/
factories?: readonly Pick<FactoryDef, "resetSequence">[];
/**
* Default seeds available to `setup({ seed: true })` and `session.runSeeds()`.
*/
seeds?: readonly SeedDef<Ctx>[];
}
/**
* Options for setting up one database test session.
*/
export interface DatabaseTestSetupOptions<Ctx> {
/**
* Run seeds after creating the database session.
*
* Pass `true` to use the harness default seeds, or pass an explicit seed
* list for this setup.
*/
seed?: boolean | readonly SeedDef<Ctx>[];
}
/**
* Active database fixture created by `createDatabaseTestHarness(...)`.
*/
export interface DatabaseTestSession<Database, Ctx> {
/**
* App-owned database fixture returned by the harness `create` function.
*/
readonly database: Database;
/**
* Context built from the database fixture for factories and seeds.
*/
readonly ctx: Ctx;
/**
* Run the default harness seeds or an explicit seed list.
*/
runSeeds(seeds?: readonly SeedDef<Ctx>[]): Promise<void>;
/**
* Reset factory sequences and the database fixture.
*/
reset(): Promise<void>;
/**
* Close and dispose this session.
*/
close(): Promise<void>;
}
/**
* Database test harness that coordinates app-owned database fixtures with
* Beignet factories and seeds.
*/
export interface DatabaseTestHarness<Database, Ctx> {
/**
* Create one isolated database test session.
*/
setup(
options?: DatabaseTestSetupOptions<Ctx>,
): Promise<DatabaseTestSession<Database, Ctx>>;
/**
* Reset all configured factory sequences.
*/
resetFactories(): void;
/**
* Close all sessions that have not already been closed.
*/
cleanup(): Promise<void>;
}
/**
* Error thrown when a seed fails.
*/
export class SeedRunError extends Error {
/**
* Seed that failed.
*/
readonly seed: SeedDef;
/**
* Original thrown value.
*/
readonly cause: unknown;
constructor(seed: SeedDef, cause: unknown) {
super(`Seed "${seed.name}" failed: ${errorMessage(cause)}`);
this.name = "SeedRunError";
this.seed = seed;
this.cause = cause;
}
}
/**
* Reset one or more factory sequences to their configured starting values.
*/
export function resetFactories(
...factories: readonly Pick<FactoryDef, "resetSequence">[]
): void {
for (const factory of factories) {
factory.resetSequence();
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function factoryArgs<Name extends string>(args: {
name: Name;
sequence: number;
ids: IdGeneratorPort;
clock: ClockPort;
}): FactoryBuildArgs<Name> {
return args;
}
function applyOverrides<Value extends object, Name extends string>(
value: Value,
args: FactoryBuildArgs<Name>,
overrides: FactoryOverrides<Value, Name> | undefined,
): Value {
if (!overrides) return value;
const resolved =
typeof overrides === "function" ? overrides(value, args) : overrides;
return {
...value,
...resolved,
};
}
function assertCount(kind: "buildList" | "createList", count: number): void {
if (!Number.isInteger(count) || count < 0) {
throw new Error(`Factory ${kind} count must be a non-negative integer.`);
}
}
/**
* Create a typed test data factory with deterministic sequence support.
*/
export function createFactory<
const Name extends string,
Value extends object,
Ctx = unknown,
Created = Value,
>(
name: Name,
options: CreateFactoryOptions<Name, Value, Ctx, Created>,
): FactoryDef<Name, Value, Ctx, Created> {
const start = options.start ?? 1;
const ids = options.ids ?? createUuidIdGenerator();
const clock = options.clock ?? createSystemClock();
let nextSequence = start;
function build(overrides?: FactoryOverrides<Value, Name>): Value {
const args = factoryArgs({
name,
sequence: nextSequence++,
ids,
clock,
});
const value = options.defaults(args);
return applyOverrides(value, args, overrides);
}
async function create(
ctx: Ctx,
overrides?: FactoryOverrides<Value, Name>,
): Promise<Created> {
if (!options.persist) {
throw new Error(
`Factory "${name}" cannot create persisted records without a persist function.`,
);
}
const value = build(overrides);
const args = factoryArgs({
name,
sequence: nextSequence - 1,
ids,
clock,
});
return options.persist(ctx, value, args);
}
return {
kind: "factory",
name,
build,
buildList(count, overrides) {
assertCount("buildList", count);
return Array.from({ length: count }, () => build(overrides));
},
create,
async createList(ctx, count, overrides) {
assertCount("createList", count);
const created: Created[] = [];
for (let index = 0; index < count; index += 1) {
created.push(await create(ctx, overrides));
}
return created;
},
resetSequence(next = start) {
nextSequence = next;
},
};
}
/**
* Define a named seed.
*/
export function defineSeed<const Name extends string, Ctx = unknown>(
name: Name,
options: DefineSeedOptions<Ctx>,
): SeedDef<Ctx, Name> {
return {
kind: "seed",
name,
description: options.description,
run: options.run,
};
}
/**
* Run seeds in order and wrap failures with the seed name.
*/
export async function runSeeds<Ctx>(
options: RunSeedsOptions<Ctx>,
): Promise<void> {
for (const seed of options.seeds) {
try {
await seed.run(options.ctx);
} catch (error) {
throw new SeedRunError(seed, error);
}
}
}
/**
* Create a database test harness for app-owned database fixtures.
*
* The harness does not know about an ORM or database provider. Apps provide
* creation, reset, close, and context mapping functions, while Beignet handles
* the repetitive test lifecycle around factory sequences and seed execution.
*/
export function createDatabaseTestHarness<Database, Ctx>(
options: CreateDatabaseTestHarnessOptions<Database, Ctx>,
): DatabaseTestHarness<Database, Ctx> {
const sessions = new Set<DatabaseTestSession<Database, Ctx>>();
function resetFactorySequences(): void {
resetFactories(...(options.factories ?? []));
}
function defaultSeeds(): readonly SeedDef<Ctx>[] {
return options.seeds ?? [];
}
async function closeSession(
session: DatabaseTestSession<Database, Ctx>,
): Promise<void> {
if (!sessions.delete(session)) return;
await options.close?.(session.database);
}
return {
async setup(setupOptions = {}) {
resetFactorySequences();
const database = await options.create();
const ctx = options.ctx(database);
const session: DatabaseTestSession<Database, Ctx> = {
database,
ctx,
async runSeeds(seeds = defaultSeeds()) {
await runSeeds({ ctx, seeds });
},
async reset() {
resetFactorySequences();
await options.reset?.(database);
},
async close() {
await closeSession(session);
},
};
sessions.add(session);
try {
if (setupOptions.seed) {
const seeds =
setupOptions.seed === true ? defaultSeeds() : setupOptions.seed;
await session.runSeeds(seeds);
}
} catch (error) {
await session.close();
throw error;
}
return session;
},
resetFactories: resetFactorySequences,
async cleanup() {
await Promise.all(Array.from(sessions, (session) => session.close()));
},
};
}