'use strict'; // An offline display is ONE event, not a recurring condition. // // The alert loop ticks every 60s and re-evaluated a still-offline device every time, // so a 2-hour dedup window turned a single outage into an alert every 2 hours for as // long as it lasted. A real prospect evaluating the product left a browser tab closed // overnight and collected six "your display is offline" mails for one outage. // // The rule pinned here: one alert per OUTAGE. The marker is devices.offline_alert_heartbeat, // holding the last_heartbeat an alert was sent for. A reconnect advances last_heartbeat, // which invalidates the marker by construction — so recovery needs no cleanup, and the // marker being on the row (not in memory) means a restart no longer re-alerts the fleet. const { test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto'); const DATA_DIR = path.join(os.tmpdir(), 'st-offalert-' + crypto.randomBytes(4).toString('hex')); fs.mkdirSync(path.join(DATA_DIR, 'db'), { recursive: true }); process.env.DATA_DIR = DATA_DIR; process.env.SELF_HOSTED = 'true'; process.env.NODE_ENV = 'test'; // Capture mail instead of sending it. Must be stubbed before alerts.js resolves it. const emailPath = require.resolve('../services/email'); const sent = []; require(emailPath); require.cache[emailPath].exports.sendEmail = async (msg) => { sent.push(msg); return { ok: true }; }; const { db } = require('../db/database'); const { __test } = require('../services/alerts'); const HOUR = 3600; let userId; const now = () => Math.floor(Date.now() / 1000); function mkDevice(name, offlineForSec, { alerts = 1 } = {}) { const id = crypto.randomUUID(); db.prepare(`INSERT INTO devices (id, name, status, last_heartbeat, user_id, created_at) VALUES (?, ?, 'offline', ?, ?, strftime('%s','now'))`) .run(id, name, now() - offlineForSec, alerts ? userId : null); return id; } const setState = (id, status, hbAgo) => db.prepare('UPDATE devices SET last_heartbeat = ?, status = ? WHERE id = ?') .run(now() - hbAgo, status, id); const markerOf = (id) => db.prepare('SELECT offline_alert_heartbeat FROM devices WHERE id = ?').get(id).offline_alert_heartbeat; const mailFor = (name) => sent.filter(m => (m.subject || '').includes(name)).length; before(() => { userId = crypto.randomUUID(); db.prepare(`INSERT INTO users (id, email, password_hash, email_alerts, created_at) VALUES (?, ?, 'x', 1, strftime('%s','now'))`) .run(userId, 'owner' + crypto.randomBytes(3).toString('hex') + '@x.local'); }); after(() => { try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch { /* */ } }); test('the column exists — the migration is wired up', () => { const cols = db.prepare('PRAGMA table_info(devices)').all().map(c => c.name); assert.ok(cols.includes('offline_alert_heartbeat')); }); test('THE BUG: a continuous outage alerts exactly once, however many times we tick', async () => { const id = mkDevice('Lobby', 20 * 60); for (let i = 0; i < 12; i++) await __test.checkOfflineDevices(); assert.equal(mailFor('Lobby'), 1, 'twelve ticks, one mail'); assert.equal(markerOf(id), db.prepare('SELECT last_heartbeat FROM devices WHERE id = ?').get(id).last_heartbeat, 'the outage is marked as alerted'); }); test('the dedup window expiring does NOT re-open a still-running outage', async () => { const id = mkDevice('Cafe', 20 * 60); await __test.checkOfflineDevices(); assert.equal(mailFor('Cafe'), 1); // Simulate >2h passing with the device still down — the old window would re-fire here. __test.alertLastSent.clear(); await __test.checkOfflineDevices(); assert.equal(mailFor('Cafe'), 1, 'still one — the outage marker outlives the window'); void id; }); test('a restart does not re-alert the offline fleet', async () => { const id = mkDevice('Warehouse', 20 * 60); await __test.checkOfflineDevices(); assert.equal(mailFor('Warehouse'), 1); __test.alertLastSent.clear(); // what a process restart actually does to in-memory state await __test.checkOfflineDevices(); assert.equal(mailFor('Warehouse'), 1, 'the marker is on the row, so it survives'); void id; }); test('a NEW outage after recovery does alert again', async () => { // The timeline has to be physical, or it proves nothing: a device can only come back // by sending a heartbeat, so a later outage ALWAYS carries a later last_heartbeat than // the one already marked. That advance is exactly what invalidates the marker. const id = mkDevice('Atrium', 1 * HOUR); await __test.checkOfflineDevices(); assert.equal(mailFor('Atrium'), 1); const firstOutage = markerOf(id); setState(id, 'online', 10 * 60); // reconnected: heartbeat advances to 10m ago await __test.checkOfflineDevices(); // observed online -> flap window cleared setState(id, 'offline', 10 * 60); // then silent since that heartbeat await __test.checkOfflineDevices(); assert.equal(mailFor('Atrium'), 2, 'a distinct outage is a distinct alert'); assert.notEqual(markerOf(id), firstOutage, 'and it is marked as its own outage'); }); test('a device already dark for more than a day never opens an alert', async () => { mkDevice('Abandoned', 30 * HOUR); await __test.checkOfflineDevices(); assert.equal(mailFor('Abandoned'), 0); }); test('alerts disabled means no mail, and no marker burned', async () => { const id = mkDevice('Silent', 20 * 60); db.prepare('UPDATE users SET email_alerts = 0 WHERE id = ?').run(userId); await __test.checkOfflineDevices(); db.prepare('UPDATE users SET email_alerts = 1 WHERE id = ?').run(userId); assert.equal(mailFor('Silent'), 0, 'respects the preference'); assert.equal(markerOf(id), null, 're-enabling alerts must not find the outage pre-marked and silently skipped'); }); test('one alert per outage still logs one activity row, not one per tick', async () => { const id = mkDevice('Ledger', 20 * 60); // Clear the flap window between ticks, or the in-memory guard alone would carry this // test and it would pass with the per-outage marker removed. for (let i = 0; i < 5; i++) { __test.alertLastSent.clear(); await __test.checkOfflineDevices(); } const n = db.prepare("SELECT COUNT(*) c FROM activity_log WHERE device_id = ? AND action = 'alert:device_offline'") .get(id).c; assert.equal(n, 1, 'the activity feed reflects the outage once'); });