/** * Unit tests for src/controllers/HealthDetailedController.ts * * Covers: * - all services healthy → HTTP 200, status "healthy" * - a service degraded (latency > 1000ms) → HTTP 207, status "degraded" * - a service unreachable (throws) → HTTP 503, status "unreachable" * - optional services (Vault, OPA) omitted when not configured * - Vault and OPA included when URLs configured */ import express, { Application } from 'express'; import request from 'supertest'; import { HealthDetailedController, HealthDetailedDeps, DbProbe } from '../../../src/controllers/HealthDetailedController'; // ── fetch mock ──────────────────────────────────────────────────────────────── type MockFetchFn = jest.MockedFunction; const mockFetch = jest.fn() as MockFetchFn; global.fetch = mockFetch; // ── Helpers ──────────────────────────────────────────────────────────────────── /** * Creates a mock DbProbe. When `error` is provided, checkLiveness() rejects * with that error (simulates unreachable DB). Otherwise it resolves after * `latencyMs` ms (0 by default — Date.now mocking handles degraded scenarios). */ function makeDbProbe(error?: Error, latencyMs = 0): DbProbe { return { checkLiveness: error ? jest.fn().mockRejectedValue(error) : jest.fn().mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve(), latencyMs)), ), }; } function makeRedisClient(pingError?: Error, latencyMs = 0): { ping(): Promise } { return { ping: pingError ? jest.fn().mockRejectedValue(pingError) : jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('PONG'), latencyMs)), ), }; } function buildApp(deps: HealthDetailedDeps): Application { const app = express(); const controller = new HealthDetailedController(deps); app.get('/health/detailed', controller.handle); return app; } // ── Tests ────────────────────────────────────────────────────────────────────── beforeEach(() => { jest.clearAllMocks(); }); describe('GET /health/detailed — all services healthy', () => { it('returns 200 with overall status "healthy" when postgres and redis respond quickly', async () => { const app = buildApp({ dbProbe: makeDbProbe(undefined, 10), redisClient: makeRedisClient(undefined, 5), }); const res = await request(app).get('/health/detailed'); expect(res.status).toBe(200); expect(res.body.status).toBe('healthy'); expect(res.body.services.postgres.status).toBe('healthy'); expect(res.body.services.redis.status).toBe('healthy'); }); it('includes version and uptime in the response body', async () => { const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), }); const res = await request(app).get('/health/detailed'); expect(typeof res.body.version).toBe('string'); expect(typeof res.body.uptime).toBe('number'); }); it('includes latencyMs for each service', async () => { const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), }); const res = await request(app).get('/health/detailed'); expect(typeof res.body.services.postgres.latencyMs).toBe('number'); expect(typeof res.body.services.redis.latencyMs).toBe('number'); }); }); describe('GET /health/detailed — degraded scenario', () => { it('returns 207 when postgres is slow (> 1000ms)', async () => { // We cannot actually wait 1000+ ms in a unit test, so we simulate by making // the pool connect throw, then override the probe timeout. // Instead, we test the degraded path by mocking a pool that reports > 1000ms // via a custom pool. We achieve this by making connect resolve after 1001ms. // Because our probe timeout is 3000ms, this simulates a degraded connection. // // To keep tests fast, we mock the Date.now() approach indirectly: // a pool that resolves after a 1 ms delay can't produce > 1000ms latency — // we test the logic contract instead by checking that slow connections // produce `degraded` status. We achieve the required latency by patching // the pool.connect to resolve with a controlled delay. // We simulate a slow postgres by making the query take 1010ms via setTimeout. // Using fake timers is not possible here with supertest async flows, so we // take a different approach: we verify the status classification logic is wired // correctly by patching the Date.now() calls to simulate elapsed time. // // Pragmatic approach: test with real timing for integration-level confidence // by using a mock pool that resolves in 0ms but whose query artificially delays. // Since 1010ms wait makes the test slow, we verify the classification branch // using a pool whose connect itself rejects — that's the "unreachable" path. // For degraded, we trust the latencyMs threshold check in the controller and // verify it via the pool events test below. // Simplest reliable approach: if postgres latencyMs would be 1001ms, status = degraded. // We mock the pool.connect to capture the flow and manually verify via the // response body latencyMs field plus the overall status. // We use a fake pool that resolves immediately but we override Date.now to // simulate elapsed time for the controller's latency check. const realDateNow = Date.now; let callCount = 0; Date.now = jest.fn(() => { callCount += 1; // First call = start timestamp (200), second call = end timestamp (1201) → 1001ms return callCount === 1 ? 200 : 1401; }); try { const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(undefined, 0), }); const res = await request(app).get('/health/detailed'); // postgres should be degraded (simulated 1201ms) expect(res.status).toBe(207); expect(res.body.status).toBe('degraded'); expect(res.body.services.postgres.status).toBe('degraded'); } finally { Date.now = realDateNow; } }); it('returns 207 when redis is slow (> 1000ms)', async () => { const realDateNow = Date.now; let callCount = 0; // postgres probe uses 2 Date.now() calls, redis probe uses 2 more Date.now = jest.fn(() => { callCount += 1; if (callCount <= 2) { // postgres: fast (50ms) return callCount === 1 ? 1000 : 1050; } // redis: slow (1200ms) return callCount === 3 ? 2000 : 3200; }); try { const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(undefined, 0), }); const res = await request(app).get('/health/detailed'); expect(res.status).toBe(207); expect(res.body.status).toBe('degraded'); expect(res.body.services.redis.status).toBe('degraded'); } finally { Date.now = realDateNow; } }); }); describe('GET /health/detailed — unreachable scenarios', () => { it('returns 503 when postgres connect() throws', async () => { const app = buildApp({ dbProbe: makeDbProbe(new Error('ECONNREFUSED')), redisClient: makeRedisClient(), }); const res = await request(app).get('/health/detailed'); expect(res.status).toBe(503); expect(res.body.status).toBe('unreachable'); expect(res.body.services.postgres.status).toBe('unreachable'); }); it('returns 503 when redis ping() throws', async () => { const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(new Error('Redis ECONNREFUSED')), }); const res = await request(app).get('/health/detailed'); expect(res.status).toBe(503); expect(res.body.status).toBe('unreachable'); expect(res.body.services.redis.status).toBe('unreachable'); }); it('returns 503 when both postgres and redis are unreachable', async () => { const app = buildApp({ dbProbe: makeDbProbe(new Error('PG down')), redisClient: makeRedisClient(new Error('Redis down')), }); const res = await request(app).get('/health/detailed'); expect(res.status).toBe(503); expect(res.body.status).toBe('unreachable'); expect(res.body.services.postgres.status).toBe('unreachable'); expect(res.body.services.redis.status).toBe('unreachable'); }); }); describe('GET /health/detailed — optional services omitted when not configured', () => { it('does not include vault in services when vaultAddr is not provided', async () => { const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), }); const res = await request(app).get('/health/detailed'); expect(res.body.services.vault).toBeUndefined(); }); it('does not include opa in services when opaUrl is not provided', async () => { const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), }); const res = await request(app).get('/health/detailed'); expect(res.body.services.opa).toBeUndefined(); }); }); describe('GET /health/detailed — Vault and OPA probes', () => { it('includes vault as healthy when Vault /v1/sys/health returns 200', async () => { mockFetch.mockResolvedValue(new Response(null, { status: 200 })); const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), vaultAddr: 'http://vault:8200', }); const res = await request(app).get('/health/detailed'); expect(res.body.services.vault).toBeDefined(); expect(['healthy', 'degraded']).toContain(res.body.services.vault.status); }); it('marks vault as unreachable when fetch throws', async () => { mockFetch.mockRejectedValue(new Error('Network failure')); const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), vaultAddr: 'http://vault:8200', }); const res = await request(app).get('/health/detailed'); expect(res.body.services.vault.status).toBe('unreachable'); }); it('includes opa as healthy when OPA /health returns 200', async () => { mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), opaUrl: 'http://opa:8181', }); const res = await request(app).get('/health/detailed'); expect(res.body.services.opa).toBeDefined(); expect(['healthy', 'degraded']).toContain(res.body.services.opa.status); }); it('marks opa as unreachable when OPA /health returns non-200', async () => { mockFetch.mockResolvedValue(new Response(null, { status: 503 })); const app = buildApp({ dbProbe: makeDbProbe(), redisClient: makeRedisClient(), opaUrl: 'http://opa:8181', }); const res = await request(app).get('/health/detailed'); expect(res.body.services.opa.status).toBe('unreachable'); }); });