import fs from 'node:fs'; import path from 'node:path'; import assert from 'node:assert'; import env from '../src/config/env.js'; import { createHourlyBackup, listBackups, pruneBackups, getBackupServiceStatus, startBackupService, stopBackupService, resolveBackupPaths, listBackupVfsFiles, recoverBackupVfsFile, } from '../src/services/backupService.js'; import { closeStore } from '../src/db/store.js'; async function runTests() { console.log('--- Starting Backup Service Verification ---'); // 1. Verify path resolution and directories const paths = resolveBackupPaths(); console.log(`Backup root dir: ${paths.rootDir}`); assert(paths.rootDir.endsWith('data/backups'), 'Backup root should be data/backups'); // 2. Execute a backup console.log('\n[Test 1] Running createHourlyBackup()...'); const backupResult = await createHourlyBackup(); assert(backupResult.success === true, 'Backup should report success'); assert(backupResult.manifest.manual === undefined, 'manual field should not exist in manifest'); console.log(`Backup created: ${backupResult.id}, size: ${(backupResult.totalSizeBytes / 1024 / 1024).toFixed(2)} MB`); console.log(`Promoted to daily: ${backupResult.promotedDaily}, Promoted to monthly: ${backupResult.promotedMonthly}`); // Verify files in the created hourly backup const hourlyBackupFolder = path.join(paths.hourlyDir, backupResult.id); assert(fs.existsSync(hourlyBackupFolder), 'Hourly backup folder should exist'); assert(fs.existsSync(path.join(hourlyBackupFolder, 'vfs.tar.gz')), 'vfs.tar.gz should exist'); assert(fs.existsSync(path.join(hourlyBackupFolder, 'storage.tar')), 'storage.tar should exist'); assert(fs.existsSync(path.join(hourlyBackupFolder, 'manifest.json')), 'manifest.json should exist'); if (env.dbProvider === 'sqlite') { const dbName = path.basename(env.sqlite.filename); assert(fs.existsSync(path.join(hourlyBackupFolder, dbName)), `${dbName} should exist`); console.log(`Verified SQLite database backup file: ${dbName}`); } // Verify daily backup directory const { dateKey, monthKey } = backupResult.manifest; const dailyFolder = path.join(paths.dailyDir, `backup-${dateKey}`); assert(fs.existsSync(dailyFolder), 'Daily backup folder should exist'); assert(fs.existsSync(path.join(dailyFolder, 'manifest.json')), 'Daily manifest should exist'); // Verify monthly backup directory const monthlyFolder = path.join(paths.monthlyDir, `backup-${monthKey}`); assert(fs.existsSync(monthlyFolder), 'Monthly backup folder should exist'); assert(fs.existsSync(path.join(monthlyFolder, 'manifest.json')), 'Monthly manifest should exist'); // 3. Test listBackups and getBackupServiceStatus console.log('\n[Test 2] Testing listBackups() and getBackupServiceStatus()...'); const status = getBackupServiceStatus(); assert(typeof status.enabled === 'boolean', 'status.enabled should be boolean'); assert(status.retention.hourlyMax === 50, 'hourly retention should be 50'); assert(status.retention.dailyMax === 50, 'daily retention should be 50'); const listing = listBackups(); assert(listing.hourly.length >= 1, 'Should have at least 1 hourly backup'); assert(listing.daily.length >= 1, 'Should have at least 1 daily backup'); assert(listing.monthly.length >= 1, 'Should have at least 1 monthly backup'); console.log(`Counts -> hourly: ${listing.counts.hourly}, daily: ${listing.counts.daily}, monthly: ${listing.counts.monthly}`); // 4. Test VFS listing and recovery from specific backup console.log('\n[Test 3] Testing listBackupVfsFiles() and recoverBackupVfsFile()...'); const vfsListing = await listBackupVfsFiles({ backupId: backupResult.id, companyId: 1 }); assert(vfsListing.backupId === backupResult.id, 'backupId should match'); assert(vfsListing.companyId === 1, 'companyId should be 1'); assert(Array.isArray(vfsListing.files), 'files should be an array'); console.log(`Found ${vfsListing.files.length} VFS files in backup for company 1.`); if (vfsListing.files.length > 0) { const fileToRecover = vfsListing.files[0]; console.log(`Attempting recovery of ${fileToRecover.name}...`); const recoverRes = await recoverBackupVfsFile({ backupId: backupResult.id, companyId: 1, filePath: fileToRecover.name, }); assert(recoverRes.success === true, 'Recovery should succeed'); assert(recoverRes.path === fileToRecover.name, 'Recovered path should match'); assert(recoverRes.sizeBytes > 0, 'Recovered size should be > 0'); const recoveredDiskPath = path.resolve(paths.vfsDir, '1', fileToRecover.name); assert(fs.existsSync(recoveredDiskPath), 'Recovered file should exist on disk'); console.log(`Successfully recovered ${fileToRecover.name} (${recoverRes.sizeBytes} bytes).`); } // Test error cases let backupNotFound = false; try { await listBackupVfsFiles({ backupId: 'backup-nonexistent', companyId: 1 }); } catch (e) { if (e.message === 'Backup not found') backupNotFound = true; } assert(backupNotFound, 'Should throw Backup not found for invalid backupId'); let fileNotFound = false; try { await recoverBackupVfsFile({ backupId: backupResult.id, companyId: 1, filePath: 'completely-nonexistent-file.xyz', }); } catch (e) { if (e.message === 'File not found in backup') fileNotFound = true; } assert(fileNotFound, 'Should throw File not found in backup for invalid file'); console.log('VFS listing and recovery tests passed.'); // 5. Test Retention Pruning (exceeding 50 entries) console.log('\n[Test 4] Testing retention pruning for > 50 entries...'); // Create dummy hourly backup folders for (let i = 1; i <= 55; i++) { const dummyName = `backup-2020-01-01_${String(i).padStart(2, '0')}-00-00`; const dummyPath = path.join(paths.hourlyDir, dummyName); fs.mkdirSync(dummyPath, { recursive: true }); fs.writeFileSync(path.join(dummyPath, 'manifest.json'), '{}'); } // Create dummy daily backup folders for (let i = 1; i <= 55; i++) { const dummyName = `backup-2020-01-${String(i).padStart(2, '0')}`; const dummyPath = path.join(paths.dailyDir, dummyName); fs.mkdirSync(dummyPath, { recursive: true }); fs.writeFileSync(path.join(dummyPath, 'manifest.json'), '{}'); } // Create dummy monthly backup folders (should never be pruned!) for (let i = 1; i <= 55; i++) { const dummyName = `backup-2010-${String(i).padStart(2, '0')}`; const dummyPath = path.join(paths.monthlyDir, dummyName); fs.mkdirSync(dummyPath, { recursive: true }); fs.writeFileSync(path.join(dummyPath, 'manifest.json'), '{}'); } const pruneResult = pruneBackups(); console.log(`Pruned ${pruneResult.hourlyPruned} hourly, ${pruneResult.dailyPruned} daily`); assert(pruneResult.hourlyPruned >= 5, 'Should have pruned at least 5 hourly backups'); assert(pruneResult.dailyPruned >= 5, 'Should have pruned at least 5 daily backups'); const listingAfterPrune = listBackups(); assert(listingAfterPrune.counts.hourly <= 50, `Hourly count should be <= 50, got ${listingAfterPrune.counts.hourly}`); assert(listingAfterPrune.counts.daily <= 50, `Daily count should be <= 50, got ${listingAfterPrune.counts.daily}`); assert(listingAfterPrune.counts.monthly >= 55, `Monthly count should NOT be pruned (got ${listingAfterPrune.counts.monthly})`); console.log('Retention pruning verified: 50 hourly max, 50 daily max, monthly preserved forever.'); // Clean up dummy test backups for (let i = 1; i <= 55; i++) { const hPath = path.join(paths.hourlyDir, `backup-2020-01-01_${String(i).padStart(2, '0')}-00-00`); if (fs.existsSync(hPath)) fs.rmSync(hPath, { recursive: true, force: true }); const dPath = path.join(paths.dailyDir, `backup-2020-01-${String(i).padStart(2, '0')}`); if (fs.existsSync(dPath)) fs.rmSync(dPath, { recursive: true, force: true }); const mPath = path.join(paths.monthlyDir, `backup-2010-${String(i).padStart(2, '0')}`); if (fs.existsSync(mPath)) fs.rmSync(mPath, { recursive: true, force: true }); } // 6. Test MSSQL warning behavior console.log('\n[Test 5] Testing MSSQL console warning behavior...'); const originalProvider = env.dbProvider; let warningCaptured = false; const origWarn = console.warn; console.warn = (...args) => { const text = args.join(' '); if (text.includes('MSSQL database backup is not implemented')) { warningCaptured = true; } origWarn(...args); }; try { env.dbProvider = 'mssql'; const mssqlBackup = await createHourlyBackup(); assert(warningCaptured, 'Console warning must be printed when running on MSSQL'); assert(mssqlBackup.manifest.files.database.backedUp === false, 'Database backup must be skipped on MSSQL'); console.log('MSSQL warning and database skip verified.'); } finally { console.warn = origWarn; env.dbProvider = originalProvider; } // 7. Test Lifecycle methods console.log('\n[Test 6] Testing startBackupService() and stopBackupService()...'); startBackupService(); assert(getBackupServiceStatus().isRunning === true, 'Service should be running'); await stopBackupService(); assert(getBackupServiceStatus().isRunning === false, 'Service should be stopped'); console.log('Service lifecycle start/stop verified.'); await closeStore(); console.log('\n--- ALL BACKUP SERVICE TESTS PASSED ---'); } runTests().catch((err) => { console.error('Test failed:', err); process.exit(1); });