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 { type Alert, type Route, alerts, modules, monitors } from '../../db/schema'; import { setupTestDatabaseAt } from '../../test-utils/database'; import { resetTestDbPath } from '../../test-utils/db-path'; import { moduleCheckAlertKey } from './keys'; import { deliverDeferred } from './notifier'; import { createPerson, createRoute } from './people'; import { clearDeferral, deferNotification, dueDeferrals, recordStepTaken } from './store'; const NIGHT = new Date('2026-07-28T03:00:00Z'); const MORNING = new Date('2026-07-28T07:00:00Z'); const KEY = moduleCheckAlertKey('caddy', 'disk-space'); describe('quiet-hours deferral', () => { let dir: string; let db: DbClient; let route: Route; const sent: { address: string; body: string }[] = []; const transport = { send: async (request: { address: string; body: string }) => { sent.push(request); return { messageId: `m-${sent.length}` }; }, }; const deps = { transportFor: () => transport, mintToken: () => 'TOKEN1', now: MORNING }; const alert = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get() as Alert; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'deferral-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); sent.length = 0; db.insert(modules) .values({ id: 'signal', name: 'Signal', version: '0.1.0', manifestData: {}, sourcePath: '/tmp/signal', }) .run(); db.insert(monitors) .values({ id: 'mon-1', kind: 'module_hook', target: 'caddy', intervalMinutes: 15, severity: 'critical', }) .run(); db.insert(alerts) .values({ id: 'alert-1', key: KEY, activeKey: KEY, monitorId: 'mon-1', state: 'firing', severity: 'critical', graceUntil: NIGHT, message: '/var 94% used', }) .run(); const peter = createPerson(db, { name: 'peter', timezone: 'UTC' }); route = createRoute(db, { personId: peter.id, transportModuleId: 'signal', address: '+15550001', canAck: true, }); }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); // D13: the clock is what advances, not the message. test('a step taken during quiet hours still advances the chain', () => { recordStepTaken(db, 'alert-1', { stepIndex: 0, nextStepDueAt: MORNING }); expect(alert().escalationStep).toBe(1); expect(alert().nextEscalationAt?.getTime()).toBe(MORNING.getTime()); }); test('a deferral is held until the window ends', () => { deferNotification(db, 'alert-1', { routeId: route.id, until: MORNING }); expect(dueDeferrals(db, NIGHT)).toHaveLength(0); expect(dueDeferrals(db, MORNING)).toHaveLength(1); }); // Otherwise a second step behind a longer window would push the message // later than the first person's window already allowed. test('the earliest pending deferral wins', () => { deferNotification(db, 'alert-1', { routeId: route.id, until: MORNING }); deferNotification(db, 'alert-1', { routeId: route.id, until: new Date('2026-07-28T09:00:00Z'), }); expect(alert().deferredUntil?.getTime()).toBe(MORNING.getTime()); }); test('a message held overnight is delivered when the window ends', async () => { deferNotification(db, 'alert-1', { routeId: route.id, until: MORNING }); const outcome = await deliverDeferred(alert(), route, deps); expect(outcome.result).toBe('sent'); expect(sent).toHaveLength(1); expect(sent[0].body).toContain(KEY); }); // The whole reason delivery is decided at window end rather than scheduled. test('an alert that resolved overnight is not announced in the morning', async () => { deferNotification(db, 'alert-1', { routeId: route.id, until: MORNING }); db.update(alerts) .set({ state: 'resolved', activeKey: null, resolvedAt: NIGHT }) .where(eq(alerts.id, 'alert-1')) .run(); const outcome = await deliverDeferred(alert(), route, deps); expect(outcome).toEqual({ result: 'skipped', reason: 'resolved' }); expect(sent).toHaveLength(0); }); test('an alert acked overnight is not re-announced', async () => { deferNotification(db, 'alert-1', { routeId: route.id, until: MORNING }); db.update(alerts).set({ state: 'acked' }).where(eq(alerts.id, 'alert-1')).run(); expect((await deliverDeferred(alert(), route, deps)).result).toBe('skipped'); expect(sent).toHaveLength(0); }); test('an alert silenced past the window end stays quiet', async () => { deferNotification(db, 'alert-1', { routeId: route.id, until: MORNING }); db.update(alerts) .set({ silencedUntil: new Date('2026-07-28T12:00:00Z') }) .where(eq(alerts.id, 'alert-1')) .run(); expect((await deliverDeferred(alert(), route, deps)).result).toBe('skipped'); expect(sent).toHaveLength(0); }); test('clearing a deferral takes it out of the due set', () => { deferNotification(db, 'alert-1', { routeId: route.id, until: MORNING }); clearDeferral(db, 'alert-1'); expect(dueDeferrals(db, MORNING)).toHaveLength(0); }); });