Files
John Dvorak d0523fcc2d 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.
2026-05-21 20:39:36 -07:00

183 lines
5.0 KiB
TypeScript

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 users = new Map<string, { id: string; email: string; name: string }>()
// CREATE — constructor
// Behavioral: the created user must be retrievable.
// Note: we do not write 'status:201' or 'response_body(this).id != null'.
// The schema already validates status codes and required fields.
// Contracts should test behavior across operations, not structure.
fastify.post('/users', {
schema: {
'x-category': 'constructor',
'x-ensures': [
// The response must have an id assigned by the server
'response_body(this).id != null',
// The echoed fields must match the request
'response_body(this).email == request_body(this).email',
'response_body(this).name == request_body(this).name',
// Cross-route: the created user must be retrievable
'response_code(GET /users/{response_body(this).id}) == 200',
],
body: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1 }
},
required: ['email', 'name']
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (req, reply) => {
const id = `usr-${crypto.createHash('sha256').update(req.body.email).digest('hex').slice(0, 8)}`
const user = { id, email: req.body.email, name: req.body.name }
users.set(id, user)
reply.status(201)
return user
})
// READ — observer
// Behavioral: the returned user must match the requested id.
fastify.get('/users/:id', {
schema: {
'x-category': 'observer',
'x-requires': [
// Precondition: the user must exist for this read to be valid
'response_code(GET /users/{request_params(this).id}) == 200'
],
'x-ensures': [
// The returned id must match the requested id (no mix-up)
'response_body(this).id == request_params(this).id',
],
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id']
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (req) => {
const user = users.get(req.params.id)
if (!user) {
throw new Error('User not found')
}
return user
})
// UPDATE — mutator
// Behavioral: after update, the change must be visible on read.
fastify.put('/users/:id', {
schema: {
'x-category': 'mutator',
'x-requires': [
// The user must exist before updating
'response_code(GET /users/{request_params(this).id}) == 200'
],
'x-ensures': [
// Cross-route: after update, reading the user shows the new data
'response_body(GET /users/{request_params(this).id}).email == request_body(this).email',
],
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id']
},
body: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1 }
}
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (req) => {
const user = users.get(req.params.id)
if (!user) {
throw new Error('User not found')
}
const updated = {
...user,
email: req.body.email ?? user.email,
name: req.body.name ?? user.name,
}
users.set(req.params.id, updated)
return updated
})
// DELETE — destructor
// Behavioral: after deletion, the user must no longer exist.
fastify.delete('/users/:id', {
schema: {
'x-category': 'destructor',
'x-requires': [
// The user must exist before deleting
'response_code(GET /users/{request_params(this).id}) == 200'
],
'x-ensures': [
// After deletion, the user is gone
'response_code(GET /users/{request_params(this).id}) == 404',
// The deleted user data is returned (matches pre-deletion read)
'response_body(this) == previous(response_body(GET /users/{request_params(this).id}))',
],
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id']
}
}
}, async (req, reply) => {
const user = users.get(req.params.id)
users.delete(req.params.id)
reply.status(200)
return user
})
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)
const check = await fastify.apophis.check('POST', '/users')
console.log('POST /users check:', check.ok ? 'PASS' : 'FAIL')