/** * Outbound Mock Runtime — Stateful * * Installs a temporary fetch patch during test execution. * Records outbound calls and returns generated or overridden responses. * * 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 */ import { convertSchema } from '../domain/schema-to-arbitrary.js' import { SeededRng } from '../infrastructure/seeded-rng.js' import { matchesTarget } from './wildcard-match.js' import type { OutboundCallRecord, ResolvedOutboundContract } from '../types.js' import * as fc from 'fast-check' 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' readonly overrides?: Record readonly body?: unknown }> 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 export function getActiveMockRuntimeId(): string | undefined { return activeRuntimeId } /** Resource store: contractName → resourceId → resourceBody */ type ResourceStore = Map> export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMockRuntime { 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) { store = new Map() resources.set(contractName, store) } 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, requestBody: unknown, requestUrl: string ): 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]! const responseField = match[2]! const reqBody = requestBody as Record | undefined if (reqBody && requestField in reqBody) { ;(body as Record)[responseField] = reqBody[requestField] } } } } // 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 else if (valueStr === 'null') value = null else if (!isNaN(Number(valueStr))) value = Number(valueStr) else if (valueStr.startsWith('"') && valueStr.endsWith('"')) value = valueStr.slice(1, -1) else continue ;(body as Record)[field] = value } } } return body } const handleResourceLifecycle = ( contract: ResolvedOutboundContract, method: string, url: string, requestBody: unknown, generatedBody: unknown ): { statusCode: number; body: unknown } => { if (!contract.resource) { return { statusCode: 200, body: generatedBody } } const store = getOrCreateResourceStore(contract.name) const createMethods = contract.resource.createMethods ?? ['POST'] const readMethods = contract.resource.readMethods ?? ['GET'] 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) } 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) } } 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 const updates = requestBody as Record ?? {} const merged = { ...existing, ...updates } store.set(id, merged) return { statusCode: 200, body: merged } } 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) return { statusCode: 200, body: { deleted: true, id } } } return { statusCode: 404, body: { error: { type: 'invalid_request_error', message: `Resource not found: ${id}` } } } } 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) } // 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 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 restore = (): void => { if (originalFetch !== undefined) { globalThis.fetch = originalFetch originalFetch = undefined } if (opts.runtimeId && activeRuntimeId === opts.runtimeId) { activeRuntimeId = undefined } } const getCalls = (name?: string): ReadonlyArray => { if (name === undefined) return calls return calls.filter((c) => c.name === name) } 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 } }