fix: harden engine, enrich failure diagnostics, close adoption gaps
- P0: CLI verify now honors test budget with seeded multi-sample - P0: Observe sampling enforced via Math.random() gate in hook-validator - P1: Remove misleading undici-mock-agent isolation option - P1: Qualify reuses shared discoverRouteDetails() with warnings - P1: Chaos/scenario config exposed via preset schema - P1: README/docs limitations updated to current state - P2: Nested response annotations prefer 2xx deterministically - P2: --changed documented as heuristic in verify.md - Add observe sink tests (sampling 0/1, sink failure non-interference) - Add verify runs regression tests (scale, determinism, variants) - Add configured-scenario qualify test (independent of OAuth fixture) - Add coverageBreakdown to qualify artifacts (per-gate route coverage) - Add production-style observe example with real sink in docs/observe.md - Add nightly/staging vs PR gating guidance to docs/qualify.md - Enrich VerifyFailure with formula-aware diagnostics: status:201 => 'HTTP 200', body field checks => actual values - Remove stale observe CLI activation message - Document outbound mocks as process-global in getting-started.md - Refresh APOPHIS_ADOPTION_AUDIT.md with current state 903 tests pass, build clean, typecheck clean.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import Fastify from 'fastify'
|
||||
import apophisPlugin from '@apophis/fastify'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const fastify = Fastify()
|
||||
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'error',
|
||||
cleanup: true,
|
||||
})
|
||||
|
||||
const processedKeys = new Set<string>()
|
||||
const ledger = new Map<string, { idempotencyKey: string; amount: number; createdAt: number }>()
|
||||
|
||||
// PAYMENT — idempotent, must not double-charge
|
||||
fastify.post('/payments', {
|
||||
schema: {
|
||||
'x-category': 'mutator',
|
||||
'x-requires': [
|
||||
'request_header(this).idempotency-key != null',
|
||||
],
|
||||
'x-ensures': [
|
||||
'response_status(this) == 201 || response_status(this) == 200',
|
||||
'response_body(this).id != null',
|
||||
// Core idempotency assurance: same key, same result
|
||||
'response_body(this).amount == request_body(this).amount',
|
||||
],
|
||||
headers: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'idempotency-key': { type: 'string' },
|
||||
},
|
||||
required: ['idempotency-key'],
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
amount: { type: 'number', minimum: 1 },
|
||||
currency: { type: 'string', minLength: 3, maxLength: 3 },
|
||||
},
|
||||
required: ['amount', 'currency'],
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
idempotencyKey: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
idempotencyKey: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const key = req.headers['idempotency-key'] as string
|
||||
|
||||
// Idempotency check: if already processed, return the saved result
|
||||
if (processedKeys.has(key)) {
|
||||
for (const record of ledger.values()) {
|
||||
if (record.idempotencyKey === key) {
|
||||
return { ...record, status: 'duplicate' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = `pay-${crypto.randomUUID().slice(0, 8)}`
|
||||
const record = {
|
||||
id,
|
||||
idempotencyKey: key,
|
||||
amount: req.body.amount,
|
||||
status: 'processed',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
processedKeys.add(key)
|
||||
ledger.set(id, record)
|
||||
reply.status(201)
|
||||
return record
|
||||
})
|
||||
|
||||
// REFUND — idempotent reversal
|
||||
fastify.post('/payments/:id/refund', {
|
||||
schema: {
|
||||
'x-category': 'mutator',
|
||||
'x-requires': [
|
||||
'response_code(GET /payments/{request_params(this).id}) == 200',
|
||||
],
|
||||
'x-ensures': [
|
||||
'response_body(this).refundId != null',
|
||||
'response_body(this).amount == previous(response_body(GET /payments/{request_params(this).id})).amount',
|
||||
// Refund must be reversible only once
|
||||
],
|
||||
headers: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
'idempotency-key': { type: 'string' },
|
||||
},
|
||||
required: ['idempotency-key'],
|
||||
},
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id'],
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
refundId: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const payment = ledger.get(req.params.id)
|
||||
if (!payment) {
|
||||
reply.status(404)
|
||||
return { error: 'Payment not found' }
|
||||
}
|
||||
|
||||
const key = req.headers['idempotency-key'] as string
|
||||
if (processedKeys.has(key)) {
|
||||
return {
|
||||
refundId: `ref-${payment.id}`,
|
||||
amount: payment.amount,
|
||||
status: 'duplicate',
|
||||
}
|
||||
}
|
||||
|
||||
processedKeys.add(key)
|
||||
reply.status(201)
|
||||
return {
|
||||
refundId: `ref-${payment.id}`,
|
||||
amount: payment.amount,
|
||||
status: 'refunded',
|
||||
}
|
||||
})
|
||||
|
||||
// PAYMENT STATUS — observer
|
||||
fastify.get('/payments/:id', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-requires': [
|
||||
'response_status(this) == 200 || response_status(this) == 404',
|
||||
],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const payment = ledger.get(req.params.id)
|
||||
if (!payment) {
|
||||
reply.status(404)
|
||||
return { error: 'Payment not found' }
|
||||
}
|
||||
return payment
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
|
||||
const result = await fastify.apophis.contract({ runs: 50 })
|
||||
console.log('Contract tests:', result.summary)
|
||||
|
||||
const stateful = await fastify.apophis.stateful({ runs: 50, seed: 42 })
|
||||
console.log('Stateful tests:', stateful.summary)
|
||||
Reference in New Issue
Block a user