feat: scoped outbound mocking via AsyncLocalStorage — concurrent tests get isolated mocks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<OutboundCallRecord>
|
||||
/** 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<OutboundMockRuntime[]>()
|
||||
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<string, Map<string, unknown>>
|
||||
|
||||
interface ScopedMockRuntime extends OutboundMockRuntime {
|
||||
handleFetch(url: string, method: string, input: RequestInfo | URL, init?: RequestInit): Promise<Response>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, { statusCode: number; body: unknown }>()
|
||||
|
||||
const getOrCreateResourceStore = (contractName: string): Map<string, unknown> => {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
@@ -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<Response> => {
|
||||
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<string, string>
|
||||
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<string, string>
|
||||
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<OutboundCallRecord> => {
|
||||
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<OutboundCallRecord> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user