@beignet/core
Version:
Core framework primitives for Beignet
607 lines • 21 kB
JavaScript
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, createOutboxEventRecorder, } 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 { createRecordingBestEffortWork, 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 { 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,
};
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;
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?.uow ??
generatedUow;
const ports = {
...portsWithoutUow,
uow,
};
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) {
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" ||
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;
}
/**
* 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