import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { archiveFileName, dumpFileName } from './backup-set.js'; import { runBackup, type RunnerDeps } from './runner.js'; import { readStatus } from './status.js'; /** * Time-accelerated nightly runs (issue #83 acceptance criteria): the clock * is injected, so "a month of nights" is a loop, with real files in a temp * backups directory and dump/archive doubles standing in for pg_dump/tar * (their real counterparts are covered by pg.ts/archive.ts on the stage). */ const DAY = 24 * 60 * 60 * 1000; const START = new Date('2026-07-01T03:00:00Z').getTime(); let dir: string; let clock: Date; let failures: Array<{ backupId: string; error: string; lastSuccessAt: string | null }>; function makeDeps(overrides: Partial = {}): RunnerDeps { return { backupsDir: dir, retentionDays: 7, now: () => new Date(clock), dump: (outFile) => writeFile(outFile, 'dump-bytes'), archive: (outFile) => writeFile(outFile, 'archive-bytes!'), onFailure: async (run) => { failures.push(run); }, log: { info: () => {}, error: () => {} }, ...overrides, }; } beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dorfteich-backup-')); clock = new Date(START); failures = []; }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); describe('runBackup', () => { it('produces a dump, a matching archive, and status.json', async () => { const status = await runBackup(makeDeps()); const names = await readdir(dir); expect(names).toContain(dumpFileName('20260701-030000')); expect(names).toContain(archiveFileName('20260701-030000')); expect(status.lastRun.outcome).toBe('succeeded'); expect(status.lastRun.sizes).toEqual({ dumpBytes: 10, archiveBytes: 14 }); expect(status.lastSuccess?.backupId).toBe('20260701-030000'); expect(readStatus(dir)).toEqual(status); }); it('keeps exactly the retention window across simulated nights', async () => { for (let night = 0; night < 30; night += 1) { clock = new Date(START + night * DAY); await runBackup(makeDeps()); } const names = (await readdir(dir)).filter((name) => name.startsWith('db-')); // Retention 7 days: the last 7 nights survive plus the current night's set. expect(names.length).toBe(8); expect(names).toContain(dumpFileName('20260730-030000')); expect(names).not.toContain(dumpFileName('20260722-030000')); expect(names).toContain(dumpFileName('20260723-030000')); }); it('records a failed run, keeps lastSuccess, alerts, and cleans partials', async () => { await runBackup(makeDeps()); clock = new Date(START + DAY); const status = await runBackup( makeDeps({ dump: async () => { throw new Error('connection refused'); }, }), ); expect(status.lastRun.outcome).toBe('failed'); expect(status.lastRun.error).toContain('connection refused'); expect(status.lastSuccess?.backupId).toBe('20260701-030000'); expect(failures).toHaveLength(1); expect(failures[0]?.lastSuccessAt).toBe(new Date(START).toISOString()); const names = await readdir(dir); expect(names.filter((name) => name.endsWith('.partial'))).toHaveLength(0); // The last good set survives a failed night. expect(names).toContain(dumpFileName('20260701-030000')); }); it('a failure alert that itself fails never breaks the run', async () => { const status = await runBackup( makeDeps({ dump: async () => { throw new Error('disk full'); }, onFailure: async () => { throw new Error('smtp down'); }, }), ); expect(status.lastRun.outcome).toBe('failed'); expect(readStatus(dir)?.lastRun.error).toContain('disk full'); }); it('after long downtime the newest complete set survives pruning', async () => { await runBackup(makeDeps()); // 60 days later (retention 7): the old set is expired but must survive // as the newest complete one until a new success replaces it. clock = new Date(START + 60 * DAY); const status = await runBackup( makeDeps({ dump: async () => { throw new Error('db gone'); }, }), ); expect(status.lastRun.outcome).toBe('failed'); expect(await readdir(dir)).toContain(dumpFileName('20260701-030000')); // The next success prunes it. clock = new Date(START + 61 * DAY); await runBackup(makeDeps()); const names = await readdir(dir); expect(names).not.toContain(dumpFileName('20260701-030000')); expect(names).toContain(dumpFileName('20260831-030000')); }); });