jest.mock('fs', () => ({ existsSync: jest.fn().mockReturnValue(false), readFileSync: jest.fn().mockReturnValue('{"services":{}}'), writeFileSync: jest.fn(), })); jest.useFakeTimers(); describe('HealthChecker', () => { let HealthChecker, healthChecker, fs; beforeEach(() => { jest.resetModules(); fs = require('fs'); fs.existsSync.mockReturnValue(false); fs.readFileSync.mockReturnValue('{"services":{}}'); fs.writeFileSync.mockImplementation(() => {}); // Fresh instance each test HealthChecker = require('../health-checker').constructor; healthChecker = new HealthChecker(); }); afterEach(() => { healthChecker.stop(); jest.clearAllTimers(); }); describe('constructor', () => { it('initializes with empty state', () => { expect(healthChecker.currentStatus).toBeInstanceOf(Map); expect(healthChecker.incidents).toEqual([]); expect(healthChecker.checking).toBe(false); }); it('loads config from file when it exists', () => { jest.resetModules(); fs = require('fs'); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(JSON.stringify({ services: { svc1: { url: 'http://test.local', enabled: true } } })); HealthChecker = require('../health-checker').constructor; const hc = new HealthChecker(); expect(hc.config.services.svc1).toBeDefined(); }); it('returns default config on parse error', () => { jest.resetModules(); fs = require('fs'); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue('invalid json'); HealthChecker = require('../health-checker').constructor; const hc = new HealthChecker(); expect(hc.config).toEqual({ services: {} }); }); }); describe('start / stop', () => { it('start sets checking to true and schedules interval', () => { // Mock checkAll to prevent real HTTP calls healthChecker.checkAll = jest.fn(); healthChecker.start(); expect(healthChecker.checking).toBe(true); expect(healthChecker.checkAll).toHaveBeenCalled(); }); it('start is idempotent (no-op if already checking)', () => { healthChecker.checkAll = jest.fn(); healthChecker.start(); healthChecker.start(); // second call expect(healthChecker.checkAll).toHaveBeenCalledTimes(1); }); it('stop clears interval and resets state', () => { healthChecker.checkAll = jest.fn(); healthChecker.start(); healthChecker.stop(); expect(healthChecker.checking).toBe(false); expect(healthChecker.checkInterval).toBeNull(); }); it('stop is idempotent (no-op if not checking)', () => { healthChecker.stop(); // should not throw expect(healthChecker.checking).toBe(false); }); }); describe('getBackoffInterval', () => { it('returns base interval when no failures', () => { const interval = healthChecker.getBackoffInterval('svc1'); expect(interval).toBe(30000); // CHECK_INTERVAL default }); it('doubles interval per consecutive failure', () => { healthChecker.consecutiveFailures.set('svc1', 1); expect(healthChecker.getBackoffInterval('svc1')).toBe(60000); healthChecker.consecutiveFailures.set('svc1', 2); expect(healthChecker.getBackoffInterval('svc1')).toBe(120000); }); it('caps at MAX_CHECK_INTERVAL', () => { healthChecker.consecutiveFailures.set('svc1', 100); expect(healthChecker.getBackoffInterval('svc1')).toBe(300000); }); }); describe('evaluateHealth', () => { it('returns true for expected status code', () => { const result = healthChecker.evaluateHealth(200, '', { expectedStatusCodes: [200] }); expect(result).toBe(true); }); it('returns false for unexpected status code', () => { const result = healthChecker.evaluateHealth(500, '', { expectedStatusCodes: [200] }); expect(result).toBe(false); }); it('defaults to accepting common 2xx/3xx codes', () => { expect(healthChecker.evaluateHealth(200, '', {})).toBe(true); expect(healthChecker.evaluateHealth(301, '', {})).toBe(true); expect(healthChecker.evaluateHealth(500, '', {})).toBe(false); }); it('checks body pattern with regex', () => { const config = { expectedBodyPattern: 'ok|healthy' }; expect(healthChecker.evaluateHealth(200, 'status: ok', config)).toBe(true); expect(healthChecker.evaluateHealth(200, 'status: error', config)).toBe(false); }); it('checks body contains text', () => { const config = { expectedBodyContains: 'alive' }; expect(healthChecker.evaluateHealth(200, 'I am alive!', config)).toBe(true); expect(healthChecker.evaluateHealth(200, 'dead', config)).toBe(false); }); }); describe('recordStatus', () => { it('updates currentStatus map', () => { const status = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() }; healthChecker.recordStatus('svc1', status); expect(healthChecker.currentStatus.get('svc1')).toEqual(status); }); it('appends to history', () => { const status1 = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() }; const status2 = { serviceId: 'svc1', status: 'down', timestamp: new Date().toISOString() }; healthChecker.recordStatus('svc1', status1); healthChecker.recordStatus('svc1', status2); expect(healthChecker.history['svc1']).toHaveLength(2); }); it('emits status-check event', () => { const handler = jest.fn(); healthChecker.on('status-check', handler); const status = { serviceId: 'svc1', status: 'up' }; healthChecker.recordStatus('svc1', status); expect(handler).toHaveBeenCalledWith(status); }); }); describe('checkService', () => { it('returns up status on successful health check', async () => { healthChecker._doRequest = jest.fn().mockResolvedValue({ healthy: true, statusCode: 200, message: 'Service is healthy', details: {} }); const config = { url: 'http://test.local' }; const result = await healthChecker.checkService('svc1', config); expect(result.status).toBe('up'); expect(result.serviceId).toBe('svc1'); }); it('returns down status on failed health check', async () => { healthChecker._doRequest = jest.fn().mockResolvedValue({ healthy: false, statusCode: 500, message: 'fail', details: {} }); const result = await healthChecker.checkService('svc1', { url: 'http://test.local' }); expect(result.status).toBe('down'); }); it('returns down status on request error', async () => { healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); const result = await healthChecker.checkService('svc1', { url: 'http://test.local' }); expect(result.status).toBe('down'); expect(result.error).toBe('ECONNREFUSED'); }); it('increments consecutive failures on error', async () => { healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail')); await healthChecker.checkService('svc1', { url: 'http://test.local' }); expect(healthChecker.consecutiveFailures.get('svc1')).toBe(1); await healthChecker.checkService('svc1', { url: 'http://test.local' }); expect(healthChecker.consecutiveFailures.get('svc1')).toBe(2); }); it('clears consecutive failures on success', async () => { healthChecker.consecutiveFailures.set('svc1', 5); healthChecker._doRequest = jest.fn().mockResolvedValue({ healthy: true, statusCode: 200, message: 'ok', details: {} }); await healthChecker.checkService('svc1', { url: 'http://test.local' }); expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false); }); }); describe('performHealthCheck', () => { it('falls back to GET when HEAD returns 501', async () => { healthChecker._doRequest = jest.fn() .mockResolvedValueOnce({ statusCode: 501 }) .mockResolvedValueOnce({ healthy: true, statusCode: 200 }); const result = await healthChecker.performHealthCheck({ url: 'http://test.local', method: 'HEAD' }); expect(healthChecker._doRequest).toHaveBeenCalledTimes(2); expect(result.statusCode).toBe(200); }); it('falls back to GET when HEAD returns 405', async () => { healthChecker._doRequest = jest.fn() .mockResolvedValueOnce({ statusCode: 405 }) .mockResolvedValueOnce({ healthy: true, statusCode: 200 }); const result = await healthChecker.performHealthCheck({ url: 'http://test.local', method: 'HEAD' }); expect(result.statusCode).toBe(200); }); it('does not fallback for GET requests returning 501', async () => { healthChecker._doRequest = jest.fn() .mockResolvedValueOnce({ statusCode: 501, healthy: false }); const result = await healthChecker.performHealthCheck({ url: 'http://test.local' }); expect(healthChecker._doRequest).toHaveBeenCalledTimes(1); }); }); describe('incidents', () => { it('createIncident adds a new incident', () => { const status = { timestamp: new Date().toISOString() }; healthChecker.createIncident('svc1', 'outage', 'Service down', status); expect(healthChecker.incidents).toHaveLength(1); expect(healthChecker.incidents[0].serviceId).toBe('svc1'); expect(healthChecker.incidents[0].type).toBe('outage'); expect(healthChecker.incidents[0].status).toBe('open'); }); it('createIncident increments existing open incident', () => { const status = { timestamp: new Date().toISOString() }; healthChecker.createIncident('svc1', 'outage', 'down', status); healthChecker.createIncident('svc1', 'outage', 'still down', status); expect(healthChecker.incidents).toHaveLength(1); expect(healthChecker.incidents[0].occurrences).toBe(2); }); it('resolveIncident sets status to resolved', () => { const status = { timestamp: new Date().toISOString() }; healthChecker.createIncident('svc1', 'outage', 'down', status); healthChecker.resolveIncident('svc1', 'outage', status); expect(healthChecker.incidents[0].status).toBe('resolved'); expect(healthChecker.incidents[0].resolvedAt).toBeDefined(); }); it('resolveIncident is no-op for non-existent incidents', () => { const status = { timestamp: new Date().toISOString() }; healthChecker.resolveIncident('svc1', 'outage', status); expect(healthChecker.incidents).toHaveLength(0); }); it('getOpenIncidents filters resolved', () => { const ts = { timestamp: new Date().toISOString() }; healthChecker.createIncident('svc1', 'outage', 'down', ts); healthChecker.createIncident('svc2', 'slow-response', 'slow', ts); healthChecker.resolveIncident('svc1', 'outage', ts); const open = healthChecker.getOpenIncidents(); expect(open).toHaveLength(1); expect(open[0].serviceId).toBe('svc2'); }); it('getIncidentHistory returns recent incidents in reverse order', () => { const ts = { timestamp: new Date().toISOString() }; healthChecker.createIncident('svc1', 'outage', 'first', ts); healthChecker.createIncident('svc2', 'outage', 'second', ts); const history = healthChecker.getIncidentHistory(); expect(history[0].serviceId).toBe('svc2'); expect(history[1].serviceId).toBe('svc1'); }); it('emits incident-created event', () => { const handler = jest.fn(); healthChecker.on('incident-created', handler); healthChecker.createIncident('svc1', 'outage', 'down', { timestamp: new Date().toISOString() }); expect(handler).toHaveBeenCalled(); }); it('emits incident-resolved event', () => { const handler = jest.fn(); healthChecker.on('incident-resolved', handler); const ts = { timestamp: new Date().toISOString() }; healthChecker.createIncident('svc1', 'outage', 'down', ts); healthChecker.resolveIncident('svc1', 'outage', ts); expect(handler).toHaveBeenCalled(); }); }); describe('calculateSeverity', () => { it('returns critical for outage', () => { expect(healthChecker.calculateSeverity('outage')).toBe('critical'); }); it('returns high for sla-violation', () => { expect(healthChecker.calculateSeverity('sla-violation')).toBe('high'); }); it('returns medium for slow-response', () => { expect(healthChecker.calculateSeverity('slow-response')).toBe('medium'); }); it('returns low for unknown', () => { expect(healthChecker.calculateSeverity('unknown')).toBe('low'); }); }); describe('checkForIncidents', () => { it('creates outage incident on status change up -> down', () => { // Simulate previous up status healthChecker.currentStatus.set('svc1', { status: 'up' }); const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 }; healthChecker.checkForIncidents('svc1', status, {}); expect(healthChecker.incidents).toHaveLength(1); expect(healthChecker.incidents[0].type).toBe('outage'); }); it('resolves outage incident on status change down -> up', () => { healthChecker.currentStatus.set('svc1', { status: 'down' }); const ts = { timestamp: new Date().toISOString() }; healthChecker.createIncident('svc1', 'outage', 'was down', ts); const status = { status: 'up', timestamp: new Date().toISOString(), responseTime: 100 }; healthChecker.checkForIncidents('svc1', status, {}); expect(healthChecker.incidents[0].status).toBe('resolved'); }); it('creates slow-response incident when exceeding threshold', () => { const status = { status: 'up', timestamp: new Date().toISOString(), responseTime: 6000 }; healthChecker.checkForIncidents('svc1', status, { slowResponseThreshold: 5000 }); expect(healthChecker.incidents.some(i => i.type === 'slow-response')).toBe(true); }); }); describe('uptime and stats', () => { beforeEach(() => { const now = Date.now(); healthChecker.history['svc1'] = [ { status: 'up', responseTime: 100, timestamp: new Date(now - 3600000).toISOString() }, { status: 'up', responseTime: 200, timestamp: new Date(now - 1800000).toISOString() }, { status: 'down', responseTime: 5000, timestamp: new Date(now - 900000).toISOString() }, { status: 'up', responseTime: 150, timestamp: new Date(now - 60000).toISOString() }, ]; }); it('calculateUptime returns correct percentage', () => { const uptime = healthChecker.calculateUptime('svc1', 24); expect(uptime).toBe(75); // 3 out of 4 checks up }); it('calculateUptime returns 100 for unknown service', () => { expect(healthChecker.calculateUptime('unknown', 24)).toBe(100); }); it('calculateAverageResponseTime returns correct average', () => { const avg = healthChecker.calculateAverageResponseTime('svc1', 24); expect(avg).toBe((100 + 200 + 5000 + 150) / 4); }); it('calculateAverageResponseTime returns 0 for unknown service', () => { expect(healthChecker.calculateAverageResponseTime('unknown', 24)).toBe(0); }); it('getServiceHistory filters by time period', () => { const history = healthChecker.getServiceHistory('svc1', 24); expect(history.length).toBe(4); // Very short period should exclude older entries const recent = healthChecker.getServiceHistory('svc1', 0.01); // ~36 seconds expect(recent.length).toBeLessThan(4); }); it('getServiceStats returns null for unknown service', () => { expect(healthChecker.getServiceStats('unknown')).toBeNull(); }); it('getServiceStats returns correct stats', () => { const stats = healthChecker.getServiceStats('svc1', 24); expect(stats.totalChecks).toBe(4); expect(stats.upChecks).toBe(3); expect(stats.downChecks).toBe(1); expect(stats.uptime).toBe(75); expect(stats.responseTime.min).toBe(100); expect(stats.responseTime.max).toBe(5000); }); }); describe('calculatePercentile', () => { it('returns correct p95', () => { const values = Array.from({ length: 100 }, (_, i) => i + 1); const p95 = healthChecker.calculatePercentile(values, 95); expect(p95).toBe(95); }); it('returns 0 for empty array', () => { expect(healthChecker.calculatePercentile([], 95)).toBe(0); }); }); describe('getCurrentStatus', () => { it('returns enriched status for all services', () => { healthChecker.config.services = { svc1: { name: 'Test Service' } }; healthChecker.currentStatus.set('svc1', { status: 'up', responseTime: 100, timestamp: new Date().toISOString() }); const result = healthChecker.getCurrentStatus(); expect(result.svc1).toBeDefined(); expect(result.svc1.name).toBe('Test Service'); expect(result.svc1.uptime).toBeDefined(); expect(result.svc1.uptime['24h']).toBeDefined(); }); }); describe('configureService / removeService', () => { it('configureService saves config to file', () => { healthChecker.configureService('svc1', { name: 'My Service', url: 'http://localhost:3000', timeout: 10000 }); expect(healthChecker.config.services.svc1).toBeDefined(); expect(healthChecker.config.services.svc1.url).toBe('http://localhost:3000'); expect(fs.writeFileSync).toHaveBeenCalled(); }); it('removeService cleans up all traces', () => { healthChecker.configureService('svc1', { url: 'http://test.local' }); healthChecker.currentStatus.set('svc1', { status: 'up' }); healthChecker.history['svc1'] = [{ status: 'up' }]; healthChecker.removeService('svc1'); expect(healthChecker.config.services.svc1).toBeUndefined(); expect(healthChecker.currentStatus.has('svc1')).toBe(false); expect(healthChecker.history['svc1']).toBeUndefined(); }); }); describe('cleanupHistory', () => { it('removes entries older than retention period', () => { const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago const recent = new Date().toISOString(); healthChecker.history['svc1'] = [ { timestamp: old, status: 'up' }, { timestamp: recent, status: 'up' }, ]; healthChecker.cleanupHistory(); expect(healthChecker.history['svc1']).toHaveLength(1); expect(healthChecker.history['svc1'][0].timestamp).toBe(recent); }); }); describe('loadConfig / saveConfig', () => { it('saveConfig writes JSON to file', () => { healthChecker.config = { services: { svc1: { url: 'http://test' } } }; healthChecker.saveConfig(); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.any(String), expect.stringContaining('"svc1"') ); }); it('saveConfig handles write errors gracefully', () => { fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); expect(() => healthChecker.saveConfig()).not.toThrow(); }); }); describe('loadHistory / saveHistory', () => { it('loadHistory returns empty object when file missing', () => { const history = healthChecker.loadHistory(); expect(history).toEqual({}); }); it('loadHistory parses JSON from file', () => { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(JSON.stringify({ svc1: [{ status: 'up' }] })); const history = healthChecker.loadHistory(); expect(history.svc1).toHaveLength(1); }); it('saveHistory writes history to file', () => { healthChecker.history = { svc1: [{ status: 'up' }] }; healthChecker.saveHistory(); expect(fs.writeFileSync).toHaveBeenCalled(); }); }); });