2026-03-10 00:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* 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'
|
2026-05-21 20:39:36 -07:00
|
|
|
import { matchesTarget } from './wildcard-match.js'
|
2026-03-10 00:00:00 -07:00
|
|
|
import type { OutboundCallRecord, ResolvedOutboundContract } from '../types.js'
|
|
|
|
|
import * as fc from 'fast-check'
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-05-21 20:39:36 -07:00
|
|
|
export interface OutboundMockOptions {
|
2026-03-10 00:00:00 -07:00
|
|
|
readonly contracts: ResolvedOutboundContract[]
|
|
|
|
|
readonly mode: 'example' | 'property'
|
|
|
|
|
readonly overrides?: Record<string, {
|
|
|
|
|
readonly forceStatus?: number
|
|
|
|
|
readonly headers?: Record<string, string>
|
|
|
|
|
readonly body?: unknown
|
|
|
|
|
}>
|
|
|
|
|
readonly unmatched: 'error' | 'passthrough'
|
|
|
|
|
readonly seed: number
|
|
|
|
|
/** Route-level behavioral contracts to constrain mock responses */
|
|
|
|
|
readonly routeEnsures?: readonly string[]
|
2026-05-21 20:39:36 -07:00
|
|
|
/** Runtime identifier for diagnostics */
|
|
|
|
|
readonly runtimeId?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let activeRuntimeId: string | undefined
|
|
|
|
|
|
|
|
|
|
export function getActiveMockRuntimeId(): string | undefined {
|
|
|
|
|
return activeRuntimeId
|
2026-03-10 00:00:00 -07:00
|
|
|
}
|
|
|
|
|
/** Resource store: contractName → resourceId → resourceBody */
|
|
|
|
|
type ResourceStore = Map<string, Map<string, unknown>>
|
|
|
|
|
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<string, { statusCode: number; body: unknown }>()
|
|
|
|
|
const getOrCreateResourceStore = (contractName: string): Map<string, unknown> => {
|
|
|
|
|
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
|
2026-03-10 00:00:00 -07:00
|
|
|
const arb = convertSchema(schema, { context: 'response' })
|
2026-03-10 00:00:00 -07:00
|
|
|
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<string, unknown> | undefined
|
|
|
|
|
if (reqBody && requestField in reqBody) {
|
|
|
|
|
;(body as Record<string, unknown>)[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<string, unknown>)[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<string, unknown>
|
|
|
|
|
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<string, unknown>
|
|
|
|
|
const updates = requestBody as Record<string, unknown> ?? {}
|
|
|
|
|
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 => {
|
2026-05-21 20:39:36 -07:00
|
|
|
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.'
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-10 00:00:00 -07:00
|
|
|
if (originalFetch !== undefined) {
|
|
|
|
|
throw new Error('OutboundMockRuntime already installed')
|
|
|
|
|
}
|
2026-05-21 20:39:36 -07:00
|
|
|
if (opts.runtimeId) {
|
|
|
|
|
activeRuntimeId = opts.runtimeId
|
|
|
|
|
}
|
2026-03-10 00:00:00 -07:00
|
|
|
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<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
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-05-21 20:39:36 -07:00
|
|
|
if (opts.runtimeId && activeRuntimeId === opts.runtimeId) {
|
|
|
|
|
activeRuntimeId = undefined
|
|
|
|
|
}
|
2026-03-10 00:00:00 -07:00
|
|
|
}
|
|
|
|
|
const getCalls = (name?: string): ReadonlyArray<OutboundCallRecord> => {
|
|
|
|
|
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 }
|
|
|
|
|
}
|