/** * 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, { runs: 10, 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') const testResult = await fastify.apophis.contract({ runs: 10 }) 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 const result = await fastify.apophis.contract({ runs: 10 }) // 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() const result = await fastify.apophis.contract({ runs: 10 }) // 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 }, async () => ({ ok: true })) fastify.get('/excluded', { schema: { 'x-category': 'observer', 'x-ensures': ['status:201'], } as Record }, async () => ({ ok: true })) await fastify.ready() const result = await fastify.apophis.contract({ runs: 10, 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 }, async () => ({ ok: true })) await fastify.ready() const result = await fastify.apophis.contract({ runs: 10, 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 }, async () => ({ ok: true })) await fastify.ready() const result = await fastify.apophis.contract({ runs: 10, 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 }, async () => ({ ok: true })) await fastify.ready() // No call-site variants; route-level variants should drive execution const result = await fastify.apophis.contract({ runs: 10 }) 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() } }) 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 }, 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() } })