/** * 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'); // 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'); const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD; const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD; function restoreEnv(name, value) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } 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; }); afterEach(() => { restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold); restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold); }); afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); 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('up, down, up, down, down resets the first streak before flipping', () => { hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeDown()); hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeDown()); expect(hc.displayedStatus.get('svc1').status).toBe('up'); expect(emitSpy).toHaveBeenCalledTimes(1); hc.recordStatus('svc1', makeDown()); expect(hc.displayedStatus.get('svc1').status).toBe('down'); expect(emitSpy).toHaveBeenCalledTimes(2); }); 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', () => { const displayedUp = makeUp(); displayedUp.timestamp = '2026-08-22T09:59:00.000Z'; displayedUp.statusCode = 200; displayedUp.message = 'healthy'; displayedUp.details = { source: 'accepted-up' }; hc.recordStatus('svc1', displayedUp); const latestRaw = makeDown(); latestRaw.timestamp = '2026-08-22T10:00:00.000Z'; latestRaw.responseTime = 987; latestRaw.statusCode = 500; latestRaw.message = 'failed probe'; latestRaw.error = 'upstream failure'; latestRaw.details = { source: 'suppressed-down' }; hc.recordStatus('svc1', latestRaw); // raw=down, displayed=up const out = hc.getCurrentStatus(); expect(out['svc1'].status).toBe('up'); // shown to API consumers expect(out['svc1'].timestamp).toBe(displayedUp.timestamp); expect(out['svc1'].statusCode).toBe(200); expect(out['svc1'].message).toBe('healthy'); expect(out['svc1'].error).toBeUndefined(); expect(out['svc1'].details).toEqual({ source: 'accepted-up' }); expect(hc.currentStatus.get('svc1')).toBe(latestRaw); }); 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', () => { 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'); }); test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => { process.env.HEALTH_DOWN_THRESHOLD = value; jest.resetModules(); const hc2 = require('../src/monitoring/health-checker'); 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()); expect(spy).toHaveBeenCalledTimes(1); hc2.recordStatus('svc1', makeDown()); expect(spy).toHaveBeenCalledTimes(2); }); test('UP_THRESHOLD env var greater than 1 is honored', () => { process.env.HEALTH_UP_THRESHOLD = '2'; jest.resetModules(); const hc2 = require('../src/monitoring/health-checker'); 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', makeDown()); hc2.recordStatus('svc1', makeUp()); expect(spy).toHaveBeenCalledTimes(1); expect(hc2.displayedStatus.get('svc1').status).toBe('down'); hc2.recordStatus('svc1', makeUp()); expect(spy).toHaveBeenCalledTimes(2); expect(hc2.displayedStatus.get('svc1').status).toBe('up'); }); test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => { process.env.HEALTH_UP_THRESHOLD = value; jest.resetModules(); const hc2 = require('../src/monitoring/health-checker'); 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', makeDown()); hc2.recordStatus('svc1', makeUp()); expect(spy).toHaveBeenCalledTimes(2); expect(hc2.displayedStatus.get('svc1').status).toBe('up'); }); test('removeService clears hysteresis state before the same ID is re-added', () => { hc.config.services.svc1 = { name: 'Service 1' }; hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeDown()); expect(hc.displayedStatus.has('svc1')).toBe(true); expect(hc.consecutiveSinceChange.get('svc1')).toBe(1); hc.consecutiveFailures.set('svc1', 3); const timer = setTimeout(() => {}, 60_000); hc.serviceTimers.set('svc1', timer); hc.saveConfig = jest.fn(); hc.removeService('svc1'); expect(hc.displayedStatus.has('svc1')).toBe(false); expect(hc.consecutiveSinceChange.has('svc1')).toBe(false); expect(hc.currentStatus.has('svc1')).toBe(false); expect(hc.consecutiveFailures.has('svc1')).toBe(false); expect(hc.serviceTimers.has('svc1')).toBe(false); hc.config.services.svc1 = { name: 'Service 1 re-added' }; const emitSpyAfterReAdd = jest.fn(); hc.on('status-check', emitSpyAfterReAdd); hc.recordStatus('svc1', makeDown()); expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1); expect(hc.displayedStatus.get('svc1').status).toBe('down'); expect(hc.consecutiveSinceChange.has('svc1')).toBe(false); }); });