import { prepareEventPayloadForTransport } from "../events/index.js"; 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 { BestEffortWork, BestEffortWorkPort } from "./best-effort-work.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 validates canonical transport output and records published events * asynchronously for later assertion. Await `publish(...)` before reading the * captured log. Subscription is not supported and throws when 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 = { async publish(event, payload, options) { const prepared = await prepareEventPayloadForTransport( event, payload, options, ); events.push({ name: event.name, payload: prepared.payload }); }, subscribe() { throw new Error("Not implemented for recording bus"); }, }; return { bus, events }; } /** * Recording best-effort-work adapter for deterministic tests. * * Calls to `defer(...)` append work without running it. `flush()` runs the * currently pending batch in FIFO order, then rejects with the callback error * or an `AggregateError` after every callback in that batch has been attempted. * Work deferred by a running callback remains pending for the next flush so * one flush stays bounded. */ export function createRecordingBestEffortWork(): { bestEffortWork: BestEffortWorkPort; pending: BestEffortWork[]; flush(): Promise; } { const pending: BestEffortWork[] = []; return { bestEffortWork: { defer(work) { pending.push(work); }, }, pending, async flush() { const batch = pending.splice(0); const errors: unknown[] = []; for (const work of batch) { try { await work(); } catch (error) { errors.push(error); } } if (errors.length === 1) { throw errors[0]; } if (errors.length > 1) { throw new AggregateError(errors, "Best-effort work batch failed."); } }, }; } /** * 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( schedule: S, options: ScheduleRunOptions> = {}, ) { 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 { /** * 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; /** * 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 { 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 { 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 { 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; /** * Convenience matcher for `entry.actor.id`. */ actorId?: string; /** * Convenience matcher for `entry.actor.type`. */ actorType?: ActivityActor["type"]; /** * Expected tenant fields. */ tenant?: Partial; /** * Convenience matcher for `entry.tenant.id`. */ tenantId?: string; /** * Expected resource fields. */ resource?: Partial; /** * 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; /** * Expected normalized message fields. */ message?: Partial; } /** * 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; } /** * 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; /** * 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 | 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; /** * Expected count of eligible messages dead-lettered after exhausting their * claim attempt budget. */ abandonedDeadLettered?: number; /** * Expected count of messages whose durable settlement remained unknown. */ settlementFailed?: number; /** * Expected count of messages whose active claim could not be confirmed. */ leaseLost?: 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; /** * 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 { 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 { 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 = {}, ): 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 = {}, ): 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 = {}, ): 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 = {}, ): 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 = {}, ): 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 = {}, ): 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 = PolicySubjectArgs 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 & string]: { name: string; ctx: TContext; ability: TAbility; expected: PolicyMatrixExpectation; reason?: string; code?: string; } & PolicyMatrixSubject[TAbility]>; }[keyof PolicyMapFromDefinitions & string]; /** * Untyped policy matrix case used internally for failure reporting. */ export type UntypedPolicyMatrixCase = { 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; 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; /** * Evaluate cases and return structured pass/fail results. */ evaluateMatrix( cases: readonly PolicyMatrixCase[], ): Promise[]>; /** * Evaluate cases and throw a combined assertion error when any fail. */ assertMatrix( cases: readonly PolicyMatrixCase[], ): Promise; }; /** * 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 >, ): PolicyTester, 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, cases: readonly PolicyMatrixCase[], ): Promise[]> { const results: PolicyMatrixResult[] = []; 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, cases: readonly PolicyMatrixCase[], ): Promise { 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, matrixCase: PolicyMatrixCase, ): Promise { const inspect = gate.inspect as DynamicGateInspect; if ("subject" in matrixCase) { return inspect(matrixCase.ctx, matrixCase.ability, matrixCase.subject); } return inspect(matrixCase.ctx, matrixCase.ability); } function policyMatrixCasePassed( matrixCase: UntypedPolicyMatrixCase, 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( matrixCase: UntypedPolicyMatrixCase, 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 = ( ctx: TContext, ability: string, subject?: unknown, ) => Promise; 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; const expectationRecord = expectation as Record; 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).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; } if ( expectation.abandonedDeadLettered !== undefined && result.abandonedDeadLettered !== expectation.abandonedDeadLettered ) { return false; } if ( expectation.settlementFailed !== undefined && result.settlementFailed !== expectation.settlementFailed ) { return false; } if ( expectation.leaseLost !== undefined && result.leaseLost !== expectation.leaseLost ) { return false; } return true; } function hasStorageTextReader( object: StorageObject, ): object is StorageObject & { text(): Promise } { return typeof (object as { text?: unknown }).text === "function"; } function hasStorageBytesReader( object: StorageObject, ): object is StorageObject & { bytes(): Promise } { return typeof (object as { bytes?: unknown }).bytes === "function"; } function omitStorageBodyExpectation( expectation: StorageObjectExpectation, ): Omit { const { bytes: _bytes, text: _text, ...metadataExpectation } = expectation; return metadataExpectation; } function partialObjectMatches( actual: unknown, expected: Record | undefined, ): boolean { if (!expected) return true; if (!actual || typeof actual !== "object") return false; const actualRecord = actual as Record; 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); } 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); }