UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

596 lines 20.4 kB
import { createMemoryErrorReporter, } from "../error-reporting/index.js"; import { createMemoryFlags } from "../flags/index.js"; import { createMemoryIdempotencyStore, } from "../idempotency/index.js"; import { createMemoryLocks } from "../locks/index.js"; import { createMemoryMailer } from "../mail/index.js"; import { createMemoryNotificationPort, } from "../notifications/index.js"; import { createMemoryOutbox, enqueueEvent, } from "../outbox/index.js"; import { createMemoryPayments, } from "../payments/index.js"; import { createMemoryAuditLog, } from "../ports/audit.js"; import { createMemoryCache } from "../ports/cache.js"; import { createFrozenClock, createSystemClock, } from "../ports/clock.js"; import { createUuidIdGenerator, } from "../ports/id-generator.js"; import { createGate, createMemoryRateLimiter, createMemoryStorage, createNoopLogger, createNoopUnitOfWork, } from "../ports/index.js"; import { createRecordingEventBus, createRecordingJobDispatcher, createTestActivityContext, createTestSystemActor, createTestUserActor, } from "../ports/testing.js"; import { createMemorySearch } 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"; /** * 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(options = {}) { 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, }; const transactionOptions = options.transaction; const flushEventsToOutbox = transactionOptions?.outbox === true; const resolveTransactionPorts = () => { const txPorts = transactionOptions?.ports; if (typeof txPorts === "function") { return txPorts(portsWithoutUow); } return txPorts ?? portsWithoutUow; }; const generatedUow = createNoopUnitOfWork(() => { 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.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?.uow ?? generatedUow; const ports = { ...portsWithoutUow, uow, }; 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) { if (value === null || typeof value !== "object") return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } function createClockAwareSleep(clock) { const maybeFrozenClock = clock; if (typeof maybeFrozenClock.advance === "function") { return async (ms) => { maybeFrozenClock.advance?.(ms); }; } return (ms) => new Promise((resolve) => setTimeout(resolve, ms)); } function createMissingTestPortMember(portKey, member) { 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, value) { // 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(overrides) { const completed = {}; for (const [key, value] of Object.entries(overrides ?? {})) { if (value === undefined) continue; completed[key] = completeTestPortOverride(key, value); } return completed; } function resolveBufferedTransactionEvents(tx) { const events = tx && typeof tx === "object" ? tx.events : undefined; const recorder = events; 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; } /** * 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(options) { 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, }; 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; } return attachTestGate(ports, merged); }; } function resolvePorts(ports) { return typeof ports === "function" ? ports() : ports; } function resolveExtra(extra, fields) { return typeof extra === "function" ? extra(fields) : (extra ?? {}); } function attachTestGate(ports, ctx) { const maybeGate = 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); }, }); return ctx; } const disposeSymbol = Symbol.dispose ?? Symbol.for("Symbol.dispose"); /** * 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() { return (options = {}) => { const fixture = createTestPorts({ 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, }; const fields = attachTestGate(ports, base); const merged = { ...fields, ...(options.extra ?? {}), }; const ctx = (Object.hasOwn(merged, "gate") ? merged : attachTestGate(ports, merged)); 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) { 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.`); }, }); } /** * 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, options = {}) { const basePorts = { ...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 = { ...basePorts, ...result.ports }; const lifecycleCtx = { ports, createServiceContext }; return { ports, result, async start() { await result.start?.(lifecycleCtx); }, async stop() { await result.stop?.(lifecycleCtx); }, }; } /** * Error thrown when a seed fails. */ export class SeedRunError extends Error { /** * Seed that failed. */ seed; /** * Original thrown value. */ cause; constructor(seed, cause) { 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) { for (const factory of factories) { factory.resetSequence(); } } function errorMessage(error) { return error instanceof Error ? error.message : String(error); } function factoryArgs(args) { return args; } function applyOverrides(value, args, overrides) { if (!overrides) return value; const resolved = typeof overrides === "function" ? overrides(value, args) : overrides; return { ...value, ...resolved, }; } function assertCount(kind, count) { 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(name, options) { const start = options.start ?? 1; const ids = options.ids ?? createUuidIdGenerator(); const clock = options.clock ?? createSystemClock(); let nextSequence = start; function build(overrides) { const args = factoryArgs({ name, sequence: nextSequence++, ids, clock, }); const value = options.defaults(args); return applyOverrides(value, args, overrides); } async function create(ctx, overrides) { 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 = []; 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(name, options) { 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(options) { 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(options) { const sessions = new Set(); function resetFactorySequences() { resetFactories(...(options.factories ?? [])); } function defaultSeeds() { return options.seeds ?? []; } async function closeSession(session) { 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 = { 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())); }, }; } //# sourceMappingURL=index.js.map