chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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 generationProfile?: 'quick' | 'standard' | 'thorough'
|
||||
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', generationProfile: opts.generationProfile })
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user