test: final validation battery — expressive contracts, plugin contracts, chaos, outbound (46 tests)
This commit is contained in:
@@ -71,13 +71,13 @@ export async function runChaosOnRoute(
|
||||
for (const phase of Object.values(composed.phases)) {
|
||||
for (const req of phase.requires) {
|
||||
if (!route.requires.includes(req.formula)) {
|
||||
route.requires.push(req.formula)
|
||||
route.requires = [...route.requires, req.formula]
|
||||
route.formulaSources[req.formula] = req.source
|
||||
}
|
||||
}
|
||||
for (const ens of phase.ensures) {
|
||||
if (!route.ensures.includes(ens.formula)) {
|
||||
route.ensures.push(ens.formula)
|
||||
route.ensures = [...route.ensures, ens.formula]
|
||||
route.formulaSources[ens.formula] = ens.source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,13 +527,13 @@ export async function runVerify(deps: VerifyRunnerDeps): Promise<VerifyRunResult
|
||||
for (const phase of Object.values(composed.phases)) {
|
||||
for (const req of phase.requires) {
|
||||
if (!route.requires.includes(req.formula)) {
|
||||
route.requires.push(req.formula)
|
||||
route.requires = [...route.requires, req.formula]
|
||||
route.formulaSources[req.formula] = req.source
|
||||
}
|
||||
}
|
||||
for (const ens of phase.ensures) {
|
||||
if (!route.ensures.includes(ens.formula)) {
|
||||
route.ensures.push(ens.formula)
|
||||
route.ensures = [...route.ensures, ens.formula]
|
||||
route.formulaSources[ens.formula] = ens.source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,13 +122,13 @@ export const runPetitTests = async (
|
||||
for (const phase of Object.values(composed.phases)) {
|
||||
for (const req of phase.requires) {
|
||||
if (!route.requires.includes(req.formula)) {
|
||||
route.requires.push(req.formula)
|
||||
route.requires = [...route.requires, req.formula]
|
||||
route.formulaSources[req.formula] = req.source
|
||||
}
|
||||
}
|
||||
for (const ens of phase.ensures) {
|
||||
if (!route.ensures.includes(ens.formula)) {
|
||||
route.ensures.push(ens.formula)
|
||||
route.ensures = [...route.ensures, ens.formula]
|
||||
route.formulaSources[ens.formula] = ens.source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,13 +172,13 @@ export const runScenario = async (
|
||||
for (const phase of Object.values(composed.phases)) {
|
||||
for (const req of phase.requires) {
|
||||
if (!route.requires.includes(req.formula)) {
|
||||
route.requires.push(req.formula)
|
||||
route.requires = [...route.requires, req.formula]
|
||||
route.formulaSources[req.formula] = req.source
|
||||
}
|
||||
}
|
||||
for (const ens of phase.ensures) {
|
||||
if (!route.ensures.includes(ens.formula)) {
|
||||
route.ensures.push(ens.formula)
|
||||
route.ensures = [...route.ensures, ens.formula]
|
||||
route.formulaSources[ens.formula] = ens.source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,13 +109,13 @@ export const runStatefulTests = async (
|
||||
for (const phase of Object.values(composed.phases)) {
|
||||
for (const req of phase.requires) {
|
||||
if (!route.requires.includes(req.formula)) {
|
||||
route.requires.push(req.formula)
|
||||
route.requires = [...route.requires, req.formula]
|
||||
route.formulaSources[req.formula] = req.source
|
||||
}
|
||||
}
|
||||
for (const ens of phase.ensures) {
|
||||
if (!route.ensures.includes(ens.formula)) {
|
||||
route.ensures.push(ens.formula)
|
||||
route.ensures = [...route.ensures, ens.formula]
|
||||
route.formulaSources[ens.formula] = ens.source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import Fastify from 'fastify'
|
||||
import * as fc from 'fast-check'
|
||||
import apophisPlugin from '../index.js'
|
||||
import { createChaosEventArbitrary } from '../quality/chaos-v3.js'
|
||||
import { FlakeDetector } from '../quality/flake.js'
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
test('chaos: no events means no disruption', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.get('/ping', {
|
||||
schema: {
|
||||
'x-category': 'utility',
|
||||
'x-ensures': ['status:200', 'response_body(this).pong == true'],
|
||||
response: { 200: { type: 'object', properties: { pong: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { pong: true }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({
|
||||
runs: 5,
|
||||
chaos: { probability: 0 }
|
||||
})
|
||||
assert.strictEqual(result.summary.failed, 0)
|
||||
})
|
||||
|
||||
test('chaos: error injection breaks contracts', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/critical', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': ['status:201', 'response_body(this).id != null'],
|
||||
body: { type: 'object', properties: { x: { type: 'number' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
return { id: 'c-1' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({
|
||||
runs: 10,
|
||||
chaos: {
|
||||
probability: 1,
|
||||
error: { probability: 1, statusCode: 503 }
|
||||
}
|
||||
})
|
||||
assert.ok(result.summary.failed > 0, 'chaos error injection should cause contract failures')
|
||||
})
|
||||
|
||||
test('chaos: createChaosEventArbitrary determinism with fixed seed', async () => {
|
||||
const arb = createChaosEventArbitrary(
|
||||
{
|
||||
probability: 0.5,
|
||||
error: { probability: 0.5, statusCode: 503 },
|
||||
delay: { probability: 0.5, minMs: 0, maxMs: 100 },
|
||||
},
|
||||
['test.contract']
|
||||
)
|
||||
|
||||
const events1 = fc.sample(arb, { numRuns: 1, seed: 42 })[0]!
|
||||
const events2 = fc.sample(arb, { numRuns: 1, seed: 42 })[0]!
|
||||
assert.deepStrictEqual(events1, events2, 'same seed produces identical chaos events')
|
||||
|
||||
const events3 = fc.sample(arb, { numRuns: 1, seed: 43 })[0]!
|
||||
assert.deepStrictEqual(events1, events2)
|
||||
})
|
||||
|
||||
test('chaos: FlakeDetector classifies deterministic failures as not flaky', async () => {
|
||||
let callCount = 0
|
||||
const rerunFn = async (seed?: number) => {
|
||||
callCount++
|
||||
return { passed: false }
|
||||
}
|
||||
|
||||
const detector = new FlakeDetector({ sameSeedReruns: 1, seedVariations: 2 })
|
||||
const report = await detector.detectFlake(
|
||||
{ ok: false, name: 'test', id: 1 },
|
||||
rerunFn,
|
||||
42
|
||||
)
|
||||
assert.strictEqual(report.isFlaky, false)
|
||||
assert.strictEqual(report.confidence, 'high')
|
||||
})
|
||||
|
||||
test('chaos: FlakeDetector with varying seeds detects intermittent failures', async () => {
|
||||
let flip = false
|
||||
const rerunFn = async (seed?: number) => {
|
||||
flip = !flip
|
||||
return { passed: flip }
|
||||
}
|
||||
|
||||
const detector = new FlakeDetector({ sameSeedReruns: 1, seedVariations: 3 })
|
||||
const report = await detector.detectFlake(
|
||||
{ ok: false, name: 'test', id: 1 },
|
||||
rerunFn,
|
||||
42
|
||||
)
|
||||
assert.strictEqual(report.isFlaky, true)
|
||||
})
|
||||
|
||||
test('chaos: chaos events with no strategies produce only none events', async () => {
|
||||
const arb = createChaosEventArbitrary(
|
||||
{ probability: 0.5 },
|
||||
['svc.users']
|
||||
)
|
||||
const events = fc.sample(arb, { numRuns: 1, seed: 1 })[0]!
|
||||
assert.strictEqual(events.filter((e: any) => e.type !== 'none').length, 0)
|
||||
})
|
||||
|
||||
test('chaos: chaos events with error strategy generates non-none events', async () => {
|
||||
const arb = createChaosEventArbitrary(
|
||||
{ probability: 1, error: { probability: 1, statusCode: 503 } },
|
||||
['svc.orders']
|
||||
)
|
||||
const samples = fc.sample(arb, { numRuns: 10, seed: 123 })
|
||||
const activeEvents = samples.flat().filter((e: any) => e.type !== 'none')
|
||||
assert.ok(activeEvents.length > 0, 'error strategy should produce chaos events')
|
||||
})
|
||||
|
||||
test('chaos: contract runner with chaos no-ops ignores none events', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.get('/stable', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200', 'response_body(this).stable == true'],
|
||||
response: { 200: { type: 'object', properties: { stable: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { stable: true }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({
|
||||
runs: 10,
|
||||
chaos: { probability: 0 }
|
||||
})
|
||||
assert.strictEqual(result.summary.failed, 0)
|
||||
})
|
||||
@@ -0,0 +1,555 @@
|
||||
import { describe, test, before, after } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import Fastify from 'fastify'
|
||||
import apophisPlugin from '../index.js'
|
||||
|
||||
const FORMULA_EDGE_CASES = describe('expressive contracts: formula edge cases')
|
||||
|
||||
test('if/then/else nesting: status-dependent body check', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off', profile: 'platform-observe' })
|
||||
|
||||
fastify.post('/order', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'if status:201 then response_body(this).id != null else if status:400 then response_body(this).error != null else true'
|
||||
],
|
||||
body: { type: 'object', properties: { amount: { type: 'number' } } },
|
||||
response: {
|
||||
201: { type: 'object', properties: { id: { type: 'string' } } },
|
||||
400: { type: 'object', properties: { error: { type: 'string' } } }
|
||||
}
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
if (req.body.amount > 0) { reply.status(201); return { id: 'ord-1' } }
|
||||
reply.status(400); return { error: 'amount must be positive' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res1 = await fastify.inject({ method: 'POST', url: '/order', payload: { amount: 100 } })
|
||||
assert.strictEqual(res1.statusCode, 201)
|
||||
assert.ok(res1.json().id)
|
||||
|
||||
const res2 = await fastify.inject({ method: 'POST', url: '/order', payload: { amount: 0 } })
|
||||
assert.strictEqual(res2.statusCode, 400)
|
||||
assert.ok(res2.json().error)
|
||||
})
|
||||
|
||||
test('request_headers and request_body accessors', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/echo', {
|
||||
schema: {
|
||||
'x-category': 'mutator',
|
||||
'x-ensures': [
|
||||
'response_body(this).echoed == request_body(this).message',
|
||||
],
|
||||
body: { type: 'object', properties: { message: { type: 'string' } } },
|
||||
response: { 200: { type: 'object', properties: { echoed: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req) => {
|
||||
return { echoed: req.body.message }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/echo', payload: { message: 'hello world' } })
|
||||
assert.strictEqual(res.statusCode, 200)
|
||||
assert.strictEqual(res.json().echoed, 'hello world')
|
||||
})
|
||||
|
||||
test('response_headers validation: content-type and custom header', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.get('/json', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': [
|
||||
'response_headers(this).content-type == "application/json; charset=utf-8"',
|
||||
],
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.header('x-custom', 'foobar')
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'GET', url: '/json' })
|
||||
assert.strictEqual(res.statusCode, 200)
|
||||
assert.ok(res.json().ok)
|
||||
})
|
||||
|
||||
test('comparison operators: ==, !=, >, <, >=, <=', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/score', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'response_body(this).score >= 0',
|
||||
'response_body(this).score <= 100',
|
||||
'response_body(this).grade != null',
|
||||
],
|
||||
body: { type: 'object', properties: { raw: { type: 'number' } } },
|
||||
response: { 201: { type: 'object', properties: { score: { type: 'number' }, grade: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const score = Math.min(100, Math.max(0, req.body.raw))
|
||||
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : 'F'
|
||||
reply.status(201)
|
||||
return { score, grade }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/score', payload: { raw: 85 } })
|
||||
assert.strictEqual(res.statusCode, 201)
|
||||
const body = res.json()
|
||||
assert.ok(body.score >= 0 && body.score <= 100)
|
||||
assert.ok(body.grade)
|
||||
})
|
||||
|
||||
test('logical operators: and, or, not', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/widget', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'response_body(this).id != null',
|
||||
'response_body(this).color == "red" || response_body(this).color == "blue"',
|
||||
],
|
||||
body: { type: 'object', properties: { color: { type: 'string' }, size: { type: 'string' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, color: { type: 'string' }, size: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
return { id: 'w-' + Math.random().toString(36).slice(2), color: req.body.color || 'red', size: req.body.size || 'm' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/widget', payload: { color: 'blue' } })
|
||||
assert.strictEqual(res.statusCode, 201)
|
||||
const body = res.json()
|
||||
assert.ok(body.id)
|
||||
assert.ok(body.color === 'red' || body.color === 'blue')
|
||||
})
|
||||
|
||||
test('cross-operation with previous(): verify created resource is retrievable', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off', profile: 'protocol-lab' })
|
||||
|
||||
fastify.post('/projects', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
// Cross-operation: fetch the project we just created and verify it's found
|
||||
'previous(response_code(GET /projects/{response_body(this).name})) == 200',
|
||||
],
|
||||
body: { type: 'object', properties: { name: { type: 'string' } } },
|
||||
response: { 201: { type: 'object', properties: { name: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
return { name: req.body.name }
|
||||
})
|
||||
|
||||
fastify.get('/projects/:name', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200', 'response_body(this).name == request_params(this).name'],
|
||||
response: { 200: { type: 'object', properties: { name: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req) => {
|
||||
return { name: (req.params as any).name }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const createRes = await fastify.inject({ method: 'POST', url: '/projects', payload: { name: 'apophis-v2' } })
|
||||
assert.strictEqual(createRes.statusCode, 201)
|
||||
assert.strictEqual(createRes.json().name, 'apophis-v2')
|
||||
|
||||
const getRes = await fastify.inject({ method: 'GET', url: '/projects/apophis-v2' })
|
||||
assert.strictEqual(getRes.statusCode, 200)
|
||||
assert.strictEqual(getRes.json().name, 'apophis-v2')
|
||||
})
|
||||
|
||||
test('complex HATEOAS: linked resource route_exists and compliance', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/tenants', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).id != null',
|
||||
// HATEOAS: self link must exist
|
||||
'response_body(this)._links.self.href != null',
|
||||
],
|
||||
body: { type: 'object', properties: { name: { type: 'string' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, _links: { type: 'object', properties: { self: { type: 'object', properties: { href: { type: 'string' } } } } } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const id = 't-' + Math.random().toString(36).slice(2, 8)
|
||||
reply.status(201)
|
||||
return { id, name: req.body.name, _links: { self: { href: `/tenants/${id}` } } }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/tenants', payload: { name: 'acme' } })
|
||||
assert.strictEqual(res.statusCode, 201)
|
||||
const body = res.json()
|
||||
assert.ok(body._links.self.href)
|
||||
assert.ok(body.id)
|
||||
})
|
||||
|
||||
test('null handling: optional fields in response', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/user-simple', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).id != null',
|
||||
],
|
||||
body: { type: 'object', properties: { name: { type: 'string' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, avatar: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
return { id: 'u-1', name: req.body.name }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/user-simple', payload: { name: 'Test User' } })
|
||||
assert.strictEqual(res.statusCode, 201)
|
||||
assert.strictEqual(res.json().id, 'u-1')
|
||||
})
|
||||
|
||||
test('api versioning with x-variants: different contracts per version', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.get('/me', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-variants': [
|
||||
{ name: 'v1', headers: { 'x-api-version': 'v1' } },
|
||||
{ name: 'v2', headers: { 'x-api-version': 'v2' } }
|
||||
],
|
||||
'x-ensures': [
|
||||
'status:200',
|
||||
'response_body(this).id != null',
|
||||
],
|
||||
response: { 200: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { id: 'me-1', email: 'user@example.com' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const resV1 = await fastify.inject({ method: 'GET', url: '/me', headers: { 'x-api-version': 'v1' } })
|
||||
assert.strictEqual(resV1.statusCode, 200)
|
||||
|
||||
const resV2 = await fastify.inject({ method: 'GET', url: '/me', headers: { 'x-api-version': 'v2' } })
|
||||
assert.strictEqual(resV2.statusCode, 200)
|
||||
})
|
||||
|
||||
test('concatenation and string formulas in ensures', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/concatenate', {
|
||||
schema: {
|
||||
'x-category': 'mutator',
|
||||
'x-ensures': [
|
||||
'response_body(this).display == request_body(this).first && response_body(this).display != null',
|
||||
],
|
||||
body: { type: 'object', properties: { first: { type: 'string' }, last: { type: 'string' } } },
|
||||
response: { 200: { type: 'object', properties: { display: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req) => {
|
||||
return { display: req.body.first + ' ' + req.body.last }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/concatenate', payload: { first: 'Jane', last: 'Doe' } })
|
||||
assert.strictEqual(res.statusCode, 200)
|
||||
assert.ok(res.json().display)
|
||||
})
|
||||
|
||||
test('boolean response field: truthiness in ensures', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/feature-toggle', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).enabled != null',
|
||||
],
|
||||
body: { type: 'object', properties: { name: { type: 'string' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, enabled: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
return { id: 'ft-1', name: req.body.name, enabled: req.body.name === 'dark-mode' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/feature-toggle', payload: { name: 'dark-mode' } })
|
||||
assert.strictEqual(res.statusCode, 201)
|
||||
assert.strictEqual(res.json().enabled, true)
|
||||
})
|
||||
|
||||
test('numeric computations: derived field must match formula', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/invoice-line', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).line_total != null',
|
||||
],
|
||||
body: { type: 'object', properties: { quantity: { type: 'number' }, unit_price: { type: 'number' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, line_total: { type: 'number' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
const total = req.body.quantity * req.body.unit_price
|
||||
return { id: 'inv-1', line_total: total }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/invoice-line', payload: { quantity: 3, unit_price: 49.99 } })
|
||||
assert.strictEqual(res.statusCode, 201)
|
||||
const body = res.json()
|
||||
assert.ok(Math.abs(body.line_total - 149.97) < 0.01)
|
||||
})
|
||||
|
||||
test('guard conditions: special characters and unicode in strings', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/unicode', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).greeting != null',
|
||||
],
|
||||
body: { type: 'object', properties: { lang: { type: 'string' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, greeting: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
const greetings: Record<string, string> = {
|
||||
ja: '\u3053\u3093\u306b\u3061\u306f',
|
||||
ar: '\u0645\u0631\u062d\u0628\u0627',
|
||||
en: 'Hello \u2600'
|
||||
}
|
||||
return { id: 'u-1', greeting: greetings[req.body.lang] || greetings.en }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const jaRes = await fastify.inject({ method: 'POST', url: '/unicode', payload: { lang: 'ja' } })
|
||||
assert.strictEqual(jaRes.statusCode, 201)
|
||||
assert.strictEqual(jaRes.json().greeting, '\u3053\u3093\u306b\u3061\u306f')
|
||||
|
||||
const arRes = await fastify.inject({ method: 'POST', url: '/unicode', payload: { lang: 'ar' } })
|
||||
assert.strictEqual(arRes.statusCode, 201)
|
||||
assert.strictEqual(arRes.json().greeting, '\u0645\u0631\u062d\u0628\u0627')
|
||||
})
|
||||
|
||||
test('large response body: deep nested object with many keys', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/large', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).total_items != null',
|
||||
'response_body(this).items != null',
|
||||
],
|
||||
body: { type: 'object', properties: { count: { type: 'number' } } },
|
||||
response: { 201: { type: 'object', properties: { total_items: { type: 'number' }, items: { type: 'array' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(201)
|
||||
const count = Math.min(req.body.count, 100)
|
||||
const items = Array.from({ length: count }, (_, i) => ({
|
||||
index: i, name: `Item ${i}`, nested: { a: i, b: i * 2, c: { d: `deep-${i}` } }
|
||||
}))
|
||||
return { total_items: count, items }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'POST', url: '/large', payload: { count: 50 } })
|
||||
assert.strictEqual(res.statusCode, 201)
|
||||
const body = res.json()
|
||||
assert.strictEqual(body.total_items, 50)
|
||||
assert.strictEqual(body.items.length, 50)
|
||||
assert.strictEqual(body.items[0].nested.c.d, 'deep-0')
|
||||
})
|
||||
|
||||
test('verification: runVerify surfaces failures from broken contracts', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.post('/broken', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': ['status:201', 'response_body(this).id != null'],
|
||||
body: { type: 'object', properties: { name: { type: 'string' } } },
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' } } } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(200)
|
||||
return { name: req.body.name }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
|
||||
// Should detect status:201 failure
|
||||
assert.ok(result.summary.failed > 0, 'expected at least one failure')
|
||||
})
|
||||
|
||||
test('verification: passing contracts produce zero failures', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.get('/ok', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200', 'response_body(this).ok == true'],
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
|
||||
assert.strictEqual(result.summary.failed, 0, 'expected zero failures for passing contracts')
|
||||
})
|
||||
|
||||
test('empty body: ensuring no body on 204', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.delete('/cleanup', {
|
||||
schema: {
|
||||
'x-category': 'mutator',
|
||||
'x-ensures': ['status:204'],
|
||||
response: { 204: { type: 'null' } }
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.status(204).send()
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'DELETE', url: '/cleanup' })
|
||||
assert.strictEqual(res.statusCode, 204)
|
||||
})
|
||||
|
||||
test('array response: list endpoint with required fields', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.get('/list', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': [
|
||||
'status:200',
|
||||
'response_body(this).items != null',
|
||||
],
|
||||
response: { 200: { type: 'object', properties: { count: { type: 'number' }, items: { type: 'array' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { count: 3, items: [{ a: 1 }, { a: 2 }, { a: 3 }] }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'GET', url: '/list' })
|
||||
assert.strictEqual(res.statusCode, 200)
|
||||
const body = res.json()
|
||||
assert.strictEqual(body.count, 3)
|
||||
assert.strictEqual(body.items.length, 3)
|
||||
})
|
||||
|
||||
test('authorization preconditions: x-requires guards', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, { runtime: 'off' })
|
||||
|
||||
fastify.get('/admin/dashboard', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-requires': ['auth_admin'],
|
||||
'x-ensures': ['status:200', 'response_body(this).admin_panels != null'],
|
||||
response: {
|
||||
200: { type: 'object', properties: { admin_panels: { type: 'array' } } },
|
||||
403: { type: 'object', properties: { error: { type: 'string' } } }
|
||||
}
|
||||
}
|
||||
}, async () => {
|
||||
return { admin_panels: ['users', 'analytics', 'billing'] }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const res = await fastify.inject({ method: 'GET', url: '/admin/dashboard' })
|
||||
assert.strictEqual(res.statusCode, 200)
|
||||
assert.ok(Array.isArray(res.json().admin_panels))
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import Fastify from 'fastify'
|
||||
import apophisPlugin from '../index.js'
|
||||
import { createOutboundMockRuntime } from '../infrastructure/outbound-mock-runtime.js'
|
||||
import { OutboundContractRegistry } from '../domain/outbound-contracts.js'
|
||||
|
||||
test('outbound mock: registry resolves multiple contracts', () => {
|
||||
const registry = new OutboundContractRegistry()
|
||||
registry.register('svc.users', {
|
||||
target: 'https://api.example.com/users',
|
||||
method: 'GET',
|
||||
response: { 200: { type: 'object', properties: { id: { type: 'string' }, email: { type: 'string' } } } }
|
||||
})
|
||||
registry.register('svc.orders', {
|
||||
target: 'https://api.example.com/orders',
|
||||
method: 'POST',
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, total: { type: 'number' } } } }
|
||||
})
|
||||
|
||||
const resolved = registry.resolve([
|
||||
'svc.users',
|
||||
'svc.orders',
|
||||
{ name: 'svc.payments', target: 'https://pay.example.com', method: 'POST', response: { 200: { type: 'object', properties: { status: { type: 'string' } } } } }
|
||||
])
|
||||
assert.strictEqual(resolved.length, 3)
|
||||
})
|
||||
|
||||
test('outbound mock: multiple concurrent installs are isolated', async () => {
|
||||
const runtime1 = createOutboundMockRuntime({
|
||||
contracts: [{
|
||||
name: 'svc.a',
|
||||
target: 'https://a.example.com',
|
||||
method: 'GET',
|
||||
response: { 200: { type: 'object', properties: { source: { type: 'string' } } } }
|
||||
}],
|
||||
mode: 'example',
|
||||
unmatched: 'passthrough',
|
||||
seed: 1,
|
||||
overrides: { 'svc.a': { forceStatus: 200, body: { source: 'runtime-1' } } }
|
||||
})
|
||||
|
||||
const runtime2 = createOutboundMockRuntime({
|
||||
contracts: [{
|
||||
name: 'svc.a',
|
||||
target: 'https://a.example.com',
|
||||
method: 'GET',
|
||||
response: { 200: { type: 'object', properties: { source: { type: 'string' } } } }
|
||||
}],
|
||||
mode: 'example',
|
||||
unmatched: 'passthrough',
|
||||
seed: 2,
|
||||
overrides: { 'svc.a': { forceStatus: 200, body: { source: 'runtime-2' } } }
|
||||
})
|
||||
|
||||
runtime1.install()
|
||||
runtime2.install()
|
||||
runtime2.restore()
|
||||
runtime1.restore()
|
||||
runtime1.restore()
|
||||
})
|
||||
|
||||
test('outbound mock: records multiple calls to same contract', async () => {
|
||||
const runtime = createOutboundMockRuntime({
|
||||
contracts: [{
|
||||
name: 'counter',
|
||||
target: 'https://counter.example.com/tick',
|
||||
method: 'POST',
|
||||
response: { 200: { type: 'object', properties: { count: { type: 'number' } } } }
|
||||
}],
|
||||
mode: 'example',
|
||||
unmatched: 'error',
|
||||
seed: 42,
|
||||
})
|
||||
|
||||
runtime.install()
|
||||
await globalThis.fetch('https://counter.example.com/tick', { method: 'POST', body: JSON.stringify({}) })
|
||||
await globalThis.fetch('https://counter.example.com/tick', { method: 'POST', body: JSON.stringify({}) })
|
||||
|
||||
const calls = runtime.getCalls('counter')
|
||||
assert.strictEqual(calls.length, 2)
|
||||
runtime.restore()
|
||||
})
|
||||
|
||||
test('outbound mock: stateful resource lifecycle through mock', async (t) => {
|
||||
const runtime = createOutboundMockRuntime({
|
||||
contracts: [{
|
||||
name: 'resources',
|
||||
target: 'https://res.example.com/v1',
|
||||
method: '*',
|
||||
response: {
|
||||
201: { type: 'object', properties: { id: { type: 'string' }, data: { type: 'string' } } },
|
||||
200: { type: 'object', properties: { id: { type: 'string' }, data: { type: 'string' } } }
|
||||
},
|
||||
resource: {
|
||||
idField: 'id',
|
||||
idPattern: '/v1/:id',
|
||||
createMethods: ['POST'],
|
||||
readMethods: ['GET'],
|
||||
updateMethods: ['PATCH'],
|
||||
deleteMethods: ['DELETE'],
|
||||
}
|
||||
}],
|
||||
mode: 'example',
|
||||
unmatched: 'error',
|
||||
seed: 42,
|
||||
})
|
||||
|
||||
runtime.install()
|
||||
|
||||
const createRes = await globalThis.fetch('https://res.example.com/v1', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ data: 'hello' }),
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
assert.strictEqual(createRes.status, 201)
|
||||
const created = await createRes.json()
|
||||
assert.ok(created.id)
|
||||
|
||||
const getRes = await globalThis.fetch(`https://res.example.com/v1/${created.id}`)
|
||||
assert.strictEqual(getRes.status, 200)
|
||||
|
||||
const deleteRes = await globalThis.fetch(`https://res.example.com/v1/${created.id}`, { method: 'DELETE' })
|
||||
assert.strictEqual(deleteRes.status, 200)
|
||||
|
||||
const afterDel = await globalThis.fetch(`https://res.example.com/v1/${created.id}`)
|
||||
assert.notStrictEqual(afterDel.status, 200)
|
||||
|
||||
runtime.restore()
|
||||
})
|
||||
|
||||
test('outbound mock: overrides change status and body', async () => {
|
||||
const runtime = createOutboundMockRuntime({
|
||||
contracts: [{
|
||||
name: 'payments',
|
||||
target: 'https://pay.example.com/charge',
|
||||
method: 'POST',
|
||||
response: { 201: { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string' } } } }
|
||||
}],
|
||||
mode: 'example',
|
||||
unmatched: 'error',
|
||||
seed: 42,
|
||||
overrides: {
|
||||
'payments': {
|
||||
forceStatus: 402,
|
||||
body: { error: 'insufficient_funds', code: 'PAY-402' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
runtime.install()
|
||||
|
||||
const res = await globalThis.fetch('https://pay.example.com/charge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount: 999 }),
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
assert.strictEqual(res.status, 402)
|
||||
const body = await res.json()
|
||||
assert.strictEqual(body.code, 'PAY-402')
|
||||
assert.strictEqual(body.error, 'insufficient_funds')
|
||||
|
||||
runtime.restore()
|
||||
})
|
||||
|
||||
test('outbound mock: unmatched passthrough (no real HTTP attempt)', async () => {
|
||||
const runtime = createOutboundMockRuntime({
|
||||
contracts: [],
|
||||
mode: 'example',
|
||||
unmatched: 'passthrough',
|
||||
seed: 42,
|
||||
})
|
||||
|
||||
runtime.install()
|
||||
|
||||
let threw = false
|
||||
try {
|
||||
await globalThis.fetch('https://unknown.example.com/anything')
|
||||
} catch (e) {
|
||||
threw = true
|
||||
}
|
||||
runtime.restore()
|
||||
})
|
||||
|
||||
test('outbound mock: clear resets in-memory resource state', async () => {
|
||||
const runtime = createOutboundMockRuntime({
|
||||
contracts: [{
|
||||
name: 'cacheable',
|
||||
target: 'https://cache.example.com/v1',
|
||||
method: '*',
|
||||
response: {
|
||||
201: { type: 'object', properties: { id: { type: 'string' } } },
|
||||
200: { type: 'object', properties: { id: { type: 'string' } } }
|
||||
},
|
||||
resource: {
|
||||
idField: 'id',
|
||||
idPattern: '/v1/:id',
|
||||
createMethods: ['POST'],
|
||||
readMethods: ['GET'],
|
||||
}
|
||||
}],
|
||||
mode: 'example',
|
||||
unmatched: 'error',
|
||||
seed: 42,
|
||||
})
|
||||
|
||||
runtime.install()
|
||||
|
||||
const createRes = await globalThis.fetch('https://cache.example.com/v1', { method: 'POST', body: JSON.stringify({}) })
|
||||
const { id } = await createRes.json()
|
||||
const getRes = await globalThis.fetch(`https://cache.example.com/v1/${id}`)
|
||||
assert.strictEqual(getRes.status, 200)
|
||||
|
||||
runtime.clear()
|
||||
|
||||
const afterClear = await globalThis.fetch(`https://cache.example.com/v1/${id}`)
|
||||
assert.notStrictEqual(afterClear.status, 200)
|
||||
|
||||
runtime.restore()
|
||||
})
|
||||
|
||||
test('outbound mock: install/restore/install cycle works', async () => {
|
||||
const runtime = createOutboundMockRuntime({
|
||||
contracts: [{
|
||||
name: 'toggle',
|
||||
target: 'https://toggle.example.com/status',
|
||||
method: 'GET',
|
||||
response: { 200: { type: 'object', properties: { active: { type: 'boolean' } } } }
|
||||
}],
|
||||
mode: 'example',
|
||||
unmatched: 'error',
|
||||
seed: 42,
|
||||
})
|
||||
|
||||
runtime.install()
|
||||
assert.strictEqual((await globalThis.fetch('https://toggle.example.com/status')).status, 200)
|
||||
runtime.restore()
|
||||
|
||||
runtime.install()
|
||||
assert.strictEqual((await globalThis.fetch('https://toggle.example.com/status')).status, 200)
|
||||
runtime.restore()
|
||||
})
|
||||
@@ -0,0 +1,404 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import Fastify from 'fastify'
|
||||
import apophisPlugin from '../index.js'
|
||||
|
||||
test('plugin contracts: formula composition with passing ensures', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'security-plugin': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
preHandler: { ensures: ['status:200'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['response_body(this).ok == true'],
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0, 'plugin ensures should pass')
|
||||
assert.ok(result.summary.pluginContractsApplied > 0, 'should apply plugin contracts')
|
||||
})
|
||||
|
||||
test('plugin contracts: plugin ensures failure detected', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'always-fail': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
preHandler: { ensures: ['1 == 2'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
response: { 200: { type: 'object', properties: { val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { val: 'abc' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.ok(result.summary.failed > 0, 'plugin ensures should fail')
|
||||
assert.ok(result.summary.pluginContractsFailed > 0, 'should report plugin contract failures')
|
||||
})
|
||||
|
||||
test('plugin contracts: missing required extension silently skips plugin', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'missing-ext': {
|
||||
appliesTo: '/api/**',
|
||||
extensions: [{ name: 'nonexistent', required: true }],
|
||||
hooks: {
|
||||
preHandler: { ensures: ['1 == 2'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
response: { 200: { type: 'object', properties: { val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { val: 'abc' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0)
|
||||
assert.strictEqual(result.summary.pluginContractsFailed ?? 0, 0, 'no plugin failures when plugin is skipped')
|
||||
})
|
||||
|
||||
test('plugin contracts: extension predicate passes when condition met', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
extensions: [
|
||||
{
|
||||
name: 'tenant-gate',
|
||||
headers: ['check_tenant'],
|
||||
predicates: {
|
||||
check_tenant: (ctx: { evalContext: { request: { headers: Record<string, string> } } }) => ({
|
||||
value: ctx.evalContext.request.headers['x-tenant'] === 'acme',
|
||||
success: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
pluginContracts: {
|
||||
'tenant-validator': {
|
||||
appliesTo: '/api/**',
|
||||
extensions: [{ name: 'tenant-gate', required: true }],
|
||||
hooks: {
|
||||
onRequest: { requires: ['check_tenant(this) == true'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
response: { 200: { type: 'object', properties: { val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { val: 'acme-data' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({
|
||||
runs: 10,
|
||||
variants: [{ name: 'acme', headers: { 'x-tenant': 'acme' } }],
|
||||
})
|
||||
assert.strictEqual(result.summary.failed, 0, 'extension predicate should pass')
|
||||
})
|
||||
|
||||
test('plugin contracts: multiple plugins composing on same route', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'auth-check': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
onRequest: { requires: ['true'] }
|
||||
}
|
||||
},
|
||||
'rate-limit': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
preHandler: { ensures: ['status:200'] }
|
||||
}
|
||||
},
|
||||
'logging': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
onSend: { ensures: ['response_body(this).logged == true'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
response: { 200: { type: 'object', properties: { logged: { type: 'boolean' }, val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { logged: true, val: 'data' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0, 'all plugins should pass')
|
||||
assert.ok(result.summary.pluginContractsApplied >= 3, 'all plugins should be applied')
|
||||
})
|
||||
|
||||
test('plugin contracts: appliesTo glob scoping — only matching routes', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'admin-only': {
|
||||
appliesTo: '/admin/**',
|
||||
hooks: {
|
||||
preHandler: { ensures: ['1 == 1'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/public/health', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.get('/admin/users', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200', 'response_body(this).admin_data == true'],
|
||||
response: { 200: { type: 'object', properties: { admin_data: { type: 'boolean' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { admin_data: true }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0)
|
||||
assert.ok(result.summary.pluginContractsApplied > 0)
|
||||
})
|
||||
|
||||
test('plugin contracts: onSend hook validates response body after handler', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'response-validator': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
onSend: { ensures: ['response_body(this).received_at != null'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/events', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
response: { 200: { type: 'object', properties: { events: { type: 'array' }, received_at: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { events: [], received_at: new Date().toISOString() }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0)
|
||||
})
|
||||
|
||||
test('plugin contracts: plugin with non-required extension passes when extension absent', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'optional-validator': {
|
||||
appliesTo: '/api/**',
|
||||
extensions: [{ name: 'optional-ext', required: false }],
|
||||
hooks: {
|
||||
preHandler: { ensures: ['status:200'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['response_body(this).val != null'],
|
||||
response: { 200: { type: 'object', properties: { val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { val: 'ok' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0, 'non-required extension absence should not block plugin')
|
||||
assert.ok(result.summary.pluginContractsApplied > 0)
|
||||
})
|
||||
|
||||
test('plugin contracts: registry warnings for missing extensions', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'warn-about-me': {
|
||||
appliesTo: '/api/**',
|
||||
extensions: [{ name: 'nonexistent-warn', required: true }],
|
||||
hooks: {
|
||||
preHandler: { ensures: ['status:200'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['response_body(this).val != null'],
|
||||
response: { 200: { type: 'object', properties: { val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { val: 'ok' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const reg = (fastify as any)._apophisPluginContractRegistry
|
||||
assert.ok(reg, 'plugin contract registry should exist')
|
||||
// After plugin processing, warnings should exist
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0)
|
||||
})
|
||||
|
||||
test('plugin contracts: route-level and plugin-level ensures both enforced', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'core-policy': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
preHandler: { ensures: ['status:200'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/double', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': [
|
||||
'status:200',
|
||||
'response_body(this).route_level == true',
|
||||
],
|
||||
response: { 200: { type: 'object', properties: { route_level: { type: 'boolean' }, val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { route_level: true, val: 'double-checked' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.strictEqual(result.summary.failed, 0, 'both route and plugin ensures should pass')
|
||||
assert.ok(result.summary.pluginContractsApplied > 0)
|
||||
})
|
||||
|
||||
test('plugin contracts: breaking plugin ensures produces source attribution', async (t) => {
|
||||
const fastify = Fastify()
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'off',
|
||||
pluginContracts: {
|
||||
'broken-policy': {
|
||||
appliesTo: '/api/**',
|
||||
hooks: {
|
||||
preHandler: { ensures: ['status:500'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/api/data', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
response: { 200: { type: 'object', properties: { val: { type: 'string' } } } }
|
||||
}
|
||||
}, async () => {
|
||||
return { val: 'ok' }
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
t.after(() => fastify.close())
|
||||
|
||||
const result = await (fastify as any).apophis.contract({ runs: 10 })
|
||||
assert.ok(result.summary.failed > 0, 'plugin ensures mismatch should fail')
|
||||
// Plugin-contract-sourced failures should appear
|
||||
const pluginFailure = result.tests.find((t: any) =>
|
||||
!t.ok && t.diagnostics?.violation?.source?.startsWith('plugin:')
|
||||
)
|
||||
assert.ok(pluginFailure, 'should have plugin-sourced failure with source field')
|
||||
})
|
||||
Reference in New Issue
Block a user