@beignet/core
Version:
Core framework primitives for Beignet
2,469 lines • 64.8 kB
text/typescript
import type {
MemoryIdempotencyEntry,
MemoryIdempotencyStore,
} from "../idempotency/index.js";
import type {
MemoryMailDelivery,
NormalizedMailMessage,
} from "../mail/index.js";
import type { MemoryNotificationDelivery } from "../notifications/index.js";
import type {
DrainOutboxResult,
OutboxErrorInfo,
OutboxMessage,
OutboxMessageKind,
OutboxMessageStatus,
} from "../outbox/index.js";
import type {
ProviderInstrumentationEventInput,
ProviderInstrumentationPort,
} from "../providers/index.js";
import type {
InferSchedulePayload,
ScheduleDef,
ScheduleRunnerPort,
ScheduleRunOptions,
} from "../schedules/index.js";
import {
type ActivityActor,
type ActivityMetadata,
type ActivityMetadataValue,
type ActivityResource,
type ActivityTenant,
type AuditLogEntry,
type AuditOutcome,
createAnonymousActor,
createServiceActor,
createSystemActor,
createTenant,
createUserActor,
} from "./audit.js";
import type { EventBusPort, JobDef, JobDispatcherPort } from "./events.js";
import {
type CreateGateOptions,
createGate,
type GateDecision,
type GatePort,
type PolicyContextFromDefinitions,
type PolicyDefinition,
type PolicyMapFromDefinitions,
type PolicySubjectArgs,
} from "./policy.js";
import type {
StorageObject,
StoragePort,
StorageVisibility,
} from "./storage.js";
/**
* A recorded event entry from the recording event bus.
*/
export interface RecordedEvent {
name: string;
payload: unknown;
}
/**
* Expected fields for a recorded event assertion.
*/
export interface RecordedEventExpectation {
/**
* Expected event name.
*/
name?: string;
/**
* Expected event payload. Object values are matched as partial objects.
*/
payload?: unknown;
}
/**
* Create a recording event bus for testing.
*
* This bus records all published events for later assertion,
* but does not support subscription (throws if called).
*
* @example
* ```ts
* const { bus, events } = createRecordingEventBus();
*
* // Inject bus into your use case
* await createUser({ ports: { eventBus: bus } });
*
* // Assert on recorded events
* expect(events).toHaveLength(1);
* expect(events[0].name).toBe("user.registered");
* expect(events[0].payload).toEqual({ userId: "123", email: "test@example.com" });
* ```
*/
export function createRecordingEventBus(): {
bus: EventBusPort;
events: RecordedEvent[];
} {
const events: RecordedEvent[] = [];
const bus: EventBusPort = {
publish(event, payload) {
events.push({ name: event.name, payload });
},
subscribe() {
throw new Error("Not implemented for recording bus");
},
};
return { bus, events };
}
/**
* A job dispatch captured by `createRecordingJobDispatcher(...)`.
*/
export interface RecordedJobDispatch {
/**
* Dispatched job name.
*/
name: string;
/**
* Job definition supplied to the dispatcher.
*/
job: JobDef;
/**
* Payload supplied to the dispatcher.
*/
payload: unknown;
}
/**
* Expected fields for a recorded job dispatch assertion.
*/
export interface RecordedJobDispatchExpectation {
/**
* Expected job name.
*/
name?: string;
/**
* Expected job payload. Object values are matched as partial objects.
*/
payload?: unknown;
}
/**
* Create a recording job dispatcher for tests.
*
* The dispatcher records dispatch intent without running the job handler. Use
* this when a use case or listener should enqueue work but the test does not
* need to execute that work inline.
*
* @returns A job dispatcher plus its captured dispatches.
*/
export function createRecordingJobDispatcher(): {
jobs: JobDispatcherPort;
dispatchedJobs: RecordedJobDispatch[];
} {
const dispatchedJobs: RecordedJobDispatch[] = [];
const jobs: JobDispatcherPort = {
dispatch(job, payload) {
dispatchedJobs.push({ name: job.name, job, payload });
},
};
return { jobs, dispatchedJobs };
}
/**
* A schedule run captured by `createRecordingScheduleRunner(...)`.
*/
export interface RecordedScheduleRun {
/**
* Schedule name.
*/
name: string;
/**
* Schedule definition supplied to the runner.
*/
schedule: ScheduleDef;
/**
* Payload supplied to the runner, when present.
*/
payload?: unknown;
/**
* Run ID supplied by the provider or test, when present.
*/
id?: string;
/**
* Provider or app source label, when present.
*/
source?: string;
/**
* Scheduled timestamp supplied to the runner, when present.
*/
scheduledAt?: ScheduleRunOptions["scheduledAt"];
/**
* Triggered timestamp supplied to the runner, when present.
*/
triggeredAt?: ScheduleRunOptions["triggeredAt"];
}
/**
* Expected fields for a recorded schedule run assertion.
*/
export interface RecordedScheduleRunExpectation {
/**
* Expected schedule name.
*/
name?: string;
/**
* Expected schedule payload. Object values are matched as partial objects.
*/
payload?: unknown;
/**
* Expected run ID.
*/
id?: string;
/**
* Expected source label.
*/
source?: string;
}
/**
* Expected fields for a provider instrumentation event assertion.
*/
export interface ProviderInstrumentationEventExpectation {
/**
* Expected instrumentation event type.
*/
type?: ProviderInstrumentationEventInput["type"];
/**
* Expected event ID.
*/
id?: string;
/**
* Expected ISO timestamp.
*/
timestamp?: string;
/**
* Expected request correlation ID.
*/
requestId?: string;
/**
* Expected trace ID.
*/
traceId?: string;
/**
* Expected span ID.
*/
spanId?: string;
/**
* Expected parent span ID.
*/
parentSpanId?: string;
/**
* Expected traceparent header value.
*/
traceparent?: string;
/**
* Expected watcher name.
*/
watcher?: string;
/**
* Expected provider name. Matches `providerName` on provider lifecycle events
* and `details.providerName` on provider instrumentation events.
*/
providerName?: string;
/**
* Expected structured details. Object values are matched as partial objects.
*/
details?: unknown;
/**
* Expected request method.
*/
method?: string;
/**
* Expected request path.
*/
path?: string;
/**
* Expected contract name.
*/
contractName?: string;
/**
* Expected status. This matches request status codes as well as job, outbox,
* and schedule status strings.
*/
status?: unknown;
/**
* Expected duration in milliseconds.
*/
durationMs?: number;
/**
* Expected human-readable summary.
*/
summary?: string;
/**
* Expected error message.
*/
message?: string;
/**
* Expected stack trace.
*/
stack?: string;
/**
* Expected use-case name on error events.
*/
useCaseName?: string;
/**
* Expected use-case or custom event name.
*/
name?: string;
/**
* Expected use-case kind.
*/
kind?: "command" | "query";
/**
* Expected use-case phase.
*/
phase?: "start" | "end" | "error";
/**
* Expected error summary.
*/
error?: string;
/**
* Expected event bus event name.
*/
eventName?: string;
/**
* Expected job name.
*/
jobName?: string;
/**
* Expected outbox message ID.
*/
messageId?: string;
/**
* Expected outbox message kind.
*/
messageKind?: "event" | "job";
/**
* Expected outbox message name.
*/
messageName?: string;
/**
* Expected schedule name.
*/
scheduleName?: string;
/**
* Expected schedule cron expression.
*/
cron?: string;
/**
* Expected schedule time zone.
*/
timezone?: string;
/**
* Expected provider lifecycle action.
*/
action?: "setup" | "start" | "stop";
/**
* Expected custom event label.
*/
label?: string;
}
/**
* Source accepted by provider instrumentation assertion helpers.
*/
export type ProviderInstrumentationAssertionSource =
| readonly ProviderInstrumentationEventInput[]
| {
/**
* Recorded provider instrumentation events.
*/
readonly events: readonly ProviderInstrumentationEventInput[];
};
/**
* Create a recording schedule runner for tests.
*
* The runner records schedule run intent without executing the schedule
* handler. Use `createInlineScheduleRunner(...)` when the test should run the
* handler.
*
* @returns A schedule runner plus its captured runs.
*/
export function createRecordingScheduleRunner(): {
runner: ScheduleRunnerPort;
runs: RecordedScheduleRun[];
} {
const runs: RecordedScheduleRun[] = [];
const runner: ScheduleRunnerPort = {
async run<S extends ScheduleDef>(
schedule: S,
options: ScheduleRunOptions<InferSchedulePayload<S>> = {},
) {
runs.push({
name: schedule.name,
schedule,
...(Object.hasOwn(options, "payload")
? { payload: options.payload }
: {}),
...(options.id !== undefined ? { id: options.id } : {}),
...(options.source !== undefined ? { source: options.source } : {}),
...(options.scheduledAt !== undefined
? { scheduledAt: options.scheduledAt }
: {}),
...(options.triggeredAt !== undefined
? { triggeredAt: options.triggeredAt }
: {}),
});
},
};
return { runner, runs };
}
/**
* Create a provider instrumentation port for tests.
*
* The port records every event and can optionally disable specific watchers.
* Use the returned `events` array with provider instrumentation assertion
* helpers.
*
* @returns A provider instrumentation port plus its captured events.
*/
export function createRecordingProviderInstrumentation(
options: {
enabledWatchers?: readonly string[];
disabledWatchers?: readonly string[];
} = {},
): {
instrumentation: ProviderInstrumentationPort;
events: ProviderInstrumentationEventInput[];
} {
const events: ProviderInstrumentationEventInput[] = [];
const enabledWatchers = options.enabledWatchers
? new Set(options.enabledWatchers)
: null;
const disabledWatchers = new Set(options.disabledWatchers ?? []);
const instrumentation: ProviderInstrumentationPort = {
record(event) {
events.push(event);
},
isWatcherEnabled(name) {
if (disabledWatchers.has(name)) return false;
return enabledWatchers ? enabledWatchers.has(name) : true;
},
};
return { instrumentation, events };
}
/**
* Options for creating a test user actor.
*/
export interface CreateTestUserActorOptions
extends Omit<ActivityActor, "type" | "id" | "metadata"> {
/**
* Optional role stored as `actor.metadata.role`.
*/
role?: string;
/**
* Additional redaction-safe actor metadata.
*/
metadata?: ActivityMetadata;
}
/**
* Options for creating a test actor that represents impersonated user access.
*/
export interface CreateTestImpersonatedUserActorOptions
extends CreateTestUserActorOptions {
/**
* Stable ID for the actor performing the impersonation.
*/
impersonatorId: string;
}
/**
* Options for creating a test tenant.
*/
export type CreateTestTenantOptions = Omit<ActivityTenant, "id">;
/**
* Context fields commonly shared by Beignet tests that exercise audit,
* authorization, route hooks, and use cases.
*/
export interface TestActivityContext {
/**
* Actor under test.
*/
actor: ActivityActor;
/**
* Tenant/account/workspace scope under test.
*/
tenant?: ActivityTenant;
/**
* Stable request ID for assertions.
*/
requestId: string;
/**
* Optional trace ID for assertions.
*/
traceId?: string;
}
/**
* Options for creating a test activity context.
*/
export interface CreateTestActivityContextOptions {
/**
* Actor under test.
*
* @default createTestUserActor()
*/
actor?: ActivityActor;
/**
* Tenant under test. Pass `null` to omit tenant context.
*
* @default createTestTenant()
*/
tenant?: ActivityTenant | null;
/**
* Request ID to expose on the context.
*
* @default "test-request"
*/
requestId?: string;
/**
* Trace ID to expose on the context.
*
* @default "test-trace"
*/
traceId?: string;
}
/**
* Create a predictable user actor for tests.
*
* Use this when authorization or audit assertions need a stable actor shape
* without repeating the `ActivityActor` object in every test.
*
* @param id - Stable user ID for the test actor.
* @param options - Optional display name, role, and metadata.
* @returns A user actor with `type: "user"`.
*/
export function createTestUserActor(
id = "user_test",
options: CreateTestUserActorOptions = {},
): ActivityActor {
const { role, metadata, ...actorOptions } = options;
const mergedMetadata = mergeRoleMetadata(metadata, role);
return createUserActor(id, {
...actorOptions,
...(mergedMetadata ? { metadata: mergedMetadata } : {}),
});
}
/**
* Create a predictable user actor for tests that exercise impersonation.
*
* The returned actor remains the effective user, with `metadata.impersonatorId`
* recording who initiated the impersonated access.
*
* @param id - Stable user ID being impersonated.
* @param options - Impersonator ID plus optional display name, role, and metadata.
* @returns A user actor with impersonation metadata.
*/
export function createTestImpersonatedUserActor(
id: string,
options: CreateTestImpersonatedUserActorOptions,
): ActivityActor {
const { impersonatorId, ...actorOptions } = options;
return createTestUserActor(id, {
...actorOptions,
metadata: {
...actorOptions.metadata,
impersonatorId,
},
});
}
/**
* Create a predictable anonymous actor for tests.
*
* @param options - Optional display name or metadata.
* @returns An anonymous actor with `type: "anonymous"`.
*/
export function createTestAnonymousActor(
options: Omit<ActivityActor, "type"> = {},
): ActivityActor {
return createAnonymousActor(options);
}
/**
* Create a predictable service actor for tests.
*
* @param id - Stable service ID.
* @param options - Optional display name or metadata.
* @returns A service actor with `type: "service"`.
*/
export function createTestServiceActor(
id = "service_test",
options: Omit<ActivityActor, "type" | "id"> = {},
): ActivityActor {
return createServiceActor(id, options);
}
/**
* Create a predictable system actor for tests.
*
* @param id - Stable system actor ID.
* @param options - Optional display name or metadata.
* @returns A system actor with `type: "system"`.
*/
export function createTestSystemActor(
id = "system_test",
options: Omit<ActivityActor, "type" | "id"> = {},
): ActivityActor {
return createSystemActor(id, options);
}
/**
* Create a predictable tenant for tests.
*
* @param id - Stable tenant ID.
* @param options - Optional slug or metadata.
* @returns A tenant descriptor.
*/
export function createTestTenant(
id = "tenant_test",
options: CreateTestTenantOptions = {},
): ActivityTenant {
return createTenant(id, options);
}
/**
* Create the activity fields commonly copied onto app test contexts.
*
* @param options - Optional actor, tenant, request ID, and trace ID overrides.
* @returns Stable activity context fields for a test.
*/
export function createTestActivityContext(
options: CreateTestActivityContextOptions = {},
): TestActivityContext {
return {
actor: options.actor ?? createTestUserActor(),
...(options.tenant === null
? {}
: { tenant: options.tenant ?? createTestTenant() }),
requestId: options.requestId ?? "test-request",
traceId: options.traceId ?? "test-trace",
};
}
/**
* Expected audit fields used by audit assertion helpers.
*/
export interface AuditLogEntryExpectation {
/**
* Expected action name.
*/
action?: string;
/**
* Expected actor fields.
*/
actor?: Partial<ActivityActor>;
/**
* Convenience matcher for `entry.actor.id`.
*/
actorId?: string;
/**
* Convenience matcher for `entry.actor.type`.
*/
actorType?: ActivityActor["type"];
/**
* Expected tenant fields.
*/
tenant?: Partial<ActivityTenant>;
/**
* Convenience matcher for `entry.tenant.id`.
*/
tenantId?: string;
/**
* Expected resource fields.
*/
resource?: Partial<ActivityResource>;
/**
* Convenience matcher for `entry.resource.id`.
*/
resourceId?: string;
/**
* Convenience matcher for `entry.resource.type`.
*/
resourceType?: string;
/**
* Expected audit outcome.
*/
outcome?: AuditOutcome;
/**
* Convenience matcher for `entry.metadata.severity`.
*/
severity?: ActivityMetadataValue;
/**
* Expected request ID.
*/
requestId?: string;
/**
* Expected trace ID.
*/
traceId?: string;
/**
* Expected metadata fields. Object values are matched as partial objects.
*/
metadata?: ActivityMetadata;
}
/**
* Expected mail delivery fields used by mail assertion helpers.
*/
export interface MailDeliveryExpectation {
/**
* Expected memory delivery ID.
*/
id?: string;
/**
* Expected subject.
*/
subject?: string;
/**
* Expected recipients.
*/
to?: NormalizedMailMessage["to"];
/**
* Expected sender.
*/
from?: NormalizedMailMessage["from"];
/**
* Expected text body.
*/
text?: string;
/**
* Expected HTML body.
*/
html?: string;
/**
* Expected message headers.
*/
headers?: Record<string, string>;
/**
* Expected normalized message fields.
*/
message?: Partial<NormalizedMailMessage>;
}
/**
* Expected notification delivery fields used by notification assertion helpers.
*/
export interface NotificationDeliveryExpectation {
/**
* Expected memory delivery ID.
*/
id?: string;
/**
* Expected notification name.
*/
notificationName?: string;
/**
* Expected parsed payload. Object values are matched as partial objects.
*/
payload?: unknown;
/**
* Expected selected channels.
*/
channels?: readonly string[];
/**
* Expected delivery metadata. Object values are matched as partial objects.
*/
metadata?: Record<string, unknown>;
}
/**
* Expected storage object fields used by storage assertion helpers.
*/
export interface StorageObjectExpectation {
/**
* Object key to look up.
*/
key: string;
/**
* Expected size in bytes.
*/
size?: number;
/**
* Expected content type.
*/
contentType?: string;
/**
* Expected cache-control value.
*/
cacheControl?: string;
/**
* Expected storage metadata.
*/
metadata?: Record<string, string>;
/**
* Expected visibility.
*/
visibility?: StorageVisibility;
/**
* Expected text body. Cannot be combined with `bytes`.
*/
text?: string;
/**
* Expected object bytes. Cannot be combined with `text`.
*/
bytes?: Uint8Array;
}
/**
* Source accepted by outbox message assertion helpers.
*
* Use a `MemoryOutboxPort` or a snapshot returned by a test adapter. Durable
* SQL adapters should expose app-owned snapshots rather than widening the
* production `OutboxPort` read surface.
*/
export type OutboxMessageAssertionSource =
| readonly OutboxMessage[]
| {
/**
* Current outbox message snapshots.
*/
readonly messages: readonly OutboxMessage[];
};
/**
* Expected outbox message fields used by outbox assertion helpers.
*/
export interface OutboxMessageExpectation {
/**
* Expected message ID.
*/
id?: string;
/**
* Expected message kind.
*/
kind?: OutboxMessageKind;
/**
* Expected event or job name.
*/
name?: string;
/**
* Expected JSON payload. Object values are matched as partial objects.
*/
payload?: unknown;
/**
* Expected delivery status.
*/
status?: OutboxMessageStatus;
/**
* Expected claim attempt count.
*/
attempts?: number;
/**
* Expected maximum delivery attempts.
*/
maxAttempts?: number;
/**
* Expected delivery timestamp.
*/
deliveredAt?: Date | null;
/**
* Expected serialized delivery error. Object values are matched as partial
* objects. Pass `null` to assert no error has been recorded.
*/
lastError?: Partial<OutboxErrorInfo> | null;
}
/**
* Expected fields for one outbox drain result assertion.
*/
export interface OutboxDrainResultExpectation {
/**
* Expected claimed count.
*/
claimed?: number;
/**
* Expected delivered count.
*/
delivered?: number;
/**
* Expected retried count.
*/
retried?: number;
/**
* Expected dead-lettered count.
*/
deadLettered?: number;
}
/**
* Source accepted by idempotency entry assertion helpers.
*
* Use a `MemoryIdempotencyStore` or a snapshot array from an app-owned adapter.
* Durable SQL adapters should expose app-owned snapshots rather than widening
* the production `IdempotencyPort` read surface.
*/
export type IdempotencyEntryAssertionSource =
| readonly MemoryIdempotencyEntry[]
| Pick<MemoryIdempotencyStore, "entries">;
/**
* Expected idempotency entry fields used by idempotency assertion helpers.
*/
export interface IdempotencyEntryExpectation {
/**
* Expected operation namespace.
*/
namespace?: string;
/**
* Expected client-provided idempotency key.
*/
key?: string;
/**
* Expected normalized scope key.
*/
scopeKey?: string;
/**
* Expected request fingerprint.
*/
fingerprint?: string;
/**
* Expected reservation status.
*/
status?: MemoryIdempotencyEntry["status"];
/**
* Expected replay result. Object values are matched as partial objects.
*/
result?: unknown;
/**
* Expected reservation timestamp.
*/
reservedAt?: Date;
/**
* Expected completion timestamp.
*/
completedAt?: Date;
/**
* Expected expiration timestamp, or `null` when no expiration is set.
*/
expiresAt?: Date | null;
}
/**
* Find the first audit entry matching the expected fields.
*
* @param entries - Audit entries captured by a memory or fake audit port.
* @param expectation - Partial audit fields to match.
* @returns The first matching entry, or `undefined`.
*/
export function findAuditEntry(
entries: readonly AuditLogEntry[],
expectation: AuditLogEntryExpectation,
): AuditLogEntry | undefined {
return entries.find((entry) => auditEntryMatches(entry, expectation));
}
/**
* Assert that an audit entry exists and return the matching entry.
*
* The helper throws a plain `Error`, so it works with Bun, Vitest, Jest, and
* other test runners.
*
* @param entries - Audit entries captured by a memory or fake audit port.
* @param expectation - Partial audit fields to match.
* @returns The matching audit entry.
* @throws Error when no entry matches.
*/
export function assertAuditEntry(
entries: readonly AuditLogEntry[],
expectation: AuditLogEntryExpectation,
): AuditLogEntry {
const entry = findAuditEntry(entries, expectation);
if (entry) return entry;
throw new Error(
`Expected audit entry matching ${formatAuditExpectation(expectation)}, but only found ${entries.length} entr${
entries.length === 1 ? "y" : "ies"
}.`,
);
}
/**
* Assert that no audit entry matches the expected fields.
*
* @param entries - Audit entries captured by a memory or fake audit port.
* @param expectation - Partial audit fields to reject.
* @throws Error when a matching entry exists.
*/
export function assertNoAuditEntry(
entries: readonly AuditLogEntry[],
expectation: AuditLogEntryExpectation,
): void {
const entry = findAuditEntry(entries, expectation);
if (!entry) return;
throw new Error(
`Expected no audit entry matching ${formatAuditExpectation(expectation)}, but found ${entry.action}.`,
);
}
/**
* Find the first recorded event matching the expected fields.
*
* @param events - Events captured by `createRecordingEventBus(...)`.
* @param expectation - Partial event fields to match.
* @returns The first matching event, or `undefined`.
*/
export function findRecordedEvent(
events: readonly RecordedEvent[],
expectation: RecordedEventExpectation,
): RecordedEvent | undefined {
return events.find((event) => recordedEventMatches(event, expectation));
}
/**
* Assert that a recorded event exists and return the matching event.
*
* @param events - Events captured by `createRecordingEventBus(...)`.
* @param expectation - Partial event fields to match.
* @returns The matching event.
* @throws Error when no event matches.
*/
export function assertRecordedEvent(
events: readonly RecordedEvent[],
expectation: RecordedEventExpectation,
): RecordedEvent {
const event = findRecordedEvent(events, expectation);
if (event) return event;
throw new Error(
`Expected recorded event matching ${formatTestingExpectation(expectation)}, but only found ${events.length} ${pluralize(
"event",
events.length,
)}.`,
);
}
/**
* Assert that no recorded event matches the expected fields.
*
* @param events - Events captured by `createRecordingEventBus(...)`.
* @param expectation - Partial event fields to reject.
* @throws Error when a matching event exists.
*/
export function assertNoRecordedEvent(
events: readonly RecordedEvent[],
expectation: RecordedEventExpectation,
): void {
const event = findRecordedEvent(events, expectation);
if (!event) return;
throw new Error(
`Expected no recorded event matching ${formatTestingExpectation(expectation)}, but found ${event.name}.`,
);
}
/**
* Find the first recorded job dispatch matching the expected fields.
*
* @param jobs - Job dispatches captured by `createRecordingJobDispatcher(...)`.
* @param expectation - Partial job fields to match.
* @returns The first matching dispatch, or `undefined`.
*/
export function findDispatchedJob(
jobs: readonly RecordedJobDispatch[],
expectation: RecordedJobDispatchExpectation,
): RecordedJobDispatch | undefined {
return jobs.find((job) => recordedJobMatches(job, expectation));
}
/**
* Assert that a job was dispatched and return the matching dispatch.
*
* @param jobs - Job dispatches captured by `createRecordingJobDispatcher(...)`.
* @param expectation - Partial job fields to match.
* @returns The matching dispatch.
* @throws Error when no dispatch matches.
*/
export function assertDispatchedJob(
jobs: readonly RecordedJobDispatch[],
expectation: RecordedJobDispatchExpectation,
): RecordedJobDispatch {
const job = findDispatchedJob(jobs, expectation);
if (job) return job;
throw new Error(
`Expected dispatched job matching ${formatTestingExpectation(expectation)}, but only found ${jobs.length} ${pluralize(
"job",
jobs.length,
)}.`,
);
}
/**
* Assert that no job dispatch matches the expected fields.
*
* @param jobs - Job dispatches captured by `createRecordingJobDispatcher(...)`.
* @param expectation - Partial job fields to reject.
* @throws Error when a matching dispatch exists.
*/
export function assertNoDispatchedJob(
jobs: readonly RecordedJobDispatch[],
expectation: RecordedJobDispatchExpectation,
): void {
const job = findDispatchedJob(jobs, expectation);
if (!job) return;
throw new Error(
`Expected no dispatched job matching ${formatTestingExpectation(expectation)}, but found ${job.name}.`,
);
}
/**
* Find the first recorded schedule run matching the expected fields.
*
* @param runs - Runs captured by `createRecordingScheduleRunner(...)`.
* @param expectation - Partial schedule fields to match.
* @returns The first matching schedule run, or `undefined`.
*/
export function findScheduleRun(
runs: readonly RecordedScheduleRun[],
expectation: RecordedScheduleRunExpectation,
): RecordedScheduleRun | undefined {
return runs.find((run) => recordedScheduleRunMatches(run, expectation));
}
/**
* Find the first provider instrumentation event matching the expected fields.
*
* @param source - Recording instrumentation result or event snapshot array.
* @param expectation - Partial event fields to match.
* @returns The first matching event, or `undefined`.
*/
export function findProviderInstrumentationEvent(
source: ProviderInstrumentationAssertionSource,
expectation: ProviderInstrumentationEventExpectation,
): ProviderInstrumentationEventInput | undefined {
return getProviderInstrumentationEvents(source).find((event) =>
providerInstrumentationEventMatches(event, expectation),
);
}
/**
* Assert that a provider instrumentation event exists and return it.
*
* @param source - Recording instrumentation result or event snapshot array.
* @param expectation - Partial event fields to match.
* @returns The matching event.
* @throws Error when no event matches.
*/
export function assertProviderInstrumentationEvent(
source: ProviderInstrumentationAssertionSource,
expectation: ProviderInstrumentationEventExpectation,
): ProviderInstrumentationEventInput {
const event = findProviderInstrumentationEvent(source, expectation);
if (event) return event;
const events = getProviderInstrumentationEvents(source);
throw new Error(
`Expected provider instrumentation event matching ${formatTestingExpectation(
expectation,
)}, but only found ${events.length} ${pluralize("event", events.length)}.`,
);
}
/**
* Assert that no provider instrumentation event matches the expected fields.
*
* @param source - Recording instrumentation result or event snapshot array.
* @param expectation - Partial event fields to reject.
* @throws Error when a matching event exists.
*/
export function assertNoProviderInstrumentationEvent(
source: ProviderInstrumentationAssertionSource,
expectation: ProviderInstrumentationEventExpectation,
): void {
const event = findProviderInstrumentationEvent(source, expectation);
if (!event) return;
throw new Error(
`Expected no provider instrumentation event matching ${formatTestingExpectation(
expectation,
)}, but found ${event.type}.`,
);
}
/**
* Assert that a schedule run was recorded and return the matching run.
*
* @param runs - Runs captured by `createRecordingScheduleRunner(...)`.
* @param expectation - Partial schedule fields to match.
* @returns The matching schedule run.
* @throws Error when no run matches.
*/
export function assertScheduleRun(
runs: readonly RecordedScheduleRun[],
expectation: RecordedScheduleRunExpectation,
): RecordedScheduleRun {
const run = findScheduleRun(runs, expectation);
if (run) return run;
throw new Error(
`Expected schedule run matching ${formatTestingExpectation(expectation)}, but only found ${runs.length} ${pluralize(
"run",
runs.length,
)}.`,
);
}
/**
* Assert that no schedule run matches the expected fields.
*
* @param runs - Runs captured by `createRecordingScheduleRunner(...)`.
* @param expectation - Partial schedule fields to reject.
* @throws Error when a matching run exists.
*/
export function assertNoScheduleRun(
runs: readonly RecordedScheduleRun[],
expectation: RecordedScheduleRunExpectation,
): void {
const run = findScheduleRun(runs, expectation);
if (!run) return;
throw new Error(
`Expected no schedule run matching ${formatTestingExpectation(expectation)}, but found ${run.name}.`,
);
}
/**
* Find the first mail delivery matching the expected fields.
*
* @param deliveries - Deliveries captured by `createMemoryMailer(...)`.
* @param expectation - Partial delivery or message fields to match.
* @returns The first matching delivery, or `undefined`.
*/
export function findMailDelivery(
deliveries: readonly MemoryMailDelivery[],
expectation: MailDeliveryExpectation,
): MemoryMailDelivery | undefined {
return deliveries.find((delivery) =>
mailDeliveryMatches(delivery, expectation),
);
}
/**
* Assert that a mail delivery exists and return the matching delivery.
*
* @param deliveries - Deliveries captured by `createMemoryMailer(...)`.
* @param expectation - Partial delivery or message fields to match.
* @returns The matching delivery.
* @throws Error when no delivery matches.
*/
export function assertMailDelivery(
deliveries: readonly MemoryMailDelivery[],
expectation: MailDeliveryExpectation,
): MemoryMailDelivery {
const delivery = findMailDelivery(deliveries, expectation);
if (delivery) return delivery;
throw new Error(
`Expected mail delivery matching ${formatTestingExpectation(expectation)}, but only found ${deliveries.length} ${pluralize(
"delivery",
deliveries.length,
)}.`,
);
}
/**
* Assert that no mail delivery matches the expected fields.
*
* @param deliveries - Deliveries captured by `createMemoryMailer(...)`.
* @param expectation - Partial delivery or message fields to reject.
* @throws Error when a matching delivery exists.
*/
export function assertNoMailDelivery(
deliveries: readonly MemoryMailDelivery[],
expectation: MailDeliveryExpectation,
): void {
const delivery = findMailDelivery(deliveries, expectation);
if (!delivery) return;
throw new Error(
`Expected no mail delivery matching ${formatTestingExpectation(expectation)}, but found ${delivery.message.subject}.`,
);
}
/**
* Find the first notification delivery matching the expected fields.
*
* @param deliveries - Deliveries captured by `createMemoryNotificationPort(...)`.
* @param expectation - Partial delivery fields to match.
* @returns The first matching delivery, or `undefined`.
*/
export function findNotificationDelivery(
deliveries: readonly MemoryNotificationDelivery[],
expectation: NotificationDeliveryExpectation,
): MemoryNotificationDelivery | undefined {
return deliveries.find((delivery) =>
notificationDeliveryMatches(delivery, expectation),
);
}
/**
* Assert that a notification delivery exists and return the matching delivery.
*
* @param deliveries - Deliveries captured by `createMemoryNotificationPort(...)`.
* @param expectation - Partial delivery fields to match.
* @returns The matching delivery.
* @throws Error when no delivery matches.
*/
export function assertNotificationDelivery(
deliveries: readonly MemoryNotificationDelivery[],
expectation: NotificationDeliveryExpectation,
): MemoryNotificationDelivery {
const delivery = findNotificationDelivery(deliveries, expectation);
if (delivery) return delivery;
throw new Error(
`Expected notification delivery matching ${formatTestingExpectation(expectation)}, but only found ${deliveries.length} ${pluralize(
"delivery",
deliveries.length,
)}.`,
);
}
/**
* Assert that no notification delivery matches the expected fields.
*
* @param deliveries - Deliveries captured by `createMemoryNotificationPort(...)`.
* @param expectation - Partial delivery fields to reject.
* @throws Error when a matching delivery exists.
*/
export function assertNoNotificationDelivery(
deliveries: readonly MemoryNotificationDelivery[],
expectation: NotificationDeliveryExpectation,
): void {
const delivery = findNotificationDelivery(deliveries, expectation);
if (!delivery) return;
throw new Error(
`Expected no notification delivery matching ${formatTestingExpectation(expectation)}, but found ${delivery.notificationName}.`,
);
}
/**
* Assert that a storage object exists and optionally matches metadata/body
* expectations.
*
* This helper works against any `StoragePort`, not only memory storage.
*
* @param storage - Storage port under test.
* @param expectation - Object key and expected fields.
* @returns The matching object metadata.
* @throws Error when the object is missing or does not match.
*/
export async function assertStorageObject(
storage: StoragePort,
expectation: StorageObjectExpectation,
): Promise<StorageObject> {
if (expectation.text !== undefined && expectation.bytes !== undefined) {
throw new Error(
`Expected storage object "${expectation.key}" to check either text or bytes, not both.`,
);
}
const object =
expectation.text !== undefined || expectation.bytes !== undefined
? await storage.get(expectation.key)
: await storage.stat(expectation.key);
if (!object) {
throw new Error(`Expected storage object "${expectation.key}" to exist.`);
}
if (!storageObjectMatches(object, expectation)) {
throw new Error(
`Expected storage object "${expectation.key}" to match ${formatTestingExpectation(
omitStorageBodyExpectation(expectation),
)}.`,
);
}
if (expectation.text !== undefined) {
const actualText = hasStorageTextReader(object)
? await object.text()
: undefined;
if (actualText !== expectation.text) {
throw new Error(
`Expected storage object "${expectation.key}" text to match ${JSON.stringify(
expectation.text,
)}.`,
);
}
} else if (expectation.bytes !== undefined) {
const actualBytes = hasStorageBytesReader(object)
? await object.bytes()
: undefined;
if (!uint8ArrayMatches(actualBytes, expectation.bytes)) {
throw new Error(
`Expected storage object "${expectation.key}" bytes to match.`,
);
}
}
return object;
}
/**
* Assert that a storage object does not exist.
*
* @param storage - Storage port under test.
* @param key - Object key expected to be absent.
* @throws Error when the object exists.
*/
export async function assertNoStorageObject(
storage: StoragePort,
key: string,
): Promise<void> {
const exists = await storage.exists(key);
if (!exists) return;
throw new Error(`Expected storage object "${key}" not to exist.`);
}
/**
* Find the first outbox message matching expected fields.
*
* @param source - Memory outbox or message snapshot array.
* @param expectation - Partial message fields to match.
* @returns The first matching message, or `undefined`.
*/
export function findOutboxMessage(
source: OutboxMessageAssertionSource,
expectation: OutboxMessageExpectation,
): OutboxMessage | undefined {
return getOutboxMessages(source).find((message) =>
outboxMessageMatches(message, expectation),
);
}
/**
* Find the first idempotency entry matching expected fields.
*
* @param source - Memory idempotency store or entry snapshot array.
* @param expectation - Partial entry fields to match.
* @returns The first matching entry, or `undefined`.
*/
export function findIdempotencyEntry(
source: IdempotencyEntryAssertionSource,
expectation: IdempotencyEntryExpectation,
): MemoryIdempotencyEntry | undefined {
return getIdempotencyEntries(source).find((entry) =>
idempotencyEntryMatches(entry, expectation),
);
}
/**
* Assert that an idempotency entry exists and return it.
*
* @param source - Memory idempotency store or entry snapshot array.
* @param expectation - Partial entry fields to match.
* @returns The matching entry.
* @throws Error when no entry matches.
*/
export function assertIdempotencyEntry(
source: IdempotencyEntryAssertionSource,
expectation: IdempotencyEntryExpectation,
): MemoryIdempotencyEntry {
const entry = findIdempotencyEntry(source, expectation);
if (entry) return entry;
const entries = getIdempotencyEntries(source);
throw new Error(
`Expected idempotency entry matching ${formatTestingExpectation(
expectation,
)}, but only found ${entries.length} ${pluralize("entry", entries.length)}.`,
);
}
/**
* Assert that no idempotency entry matches expected fields.
*
* @param source - Memory idempotency store or entry snapshot array.
* @param expectation - Partial entry fields to reject.
* @throws Error when a matching entry exists.
*/
export function assertNoIdempotencyEntry(
source: IdempotencyEntryAssertionSource,
expectation: IdempotencyEntryExpectation,
): void {
const entry = findIdempotencyEntry(source, expectation);
if (!entry) return;
throw new Error(
`Expected no idempotency entry matching ${formatTestingExpectation(
expectation,
)}, but found ${entry.namespace}:${entry.key}.`,
);
}
/**
* Assert that a matching idempotency entry is still in progress.
*
* @param source - Memory idempotency store or entry snapshot array.
* @param expectation - Entry fields to match.
* @returns The matching in-progress entry.
*/
export function assertIdempotencyInProgress(
source: IdempotencyEntryAssertionSource,
expectation: Omit<IdempotencyEntryExpectation, "status"> = {},
): MemoryIdempotencyEntry {
return assertIdempotencyEntry(source, {
...expectation,
status: "in-progress",
});
}
/**
* Assert that a matching idempotency entry completed.
*
* @param source - Memory idempotency store or entry snapshot array.
* @param expectation - Entry fields to match.
* @returns The matching completed entry.
*/
export function assertIdempotencyCompleted(
source: IdempotencyEntryAssertionSource,
expectation: Omit<IdempotencyEntryExpectation, "status"> = {},
): MemoryIdempotencyEntry {
return assertIdempotencyEntry(source, {
...expectation,
status: "completed",
});
}
/**
* Assert that an outbox message exists and return it.
*
* @param source - Memory outbox or message snapshot array.
* @param expectation - Partial message fields to match.
* @returns The matching message.
* @throws Error when no message matches.
*/
export function assertOutboxMessage(
source: OutboxMessageAssertionSource,
expectation: OutboxMessageExpectation,
): OutboxMessage {
const message = findOutboxMessage(source, expectation);
if (message) return message;
const messages = getOutboxMessages(source);
throw new Error(
`Expected outbox message matching ${formatTestingExpectation(expectation)}, but only found ${messages.length} ${pluralize(
"message",
messages.length,
)}.`,
);
}
/**
* Assert that no outbox message matches expected fields.
*
* @param source - Memory outbox or message snapshot array.
* @param expectation - Partial message fields to reject.
* @throws Error when a matching message exists.
*/
export function assertNoOutboxMessage(
source: OutboxMessageAssertionSource,
expectation: OutboxMessageExpectation,
): void {
const message = findOutboxMessage(source, expectation);
if (!message) return;
throw new Error(
`Expected no outbox message matching ${formatTestingExpectation(expectation)}, but found ${message.kind} "${message.name}".`,
);
}
/**
* Assert that an outbox message is pending.
*
* @param source - Memory outbox or message snapshot array.
* @param expectation - Message fields to match.
* @returns The matching pending message.
*/
export function assertOutboxPending(
source: OutboxMessageAssertionSource,
expectation: Omit<OutboxMessageExpectation, "status"> = {},
): OutboxMessage {
return assertOutboxMessage(source, {
...expectation,
status: "pending",
});
}
/**
* Assert that an outbox message was delivered.
*
* @param source - Memory outbox or message snapshot array.
* @param expectation - Message fields to match.
* @returns The matching delivered message.
*/
export function assertOutboxDelivered(
source: OutboxMessageAssertionSource,
expectation: Omit<OutboxMessageExpectation, "status"> = {},
): OutboxMessage {
return assertOutboxMessage(source, {
...expectation,
status: "delivered",
});
}
/**
* Assert that an outbox message is pending after at least one failed attempt.
*
* Use this for retry-scheduled assertions after `drainOutbox(...)` returns a
* retried count.
*
* @param source - Memory outbox or message snapshot array.
* @param expectation - Message fields to match.
* @returns The matching retry-scheduled message.
*/
export function assertOutboxRetryScheduled(
source: OutboxMessageAssertionSource,
expectation: Omit<OutboxMessageExpectation, "status"> = {},
): OutboxMessage {
const message = assertOutboxMessage(source, {
...expectation,
status: "pending",
});
if (message.attempts < 1 || !message.lastError) {
throw new Error(
`Expected outbox message "${message.id}" to have a recorded failed attempt.`,
);
}
return message;
}
/**
* Assert that an outbox message was dead-lettered.
*
* @param source - Memory outbox or message snapshot array.
* @param expectation - Message fields to match.
* @returns The matching dead-lettered message.
*/
export function assertOutboxDeadLettered(
source: OutboxMessageAssertionSource,
expectation: Omit<OutboxMessageExpectation, "status"> = {},
): OutboxMessage {
return assertOutboxMessage(source, {
...expectation,
status: "deadLettered",
});
}
/**
* Assert that a drain result matches expected counts.
*
* @param result - Result returned by `drainOutbox(...)`.
* @param expectation - Partial count expectation.
* @throws Error when any supplied count differs.
*/
export function assertOutboxDrainResult(
result: DrainOutboxResult,
expectation: OutboxDrainResultExpectation,
): void {
if (!outboxDrainResultMatches(result, expectation)) {
throw new Error(
`Expected outbox drain result matching ${formatTestingExpectation(
expectation,
)}, but received ${formatTestingExpectation(result)}.`,
);
}
}
/**
* Expected outcome for one policy matrix case.
*/
export type PolicyMatrixExpectation = "allow" | "deny";
type PolicyMatrixSubject<TResolver> =
PolicySubjectArgs<TResolver> extends [subject: infer Subject]
? { subject: Subject }
: { subject?: never };
/**
* One typed authorization matrix case for a policy ability.
*/
export type PolicyMatrixCase<
TContext,
TPolicies extends readonly PolicyDefinition[] = readonly PolicyDefinition[],
> = {
[TAbility in keyof PolicyMapFromDefinitions<TPolicies> & string]: {
name: string;
ctx: TContext;
ability: TAbility;
expected: PolicyMatrixExpectation;
reason?: string;
code?: string;
} & PolicyMatrixSubject<PolicyMapFromDefinitions<TPolicies>[TAbility]>;
}[keyof PolicyMapFromDefinitions<TPolicies> & string];
/**
* Untyped policy matrix case used internally for failure reporting.
*/
export type UntypedPolicyMatrixCase<TContext> = {
name: string;
ctx: TContext;
ability: string;
subject?: unknown;
expected: PolicyMatrixExpectation;
reason?: string;
code?: string;
};
/**
* Result for one evaluated policy matrix case.
*/
export type PolicyMatrixResult<
TContext,
TPolicies extends readonly PolicyDefinition[] = readonly PolicyDefinition[],
> = {
case: PolicyMatrixCase<TContext, TPolicies>;
decision: GateDecision;
passed: boolean;
message?: string;
};
/**
* Test helper for evaluating authorization policies.
*/
export type PolicyTester<
TContext,
TPolicies extends readonly PolicyDefinition[],
> = {
/**
* Gate created from the same policies, useful for direct assertions.
*/
gate: GatePort<TContext, TPolicies>;
/**
* Evaluate cases and return structured pass/fail results.
*/
evaluateMatrix(
cases: readonly PolicyMatrixCase<TContext, TPolicies>[],
): Promise<PolicyMatrixResult<TContext, TPolicies>[]>;
/**
* Evaluate cases and throw a combined assertion error when any fail.
*/
assertMatrix(
cases: readonly PolicyMatrixCase<TContext, TPolicies>[],
): Promise<void>;
};
/**
* Create a policy tester from the same options used by `createGate(...)`.
*
* Use this for table-driven authorization tests that document who can perform
* each ability against which subject.
*
* @param options - Policy definitions and optional denial mapper.
* @returns A policy tester with a gate plus matrix helpers.
*/
export function createPolicyTester<
const TPolicies extends readonly PolicyDefinition[],
>(
options: CreateGateOptions<
PolicyContextFromDefinitions<TPolicies>,
TPolicies
>,
): PolicyTester<PolicyContextFromDefinitions<TPolicies>, TPolicies> {
const gate = createGate(options);
return {
gate,
evaluateMatrix: (cases) => evaluatePolicyMatrix(gate, cases),
assertMatrix: (cases) => assertPolicyMatrix(gate, cases),
};
}
/**
* Evaluate a table of policy cases without throwing.
*
* @param gate - Gate under test.
* @param cases - Matrix cases to evaluate.
* @returns Structured result for each case.
*/
export async function evaluatePolicyMatrix<
TContext,
TPolicies extends readonly PolicyDefinition[],
>(
gate: GatePort<TContext, TPolicies>,
cases: readonly PolicyMatrixCase<TContext, TPolicies>[],
): Promise<PolicyMatrixResult<TContext, TPolicies>[]> {
const results: PolicyMatrixResult<TContext, TPolicies>[] = [];
for (const matrixCase of cases) {
const decision = await inspectPolicyMatrixCase(gate, matrixCase);
const passed = policyMatrixCasePassed(matrixCase, decision);
results.push({
case: matrixCase,
decision,
passed,
message: passed
? undefined
: policyMatrixFailureMessage(matrixCase, decision),
});
}
return results;
}
/**
* Assert that all policy matrix cases pass.
*
* @param gate - Gate under test.
* @param cases - Matrix cases to evaluate.
* @throws Combined error listing every failed case.
*/
export async function assertPolicyMatrix<
TContext,
TPolicies extends readonly PolicyDefinition[],
>(
gate: GatePort<TContext, TPolicies>,
cases: readonly PolicyMatrixCase<TContext, TPolicies>[],
): Promise<void> {
const results = await evaluatePolicyMatrix(gate, cases);
const failures = results.filter((result) => !result.passed);
if (failures.length === 0) return;
throw new Error(
[
`Policy matrix failed for ${failures.length} case${
failures.length === 1 ? "" : "s"
}.`,
...failures.map((failure) => `- ${failure.message}`),
].join("\n"),
);
}
async function inspectPolicyMatrixCase<
TContext,
TPolicies extends readonly PolicyDefinition[],
>(
gate: GatePort<TContext, TPolicies>,
matrixCase: PolicyMatrixCase<TContext, TPolicies>,
): Promise<GateDecision> {
const inspect = gate.inspect as DynamicGateInspect<TContext>;
if ("subject" in matrixCase) {
return inspect(matrixCase.ctx, matrixCase.ability, matrixCase.subject);
}
return inspect(matrixCase.ctx, matrixCase.ability);
}
function policyMatrixCasePassed<TContext>(
matrixCase: UntypedPolicyMatrixCase<TContext>,
decision: GateDecision,
): boolean {
if (matrixCase.expected === "allow" && !decision.allowed) return false;
if (matrixCase.expected === "deny" && decision.allowed) return false;
if (matrixCase.reason && decision.allowed) return false;
if (matrixCase.code && decision.allowed) return false;
if (decision.allowed) return true;
return (
(!matrixCase.reason || decision.reason === matrixCase.reason) &&
(!matrixCase.code || decision.code === matrixCase.code)
);
}
function policyMatrixFailureMessage<TContext>(
matrixCase: UntypedPolicyMatrixCase<TContext>,
decision: GateDecision,
): string {
const expected: string[] = [matrixCase.expected];
if (matrixCase.reason) expected.push(`reason "${matrixCase.reason}"`);
if (matrixCase.code) expected.push(`code "${matrixCase.code}"`);
const actual = decision.allowed
? "allow"
: [
"deny",
decision.reason ? `reason "${decision.reason}"` : undefined,
decision.code ? `code "${decision.code}"` : undefined,
]
.filter(Boolean)
.join(", ");
return `${matrixCase.name}: expected ${expected.join(", ")}, received ${actual}.`;
}
type DynamicGateInspect<TContext> = (
ctx: TContext,
ability: string,
subject?: unknown,
) => Promise<GateDecision>;
function mergeRoleMetadata(
metadata: ActivityMetadata | undefined,
role: string | undefined,
): ActivityMetadata | undefined {
if (!role) return metadata;
return {
...metadata,
role,
};
}
function auditEntryMatches(
entry: AuditLogEntry,
expectation: AuditLogEntryExpectation,
): boolean {
if (expectation.action !== undefined && entry.action !== expectation.action) {
return false;
}
if (
expectation.outcome !== undefined &&
entry.outcome !== expectation.outcome
) {
return false;
}
if (
expectation.requestId !== undefined &&
entry.requestId !== expectation.requestId
) {
return false;
}
if (
expectation.traceId !== undefined &&
entry.traceId !== expectation.traceId
) {
return false;
}
if (
expectation.actorId !== undefined &&
entry.actor.id !== expectation.actorId
) {
return false;
}
if (
expectation.actorType !== undefined &&
entry.actor.type !== expectation.actorType
) {
return false;
}
if (
expectation.tenantId !== undefined &&
entry.tenant?.id !== expectation.tenantId
) {
return false;
}
if (
expectation.resourceId !== undefined &&
entry.resource?.id !== expectation.resourceId
) {
return false;
}
if (
expectation.resourceType !== undefined &&
entry.resource?.type !== expectation.resourceType
) {
return false;
}
if (
expectation.severity !== undefined &&
entry.metadata?.severity !== expectation.severity
) {
return false;
}
return (
partialObjectMatches(entry.actor, expectation.actor) &&
partialObjectMatches(entry.tenant, expectation.tenant) &&
partialObjectMatches(entry.resource, expectation.resource) &&
partialObjectMatches(entry.metadata, expectation.metadata)
);
}
function recordedEventMatches(
event: RecordedEvent,
expectation: RecordedEventExpectation,
): boolean {
if (expectation.name !== undefined && event.name !== expectation.name) {
return false;
}
return expectation.payload === undefined
? true
: valueMatches(event.payload, expectation.payload);
}
function recordedJobMatches(
job: RecordedJobDispatch,
expectation: RecordedJobDispatchExpectation,
): boolean {
if (expectation.name !== undefined && job.name !== expectation.name) {
return false;
}
return expectation.payload === undefined
? true
: valueMatches(job.payload, expectation.payload);
}
function recordedScheduleRunMatches(
run: RecordedScheduleRun,
expectation: RecordedScheduleRunExpectation,
): boolean {
if (expectation.name !== undefined && run.name !== expectation.name) {
return false;
}
if (expectation.id !== undefined && run.id !== expectation.id) {
return false;
}
if (expectation.source !== undefined && run.source !== expectation.source) {
return false;
}
return expectation.payload === undefined
? true
: valueMatches(run.payload, expectation.payload);
}
function getProviderInstrumentationEvents(
source: ProviderInstrumentationAssertionSource,
): readonly ProviderInstrumentationEventInput[] {
return "events" in source ? source.events : source;
}
function providerInstrumentationEventMatches(
event: ProviderInstrumentationEventInput,
expectation: ProviderInstrumentationEventExpectation,
): boolean {
const eventRecord = event as unknown as Record<string, unknown>;
const expectationRecord = expectation as Record<string, unknown>;
const directKeys = [
"type",
"id",
"timestamp",
"requestId",
"traceId",
"spanId",
"parentSpanId",
"traceparent",
"watcher",
"method",
"path",
"contractName",
"status",
"durationMs",
"summary",
"message",
"stack",
"useCaseName",
"name",
"kind",
"phase",
"error",
"eventName",
"jobName",
"messageId",
"messageKind",
"messageName",
"scheduleName",
"cron",
"timezone",
"action",
"label",
] as const;
for (const key of directKeys) {
if (
Object.hasOwn(expectation, key) &&
!valueMatches(eventRecord[key], expectationRecord[key])
) {
return false;
}
}
if (
expectation.providerName !== undefined &&
providerNameFromInstrumentationEvent(event) !== expectation.providerName
) {
return false;
}
return expectation.details === undefined
? true
: valueMatches(event.details, expectation.details);
}
function providerNameFromInstrumentationEvent(
event: ProviderInstrumentationEventInput,
): string | undefined {
if (event.type === "provider") return event.providerName;
if (!event.details || typeof event.details !== "object") return undefined;
return (event.details as { providerName?: unknown }).providerName as
| string
| undefined;
}
function mailDeliveryMatches(
delivery: MemoryMailDelivery,
expectation: MailDeliveryExpectation,
): boolean {
if (expectation.id !== undefined && delivery.id !== expectation.id) {
return false;
}
if (
expectation.subject !== undefined &&
delivery.message.subject !== expectation.subject
) {
return false;
}
if (
expectation.to !== undefined &&
!valueMatches(delivery.message.to, expectation.to)
) {
return false;
}
if (
expectation.from !== undefined &&
!valueMatches(delivery.message.from, expectation.from)
) {
return false;
}
if (
expectation.text !== undefined &&
delivery.message.text !== expectation.text
) {
return false;
}
if (
expectation.html !== undefined &&
delivery.message.html !== expectation.html
) {
return false;
}
return (
partialObjectMatches(delivery.message.headers, expectation.headers) &&
partialObjectMatches(delivery.message, expectation.message)
);
}
function notificationDeliveryMatches(
delivery: MemoryNotificationDelivery,
expectation: NotificationDeliveryExpectation,
): boolean {
if (expectation.id !== undefined && delivery.id !== expectation.id) {
return false;
}
if (
expectation.notificationName !== undefined &&
delivery.notificationName !== expectation.notificationName
) {
return false;
}
if (
expectation.channels !== undefined &&
!valueMatches(delivery.channels, expectation.channels)
) {
return false;
}
if (
expectation.payload !== undefined &&
!valueMatches(delivery.payload, expectation.payload)
) {
return false;
}
return partialObjectMatches(delivery.metadata, expectation.metadata);
}
function storageObjectMatches(
object: StorageObject,
expectation: StorageObjectExpectation,
): boolean {
if (object.key !== expectation.key) return false;
if (expectation.size !== undefined && object.size !== expectation.size) {
return false;
}
if (
expectation.contentType !== undefined &&
object.contentType !== expectation.contentType
) {
return false;
}
if (
expectation.cacheControl !== undefined &&
object.cacheControl !== expectation.cacheControl
) {
return false;
}
if (
expectation.visibility !== undefined &&
object.visibility !== expectation.visibility
) {
return false;
}
return partialObjectMatches(object.metadata, expectation.metadata);
}
function getOutboxMessages(
source: OutboxMessageAssertionSource,
): readonly OutboxMessage[] {
return "messages" in source ? source.messages : source;
}
function getIdempotencyEntries(
source: IdempotencyEntryAssertionSource,
): readonly MemoryIdempotencyEntry[] {
if (Array.isArray(source)) return source;
return (source as Pick<MemoryIdempotencyStore, "entries">).entries;
}
function idempotencyEntryMatches(
entry: MemoryIdempotencyEntry,
expectation: IdempotencyEntryExpectation,
): boolean {
if (
expectation.namespace !== undefined &&
entry.namespace !== expectation.namespace
) {
return false;
}
if (expectation.key !== undefined && entry.key !== expectation.key) {
return false;
}
if (
expectation.scopeKey !== undefined &&
entry.scopeKey !== expectation.scopeKey
) {
return false;
}
if (
expectation.fingerprint !== undefined &&
entry.fingerprint !== expectation.fingerprint
) {
return false;
}
if (expectation.status !== undefined && entry.status !== expectation.status) {
return false;
}
if (
expectation.result !== undefined &&
!valueMatches(entry.result, expectation.result)
) {
return false;
}
if (
expectation.reservedAt !== undefined &&
!valueMatches(entry.reservedAt, expectation.reservedAt)
) {
return false;
}
if (
expectation.completedAt !== undefined &&
!valueMatches(entry.completedAt, expectation.completedAt)
) {
return false;
}
if (
expectation.expiresAt !== undefined &&
!valueMatches(entry.expiresAt, expectation.expiresAt)
) {
return false;
}
return true;
}
function outboxMessageMatches(
message: OutboxMessage,
expectation: OutboxMessageExpectation,
): boolean {
if (expectation.id !== undefined && message.id !== expectation.id) {
return false;
}
if (expectation.kind !== undefined && message.kind !== expectation.kind) {
return false;
}
if (expectation.name !== undefined && message.name !== expectation.name) {
return false;
}
if (
expectation.status !== undefined &&
message.status !== expectation.status
) {
return false;
}
if (
expectation.attempts !== undefined &&
message.attempts !== expectation.attempts
) {
return false;
}
if (
expectation.maxAttempts !== undefined &&
message.maxAttempts !== expectation.maxAttempts
) {
return false;
}
if (
expectation.deliveredAt !== undefined &&
!valueMatches(message.deliveredAt, expectation.deliveredAt)
) {
return false;
}
if (
expectation.lastError !== undefined &&
!valueMatches(message.lastError, expectation.lastError)
) {
return false;
}
return expectation.payload === undefined
? true
: valueMatches(message.payload, expectation.payload);
}
function outboxDrainResultMatches(
result: DrainOutboxResult,
expectation: OutboxDrainResultExpectation,
): boolean {
if (
expectation.claimed !== undefined &&
result.claimed !== expectation.claimed
) {
return false;
}
if (
expectation.delivered !== undefined &&
result.delivered !== expectation.delivered
) {
return false;
}
if (
expectation.retried !== undefined &&
result.retried !== expectation.retried
) {
return false;
}
if (
expectation.deadLettered !== undefined &&
result.deadLettered !== expectation.deadLettered
) {
return false;
}
return true;
}
function hasStorageTextReader(
object: StorageObject,
): object is StorageObject & { text(): Promise<string> } {
return typeof (object as { text?: unknown }).text === "function";
}
function hasStorageBytesReader(
object: StorageObject,
): object is StorageObject & { bytes(): Promise<Uint8Array> } {
return typeof (object as { bytes?: unknown }).bytes === "function";
}
function omitStorageBodyExpectation(
expectation: StorageObjectExpectation,
): Omit<StorageObjectExpectation, "bytes" | "text"> {
const { bytes: _bytes, text: _text, ...metadataExpectation } = expectation;
return metadataExpectation;
}
function partialObjectMatches(
actual: unknown,
expected: Record<string, unknown> | undefined,
): boolean {
if (!expected) return true;
if (!actual || typeof actual !== "object") return false;
const actualRecord = actual as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
if (!valueMatches(actualRecord[key], expectedValue)) return false;
}
return true;
}
function valueMatches(actual: unknown, expected: unknown): boolean {
if (actual instanceof Date || expected instanceof Date) {
return (
actual instanceof Date &&
expected instanceof Date &&
actual.getTime() === expected.getTime()
);
}
if (actual instanceof Uint8Array || expected instanceof Uint8Array) {
return uint8ArrayMatches(actual, expected);
}
if (
expected &&
typeof expected === "object" &&
!Array.isArray(expected) &&
actual &&
typeof actual === "object" &&
!Array.isArray(actual)
) {
return partialObjectMatches(actual, expected as Record<string, unknown>);
}
if (Array.isArray(expected) || Array.isArray(actual)) {
return JSON.stringify(actual) === JSON.stringify(expected);
}
return Object.is(actual, expected);
}
function uint8ArrayMatches(actual: unknown, expected: unknown): boolean {
if (!(actual instanceof Uint8Array) || !(expected instanceof Uint8Array)) {
return false;
}
if (actual.byteLength !== expected.byteLength) return false;
return actual.every((byte, index) => byte === expected[index]);
}
function pluralize(word: string, count: number): string {
return count === 1 ? word : `${word}s`;
}
function formatTestingExpectation(expectation: unknown): string {
return JSON.stringify(expectation);
}
function formatAuditExpectation(expectation: AuditLogEntryExpectation): string {
return formatTestingExpectation(expectation);
}