import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../../db/client'; import { alerts, monitors } from '../../db/schema'; import { setupTestDatabaseAt } from '../../test-utils/database'; import { resetTestDbPath } from '../../test-utils/db-path'; import { moduleAlertKey, moduleCheckAlertKey } from './keys'; import type { ReconcileAction } from './reconcile'; import { applyReconcileActions, confirmStillFailing, loadLiveAlerts, markSuppressed, markUnsuppressed, promoteReadyAlerts, summariseByModule, } from './store'; const NOW = new Date('2026-07-28T03:00:00Z'); const LATER = new Date('2026-07-28T03:20:00Z'); const MONITOR = 'mon-1'; const DISK = moduleCheckAlertKey('caddy', 'disk-space'); describe('alert store', () => { let dir: string; let db: DbClient; const context = { monitorId: MONITOR, now: NOW }; const create = (key: string): ReconcileAction => ({ type: 'create', key, severity: 'critical', message: 'bad', graceUntil: NOW, }); beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'alert-store-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); db.insert(monitors) .values({ id: MONITOR, kind: 'module_hook', target: 'caddy', intervalMinutes: 15, severity: 'critical', }) .run(); }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); test('create inserts a live alert discoverable by loadLiveAlerts', () => { const { createdIds } = applyReconcileActions(db, [create(DISK)], context); expect(createdIds).toHaveLength(1); expect(loadLiveAlerts(db, MONITOR)).toEqual([{ id: createdIds[0], key: DISK }]); }); test('refresh updates message and severity without changing identity', () => { const { createdIds } = applyReconcileActions(db, [create(DISK)], context); applyReconcileActions( db, [ { type: 'refresh', alertId: createdIds[0], key: DISK, severity: 'warning', message: '/var 80% used', }, ], { ...context, now: LATER }, ); const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get(); expect(row?.message).toBe('/var 80% used'); expect(row?.severity).toBe('warning'); expect(row?.lastSeenAt).toEqual(LATER); expect(row?.firstFiredAt).toEqual(NOW); }); test('resolve clears activeKey so the alert leaves the live set', () => { const { createdIds } = applyReconcileActions(db, [create(DISK)], context); applyReconcileActions(db, [{ type: 'resolve', alertId: createdIds[0], key: DISK }], context); expect(loadLiveAlerts(db, MONITOR)).toEqual([]); const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get(); expect(row?.state).toBe('resolved'); expect(row?.activeKey).toBeNull(); expect(row?.resolvedAt).toEqual(NOW); }); // The whole point of the activeKey/index pairing: history accumulates, but // only one row per key is ever live. test('a key can re-fire after resolving, and both rows persist', () => { const first = applyReconcileActions(db, [create(DISK)], context); applyReconcileActions( db, [{ type: 'resolve', alertId: first.createdIds[0], key: DISK }], context, ); const second = applyReconcileActions(db, [create(DISK)], { ...context, now: LATER }); expect(second.createdIds[0]).not.toBe(first.createdIds[0]); expect(loadLiveAlerts(db, MONITOR)).toEqual([{ id: second.createdIds[0], key: DISK }]); const all = db.select().from(alerts).where(eq(alerts.key, DISK)).all(); expect(all).toHaveLength(2); }); test('two live alerts for one key are rejected by the index', () => { applyReconcileActions(db, [create(DISK)], context); expect(() => applyReconcileActions(db, [create(DISK)], context)).toThrow(); }); test('suppression is recorded with its cause', () => { const { createdIds } = applyReconcileActions(db, [create(DISK)], context); markSuppressed(db, createdIds[0], { alertId: 'ancestor-1' }); const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get(); expect(row?.state).toBe('suppressed'); expect(row?.suppressedByAlertId).toBe('ancestor-1'); }); // Un-suppression must not page immediately, and must not fire every step // whose delay elapsed while suppressed. test('un-suppression sets awaitingConfirmation and restarts the clock', () => { const { createdIds } = applyReconcileActions(db, [create(DISK)], context); markSuppressed(db, createdIds[0], { alertId: 'ancestor-1' }); markUnsuppressed(db, createdIds[0], LATER); const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get(); expect(row?.state).toBe('firing'); expect(row?.suppressedByAlertId).toBeNull(); expect(row?.awaitingConfirmation).toBe(true); expect(row?.unsuppressedAt).toEqual(LATER); expect(row?.escalationStep).toBe(0); }); test('a confirming run clears awaitingConfirmation', () => { const { createdIds } = applyReconcileActions(db, [create(DISK)], context); markUnsuppressed(db, createdIds[0], LATER); confirmStillFailing(db, createdIds, LATER); const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get(); expect(row?.awaitingConfirmation).toBe(false); }); test('confirming an empty list is a no-op', () => { expect(() => confirmStillFailing(db, [], NOW)).not.toThrow(); }); describe('promoteReadyAlerts', () => { // Nothing else writes pending -> firing, so without this every alert reads // as pending forever. test('a pending alert past its grace window becomes firing', () => { applyReconcileActions(db, [create(DISK)], context); expect(promoteReadyAlerts(db, LATER)).toBe(1); const row = db.select().from(alerts).where(eq(alerts.key, DISK)).get(); expect(row?.state).toBe('firing'); }); test('a pending alert inside its grace window is left alone', () => { applyReconcileActions( db, [{ type: 'create', key: DISK, severity: 'critical', message: 'bad', graceUntil: LATER }], context, ); expect(promoteReadyAlerts(db, NOW)).toBe(0); const row = db.select().from(alerts).where(eq(alerts.key, DISK)).get(); expect(row?.state).toBe('pending'); }); // Promoting these would lose the record of what explains them. test('a suppressed alert is not promoted', () => { const { createdIds } = applyReconcileActions(db, [create(DISK)], context); markSuppressed(db, createdIds[0], { alertId: 'ancestor-1' }); expect(promoteReadyAlerts(db, LATER)).toBe(0); const row = db.select().from(alerts).where(eq(alerts.id, createdIds[0])).get(); expect(row?.state).toBe('suppressed'); }); test('promotion is idempotent', () => { applyReconcileActions(db, [create(DISK)], context); expect(promoteReadyAlerts(db, LATER)).toBe(1); expect(promoteReadyAlerts(db, LATER)).toBe(0); }); }); }); describe('summariseByModule', () => { const row = (key: string, state: 'firing' | 'suppressed') => ({ key, severity: 'critical', state }) as never; test('groups item and module keys under the module', () => { const summary = summariseByModule([ row(moduleCheckAlertKey('caddy', 'disk'), 'firing'), row(moduleAlertKey('caddy'), 'firing'), row(moduleAlertKey('forgejo'), 'firing'), ]); expect(summary.get('caddy')).toHaveLength(2); expect(summary.get('forgejo')).toHaveLength(1); }); test('marks suppressed alerts so the column can say so', () => { const summary = summariseByModule([row(moduleAlertKey('caddy'), 'suppressed')]); expect(summary.get('caddy')?.[0].suppressed).toBe(true); }); test('ignores built-in alerts, which are not module-scoped', () => { const summary = summariseByModule([row('builtin:machines_reachable/machine:iot', 'firing')]); expect(summary.size).toBe(0); }); });