/** * The migrate step, and the invariant it protects. * * A `module_hook` monitor's stored `intervalMinutes` and `enabled` are no * longer read: cadence resolves from `module_configs`. That is only safe on an * existing fleet because `celilo system migrate` — which the `.deb` postinst * runs on every apt upgrade — carries the diverging rows over first. Without * it, the upgrade that shipped this change would silently revert every * operator's hand-set cadence and resume watching modules they had disabled. */ 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 { closeDb, getDb } from '../../db/client'; import { runMigrations } from '../../db/migrate'; import { modules, monitors } from '../../db/schema'; import { resetTestDbPath } from '../../test-utils/db-path'; import { getModuleConfigValue, upsertModuleConfig } from '../module-config'; import { migrateMonitorCadences } from './cadence-migration'; import { HEALTH_CHECK_INTERVAL_CONFIG_KEY, loadModuleHealthCadences } from './health-cadence'; function addModule(id: string, interval?: string): void { getDb() .insert(modules) .values({ id, name: id, sourcePath: `/tmp/${id}`, version: '1.0.0', state: 'INSTALLED', manifestData: { id, hooks: { health_check: { script: './health.ts', ...(interval ? { interval } : {}) } }, }, }) .run(); } function addMonitor(target: string, intervalMinutes: number, enabled = true): void { getDb() .insert(monitors) .values({ id: `monitor-${target}`, kind: 'module_hook', target, intervalMinutes, enabled, }) .run(); } function override(moduleId: string): string | undefined { const row = getModuleConfigValue(moduleId, HEALTH_CHECK_INTERVAL_CONFIG_KEY, getDb()); return row === null ? undefined : String(row.value); } describe('migrateMonitorCadences', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-cadence-migration-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); test("a cadence that diverges from the manifest is carried over, so the upgrade can't revert it", () => { addModule('caddy', '15m'); addMonitor('caddy', 60); // an operator re-cadenced this to hourly const report = migrateMonitorCadences(getDb()); expect(override('caddy')).toBe('hourly'); expect(report.written.get('caddy')).toBe('hourly'); }); test('a disabled monitor becomes manual, so a module nobody wanted watched stays unwatched', () => { addModule('lunacycle', '15m'); addMonitor('lunacycle', 15, false); migrateMonitorCadences(getDb()); expect(override('lunacycle')).toBe('manual'); }); test('a cadence matching the manifest writes nothing — it must stay free to follow corrections', () => { addModule('forgejo', '15m'); addMonitor('forgejo', 15); const report = migrateMonitorCadences(getDb()); expect(override('forgejo')).toBeUndefined(); expect(report.unchanged).toContain('forgejo'); }); test('an existing override is never overwritten', () => { addModule('authentik', '15m'); addMonitor('authentik', 60); upsertModuleConfig(getDb(), 'authentik', HEALTH_CHECK_INTERVAL_CONFIG_KEY, 'daily'); migrateMonitorCadences(getDb()); expect(override('authentik')).toBe('daily'); }); test('a second run changes nothing', () => { addModule('caddy', '15m'); addMonitor('caddy', 60); migrateMonitorCadences(getDb()); const second = migrateMonitorCadences(getDb()); expect(second.written.size).toBe(0); expect(override('caddy')).toBe('hourly'); }); }); describe("a module_hook row's stored cadence is not consulted", () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-cadence-row-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); // A future reader that reintroduces the dependency on the row fails here // rather than silently regressing to write-time resolution (design.md D8). test('the resolved cadence follows the manifest, not the row', () => { addModule('caddy', '15m'); addMonitor('caddy', 9999); expect(loadModuleHealthCadences(getDb()).get('caddy')?.cadence).toEqual({ minutes: 15 }); }); test('the resolved cadence follows the override, not the row', () => { addModule('caddy', '15m'); addMonitor('caddy', 9999); upsertModuleConfig(getDb(), 'caddy', HEALTH_CHECK_INTERVAL_CONFIG_KEY, '1h'); expect(loadModuleHealthCadences(getDb()).get('caddy')?.cadence).toEqual({ minutes: 60 }); }); });