/** * The run path and the alert path must agree about how old a backup is. * * They did not. `isBackupDue` measured from `backups.startedAt` while the * freshness audit measured from `completedAt`, so the two disagreed by however * long the backup itself took — 5.5 minutes for forgejo, and unbounded for * anything larger. The shared accessor prevented drift where it was looking and * the drift moved into the columns underneath it (design.md D6). * * This is the gate for that: one completed backup whose start and finish are * far apart, read by both paths, which must return the same instant. */ 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 { backupStorages, backups, modules } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { loadBackupAuditInfo } from './audit/backup-source'; import { findBackupEligibleModules } from './backup-create'; import { loadBackupHistory } from './backup-metadata'; import { BACKUP_SCHEDULE_CONFIG_KEY, effectiveBackupSchedule } from './backup-schedule'; import { parseCadence } from './cadence'; import { configOverride, upsertModuleConfig } from './module-config'; const STARTED = new Date('2026-04-25T00:00:00Z'); // Six hours later — an extreme but not absurd gap for a multi-gigabyte module. const COMPLETED = new Date('2026-04-25T06:00:00Z'); describe('backup age: the run path and the audit path read the same column', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-backup-age-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); const db = getDb(); db.insert(modules) .values({ id: 'forgejo', name: 'Forgejo', sourcePath: dir, version: '1.0.0', state: 'INSTALLED', manifestData: { id: 'forgejo', hooks: { on_backup: { script: './backup.ts' } }, backup: { schedule: 'daily' }, }, }) .run(); db.insert(backupStorages) .values({ id: 'storage-1', storageId: 'local', name: 'Local', providerName: 'local', credentialsEncrypted: '', providerConfig: {}, verified: true, }) .run(); db.insert(backups) .values({ id: 'backup-1', moduleId: 'forgejo', storageId: 'storage-1', storagePath: '2026-04-25/forgejo.backup', backupType: 'module_data', status: 'completed', startedAt: STARTED, completedAt: COMPLETED, }) .run(); }); afterEach(() => { closeDb(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); test('both measure from when the backup COMPLETED, not when it started', () => { const history = loadBackupHistory('forgejo'); const [audited] = loadBackupAuditInfo(getDb()); expect(history.lastSuccessAt?.getTime()).toBe(COMPLETED.getTime()); expect(audited.lastSuccessfulBackupAt).toBe(COMPLETED.getTime()); expect(history.lastSuccessAt?.getTime()).toBe(audited.lastSuccessfulBackupAt ?? 0); }); test('both resolve the same cadence for the same module', () => { const [audited] = loadBackupAuditInfo(getDb()); const fromAudit = effectiveBackupSchedule(audited.manifest, audited.scheduleOverride); expect(fromAudit).toEqual({ minutes: 24 * 60 }); expect(parseCadence('daily')).toEqual(fromAudit); }); test("an operator's override reaches the sweep and the audit alike", () => { upsertModuleConfig(getDb(), 'forgejo', BACKUP_SCHEDULE_CONFIG_KEY, '6h'); const [audited] = loadBackupAuditInfo(getDb()); const [eligible] = findBackupEligibleModules(); const fromAudit = effectiveBackupSchedule(audited.manifest, audited.scheduleOverride); const fromSweep = effectiveBackupSchedule( eligible.manifest, configOverride(eligible.configs, BACKUP_SCHEDULE_CONFIG_KEY), ); expect(fromAudit).toEqual({ minutes: 360 }); expect(fromSweep).toEqual(fromAudit); }); });