UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,596 lines (1,503 loc) 41.2 kB
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, enqueueEvent, 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 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 { 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> { [key: string]: 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; /** * 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; /** * 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> = { [K in keyof Ports]?: 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 { 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, 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; for (const entry of events.entries()) { await enqueueEvent(outbox, entry.event, entry.payload); } events.clear(); } 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, 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"> { const events = tx && typeof tx === "object" ? (tx as { events?: unknown }).events : undefined; const recorder = events as | Partial<Pick<BufferedDomainEventRecorder, "entries" | "clear">> | undefined; if ( !recorder || typeof recorder.entries !== "function" || typeof recorder.clear !== "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">; } /** * 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`. */ [disposeSymbol](): 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, [disposeSymbol]: 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())); }, }; }