BillTracker/tests/backupAndCleanup.test.js

286 lines
12 KiB
JavaScript

'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,
importBackupBuffer,
checksumFile,
} = require('../services/backupService');
const {
validateScheduleSettings,
computeNextRun,
} = 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 () => {
// Raise retention so createBackup's own internal pruning doesn't interfere.
setSetting('backup_schedule_retention_count', '100');
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('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);
});
// ─────────────────────────────────────────────────────────────────────────────
// 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 {}
});