2026-03-10 00:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* Integration Tests - Complete end-to-end testing of APOPHIS functionality.
|
|
|
|
|
* Tests plugin registration, scope discovery, route contracts, hooks, test execution, and cleanup.
|
|
|
|
|
*/
|
|
|
|
|
import { test } from 'node:test'
|
|
|
|
|
import assert from 'node:assert'
|
|
|
|
|
import Fastify from 'fastify'
|
|
|
|
|
import type { FastifyInstance } from 'fastify'
|
|
|
|
|
import apophisPlugin from '../index.js'
|
|
|
|
|
import { runPetitTests } from '../test/petit-runner.js'
|
|
|
|
|
import { CleanupManager } from '../infrastructure/cleanup-manager.js'
|
|
|
|
|
import { ScopeRegistry } from '../infrastructure/scope-registry.js'
|
|
|
|
|
import { discoverRoutes } from '../domain/discovery.js'
|
|
|
|
|
import { registerValidationHooks } from '../infrastructure/hook-validator.js'
|
|
|
|
|
import swagger from '@fastify/swagger'
|
|
|
|
|
import type { ApophisDecorations, RouteContract } from '../types.js'
|
|
|
|
|
|
|
|
|
|
// Extend FastifyInstance type for tests
|
|
|
|
|
type TestFastifyInstance = FastifyInstance & {
|
|
|
|
|
apophis: ApophisDecorations
|
|
|
|
|
}
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Test Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const createTestApi = () => {
|
|
|
|
|
const fastify = Fastify()
|
|
|
|
|
fastify.get('/health', {
|
|
|
|
|
schema: {
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { status: { type: 'string' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async () => ({ status: 'ok' }))
|
|
|
|
|
fastify.post('/items', {
|
|
|
|
|
schema: {
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { name: { type: 'string' } },
|
|
|
|
|
required: ['name']
|
|
|
|
|
},
|
|
|
|
|
response: {
|
|
|
|
|
201: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' }, name: { type: 'string' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req) => {
|
|
|
|
|
return { id: '123', name: (req.body as any).name }
|
|
|
|
|
})
|
|
|
|
|
fastify.get('/items/:id', {
|
|
|
|
|
schema: {
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } },
|
|
|
|
|
required: ['id']
|
|
|
|
|
},
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' }, name: { type: 'string' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req) => {
|
|
|
|
|
return { id: (req.params as any).id, name: 'test-item' }
|
|
|
|
|
})
|
|
|
|
|
fastify.delete('/items/:id', {
|
|
|
|
|
schema: {
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } },
|
|
|
|
|
required: ['id']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async () => {
|
|
|
|
|
return { deleted: true }
|
|
|
|
|
})
|
|
|
|
|
return fastify
|
|
|
|
|
}
|
|
|
|
|
const createContractApi = () => {
|
|
|
|
|
const fastify = Fastify()
|
|
|
|
|
fastify.post('/resources', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'constructor',
|
|
|
|
|
'x-requires': [],
|
|
|
|
|
'x-ensures': ['status:201'],
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { name: { type: 'string' } },
|
|
|
|
|
required: ['name']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req, reply) => {
|
|
|
|
|
reply.status(201)
|
|
|
|
|
return { id: 'res-123', name: (req.body as any).name }
|
|
|
|
|
})
|
|
|
|
|
fastify.get('/resources/:id', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-requires': ['resources:res-123'],
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } },
|
|
|
|
|
required: ['id']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req) => {
|
|
|
|
|
return { id: (req.params as any).id, name: 'test-resource' }
|
|
|
|
|
})
|
|
|
|
|
return fastify
|
|
|
|
|
}
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Integration Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
test('plugin registers apophis decorations on fastify', async () => {
|
|
|
|
|
const fastify = Fastify()
|
|
|
|
|
let decorations: ApophisDecorations | undefined
|
|
|
|
|
try {
|
|
|
|
|
// Register swagger first as it's a dependency, then apophis
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, { runtime: 'error' })
|
|
|
|
|
// Register a test plugin to capture the decorations from within the same scope
|
|
|
|
|
await fastify.register(async (instance) => {
|
|
|
|
|
decorations = (instance as unknown as TestFastifyInstance).apophis
|
|
|
|
|
})
|
|
|
|
|
// Ready must be called after all plugins are registered
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
assert.ok(decorations, 'apophis decoration should exist')
|
|
|
|
|
assert.ok(typeof decorations?.contract === 'function', 'contract should be a function')
|
|
|
|
|
assert.ok(typeof decorations?.stateful === 'function', 'stateful should be a function')
|
|
|
|
|
assert.ok(typeof decorations?.check === 'function', 'check should be a function')
|
|
|
|
|
assert.ok(typeof decorations?.cleanup === 'function', 'cleanup should be a function')
|
|
|
|
|
assert.ok(typeof decorations?.spec === 'function', 'spec should be a function')
|
|
|
|
|
assert.ok(decorations?.scope, 'scope should exist')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('scope auto-discovery loads scopes from environment variables', async () => {
|
|
|
|
|
const originalEnv = process.env
|
|
|
|
|
process.env = {
|
|
|
|
|
...originalEnv,
|
|
|
|
|
APOPHIS_SCOPE_TEST: JSON.stringify({
|
|
|
|
|
tenantId: 'test-tenant',
|
|
|
|
|
applicationId: 'test-app',
|
|
|
|
|
headers: { 'x-api-key': 'secret123' }
|
|
|
|
|
}),
|
|
|
|
|
APOPHIS_SCOPE_PROD: JSON.stringify({
|
|
|
|
|
tenantId: 'prod-tenant',
|
|
|
|
|
applicationId: 'prod-app',
|
|
|
|
|
headers: { 'x-api-key': 'prod-secret' }
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const registry = new ScopeRegistry()
|
|
|
|
|
assert.ok(registry.scopes.has('test'), 'test scope should be discovered')
|
|
|
|
|
assert.ok(registry.scopes.has('prod'), 'prod scope should be discovered')
|
|
|
|
|
const testScope = registry.scopes.get('test')
|
|
|
|
|
assert.strictEqual(testScope?.metadata?.tenantId, 'test-tenant')
|
|
|
|
|
assert.strictEqual(testScope?.metadata?.applicationId, 'test-app')
|
|
|
|
|
assert.strictEqual(testScope?.headers['x-api-key'], 'secret123')
|
|
|
|
|
const prodScope = registry.scopes.get('prod')
|
|
|
|
|
assert.strictEqual(prodScope?.metadata?.tenantId, 'prod-tenant')
|
|
|
|
|
} finally {
|
|
|
|
|
process.env = originalEnv
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('route discovery extracts contracts from registered routes', async () => {
|
|
|
|
|
const fastify = createTestApi()
|
|
|
|
|
try {
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
// Fastify v5 doesn't expose routes directly, so we construct the expected route array
|
|
|
|
|
const mockRoutes = [
|
|
|
|
|
{ method: 'GET', url: '/health', schema: { response: { 200: { type: 'object', properties: { status: { type: 'string' } } } } } },
|
|
|
|
|
{ method: 'POST', url: '/items', schema: { body: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } } },
|
|
|
|
|
{ method: 'GET', url: '/items/:id', schema: { params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
|
|
|
|
|
{ method: 'DELETE', url: '/items/:id', schema: { params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } }
|
|
|
|
|
]
|
|
|
|
|
const routes = discoverRoutes({ routes: mockRoutes })
|
|
|
|
|
assert.strictEqual(routes.length, 4, 'should discover 4 routes')
|
|
|
|
|
const healthRoute = routes.find(r => r.path === '/health' && r.method === 'GET')
|
|
|
|
|
assert.ok(healthRoute, 'health route should be discovered')
|
|
|
|
|
assert.strictEqual(healthRoute?.category, 'utility')
|
|
|
|
|
const createRoute = routes.find(r => r.path === '/items' && r.method === 'POST')
|
|
|
|
|
assert.ok(createRoute, 'create items route should be discovered')
|
|
|
|
|
assert.strictEqual(createRoute?.category, 'constructor')
|
|
|
|
|
const getRoute = routes.find(r => r.path === '/items/:id' && r.method === 'GET')
|
|
|
|
|
assert.ok(getRoute, 'get item route should be discovered')
|
|
|
|
|
const deleteRoute = routes.find(r => r.path === '/items/:id' && r.method === 'DELETE')
|
|
|
|
|
assert.ok(deleteRoute, 'delete item route should be discovered')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('spec generation returns OpenAPI spec with x-apophis-contracts', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
// Register swagger first as it's a dependency, then apophis
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, {})
|
|
|
|
|
// Mock routes array for discovery (Fastify v5 doesn't expose routes directly)
|
|
|
|
|
const mockRoutes = [
|
|
|
|
|
{
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/test',
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-requires': ['auth'],
|
|
|
|
|
response: { 200: { type: 'object' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
Object.assign(fastify, { routes: mockRoutes })
|
|
|
|
|
// Ready must be called after all plugins and routes are registered
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const spec = fastify.apophis.spec()
|
|
|
|
|
assert.ok(spec, 'spec should be generated')
|
|
|
|
|
assert.ok(Array.isArray(spec['x-apophis-contracts']), 'should have x-apophis-contracts array')
|
|
|
|
|
const contracts = spec['x-apophis-contracts'] as any[]
|
|
|
|
|
const testContract = contracts.find(c => c.path === '/test')
|
|
|
|
|
assert.ok(testContract, 'test route contract should exist')
|
|
|
|
|
assert.strictEqual(testContract.method, 'GET')
|
|
|
|
|
assert.strictEqual(testContract.category, 'observer')
|
|
|
|
|
assert.deepStrictEqual(testContract.requires, ['auth'])
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('petit-runner executes tests against real API', async () => {
|
|
|
|
|
const fastify = createTestApi()
|
|
|
|
|
try {
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
// Mock routes for petit-runner since Fastify v5 doesn't expose them directly
|
|
|
|
|
const mockRoutes = [
|
|
|
|
|
{ method: 'GET', url: '/health', schema: { response: { 200: { type: 'object', properties: { status: { type: 'string' } } } } } },
|
|
|
|
|
{ method: 'POST', url: '/items', schema: { body: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, response: { 201: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } } } } } },
|
|
|
|
|
{ method: 'GET', url: '/items/:id', schema: { params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
|
|
|
|
|
{ method: 'DELETE', url: '/items/:id', schema: { params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } }
|
|
|
|
|
]
|
|
|
|
|
const fastifyWithRoutes = Object.assign(fastify, { routes: mockRoutes })
|
|
|
|
|
const result = await runPetitTests(fastifyWithRoutes as any, {
|
2026-03-10 00:00:00 -07:00
|
|
|
runs: 10,
|
2026-03-10 00:00:00 -07:00
|
|
|
scope: undefined,
|
|
|
|
|
seed: undefined
|
|
|
|
|
})
|
|
|
|
|
assert.ok(result.tests.length > 0, 'should have test results')
|
|
|
|
|
assert.ok(result.summary.timeMs >= 0, 'should have timing')
|
|
|
|
|
const passed = result.tests.filter(t => t.ok && !t.directive).length
|
|
|
|
|
const failed = result.tests.filter(t => !t.ok).length
|
|
|
|
|
assert.strictEqual(result.summary.passed, passed, 'passed count should match')
|
|
|
|
|
assert.strictEqual(result.summary.failed, failed, 'failed count should match')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('cleanup manager tracks and deletes resources', async () => {
|
|
|
|
|
const fastify = createTestApi()
|
|
|
|
|
try {
|
|
|
|
|
const scope = new ScopeRegistry()
|
|
|
|
|
const cleanup = new CleanupManager(fastify, scope)
|
|
|
|
|
cleanup.track({
|
|
|
|
|
type: 'items',
|
|
|
|
|
id: '123',
|
|
|
|
|
url: '/items/123',
|
|
|
|
|
scope: null,
|
|
|
|
|
timestamp: Date.now()
|
|
|
|
|
})
|
|
|
|
|
cleanup.track({
|
|
|
|
|
type: 'items',
|
|
|
|
|
id: '456',
|
|
|
|
|
url: '/items/456',
|
|
|
|
|
scope: null,
|
|
|
|
|
timestamp: Date.now()
|
|
|
|
|
})
|
|
|
|
|
assert.strictEqual(cleanup.resources.length, 2, 'should track 2 resources')
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const results = await cleanup.cleanup()
|
|
|
|
|
assert.strictEqual(results.length, 2, 'should cleanup 2 resources')
|
|
|
|
|
assert.strictEqual(cleanup.resources.length, 0, 'resources should be cleared after cleanup')
|
|
|
|
|
const firstResult = results[0]
|
|
|
|
|
assert.ok(firstResult?.resource, 'should have resource info')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('hook validator fires on routes with x-requires', async () => {
|
|
|
|
|
const fastify = createContractApi()
|
|
|
|
|
try {
|
|
|
|
|
registerValidationHooks(fastify, { validateRuntime: true, runtimeLevel: 'error' })
|
|
|
|
|
let preHandlerCalled = false
|
|
|
|
|
let onResponseCalled = false
|
|
|
|
|
fastify.addHook('preHandler', async () => {
|
|
|
|
|
preHandlerCalled = true
|
|
|
|
|
})
|
|
|
|
|
fastify.addHook('onResponse', async () => {
|
|
|
|
|
onResponseCalled = true
|
|
|
|
|
})
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const response = await fastify.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/resources',
|
|
|
|
|
payload: { name: 'test' }
|
|
|
|
|
})
|
|
|
|
|
assert.strictEqual(response.statusCode, 201, 'should return 201')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('full integration: plugin + routes + test execution', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
// Register swagger first as it's a dependency, then apophis
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, { runtime: 'error' })
|
|
|
|
|
// Mock routes array for discovery (Fastify v5 doesn't expose routes directly)
|
|
|
|
|
const mockRoutes = [
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/users',
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'constructor',
|
|
|
|
|
'x-ensures': ['status:201'],
|
|
|
|
|
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' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
method: 'GET',
|
|
|
|
|
url: '/users/:id',
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } },
|
|
|
|
|
required: ['id']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
Object.assign(fastify, { routes: mockRoutes })
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
assert.ok(fastify.apophis, 'plugin should be registered')
|
|
|
|
|
const spec = fastify.apophis.spec()
|
|
|
|
|
assert.ok(spec['x-apophis-contracts'], 'spec should have contracts')
|
|
|
|
|
const contracts = spec['x-apophis-contracts'] as any[]
|
|
|
|
|
assert.strictEqual(contracts.length, 2, 'should have 2 route contracts')
|
|
|
|
|
const createUserContract = contracts.find(c => c.path === '/users' && c.method === 'POST')
|
|
|
|
|
assert.ok(createUserContract, 'create user contract should exist')
|
|
|
|
|
assert.strictEqual(createUserContract.category, 'constructor')
|
2026-03-10 00:00:00 -07:00
|
|
|
const testResult = await fastify.apophis.contract({ runs: 10 })
|
2026-03-10 00:00:00 -07:00
|
|
|
assert.ok(Array.isArray(testResult.tests), 'tests should be an array')
|
|
|
|
|
assert.ok(testResult.tests.length > 0, 'tests should not be empty')
|
|
|
|
|
await fastify.apophis.cleanup()
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('mode filtering: stateful mode only runs constructor/mutator routes', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, { runtime: 'error' })
|
|
|
|
|
// Register real routes with different categories
|
|
|
|
|
fastify.post('/items', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'constructor',
|
|
|
|
|
'x-ensures': ['status:201'],
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { name: { type: 'string' } },
|
|
|
|
|
required: ['name']
|
|
|
|
|
},
|
|
|
|
|
response: {
|
|
|
|
|
201: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' }, name: { type: 'string' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req, reply) => {
|
|
|
|
|
reply.status(201)
|
|
|
|
|
return { id: '123', name: (req.body as any).name }
|
|
|
|
|
})
|
|
|
|
|
fastify.put('/items/:id', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'mutator',
|
|
|
|
|
'x-ensures': ['status:200'],
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } },
|
|
|
|
|
required: ['id']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req) => {
|
|
|
|
|
return { id: (req.params as any).id, updated: true }
|
|
|
|
|
})
|
|
|
|
|
fastify.get('/items/:id', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } },
|
|
|
|
|
required: ['id']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req) => {
|
|
|
|
|
return { id: (req.params as any).id, name: 'test' }
|
|
|
|
|
})
|
|
|
|
|
fastify.get('/health', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'utility',
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { status: { type: 'string' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async () => ({ status: 'ok' }))
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
// Run in stateful mode
|
2026-03-10 00:00:00 -07:00
|
|
|
const result = await fastify.apophis.contract({ runs: 10 })
|
2026-03-10 00:00:00 -07:00
|
|
|
// In stateful mode, utility routes should be excluded
|
|
|
|
|
// The test should only run constructor and mutator routes
|
|
|
|
|
assert.ok(Array.isArray(result.tests), 'tests should be an array')
|
|
|
|
|
// Verify no utility routes were executed
|
|
|
|
|
const utilityTests = result.tests.filter(t => t.name.includes('/health'))
|
|
|
|
|
assert.strictEqual(utilityTests.length, 0, 'utility routes should not run in stateful mode')
|
|
|
|
|
// In stateful mode, observer routes may still be present (they're not utility)
|
|
|
|
|
// The key assertion is that utility routes are excluded
|
|
|
|
|
const constructorTests = result.tests.filter(t => t.name.includes('POST /items'))
|
|
|
|
|
assert.ok(constructorTests.length > 0, 'constructor routes should run in stateful mode')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('failing contract produces ContractViolation with suggestion', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, {})
|
|
|
|
|
// Register a real route that returns 200 but contract expects 201
|
|
|
|
|
fastify.post('/broken', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'constructor',
|
|
|
|
|
'x-ensures': ['status:201'],
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { name: { type: 'string' } },
|
|
|
|
|
required: ['name']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async () => {
|
|
|
|
|
return { status: 'created' } // Returns 200, not 201
|
|
|
|
|
})
|
|
|
|
|
await fastify.ready()
|
2026-03-10 00:00:00 -07:00
|
|
|
const result = await fastify.apophis.contract({ runs: 10 })
|
2026-03-10 00:00:00 -07:00
|
|
|
// Find the failing test
|
|
|
|
|
const failingTests = result.tests.filter(t => !t.ok)
|
|
|
|
|
assert.ok(failingTests.length > 0, 'should have at least one failing test')
|
|
|
|
|
const failure = failingTests[0]
|
|
|
|
|
assert.ok(failure!.diagnostics, 'failure should have diagnostics')
|
|
|
|
|
const violation = failure!.diagnostics!.violation as { formula: string; suggestion: string } | undefined
|
|
|
|
|
assert.ok(violation, 'failure should have a ContractViolation')
|
|
|
|
|
assert.strictEqual(violation!.formula, 'status:201', 'violation should be for status:201')
|
|
|
|
|
assert.ok(violation!.suggestion, 'violation should have a suggestion')
|
|
|
|
|
assert.ok(violation!.suggestion.includes('201'), 'suggestion should mention expected status')
|
|
|
|
|
assert.ok((violation as any).request, 'violation should include request context')
|
|
|
|
|
assert.ok((violation as any).response, 'violation should include response context')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('contracts extracted from routes with annotations', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, { runtime: 'error' })
|
|
|
|
|
// Register routes with full contract annotations
|
|
|
|
|
fastify.post('/orders', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'constructor',
|
|
|
|
|
'x-requires': ['auth'],
|
|
|
|
|
'x-ensures': ['status:201', 'response_body(this).id != null'],
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { product: { type: 'string' }, quantity: { type: 'number' } },
|
|
|
|
|
required: ['product', 'quantity']
|
|
|
|
|
},
|
|
|
|
|
response: {
|
|
|
|
|
201: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
id: { type: 'string' },
|
|
|
|
|
product: { type: 'string' },
|
|
|
|
|
quantity: { type: 'number' },
|
|
|
|
|
total: { type: 'number' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req) => {
|
|
|
|
|
return {
|
|
|
|
|
id: 'order-123',
|
|
|
|
|
product: (req.body as any).product,
|
|
|
|
|
quantity: (req.body as any).quantity,
|
|
|
|
|
total: (req.body as any).quantity * 10
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
fastify.get('/orders/:id', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-requires': ['request_params(this).id != null'],
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } },
|
|
|
|
|
required: ['id']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async (req) => {
|
|
|
|
|
return {
|
|
|
|
|
id: (req.params as any).id,
|
|
|
|
|
product: 'widget',
|
|
|
|
|
quantity: 2,
|
|
|
|
|
total: 20
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const spec = fastify.apophis.spec()
|
|
|
|
|
const contracts = spec['x-apophis-contracts'] as any[]
|
|
|
|
|
// Verify POST /orders contract
|
|
|
|
|
const orderContract = contracts.find(c => c.path === '/orders' && c.method === 'POST')
|
|
|
|
|
assert.ok(orderContract, 'order contract should exist')
|
|
|
|
|
assert.strictEqual(orderContract.category, 'constructor')
|
|
|
|
|
assert.deepStrictEqual(orderContract.requires, ['auth'])
|
|
|
|
|
assert.ok(orderContract.ensures.includes('status:201'))
|
|
|
|
|
assert.ok(orderContract.ensures.includes('response_body(this).id != null'))
|
|
|
|
|
assert.ok(Array.isArray(orderContract.invariants), 'invariants should be represented as an array')
|
|
|
|
|
// Verify GET /orders/:id contract
|
|
|
|
|
const getOrderContract = contracts.find(c => c.path === '/orders/:id' && c.method === 'GET')
|
|
|
|
|
assert.ok(getOrderContract, 'get order contract should exist')
|
|
|
|
|
assert.strictEqual(getOrderContract.category, 'observer')
|
|
|
|
|
assert.deepStrictEqual(getOrderContract.requires, ['request_params(this).id != null'])
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('integration: prefix option is captured in route discovery', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, { runtime: 'error' })
|
|
|
|
|
// Register a nested plugin with a prefix
|
|
|
|
|
await fastify.register(async (instance) => {
|
|
|
|
|
instance.get('/users', {
|
|
|
|
|
schema: {
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { id: { type: 'string' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, async () => ({ id: 'user-1' }))
|
|
|
|
|
}, { prefix: '/api/v1' })
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const spec = fastify.apophis.spec()
|
|
|
|
|
const contracts = spec['x-apophis-contracts'] as any[]
|
|
|
|
|
// Should discover the route with the prefix included
|
|
|
|
|
const userContract = contracts.find(c => c.path === '/api/v1/users')
|
|
|
|
|
assert.ok(userContract, 'route with prefix should be discovered as /api/v1/users')
|
|
|
|
|
assert.strictEqual(userContract.method, 'GET')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('integration: cache enabled by default, disabled via APOPHIS_DISABLE_CACHE', async () => {
|
|
|
|
|
const originalEnv = process.env.NODE_ENV
|
|
|
|
|
const originalDisable = process.env.APOPHIS_DISABLE_CACHE
|
|
|
|
|
try {
|
|
|
|
|
// Cache is enabled by default in all environments
|
|
|
|
|
delete process.env.NODE_ENV
|
|
|
|
|
delete process.env.APOPHIS_DISABLE_CACHE
|
|
|
|
|
const cacheModule = await import('../incremental/cache.js')
|
|
|
|
|
cacheModule.invalidateCache()
|
|
|
|
|
const route: RouteContract = {
|
|
|
|
|
path: '/test',
|
|
|
|
|
method: 'GET',
|
|
|
|
|
category: 'observer',
|
|
|
|
|
requires: [],
|
|
|
|
|
ensures: [],
|
|
|
|
|
invariants: [],
|
|
|
|
|
regexPatterns: {},
|
|
|
|
|
validateRuntime: true,
|
|
|
|
|
}
|
|
|
|
|
cacheModule.storeCache(route, [{ params: {}, headers: {} }])
|
|
|
|
|
const entry = cacheModule.lookupCache(route)
|
|
|
|
|
assert.ok(entry, 'cache should be enabled by default')
|
|
|
|
|
// Disable cache via env var
|
|
|
|
|
process.env.APOPHIS_DISABLE_CACHE = '1'
|
|
|
|
|
const cacheModule2 = await import('../incremental/cache.js')
|
|
|
|
|
cacheModule2.invalidateCache()
|
|
|
|
|
cacheModule2.storeCache(route, [{ params: {}, headers: {} }])
|
|
|
|
|
const entry2 = cacheModule2.lookupCache(route)
|
|
|
|
|
assert.strictEqual(entry2, undefined, 'cache should be disabled when APOPHIS_DISABLE_CACHE=1')
|
|
|
|
|
} finally {
|
|
|
|
|
process.env.NODE_ENV = originalEnv
|
|
|
|
|
process.env.APOPHIS_DISABLE_CACHE = originalDisable
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
test('integration: contract routes option limits tested routes', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, {})
|
|
|
|
|
fastify.get('/included', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-ensures': ['status:200'],
|
|
|
|
|
} as Record<string, unknown>
|
|
|
|
|
}, async () => ({ ok: true }))
|
|
|
|
|
fastify.get('/excluded', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-ensures': ['status:201'],
|
|
|
|
|
} as Record<string, unknown>
|
|
|
|
|
}, async () => ({ ok: true }))
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const result = await fastify.apophis.contract({
|
2026-03-10 00:00:00 -07:00
|
|
|
runs: 10,
|
2026-03-10 00:00:00 -07:00
|
|
|
routes: ['GET /included'],
|
|
|
|
|
})
|
|
|
|
|
const includedTests = result.tests.filter(t => t.name.includes('GET /included'))
|
|
|
|
|
const excludedTests = result.tests.filter(t => t.name.includes('GET /excluded'))
|
|
|
|
|
assert.ok(includedTests.length > 0, 'included route should be tested')
|
|
|
|
|
assert.strictEqual(excludedTests.length, 0, 'excluded route should not be tested')
|
|
|
|
|
assert.strictEqual(result.summary.failed, 0, 'excluded failing route should not affect results')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('integration: contract variants are tagged and run in declared order', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, {})
|
|
|
|
|
fastify.get('/variant-order', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-ensures': ['status:200'],
|
|
|
|
|
} as Record<string, unknown>
|
|
|
|
|
}, async () => ({ ok: true }))
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const result = await fastify.apophis.contract({
|
2026-03-10 00:00:00 -07:00
|
|
|
runs: 10,
|
2026-03-10 00:00:00 -07:00
|
|
|
variants: [
|
|
|
|
|
{ name: 'json', headers: { accept: 'application/json' } },
|
|
|
|
|
{ name: 'xml', headers: { accept: 'application/xml' } },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
const jsonTests = result.tests.filter((t) => t.name.startsWith('[variant:json]'))
|
|
|
|
|
const xmlTests = result.tests.filter((t) => t.name.startsWith('[variant:xml]'))
|
|
|
|
|
assert.ok(jsonTests.length > 0, 'json variant should produce tests')
|
|
|
|
|
assert.ok(xmlTests.length > 0, 'xml variant should produce tests')
|
|
|
|
|
const firstXmlIndex = result.tests.findIndex((t) => t.name.startsWith('[variant:xml]'))
|
|
|
|
|
const firstJsonIndex = result.tests.findIndex((t) => t.name.startsWith('[variant:json]'))
|
|
|
|
|
assert.ok(firstJsonIndex >= 0 && firstXmlIndex >= 0 && firstJsonIndex < firstXmlIndex, 'variant order should follow declaration order')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('integration: variant headers override scope headers', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, {
|
|
|
|
|
scopes: {
|
|
|
|
|
default: {
|
|
|
|
|
headers: {
|
|
|
|
|
accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
fastify.get('/variant-header', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-ensures': [
|
|
|
|
|
'request_headers(this).accept == "application/xml"',
|
|
|
|
|
'status:200',
|
|
|
|
|
],
|
|
|
|
|
} as Record<string, unknown>
|
|
|
|
|
}, async () => ({ ok: true }))
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const result = await fastify.apophis.contract({
|
2026-03-10 00:00:00 -07:00
|
|
|
runs: 10,
|
2026-03-10 00:00:00 -07:00
|
|
|
variants: [
|
|
|
|
|
{ name: 'xml', headers: { accept: 'application/xml' } },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
assert.strictEqual(result.summary.failed, 0)
|
|
|
|
|
assert.ok(result.tests.some((t) => t.name.startsWith('[variant:xml]')))
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('integration: route-level x-variants are extracted and executed', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, {})
|
|
|
|
|
fastify.get('/route-variant', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
'x-variants': [
|
|
|
|
|
{ name: 'json', headers: { accept: 'application/json' } },
|
|
|
|
|
{ name: 'xml', headers: { accept: 'application/xml' } },
|
|
|
|
|
],
|
|
|
|
|
'x-ensures': ['status:200'],
|
|
|
|
|
} as Record<string, unknown>
|
|
|
|
|
}, async () => ({ ok: true }))
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
// No call-site variants; route-level variants should drive execution
|
2026-03-10 00:00:00 -07:00
|
|
|
const result = await fastify.apophis.contract({ runs: 10 })
|
2026-03-10 00:00:00 -07:00
|
|
|
const jsonTests = result.tests.filter((t) => t.name.includes('[variant:json]'))
|
|
|
|
|
const xmlTests = result.tests.filter((t) => t.name.includes('[variant:xml]'))
|
|
|
|
|
assert.ok(jsonTests.length > 0, 'route json variant should produce tests')
|
|
|
|
|
assert.ok(xmlTests.length > 0, 'route xml variant should produce tests')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-03-10 00:00:00 -07:00
|
|
|
|
|
|
|
|
test('integration: inferred contracts are guarded by status code', async () => {
|
|
|
|
|
const fastify = Fastify() as unknown as TestFastifyInstance
|
|
|
|
|
try {
|
|
|
|
|
await fastify.register(swagger, {})
|
|
|
|
|
await fastify.register(apophisPlugin, {})
|
|
|
|
|
fastify.get('/status-guarded', {
|
|
|
|
|
schema: {
|
|
|
|
|
'x-category': 'observer',
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { status: { type: 'string', const: 'success' } },
|
|
|
|
|
required: ['status']
|
|
|
|
|
},
|
|
|
|
|
404: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: { error: { type: 'string' } },
|
|
|
|
|
required: ['error']
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} as Record<string, unknown>
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
// Return 404 to verify the 200-schema const doesn't fail
|
|
|
|
|
reply.status(404)
|
|
|
|
|
return { error: 'not found' }
|
|
|
|
|
})
|
|
|
|
|
await fastify.ready()
|
|
|
|
|
const result = await fastify.apophis.contract({ runs: 10 })
|
|
|
|
|
// Should pass because the inferred const contract is guarded:
|
|
|
|
|
// response_code(this) == 200 => response_body(this).status == "success"
|
|
|
|
|
// The 404 response doesn't trigger the antecedent, so the implication holds.
|
|
|
|
|
assert.strictEqual(result.summary.failed, 0, 'inferred 200-schema const should not fail on 404')
|
|
|
|
|
} finally {
|
|
|
|
|
await fastify.close()
|
|
|
|
|
}
|
|
|
|
|
})
|