'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); // ── Test environment ───────────────────────────────────────────────────────── // Set env vars before any require so modules pick up the right paths. const testId = `backup-cleanup-test-${process.pid}`; const dbPath = path.join(os.tmpdir(), `${testId}.sqlite`); const backupPath = path.join(os.tmpdir(), `${testId}-backups`); process.env.DB_PATH = dbPath; process.env.BACKUP_PATH = backupPath; const { closeDb, setSetting } = require('../db/database'); const { BACKUP_DIR, createBackup, deleteBackup, listBackups, applyRetention, applyScheduledRetention, importBackupBuffer, checksumFile, } = require('../services/backupService'); const { validateScheduleSettings, computeNextRun, runScheduledBackupNow, } = require('../services/backupScheduler'); const { pruneOrphanedBackupPartials, pruneStaleExportFiles, } = require('../services/cleanupService'); // ── Teardown ───────────────────────────────────────────────────────────────── test.after(() => { closeDb(); try { fs.unlinkSync(dbPath); } catch {} try { fs.rmSync(backupPath, { recursive: true, force: true }); } catch {} }); // ───────────────────────────────────────────────────────────────────────────── // backupService // ───────────────────────────────────────────────────────────────────────────── test('BACKUP_DIR resolves to the BACKUP_PATH env var', () => { assert.equal(BACKUP_DIR, path.resolve(backupPath)); }); test('createBackup writes a valid .sqlite file and returns metadata', async () => { const meta = await createBackup('bill-tracker-backup'); assert.ok(meta.id, 'has id'); assert.ok(meta.filename.endsWith('.sqlite')); assert.equal(meta.type, 'manual'); assert.ok(meta.size_bytes > 0); assert.match(meta.checksum, /^[a-f0-9]{64}$/); assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id))); }); test('listBackups returns the created backup sorted newest-first', async () => { const list = listBackups(); assert.ok(list.length >= 1); assert.ok(list[0].id.startsWith('bill-tracker-backup-') || list[0].id.startsWith('scheduled-backup-')); }); test('deleteBackup removes the file', async () => { const meta = await createBackup('bill-tracker-backup'); const filePath = path.join(BACKUP_DIR, meta.id); assert.ok(fs.existsSync(filePath)); deleteBackup(meta.id); assert.ok(!fs.existsSync(filePath)); }); test('applyRetention keeps only the requested number of backups', async () => { for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} } await createBackup('bill-tracker-backup'); await createBackup('bill-tracker-backup'); await createBackup('bill-tracker-backup'); assert.equal(listBackups().length, 3); const { deleted } = applyRetention(1); assert.equal(deleted.length, 2); assert.equal(listBackups().length, 1); setSetting('backup_schedule_retention_count', '2'); }); test('applyScheduledRetention only prunes scheduled backups', async () => { for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} } const manual = await createBackup('bill-tracker-backup'); await createBackup('scheduled-backup'); await createBackup('scheduled-backup'); await createBackup('scheduled-backup'); const { deleted } = applyScheduledRetention(2); const backups = listBackups(); const scheduled = backups.filter(backup => backup.type === 'scheduled'); assert.equal(deleted.length, 1); assert.equal(scheduled.length, 2); assert.ok(backups.some(backup => backup.id === manual.id), 'manual backup was not pruned'); }); test('importBackupBuffer accepts a valid SQLite buffer', async () => { // Create a real backup, then re-import its bytes const src = await createBackup('bill-tracker-backup'); const buf = fs.readFileSync(path.join(BACKUP_DIR, src.id)); const meta = await importBackupBuffer(buf); assert.equal(meta.type, 'imported'); assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id))); }); test('importBackupBuffer rejects non-SQLite data', async () => { const buf = Buffer.from('this is not a sqlite database at all'); await assert.rejects( () => importBackupBuffer(buf), err => { assert.ok(err.status === 400); return true; } ); }); test('importBackupBuffer rejects a checksum mismatch', async () => { const src = await createBackup('bill-tracker-backup'); const buf = fs.readFileSync(path.join(BACKUP_DIR, src.id)); await assert.rejects( () => importBackupBuffer(buf, { expectedChecksum: 'a'.repeat(64) }), err => { assert.ok(err.status === 400); assert.match(err.message, /checksum/i); return true; } ); }); test('checksumFile returns a 64-char hex string', async () => { const src = await createBackup('bill-tracker-backup'); const sum = checksumFile(path.join(BACKUP_DIR, src.id)); assert.match(sum, /^[a-f0-9]{64}$/); }); // ───────────────────────────────────────────────────────────────────────────── // backupScheduler // ───────────────────────────────────────────────────────────────────────────── test('validateScheduleSettings accepts valid daily settings', () => { const s = validateScheduleSettings({ enabled: true, frequency: 'daily', time: '03:30', retention_count: '7' }); assert.equal(s.enabled, true); assert.equal(s.frequency, 'daily'); assert.equal(s.time, '03:30'); assert.equal(s.retention_count, 7); }); test('validateScheduleSettings rejects bad frequency', () => { assert.throws( () => validateScheduleSettings({ enabled: true, frequency: 'hourly', time: '02:00', retention_count: '2' }), /frequency/i ); }); test('validateScheduleSettings rejects bad time format', () => { assert.throws( () => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '25:00', retention_count: '2' }), /time/i ); }); test('validateScheduleSettings rejects retention_count out of range', () => { assert.throws( () => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '02:00', retention_count: '0' }), /retention/i ); }); test('computeNextRun returns null when disabled', () => { const result = computeNextRun({ enabled: false, frequency: 'daily', time: '02:00' }); assert.equal(result, null); }); test('computeNextRun returns a future ISO string when enabled', () => { const from = new Date('2026-05-29T10:00:00Z'); const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from); assert.ok(typeof result === 'string'); assert.ok(new Date(result) > from); }); test('computeNextRun advances to next day when time has already passed today', () => { const from = new Date('2026-05-29T15:00:00Z'); // 3 PM UTC const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from); const next = new Date(result); // Next run should be 2 AM the following day, so > 24h away assert.ok(next.getTime() - from.getTime() > 12 * 60 * 60 * 1000); }); test('runScheduledBackupNow keeps only the configured number of scheduled backups', async () => { for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} } setSetting('backup_schedule_retention_count', '2'); await runScheduledBackupNow(); await runScheduledBackupNow(); await runScheduledBackupNow(); const scheduled = listBackups().filter(backup => backup.type === 'scheduled'); assert.equal(scheduled.length, 2); }); // ───────────────────────────────────────────────────────────────────────────── // cleanupService — pruneOrphanedBackupPartials // ───────────────────────────────────────────────────────────────────────────── test('pruneOrphanedBackupPartials removes .partial files older than 2 hours', () => { fs.mkdirSync(backupPath, { recursive: true }); const oldPartial = path.join(backupPath, 'old.partial'); const newPartial = path.join(backupPath, 'new.partial'); fs.writeFileSync(oldPartial, 'data'); fs.writeFileSync(newPartial, 'data'); // Back-date the old file's mtime by 3 hours const old = new Date(Date.now() - 3 * 60 * 60 * 1000); fs.utimesSync(oldPartial, old, old); const result = pruneOrphanedBackupPartials(backupPath); assert.equal(result.removed, 1); assert.equal(result.errors, 0); assert.ok(!fs.existsSync(oldPartial)); assert.ok(fs.existsSync(newPartial)); // cleanup try { fs.unlinkSync(newPartial); } catch {} }); test('pruneOrphanedBackupPartials is a no-op for a missing directory', () => { const result = pruneOrphanedBackupPartials('/nonexistent/path'); assert.equal(result.removed, 0); assert.equal(result.errors, 0); }); // ───────────────────────────────────────────────────────────────────────────── // cleanupService — pruneStaleExportFiles // ───────────────────────────────────────────────────────────────────────────── test('pruneStaleExportFiles removes old .sqlite export files', () => { const tmpDir = os.tmpdir(); const oldFile = path.join(tmpDir, `bill-tracker-user-1-${Date.now()}.sqlite`); const newFile = path.join(tmpDir, `bill-tracker-user-2-${Date.now()}.sqlite`); fs.writeFileSync(oldFile, 'fake'); fs.writeFileSync(newFile, 'fake'); const old = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 h ago fs.utimesSync(oldFile, old, old); const result = pruneStaleExportFiles(2); assert.ok(result.removed >= 1); assert.ok(!fs.existsSync(oldFile)); try { fs.unlinkSync(newFile); } catch {} }); test('pruneStaleExportFiles removes .xlsx files older than 24 hours', () => { const tmpDir = os.tmpdir(); const oldXlsx = path.join(tmpDir, `bill-tracker-user-3-${Date.now()}.xlsx`); const newXlsx = path.join(tmpDir, `bill-tracker-user-4-${Date.now()}.xlsx`); fs.writeFileSync(oldXlsx, 'fake'); fs.writeFileSync(newXlsx, 'fake'); const old = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 h ago fs.utimesSync(oldXlsx, old, old); const result = pruneStaleExportFiles(2); assert.ok(result.removed >= 1); assert.ok(!fs.existsSync(oldXlsx)); try { fs.unlinkSync(newXlsx); } catch {} }); test('pruneStaleExportFiles does not remove .xlsx files younger than 24 hours', () => { const tmpDir = os.tmpdir(); const recentXlsx = path.join(tmpDir, `bill-tracker-user-5-${Date.now()}.xlsx`); fs.writeFileSync(recentXlsx, 'fake'); // mtime stays at now — well within 24 h const result = pruneStaleExportFiles(2); assert.ok(fs.existsSync(recentXlsx)); try { fs.unlinkSync(recentXlsx); } catch {} }); test('pruneStaleExportFiles ignores non-bill-tracker files in tmpdir', () => { const tmpDir = os.tmpdir(); const other = path.join(tmpDir, `some-other-tool-export-${Date.now()}.sqlite`); fs.writeFileSync(other, 'fake'); const old = new Date(Date.now() - 48 * 60 * 60 * 1000); fs.utimesSync(other, old, old); const result = pruneStaleExportFiles(2); assert.ok(fs.existsSync(other), 'should not have deleted unrelated file'); assert.equal(result.errors, 0); try { fs.unlinkSync(other); } catch {} });