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 type { DbClient } from '../../db/client'; import { modules, monitors } from '../../db/schema'; import { setupTestDatabaseAt } from '../../test-utils/database'; import { resetTestDbPath } from '../../test-utils/db-path'; import { closeDeployWindows, ensureMonitorOnDeploy, modulesInDeployWindow, openDeployWindow, } from './deploy-hooks'; const NOW = new Date('2026-07-28T12:00:00Z'); const LATER = new Date('2026-07-28T12:05:00Z'); describe('deploy hooks', () => { let dir: string; let db: DbClient; const addModule = (id: string, manifestData: Record = {}) => db .insert(modules) .values({ id, name: id, version: '1.0.0', manifestData, sourcePath: `/tmp/${id}` }) .run(); beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'deploy-hooks-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); describe('suppression windows', () => { test('opening a window puts the module in the suppressed set', () => { addModule('caddy'); openDeployWindow(db, 'caddy', NOW); expect(modulesInDeployWindow(db).has('caddy')).toBe(true); }); test('closing it takes the module back out', () => { addModule('caddy'); openDeployWindow(db, 'caddy', NOW); expect(closeDeployWindows(db, 'caddy', LATER)).toBe(1); expect(modulesInDeployWindow(db).has('caddy')).toBe(false); }); // A redeploy during a deploy must not need two closes to become audible. test('opening twice reuses the same window', () => { addModule('caddy'); const first = openDeployWindow(db, 'caddy', NOW); const second = openDeployWindow(db, 'caddy', NOW); expect(second).toBe(first); expect(closeDeployWindows(db, 'caddy', LATER)).toBe(1); }); // Closing by module rather than by id is what makes a crashed deploy // recoverable — its window id is gone, but the module is still known. test('a window orphaned by a crash is closed on the next attempt', () => { addModule('caddy'); openDeployWindow(db, 'caddy', NOW); // Simulate a crash: nobody closed it. The next deploy closes it anyway. expect(closeDeployWindows(db, 'caddy', LATER)).toBe(1); expect(modulesInDeployWindow(db).size).toBe(0); }); test('one module deploying does not silence another', () => { addModule('caddy'); addModule('forgejo'); openDeployWindow(db, 'caddy', NOW); const suppressed = modulesInDeployWindow(db); expect(suppressed.has('caddy')).toBe(true); expect(suppressed.has('forgejo')).toBe(false); }); // Alerting bookkeeping must never fail a deploy. test('an unknown module does not throw', () => { expect(() => openDeployWindow(db, 'nope', NOW)).not.toThrow(); expect(() => closeDeployWindows(db, 'nope', NOW)).not.toThrow(); }); }); describe('monitor auto-create', () => { test('a module declaring an interval gets a monitor', () => { addModule('caddy', { hooks: { health_check: { script: 'x', interval: '15m' } } }); expect(ensureMonitorOnDeploy(db, 'caddy')).toBe(true); const monitor = db.select().from(monitors).all()[0]; expect(monitor.target).toBe('caddy'); expect(monitor.intervalMinutes).toBe(15); }); // The row carries severity, escalation policy and lastRunAt; WHETHER it // runs comes from the module's effective cadence. So a module with the hook // and no suggested interval still gets a row — otherwise an operator who // sets `health_check_interval` on it would have nothing to schedule against. test('a module with the hook but no interval still gets a row to carry its state', () => { addModule('caddy', { hooks: { health_check: { script: 'x' } } }); expect(ensureMonitorOnDeploy(db, 'caddy')).toBe(true); expect(db.select().from(monitors).all()).toHaveLength(1); }); test('a module with no health_check hook at all gets nothing', () => { addModule('signal', { hooks: {} }); expect(ensureMonitorOnDeploy(db, 'signal')).toBe(false); expect(db.select().from(monitors).all()).toEqual([]); }); // Not because the row protects an operator's setting any more — that lives // in module_configs now — but because a redeploy must not duplicate rows. test('a second deploy does not create a second row', () => { addModule('caddy', { hooks: { health_check: { script: 'x', interval: '15m' } } }); ensureMonitorOnDeploy(db, 'caddy'); expect(ensureMonitorOnDeploy(db, 'caddy')).toBe(false); expect(db.select().from(monitors).all()).toHaveLength(1); }); test('an unknown module does not throw', () => { expect(ensureMonitorOnDeploy(db, 'nope')).toBe(false); }); }); });