import Database from 'better-sqlite3'; import { execFileSync } from 'node:child_process'; import { readdirSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { createBackup, listBackups, planRetention } from '../src/backups/backup'; import { makeApp, setUpAdmin, type TestApp } from './helpers'; const nightly = (y: number, m: number, d: number) => { const date = `${y}${String(m).padStart(2, '0')}${String(d).padStart(2, '0')}`; return { name: `spendlight-nightly-${date}-023000.db`, kind: 'nightly' as const, createdAt: new Date(y, m - 1, d, 2, 30).toISOString(), }; }; describe('backup retention', () => { it('keeps 14 daily copies plus the newest of each of 12 months', () => { const list = []; // Every night from Jan 1 2025 to Oct 7 2026. for (let d = new Date(2025, 0, 1); d <= new Date(2026, 9, 7); d.setDate(d.getDate() + 1)) { list.push(nightly(d.getFullYear(), d.getMonth() + 1, d.getDate())); } const doomed = new Set(planRetention(list, { keepDaily: 14, keepMonthly: 12 })); const kept = list.filter((b) => !doomed.has(b.name)).map((b) => b.name.slice(19, 27)); expect(kept.filter((d) => d >= '20260924')).toHaveLength(14); // the last 14 nights const months = new Set(kept.map((d) => d.slice(0, 6))); expect(months.size).toBe(12); // Nov 2025 through Oct 2026 expect(kept).toContain('20251130'); // newest of the oldest kept month expect(kept).not.toContain('20251031'); }); it('keeps the newest 10 manual and 5 pre-upgrade copies', () => { const make = (kind: 'manual' | 'pre-upgrade', i: number) => ({ name: `spendlight-${kind}-202610${String(i).padStart(2, '0')}-120000.db`, kind, createdAt: new Date(2026, 9, i, 12).toISOString(), }); const list = [...Array.from({ length: 12 }, (_, i) => make('manual', i + 1)), ...Array.from({ length: 7 }, (_, i) => make('pre-upgrade', i + 1))]; const doomed = planRetention(list, { keepDaily: 14, keepMonthly: 12 }); expect(doomed.filter((n) => n.includes('manual'))).toEqual([ 'spendlight-manual-20261002-120000.db', 'spendlight-manual-20261001-120000.db', ]); expect(doomed.filter((n) => n.includes('pre-upgrade'))).toHaveLength(2); }); }); describe('backup files', () => { let t: TestApp; afterEach(() => t.cleanup()); it('writes a complete, valid Spendlight database and lists it', async () => { t = await makeApp(); await setUpAdmin(t); const dir = t.ctx.config.backupDir; const backup = await createBackup(t.ctx.sqlite, dir, 'manual'); expect(readdirSync(dir).filter((f) => f.endsWith('.partial'))).toHaveLength(0); expect(listBackups(dir).map((b) => b.name)).toContain(backup.name); const copy = new Database(join(dir, backup.name), { readonly: true }); expect((copy.prepare("SELECT value FROM setting WHERE key = 'app'").get() as { value: string }).value).toBe('"spendlight"'); expect((copy.prepare('SELECT username FROM user').get() as { username: string }).username).toBe('chris'); copy.close(); }); it('lets an admin back up from the app and download the file', async () => { t = await makeApp(); const admin = await setUpAdmin(t); const made = await admin.post('/api/v1/backups'); expect(made.status).toBe(200); expect((await admin.get('/api/v1/backups')).json).toMatchObject({ nightlyAt: '02:30', keepDaily: 14, keepMonthly: 12 }); const download = await admin.get(`/api/v1/backups/${made.json.name}/download`); expect(download.status).toBe(200); expect(download.headers.get('content-disposition')).toContain(made.json.name); expect((await admin.get('/api/v1/backups/..%2F..%2Fetc%2Fpasswd/download')).status).toBe(404); }); }); describe('version stamps', () => { const next = (previous: string, today: string) => execFileSync('bash', [fileURLToPath(new URL('../../scripts/next-version.sh', import.meta.url)), previous], { env: { ...process.env, TODAY: today } }) .toString() .trim(); it('counts up through the day and restarts at .01 the next', () => { expect(next('dev', '20261007')).toBe('20261007.01'); expect(next('20261007.01', '20261007')).toBe('20261007.02'); expect(next('20261007.09', '20261007')).toBe('20261007.10'); expect(next('20261007.14', '20261008')).toBe('20261008.01'); }); });