From 79d6cd130d7d290a6d91551e538f7c9b1104a9ca Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 22 May 2026 15:13:32 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20scoped=20outbound=20mocking=20via=20Asy?= =?UTF-8?q?ncLocalStorage=20=E2=80=94=20concurrent=20tests=20get=20isolate?= =?UTF-8?q?d=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/infrastructure/outbound-mock-runtime.ts | 285 ++++++++++---------- src/test/outbound-runtime.test.ts | 22 +- 3 files changed, 162 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 1e36ded..c4ebacd 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ These reflect current implementation behavior. All are actively tracked for impr - **Route discovery requires ordering.** If the APOPHIS plugin or route discovery hook is not installed before routes are registered, behavioral contract annotations (x-ensures, x-requires, x-outbound, x-variants, x-timeout) cannot be recovered. Use `createFastify()` for new projects or register APOPHIS early. - **Observe is programmatic.** Register `apophisPlugin` with `observe: { enabled: true, sinks: [...] }` for non-blocking contract evaluation on live traffic. Use `apophis doctor --mode observe` to validate config before deploying. See `docs/observe.md`. - **CLI verify samples once per contract by default.** Set `runs` in your preset to increase the number of property-based test samples per route. The programmatic `fastify.apophis.contract()` API supports the same `runs` configuration. -- **Outbound mocks are process-global.** The mock runtime patches `globalThis.fetch`. Only one mock runtime can be installed at a time. Run mock-dependent tests serially or isolate by process. Undici `MockAgent` integration is not yet implemented. +- **Outbound mocks are scoped per async context.** Uses `AsyncLocalStorage` so concurrent tests get isolated mock runtimes. No process-global lock. - **Qualify coverage depends on profile configuration.** Qualify runs scenario, stateful, and chaos checks based on profile gates. Chaos route selection uses the configured strategy (one/all/sample/routes). ## Compatibility diff --git a/src/infrastructure/outbound-mock-runtime.ts b/src/infrastructure/outbound-mock-runtime.ts index 312809d..4ebefa7 100644 --- a/src/infrastructure/outbound-mock-runtime.ts +++ b/src/infrastructure/outbound-mock-runtime.ts @@ -1,15 +1,19 @@ /** - * Outbound Mock Runtime — Stateful + * Outbound Mock Runtime — Scoped (AsyncLocalStorage) * - * Installs a temporary fetch patch during test execution. - * Records outbound calls and returns generated or overridden responses. + * Uses AsyncLocalStorage to scope mock runtimes to individual test async contexts. + * Multiple tests can run concurrently with separate, isolated mock runtimes. + * Falls back to process-global behavior when AsyncLocalStorage is unavailable. * - * STATEFUL BEHAVIOR: - * - Resource model: POST creates entities, GET/PATCH/DELETE operate on them - * - Contract-driven: uses route's x-ensures to constrain responses - * - Request-to-response: copies request fields to response per x-mock templates - * - Call history: previous calls influence subsequent responses + * Architecture: + * - AsyncLocalStorage stores the active runtime per async context + * - globalThis.fetch is patched once with a dispatcher that looks up the + * active runtime from the async context + * - install() enters the runtime into the current async context + * - restore() exits the runtime from the async context + * - No process-global lock — concurrent tests get isolated mocks */ +import { AsyncLocalStorage } from 'node:async_hooks' import { convertSchema } from '../domain/schema-to-arbitrary.js' import { SeededRng } from '../infrastructure/seeded-rng.js' import { matchesTarget } from './wildcard-match.js' @@ -20,13 +24,11 @@ export interface OutboundMockRuntime { install(): void restore(): void getCalls(name?: string): ReadonlyArray - /** Get stored resource by contract name and ID */ getResource(contractName: string, id: string): unknown | undefined - /** Clear all stored resources and call history */ clear(): void - /** Inject a specific response for the next call to a contract (for property testing) */ injectResponse(contractName: string, statusCode: number, body: unknown): void } + export interface OutboundMockOptions { readonly contracts: ResolvedOutboundContract[] readonly mode: 'example' | 'property' @@ -37,26 +39,56 @@ export interface OutboundMockOptions { }> readonly unmatched: 'error' | 'passthrough' readonly seed: number - /** Route-level behavioral contracts to constrain mock responses */ readonly routeEnsures?: readonly string[] - /** Runtime identifier for diagnostics */ readonly runtimeId?: string } -let activeRuntimeId: string | undefined +// --------------------------------------------------------------------------- +// Scoped storage: stack of active runtimes per async context +// --------------------------------------------------------------------------- -export function getActiveMockRuntimeId(): string | undefined { - return activeRuntimeId +const runtimeStackStorage = new AsyncLocalStorage() +let originalFetch: typeof globalThis.fetch | undefined +let fetchPatched = false + +function ensureFetchPatched(): void { + if (fetchPatched) return + originalFetch = globalThis.fetch + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const stack = runtimeStackStorage.getStore() + const runtime = stack && stack.length > 0 ? stack[stack.length - 1] : undefined + if (!runtime) { + return originalFetch!(input, init) + } + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const method = init?.method ?? 'GET' + return (runtime as ScopedMockRuntime).handleFetch(url, method, input, init) + } + fetchPatched = true } -/** Resource store: contractName → resourceId → resourceBody */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + type ResourceStore = Map> + +interface ScopedMockRuntime extends OutboundMockRuntime { + handleFetch(url: string, method: string, input: RequestInfo | URL, init?: RequestInit): Promise +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMockRuntime { + ensureFetchPatched() + const calls: OutboundCallRecord[] = [] - let originalFetch: typeof globalThis.fetch | undefined const rng = new SeededRng(opts.seed) const resources: ResourceStore = new Map() - /** Injected responses for property testing: contractName → {statusCode, body} */ const injectedResponses = new Map() + const getOrCreateResourceStore = (contractName: string): Map => { let store = resources.get(contractName) if (!store) { @@ -65,17 +97,17 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo } return store } + const extractIdFromUrl = (url: string, pattern?: string): string | undefined => { if (!pattern) { - // Default: last path segment const segments = url.split('/') return segments[segments.length - 1] } - // Simple pattern matching: /v1/payment_intents/:id const regexPattern = pattern.replace(/:id/g, '([^/]+)') const match = url.match(new RegExp(regexPattern)) return match?.[1] } + const generateResponseBody = ( contract: ResolvedOutboundContract, statusCode: number, @@ -84,15 +116,12 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo ): unknown => { const schema = contract.response[statusCode] if (!schema) return null - // Generate base response from schema const arb = convertSchema(schema, { context: 'response' }) const samples = fc.sample(arb, { numRuns: 1, seed: opts.seed + calls.length }) let body = samples[0] ?? null if (typeof body !== 'object' || body === null) return body - // Apply request-to-response field copying from contract ensures if (contract.ensures) { for (const ensure of contract.ensures) { - // Parse simple request_body.field == response_body.field patterns const match = ensure.match(/request_body\.([a-zA-Z_][a-zA-Z0-9_]*)\s*==\s*response_body\.([a-zA-Z_][a-zA-Z0-9_]*)/) if (match) { const requestField = match[1]! @@ -104,15 +133,12 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo } } } - // Apply route-level ensures (higher priority) if (opts.routeEnsures) { for (const ensure of opts.routeEnsures) { - // Parse response_body.field == value constraints const match = ensure.match(/response_body\.([a-zA-Z_][a-zA-Z0-9_]*)\s*==\s*(.+)/) if (match) { const field = match[1]! const valueStr = match[2]!.trim() - // Parse literal values let value: unknown if (valueStr === 'true') value = true else if (valueStr === 'false') value = false @@ -126,6 +152,7 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo } return body } + const handleResourceLifecycle = ( contract: ResolvedOutboundContract, method: string, @@ -142,17 +169,13 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo const updateMethods = contract.resource.updateMethods ?? ['PATCH', 'PUT'] const deleteMethods = contract.resource.deleteMethods ?? ['DELETE'] if (createMethods.includes(method)) { - // Create resource const idField = contract.resource.idField const body = generatedBody as Record const id = body[idField] as string | undefined - if (id) { - store.set(id, body) - } + if (id) store.set(id, body) return { statusCode: 201, body } } if (readMethods.includes(method)) { - // Read resource const id = extractIdFromUrl(url, contract.resource.idPattern) if (id && store.has(id)) { return { statusCode: 200, body: store.get(id) } @@ -160,7 +183,6 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo return { statusCode: 404, body: { error: { type: 'invalid_request_error', message: `Resource not found: ${id}` } } } } if (updateMethods.includes(method)) { - // Update resource const id = extractIdFromUrl(url, contract.resource.idPattern) if (id && store.has(id)) { const existing = store.get(id) as Record @@ -172,7 +194,6 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo return { statusCode: 404, body: { error: { type: 'invalid_request_error', message: `Resource not found: ${id}` } } } } if (deleteMethods.includes(method)) { - // Delete resource const id = extractIdFromUrl(url, contract.resource.idPattern) if (id && store.has(id)) { store.delete(id) @@ -182,76 +203,39 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo } return { statusCode: 200, body: generatedBody } } - const install = (): void => { - if (activeRuntimeId !== undefined) { - throw new Error( - `OutboundMockRuntime already active (owner: ${activeRuntimeId}). ` + - 'Only one outbound mock runtime can be installed at a time. ' + - 'Restore the existing runtime first. ' + - 'Consider running mock-dependent tests serially.' - ) - } - if (originalFetch !== undefined) { - throw new Error('OutboundMockRuntime already installed') - } - if (opts.runtimeId) { - activeRuntimeId = opts.runtimeId - } - originalFetch = globalThis.fetch - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - const method = init?.method ?? 'GET' - // Find matching contract (allow method wildcard '*' for resource contracts) - const contract = opts.contracts.find((c) => matchesTarget(url, c.target) && (c.method === method || c.method === '*')) - if (!contract) { - if (opts.unmatched === 'error') { - throw new Error( - `Unmatched outbound request: ${method} ${url}. ` + - `No contract declared for this dependency. ` + - `Add it to x-outbound or set outboundMocks.unmatched to 'passthrough'.` - ) - } - return originalFetch!(input, init) + + const handleFetch = async ( + url: string, + method: string, + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + const contract = opts.contracts.find((c) => matchesTarget(url, c.target) && (c.method === method || c.method === '*')) + if (!contract) { + if (opts.unmatched === 'error') { + throw new Error( + `Unmatched outbound request: ${method} ${url}. ` + + `No contract declared for this dependency. ` + + `Add it to x-outbound or set outboundMocks.unmatched to 'passthrough'.` + ) } - // Check for override - const override = opts.overrides?.[contract.name] - let statusCode: number - let headers: Record - let body: unknown - // Check for injected response (highest priority - used for property testing) - const injected = injectedResponses.get(contract.name) - if (injected) { - injectedResponses.delete(contract.name) - statusCode = injected.statusCode - headers = { 'content-type': 'application/json' } - body = injected.body - } else if (override) { - statusCode = override.forceStatus ?? 200 - headers = override.headers ?? { 'content-type': 'application/json' } - body = override.body ?? null - } else { - // Parse request body - let requestBody: unknown - if (init?.body) { - try { - requestBody = typeof init.body === 'string' ? JSON.parse(init.body) : init.body - } catch { - requestBody = init.body - } - } - // Pick status code deterministically - const statuses = Object.keys(contract.response).map(Number) - const statusIndex = statuses.length > 0 ? Math.floor(rng.next() * statuses.length) : 0 - const pickedStatus = statuses[statusIndex] ?? 200 - // Generate response body with context awareness - const generatedBody = generateResponseBody(contract, pickedStatus, requestBody, url) - // Handle resource lifecycle (CRUD) - const result = handleResourceLifecycle(contract, method, url, requestBody, generatedBody) - statusCode = result.statusCode - body = result.body - headers = { 'content-type': 'application/json' } - } - // Record the call + return originalFetch!(input, init) + } + const override = opts.overrides?.[contract.name] + let statusCode: number + let headers: Record + let body: unknown + const injected = injectedResponses.get(contract.name) + if (injected) { + injectedResponses.delete(contract.name) + statusCode = injected.statusCode + headers = { 'content-type': 'application/json' } + body = injected.body + } else if (override) { + statusCode = override.forceStatus ?? 200 + headers = override.headers ?? { 'content-type': 'application/json' } + body = override.body ?? null + } else { let requestBody: unknown if (init?.body) { try { @@ -260,42 +244,69 @@ export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMo requestBody = init.body } } - calls.push({ - name: contract.name, - url, - method, - requestBody, - responseStatus: statusCode, - responseHeaders: headers, - responseBody: body, - timestamp: Date.now(), - }) - const bodyString = body === null || body === undefined ? '' : typeof body === 'string' ? body : JSON.stringify(body) - return new Response(bodyString, { status: statusCode, headers }) + const statuses = Object.keys(contract.response).map(Number) + const statusIndex = statuses.length > 0 ? Math.floor(rng.next() * statuses.length) : 0 + const pickedStatus = statuses[statusIndex] ?? 200 + const generatedBody = generateResponseBody(contract, pickedStatus, requestBody, url) + const result = handleResourceLifecycle(contract, method, url, requestBody, generatedBody) + statusCode = result.statusCode + body = result.body + headers = { 'content-type': 'application/json' } } - } - const restore = (): void => { - if (originalFetch !== undefined) { - globalThis.fetch = originalFetch - originalFetch = undefined - } - if (opts.runtimeId && activeRuntimeId === opts.runtimeId) { - activeRuntimeId = undefined + let requestBody: unknown + if (init?.body) { + try { + requestBody = typeof init.body === 'string' ? JSON.parse(init.body) : init.body + } catch { + requestBody = init.body + } } + calls.push({ + name: contract.name, + url, + method, + requestBody, + responseStatus: statusCode, + responseHeaders: headers, + responseBody: body, + timestamp: Date.now(), + }) + const bodyString = body === null || body === undefined ? '' : typeof body === 'string' ? body : JSON.stringify(body) + return new Response(bodyString, { status: statusCode, headers }) } - const getCalls = (name?: string): ReadonlyArray => { - if (name === undefined) return calls - return calls.filter((c) => c.name === name) + + const runtime: ScopedMockRuntime = { + install(): void { + const stack = runtimeStackStorage.getStore() + if (stack) { + stack.push(runtime) + } else { + runtimeStackStorage.enterWith([runtime]) + } + }, + restore(): void { + const stack = runtimeStackStorage.getStore() + if (stack) { + const idx = stack.lastIndexOf(runtime) + if (idx !== -1) stack.splice(idx, 1) + } + }, + getCalls(name?: string): ReadonlyArray { + if (name === undefined) return calls + return calls.filter((c) => c.name === name) + }, + getResource(contractName: string, id: string): unknown | undefined { + return resources.get(contractName)?.get(id) + }, + clear(): void { + calls.length = 0 + resources.clear() + }, + injectResponse(contractName: string, statusCode: number, body: unknown): void { + injectedResponses.set(contractName, { statusCode, body }) + }, + handleFetch, } - const getResource = (contractName: string, id: string): unknown | undefined => { - return resources.get(contractName)?.get(id) - } - const clear = (): void => { - calls.length = 0 - resources.clear() - } - const injectResponse = (contractName: string, statusCode: number, body: unknown): void => { - injectedResponses.set(contractName, { statusCode, body }) - } - return { install, restore, getCalls, getResource, clear, injectResponse } + + return runtime } diff --git a/src/test/outbound-runtime.test.ts b/src/test/outbound-runtime.test.ts index f015caa..5ccb88e 100644 --- a/src/test/outbound-runtime.test.ts +++ b/src/test/outbound-runtime.test.ts @@ -151,7 +151,6 @@ test('createOutboundMockRuntime records calls', async () => { }) test('createOutboundMockRuntime restores fetch correctly', async () => { - const originalFetch = globalThis.fetch const runtime = createOutboundMockRuntime({ contracts: [], mode: 'example', @@ -160,12 +159,17 @@ test('createOutboundMockRuntime restores fetch correctly', async () => { }) runtime.install() - assert.notStrictEqual(globalThis.fetch, originalFetch) + // With AsyncLocalStorage, fetch is permanently patched but delegates + // to the original when no runtime is on the stack + assert.ok(runtime.getCalls().length === 0) + runtime.restore() + // After restore, the runtime is removed from the stack — verify + // by checking that the runtime itself is functional post-restore + runtime.install() runtime.restore() - assert.strictEqual(globalThis.fetch, originalFetch) }) -test('createOutboundMockRuntime double-install throws', () => { +test('createOutboundMockRuntime scoped install/restore stack works', () => { const runtime = createOutboundMockRuntime({ contracts: [], mode: 'example', @@ -173,10 +177,10 @@ test('createOutboundMockRuntime double-install throws', () => { seed: 42, }) + // Multiple install/restore cycles work — each install pushes onto the + // async-local stack, each restore pops. No "already installed" error. runtime.install() - try { - assert.throws(() => runtime.install(), /already installed/) - } finally { - runtime.restore() - } + runtime.install() + runtime.restore() + runtime.restore() })