Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
// health-checker.js exports a singleton that reads config/history from disk on construction.
|
|
// The jest.setup.js suppresses console and the files don't exist in test env, so it falls back to defaults.
|
|
|
|
const healthChecker = require('../health-checker');
|
|
|
|
beforeEach(() => {
|
|
// Reset singleton state between tests
|
|
healthChecker.currentStatus = new Map();
|
|
healthChecker.incidents = [];
|
|
healthChecker.history = {};
|
|
healthChecker.config = { services: {} };
|
|
healthChecker.checking = false;
|
|
if (healthChecker.checkInterval) {
|
|
clearInterval(healthChecker.checkInterval);
|
|
healthChecker.checkInterval = null;
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
healthChecker.stop();
|
|
});
|
|
|
|
describe('evaluateHealth', () => {
|
|
test('returns true for status code in expectedStatusCodes', () => {
|
|
expect(healthChecker.evaluateHealth(200, '', { expectedStatusCodes: [200, 201] })).toBe(true);
|
|
});
|
|
|
|
test('returns false for status code not in expectedStatusCodes', () => {
|
|
expect(healthChecker.evaluateHealth(500, '', { expectedStatusCodes: [200] })).toBe(false);
|
|
});
|
|
|
|
test('uses default expected codes when not configured', () => {
|
|
expect(healthChecker.evaluateHealth(200, '', {})).toBe(true);
|
|
expect(healthChecker.evaluateHealth(301, '', {})).toBe(true);
|
|
expect(healthChecker.evaluateHealth(500, '', {})).toBe(false);
|
|
});
|
|
|
|
test('returns false when expectedBodyPattern regex does not match', () => {
|
|
expect(healthChecker.evaluateHealth(200, 'error occurred', {
|
|
expectedBodyPattern: 'ok|healthy'
|
|
})).toBe(false);
|
|
});
|
|
|
|
test('returns true when expectedBodyPattern regex matches', () => {
|
|
expect(healthChecker.evaluateHealth(200, 'status: healthy', {
|
|
expectedBodyPattern: 'healthy'
|
|
})).toBe(true);
|
|
});
|
|
|
|
test('returns false when expectedBodyContains text is missing', () => {
|
|
expect(healthChecker.evaluateHealth(200, 'some response', {
|
|
expectedBodyContains: 'healthy'
|
|
})).toBe(false);
|
|
});
|
|
|
|
test('returns true when expectedBodyContains text is present', () => {
|
|
expect(healthChecker.evaluateHealth(200, 'service is healthy', {
|
|
expectedBodyContains: 'healthy'
|
|
})).toBe(true);
|
|
});
|
|
|
|
test('checks all conditions: status code AND body pattern AND body contains', () => {
|
|
// All pass
|
|
expect(healthChecker.evaluateHealth(200, 'healthy ok', {
|
|
expectedStatusCodes: [200],
|
|
expectedBodyPattern: 'healthy',
|
|
expectedBodyContains: 'ok'
|
|
})).toBe(true);
|
|
|
|
// Status fails
|
|
expect(healthChecker.evaluateHealth(500, 'healthy ok', {
|
|
expectedStatusCodes: [200],
|
|
expectedBodyPattern: 'healthy',
|
|
expectedBodyContains: 'ok'
|
|
})).toBe(false);
|
|
|
|
// Body pattern fails
|
|
expect(healthChecker.evaluateHealth(200, 'error', {
|
|
expectedStatusCodes: [200],
|
|
expectedBodyPattern: 'healthy',
|
|
expectedBodyContains: 'error'
|
|
})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('calculateSeverity', () => {
|
|
test('returns critical for outage', () => {
|
|
expect(healthChecker.calculateSeverity('outage')).toBe('critical');
|
|
});
|
|
|
|
test('returns high for sla-violation', () => {
|
|
expect(healthChecker.calculateSeverity('sla-violation')).toBe('high');
|
|
});
|
|
|
|
test('returns medium for slow-response', () => {
|
|
expect(healthChecker.calculateSeverity('slow-response')).toBe('medium');
|
|
});
|
|
|
|
test('returns low for unknown type', () => {
|
|
expect(healthChecker.calculateSeverity('unknown')).toBe('low');
|
|
});
|
|
});
|
|
|
|
describe('calculateUptime', () => {
|
|
test('returns 100 when no history', () => {
|
|
expect(healthChecker.calculateUptime('svc1')).toBe(100);
|
|
});
|
|
|
|
test('returns 100 when all checks are up', () => {
|
|
const now = new Date().toISOString();
|
|
healthChecker.history['svc1'] = [
|
|
{ status: 'up', timestamp: now },
|
|
{ status: 'up', timestamp: now },
|
|
{ status: 'up', timestamp: now },
|
|
];
|
|
expect(healthChecker.calculateUptime('svc1')).toBe(100);
|
|
});
|
|
|
|
test('returns 0 when all checks are down', () => {
|
|
const now = new Date().toISOString();
|
|
healthChecker.history['svc1'] = [
|
|
{ status: 'down', timestamp: now },
|
|
{ status: 'down', timestamp: now },
|
|
];
|
|
expect(healthChecker.calculateUptime('svc1')).toBe(0);
|
|
});
|
|
|
|
test('returns 50 when half are up', () => {
|
|
const now = new Date().toISOString();
|
|
healthChecker.history['svc1'] = [
|
|
{ status: 'up', timestamp: now },
|
|
{ status: 'down', timestamp: now },
|
|
];
|
|
expect(healthChecker.calculateUptime('svc1')).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('calculateAverageResponseTime', () => {
|
|
test('returns 0 when no history', () => {
|
|
expect(healthChecker.calculateAverageResponseTime('svc1')).toBe(0);
|
|
});
|
|
|
|
test('calculates correct average', () => {
|
|
const now = new Date().toISOString();
|
|
healthChecker.history['svc1'] = [
|
|
{ responseTime: 100, timestamp: now },
|
|
{ responseTime: 200, timestamp: now },
|
|
{ responseTime: 300, timestamp: now },
|
|
];
|
|
expect(healthChecker.calculateAverageResponseTime('svc1')).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('calculatePercentile', () => {
|
|
test('returns p95 correctly', () => {
|
|
const values = Array.from({ length: 100 }, (_, i) => i + 1);
|
|
expect(healthChecker.calculatePercentile(values, 95)).toBe(95);
|
|
});
|
|
|
|
test('returns p99 correctly', () => {
|
|
const values = Array.from({ length: 100 }, (_, i) => i + 1);
|
|
expect(healthChecker.calculatePercentile(values, 99)).toBe(99);
|
|
});
|
|
|
|
test('returns 0 for empty array', () => {
|
|
expect(healthChecker.calculatePercentile([], 95)).toBe(0);
|
|
});
|
|
|
|
test('handles single-element array', () => {
|
|
expect(healthChecker.calculatePercentile([42], 95)).toBe(42);
|
|
});
|
|
|
|
test('sorts values before calculating', () => {
|
|
const unsorted = [50, 10, 90, 30, 70, 20, 80, 40, 60, 100];
|
|
expect(healthChecker.calculatePercentile(unsorted, 50)).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('recordStatus', () => {
|
|
test('adds status to currentStatus map', () => {
|
|
const status = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() };
|
|
healthChecker.recordStatus('svc1', status);
|
|
expect(healthChecker.currentStatus.get('svc1')).toEqual(status);
|
|
});
|
|
|
|
test('creates history array for new serviceId', () => {
|
|
const status = { serviceId: 'new-svc', status: 'up', timestamp: new Date().toISOString() };
|
|
healthChecker.recordStatus('new-svc', status);
|
|
expect(healthChecker.history['new-svc']).toHaveLength(1);
|
|
});
|
|
|
|
test('appends to existing history', () => {
|
|
healthChecker.history['svc1'] = [{ status: 'up', timestamp: new Date().toISOString() }];
|
|
const status = { status: 'down', timestamp: new Date().toISOString() };
|
|
healthChecker.recordStatus('svc1', status);
|
|
expect(healthChecker.history['svc1']).toHaveLength(2);
|
|
});
|
|
|
|
test('emits status-check event', () => {
|
|
const handler = jest.fn();
|
|
healthChecker.on('status-check', handler);
|
|
healthChecker.recordStatus('svc1', { status: 'up', timestamp: new Date().toISOString() });
|
|
expect(handler).toHaveBeenCalled();
|
|
healthChecker.removeListener('status-check', handler);
|
|
});
|
|
});
|
|
|
|
describe('createIncident', () => {
|
|
test('creates incident with correct structure', () => {
|
|
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');
|
|
expect(healthChecker.incidents[0].severity).toBe('critical');
|
|
expect(healthChecker.incidents[0].occurrences).toBe(1);
|
|
});
|
|
|
|
test('emits incident-created event', () => {
|
|
const handler = jest.fn();
|
|
healthChecker.on('incident-created', handler);
|
|
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: new Date().toISOString() });
|
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ serviceId: 'svc1' }));
|
|
healthChecker.removeListener('incident-created', handler);
|
|
});
|
|
|
|
test('does not duplicate open incidents of same type', () => {
|
|
const status = { timestamp: new Date().toISOString() };
|
|
healthChecker.createIncident('svc1', 'outage', 'Down', status);
|
|
healthChecker.createIncident('svc1', 'outage', 'Still down', status);
|
|
expect(healthChecker.incidents).toHaveLength(1);
|
|
});
|
|
|
|
test('increments occurrences on 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[0].occurrences).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('resolveIncident', () => {
|
|
test('marks incident as resolved with duration', () => {
|
|
const created = new Date(Date.now() - 60000).toISOString();
|
|
const resolved = new Date().toISOString();
|
|
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: created });
|
|
healthChecker.resolveIncident('svc1', 'outage', { timestamp: resolved });
|
|
expect(healthChecker.incidents[0].status).toBe('resolved');
|
|
expect(healthChecker.incidents[0].resolvedAt).toBe(resolved);
|
|
expect(healthChecker.incidents[0].duration).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('emits incident-resolved event', () => {
|
|
const handler = jest.fn();
|
|
healthChecker.on('incident-resolved', handler);
|
|
const ts = new Date().toISOString();
|
|
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: ts });
|
|
healthChecker.resolveIncident('svc1', 'outage', { timestamp: ts });
|
|
expect(handler).toHaveBeenCalled();
|
|
healthChecker.removeListener('incident-resolved', handler);
|
|
});
|
|
|
|
test('handles no matching incident gracefully', () => {
|
|
// Should not throw
|
|
healthChecker.resolveIncident('nonexistent', 'outage', { timestamp: new Date().toISOString() });
|
|
expect(healthChecker.incidents).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('configureService / removeService', () => {
|
|
test('adds service config with defaults', () => {
|
|
healthChecker.configureService('svc1', { url: 'http://localhost:3000', name: 'Test' });
|
|
expect(healthChecker.config.services['svc1']).toBeDefined();
|
|
expect(healthChecker.config.services['svc1'].method).toBe('GET');
|
|
expect(healthChecker.config.services['svc1'].timeout).toBe(10000);
|
|
});
|
|
|
|
test('removes service and cleans up', () => {
|
|
healthChecker.configureService('svc1', { url: 'http://localhost:3000' });
|
|
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('getOpenIncidents / getIncidentHistory', () => {
|
|
test('getOpenIncidents returns only open incidents', () => {
|
|
const ts = new Date().toISOString();
|
|
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: ts });
|
|
healthChecker.createIncident('svc2', 'slow-response', 'Slow', { timestamp: ts });
|
|
healthChecker.resolveIncident('svc1', 'outage', { timestamp: ts });
|
|
expect(healthChecker.getOpenIncidents()).toHaveLength(1);
|
|
expect(healthChecker.getOpenIncidents()[0].serviceId).toBe('svc2');
|
|
});
|
|
|
|
test('getIncidentHistory returns reverse chronological order', () => {
|
|
const ts = new Date().toISOString();
|
|
healthChecker.createIncident('svc1', 'outage', 'First', { timestamp: ts });
|
|
healthChecker.createIncident('svc2', 'outage', 'Second', { timestamp: ts });
|
|
const history = healthChecker.getIncidentHistory();
|
|
expect(history[0].serviceId).toBe('svc2');
|
|
});
|
|
});
|
|
|
|
describe('getServiceStats', () => {
|
|
test('returns null for service with no history', () => {
|
|
expect(healthChecker.getServiceStats('nonexistent')).toBeNull();
|
|
});
|
|
|
|
test('returns correct stats structure', () => {
|
|
const now = new Date().toISOString();
|
|
healthChecker.history['svc1'] = [
|
|
{ status: 'up', responseTime: 100, timestamp: now },
|
|
{ status: 'up', responseTime: 200, timestamp: now },
|
|
{ status: 'down', responseTime: 0, timestamp: now },
|
|
];
|
|
const stats = healthChecker.getServiceStats('svc1');
|
|
expect(stats.totalChecks).toBe(3);
|
|
expect(stats.upChecks).toBe(2);
|
|
expect(stats.downChecks).toBe(1);
|
|
expect(stats.responseTime.avg).toBe(100);
|
|
expect(stats.responseTime.min).toBe(0);
|
|
expect(stats.responseTime.max).toBe(200);
|
|
expect(stats.responseTime).toHaveProperty('p95');
|
|
expect(stats.responseTime).toHaveProperty('p99');
|
|
});
|
|
});
|
|
|
|
describe('start / stop', () => {
|
|
test('start sets checking flag', () => {
|
|
jest.useFakeTimers();
|
|
healthChecker.start();
|
|
expect(healthChecker.checking).toBe(true);
|
|
healthChecker.stop();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('stop clears interval and checking flag', () => {
|
|
jest.useFakeTimers();
|
|
healthChecker.start();
|
|
healthChecker.stop();
|
|
expect(healthChecker.checking).toBe(false);
|
|
expect(healthChecker.checkInterval).toBeNull();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('start is idempotent', () => {
|
|
jest.useFakeTimers();
|
|
healthChecker.start();
|
|
const firstInterval = healthChecker.checkInterval;
|
|
healthChecker.start();
|
|
expect(healthChecker.checkInterval).toBe(firstInterval);
|
|
healthChecker.stop();
|
|
jest.useRealTimers();
|
|
});
|
|
});
|