/** * Health endpoint tests * * Verifies: * - /health/live always returns 200 * - /health/ready returns 200 with valid structure when all deps OK * - /health/ready returns 503 when a critical dep is down * - /health/ready does NOT crash with "res.status is not a function" */ const express = require('express'); const request = require('supertest'); // Mock dockerode BEFORE anything else jest.mock('dockerode', () => { return jest.fn().mockImplementation(() => ({ ping: jest.fn().mockImplementation(() => { if (process.env.MOCK_DOCKER_DOWN === '1') { return Promise.reject(new Error('docker unreachable')); } return Promise.resolve('OK'); }) })); }); // Build a minimal Express app with the same health handlers as src/app.js function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) { process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1'; const app = express(); const config = { CONFIG_FILE: '/tmp/dc-test-config.json', SERVICES_FILE: '/tmp/dc-test-services.json', CADDY_ADMIN_URL: 'http://localhost:2019' }; // Mock fs const fs = require('fs'); const realExistsSync = fs.existsSync; const realReadFileSync = fs.readFileSync; fs.existsSync = (p) => { if (p === config.CONFIG_FILE) return configOk; if (p === config.SERVICES_FILE) return servicesOk; return realExistsSync(p); }; fs.readFileSync = (p, ...args) => { if (p === config.CONFIG_FILE) { if (!configOk) throw new Error('config not found'); return '{}'; } if (p === config.SERVICES_FILE) { if (!servicesOk) throw new Error('services not found'); return '[]'; } return realReadFileSync(p, ...args); }; // /health/live (matches src/app.js exactly) app.get('/health/live', (req, res) => { res.json({ status: 'alive', uptime: process.uptime() }); }); // /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern) const { asyncHandler } = require('../src/utils/async-handler'); const logError = async () => {}; // noop logger const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test'); app.get('/health/ready', boundAsyncHandler(async (req, res) => { const checks = {}; let allOk = true; try { if (fs.existsSync(config.CONFIG_FILE)) { fs.readFileSync(config.CONFIG_FILE, 'utf8'); checks.configFile = { ok: true }; } else { checks.configFile = { ok: false, error: 'Config file not found' }; allOk = false; } } catch (e) { checks.configFile = { ok: false, error: e.message }; allOk = false; } try { if (fs.existsSync(config.SERVICES_FILE)) { fs.readFileSync(config.SERVICES_FILE, 'utf8'); checks.servicesFile = { ok: true }; } else { checks.servicesFile = { ok: false, error: 'Services file not found' }; allOk = false; } } catch (e) { checks.servicesFile = { ok: false, error: e.message }; allOk = false; } try { const docker = require('dockerode')(); await docker.ping(); checks.docker = { ok: true }; } catch (e) { checks.docker = { ok: false, error: e.message }; allOk = false; } try { const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019'; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal }); clearTimeout(timeout); checks.caddy = { ok: response.ok, status: response.status }; if (!response.ok) allOk = false; } catch (e) { checks.caddy = { ok: false, error: e.message }; allOk = false; } const body = { status: allOk ? 'ready' : 'not-ready', timestamp: new Date().toISOString(), checks }; res.status(allOk ? 200 : 503).json(body); })); return app; } describe('Health Endpoints', () => { beforeEach(() => { delete process.env.MOCK_DOCKER_DOWN; }); describe('GET /health/live', () => { it('always returns 200 with status: alive', async () => { const app = buildApp(); const res = await request(app).get('/health/live'); expect(res.status).toBe(200); expect(res.body.status).toBe('alive'); expect(typeof res.body.uptime).toBe('number'); }); it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => { const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false }); const res = await request(app).get('/health/live'); expect(res.status).toBe(200); }); }); describe('GET /health/ready', () => { it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => { const app = buildApp(); const res = await request(app).get('/health/ready'); // config + services + docker should all be OK expect(res.body.checks.configFile.ok).toBe(true); expect(res.body.checks.servicesFile.ok).toBe(true); expect(res.body.checks.docker.ok).toBe(true); // caddy is tested in sandbox — may be 403 or 200 expect(res.body).toHaveProperty('checks'); expect(res.body).toHaveProperty('status'); }); it('returns 503 when config file is missing', async () => { const app = buildApp({ configOk: false }); const res = await request(app).get('/health/ready'); expect(res.status).toBe(503); expect(res.body.status).toBe('not-ready'); expect(res.body.checks.configFile.ok).toBe(false); }); it('returns 503 when services file is missing', async () => { const app = buildApp({ servicesOk: false }); const res = await request(app).get('/health/ready'); expect(res.status).toBe(503); expect(res.body.checks.servicesFile.ok).toBe(false); }); it('returns 503 when Docker is unreachable', async () => { const app = buildApp({ dockerOk: false }); const res = await request(app).get('/health/ready'); expect(res.status).toBe(503); expect(res.body.checks.docker.ok).toBe(false); }); it('does NOT crash with "res.status is not a function" when dependencies fail', async () => { const app = buildApp({ dockerOk: false }); const res = await request(app).get('/health/ready'); const bodyStr = JSON.stringify(res.body); expect(bodyStr).not.toMatch(/res\.status is not a function/); // Should always be a valid response object expect(res.body).toHaveProperty('checks'); }); it('responds with all 4 expected check keys', async () => { const app = buildApp(); const res = await request(app).get('/health/ready'); expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']); }); }); });