/** * Tests for DC-086: asymmetric hysteresis on the dashboard service badge. * * - First probe always emits (no prior state). * - Same-status probe does NOT re-emit (dedup against repeated green). * - One "down" then back to "up" keeps the badge green (no flicker). * - Two consecutive "down" probes flip the badge to red. * - One "up" after a down streak flips back to green (fast recovery). * - History retains every raw probe even when no emit happens. * - getCurrentStatus returns displayed status, not raw. */ 'use strict'; const path = require('path'); const fs = require('fs'); const os = require('os'); // Use an isolated data dir so test history doesn't pollute the real one. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-hyst-')); process.env.HEALTH_DATA_DIR = tmpDir; process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json'); process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json'); const { HealthChecker } = require('../src/monitoring/health-checker'); // Module exports a singleton instance, not a class — see module.exports in // src/monitoring/health-checker.js. The test creates fresh state by replacing // the relevant maps on the singleton in beforeEach. const healthCheckerSingleton = require('../src/monitoring/health-checker'); function makeUp(serviceId = 'svc1') { return { serviceId, timestamp: new Date().toISOString(), status: 'up', responseTime: 50, statusCode: 200, message: 'Service is healthy', details: { headers: {}, bodyLength: 12 } }; } function makeDown(serviceId = 'svc1') { return { serviceId, timestamp: new Date().toISOString(), status: 'down', responseTime: 50, statusCode: 500, message: 'fail', details: { headers: {}, bodyLength: 0 } }; } describe('DC-086: hysteresis on the dashboard badge', () => { let hc; let emitSpy; beforeEach(() => { // Reset the singleton's per-test state so each case starts clean. healthCheckerSingleton.displayedStatus = new Map(); healthCheckerSingleton.consecutiveSinceChange = new Map(); healthCheckerSingleton.currentStatus = new Map(); healthCheckerSingleton.history = {}; healthCheckerSingleton.removeAllListeners('status-check'); emitSpy = jest.fn(); healthCheckerSingleton.on('status-check', emitSpy); hc = healthCheckerSingleton; }); test('first probe (no prior state) emits', () => { hc.recordStatus('svc1', makeUp()); expect(emitSpy).toHaveBeenCalledTimes(1); expect(emitSpy.mock.calls[0][0].status).toBe('up'); }); test('second probe with same status does NOT re-emit', () => { hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeUp()); expect(emitSpy).toHaveBeenCalledTimes(1); }); test('one "down" then "up" keeps the badge green (the flicker bug)', () => { hc.recordStatus('svc1', makeUp()); // baseline: green, emit 1 hc.recordStatus('svc1', makeDown()); // one blip — keep green, no emit hc.recordStatus('svc1', makeUp()); // recovered — still green, no emit expect(emitSpy).toHaveBeenCalledTimes(1); expect(hc.displayedStatus.get('svc1').status).toBe('up'); }); test('two consecutive "down" probes flip the badge to red', () => { hc.recordStatus('svc1', makeUp()); // baseline: green hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1) hc.recordStatus('svc1', makeDown()); // blip #2 — flip red (counter=2 >= DOWN_THRESHOLD) expect(emitSpy).toHaveBeenCalledTimes(2); expect(emitSpy.mock.calls[1][0].status).toBe('down'); expect(hc.displayedStatus.get('svc1').status).toBe('down'); }); test('one "up" after a down streak flips back to green (fast recovery)', () => { hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeDown()); hc.recordStatus('svc1', makeDown()); // now red expect(hc.displayedStatus.get('svc1').status).toBe('down'); hc.recordStatus('svc1', makeUp()); // first green — flip back expect(emitSpy).toHaveBeenCalledTimes(3); expect(emitSpy.mock.calls[2][0].status).toBe('up'); expect(hc.displayedStatus.get('svc1').status).toBe('up'); }); test('history retains every raw probe even when no emit happens', () => { hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeDown()); // blip, no emit hc.recordStatus('svc1', makeUp()); // recovery, no emit expect(hc.history['svc1'].length).toBe(3); expect(hc.history['svc1'][0].status).toBe('up'); expect(hc.history['svc1'][1].status).toBe('down'); expect(hc.history['svc1'][2].status).toBe('up'); }); test('getCurrentStatus returns the displayed status, not the raw probe', () => { hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeDown()); // raw=down, displayed=up const out = hc.getCurrentStatus(); expect(out['svc1'].status).toBe('up'); // shown to API consumers }); test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => { for (let i = 0; i < 50; i++) hc.recordStatus('svc1', makeUp()); expect(emitSpy).toHaveBeenCalledTimes(1); }); test('a long steady-green-then-steady-red transition: 1 emit (up), 1 emit (red)', () => { for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeUp()); expect(emitSpy).toHaveBeenCalledTimes(1); hc.recordStatus('svc1', makeDown()); hc.recordStatus('svc1', makeDown()); // flips to red expect(emitSpy).toHaveBeenCalledTimes(2); for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeDown()); expect(emitSpy).toHaveBeenCalledTimes(2); // no further broadcasts }); test('DOWN_THRESHOLD env var is honored', () => { const origDown = process.env.HEALTH_DOWN_THRESHOLD; process.env.HEALTH_DOWN_THRESHOLD = '3'; jest.resetModules(); const HC2Module = require('../src/monitoring/health-checker'); // Module is a singleton with DOWN_THRESHOLD captured at module load — // resetModules gives us a fresh module-level instance with the new env. const hc2 = HC2Module; hc2.displayedStatus = new Map(); hc2.consecutiveSinceChange = new Map(); hc2.currentStatus = new Map(); hc2.history = {}; hc2.removeAllListeners('status-check'); const spy = jest.fn(); hc2.on('status-check', spy); hc2.recordStatus('svc1', makeUp()); hc2.recordStatus('svc1', makeDown()); // 1 hc2.recordStatus('svc1', makeDown()); // 2 — still green (need 3) expect(spy).toHaveBeenCalledTimes(1); expect(hc2.displayedStatus.get('svc1').status).toBe('up'); hc2.recordStatus('svc1', makeDown()); // 3 — flip expect(spy).toHaveBeenCalledTimes(2); expect(hc2.displayedStatus.get('svc1').status).toBe('down'); if (origDown === undefined) delete process.env.HEALTH_DOWN_THRESHOLD; else process.env.HEALTH_DOWN_THRESHOLD = origDown; }); });