Files
apophis-fastify/src/infrastructure/outbound-mock-runtime.ts
T

287 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 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 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
}
interface OutboundMockOptions {
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[]
}
/** 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
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<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 => {
if (originalFetch !== undefined) {
throw new Error('OutboundMockRuntime already installed')
}
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
}
}
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 }
}
function matchesTarget(url: string, target: string): boolean {
if (target === url) return true
if (target.includes('*')) {
const regex = new RegExp('^' + target.replace(/\*/g, '.*') + '$')
return regex.test(url)
}
return url.includes(target)
}