import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { rm, mkdir, writeFile, readdir } from "node:fs/promises"; import { BackupManager, createBackupManager, generateBackupFilename, validateBackupSchedule, validateBackupRetention, validateBackupDir, runBackupCommand, } from "./backup.js"; import type { ProjectSettings } from "./types.js"; describe("BackupManager", () => { let tempDir: string; let kbDir: string; let backupManager: BackupManager; beforeEach(async () => { // Use fake timers for deterministic timestamp control vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-")); kbDir = join(tempDir, ".fusion"); await mkdir(kbDir, { recursive: true }); // Create a dummy database file writeFileSync(join(kbDir, "fusion.db"), "dummy database content"); backupManager = new BackupManager(kbDir); }); afterEach(async () => { vi.useRealTimers(); await rm(tempDir, { recursive: true, force: true }); }); describe("createBackup", () => { it("should create a backup file with correct name pattern", async () => { const backup = await backupManager.createBackup(); expect(backup.filename).toMatch(/^fusion-\d{4}-\d{2}-\d{2}-\d{6}\.db$/); expect(existsSync(backup.path)).toBe(true); }); it("should copy database content correctly", async () => { const backup = await backupManager.createBackup(); const originalContent = readFileSync(join(kbDir, "fusion.db"), "utf-8"); const backupContent = readFileSync(backup.path, "utf-8"); expect(backupContent).toBe(originalContent); }); it("should return correct backup info", async () => { const backup = await backupManager.createBackup(); expect(backup.filename).toBeDefined(); expect(backup.createdAt).toBeDefined(); expect(backup.size).toBeGreaterThan(0); expect(backup.path).toContain(backup.filename); }); it("should create backup directory if it does not exist", async () => { const customBackupDir = "custom-backups"; const manager = new BackupManager(kbDir, { backupDir: customBackupDir }); const customBackupPath = join(tempDir, customBackupDir); expect(existsSync(customBackupPath)).toBe(false); await manager.createBackup(); expect(existsSync(customBackupPath)).toBe(true); }); }); describe("listBackups", () => { it("should return empty array when no backups exist", async () => { const backups = await backupManager.listBackups(); expect(backups).toEqual([]); }); it("should return sorted array newest-first", async () => { // Create multiple backups by advancing system time deterministically const backup1 = await backupManager.createBackup(); vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z")); const backup2 = await backupManager.createBackup(); vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); const backup3 = await backupManager.createBackup(); const backups = await backupManager.listBackups(); expect(backups).toHaveLength(3); // Verify sorted by createdAt descending (newest first) expect(backups[0].createdAt >= backups[1].createdAt).toBe(true); expect(backups[1].createdAt >= backups[2].createdAt).toBe(true); // Verify correct ordering by filename expect(backups[0].filename).toBe(backup3.filename); expect(backups[1].filename).toBe(backup2.filename); expect(backups[2].filename).toBe(backup1.filename); }); it("should only list files matching backup pattern", async () => { await backupManager.createBackup(); // Create some non-backup files const backupDir = join(tempDir, ".fusion/backups"); await writeFile(join(backupDir, "not-a-backup.txt"), "content"); await writeFile(join(backupDir, "random.db"), "content"); const backups = await backupManager.listBackups(); expect(backups).toHaveLength(1); expect(backups[0].filename).toMatch(/^fusion-\d{4}-\d{2}-\d{2}-\d{6}\.db$/); }); it("should return correct file sizes", async () => { const backup = await backupManager.createBackup(); const backups = await backupManager.listBackups(); expect(backups[0].size).toBe(backup.size); }); it("should list legacy kb-* backups alongside new fusion-* backups", async () => { // Create a new-style backup await backupManager.createBackup(); // Create a legacy-style backup file manually const backupDir = join(tempDir, ".fusion/backups"); await writeFile(join(backupDir, "kb-2025-12-31-120000.db"), "legacy backup content"); const backups = await backupManager.listBackups(); expect(backups).toHaveLength(2); const filenames = backups.map((b) => b.filename); expect(filenames).toContain("kb-2025-12-31-120000.db"); expect(filenames.some((f) => f.startsWith("fusion-"))).toBe(true); }); it("should parse timestamps from legacy kb-* filenames", async () => { const backupDir = join(tempDir, ".fusion/backups"); await mkdir(backupDir, { recursive: true }); await writeFile(join(backupDir, "kb-2025-06-15-083000.db"), "legacy"); const backups = await backupManager.listBackups(); expect(backups).toHaveLength(1); expect(backups[0].createdAt).toBe("2025-06-15T08:30:00Z"); }); it("should parse timestamps from legacy kb-pre-restore filenames", async () => { const backupDir = join(tempDir, ".fusion/backups"); await mkdir(backupDir, { recursive: true }); await writeFile(join(backupDir, "kb-pre-restore-2025-06-15-083000.db"), "legacy pre-restore"); const backups = await backupManager.listBackups(); expect(backups).toHaveLength(1); expect(backups[0].filename).toBe("kb-pre-restore-2025-06-15-083000.db"); expect(backups[0].createdAt).toBe("2025-06-15T08:30:00Z"); }); it("should list only legacy kb-* backups when no fusion-* exist", async () => { const backupDir = join(tempDir, ".fusion/backups"); await mkdir(backupDir, { recursive: true }); await writeFile(join(backupDir, "kb-2025-01-01-000000.db"), "legacy1"); await writeFile(join(backupDir, "kb-2025-01-02-000000.db"), "legacy2"); const backups = await backupManager.listBackups(); expect(backups).toHaveLength(2); expect(backups.every((b) => b.filename.startsWith("kb-"))).toBe(true); }); }); describe("cleanupOldBackups", () => { it("should not delete when backup count is within retention", async () => { // Create 3 backups with retention of 7 by advancing time for (let i = 0; i < 3; i++) { vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`)); await backupManager.createBackup(); } const deleted = await backupManager.cleanupOldBackups(); expect(deleted).toBe(0); const backups = await backupManager.listBackups(); expect(backups).toHaveLength(3); }); it("should delete oldest backups exceeding retention", async () => { const manager = new BackupManager(kbDir, { retention: 2 }); // Create 4 backups by advancing time deterministically for (let i = 0; i < 4; i++) { vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`)); await manager.createBackup(); } const deleted = await manager.cleanupOldBackups(); expect(deleted).toBe(2); // 4 - 2 = 2 deleted const backups = await manager.listBackups(); expect(backups).toHaveLength(2); }); it("should keep the newest backups after cleanup", async () => { const manager = new BackupManager(kbDir, { retention: 2 }); // Create 4 backups and record their names by advancing time const backupNames: string[] = []; for (let i = 0; i < 4; i++) { vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`)); const backup = await manager.createBackup(); backupNames.push(backup.filename); } await manager.cleanupOldBackups(); const backups = await manager.listBackups(); const remainingNames = backups.map((b) => b.filename); // Should keep the 2 newest (last 2 in the array) expect(remainingNames).toContain(backupNames[2]); expect(remainingNames).toContain(backupNames[3]); expect(remainingNames).not.toContain(backupNames[0]); expect(remainingNames).not.toContain(backupNames[1]); }); }); describe("restoreBackup", () => { it("should restore backup to main database location", async () => { const backup = await backupManager.createBackup(); // Modify the original database await writeFile(join(kbDir, "fusion.db"), "modified content"); // Restore the backup await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: false }); // Verify the restore const restoredContent = readFileSync(join(kbDir, "fusion.db"), "utf-8"); expect(restoredContent).toBe("dummy database content"); }); it("should throw when backup file does not exist", async () => { await expect( backupManager.restoreBackup("nonexistent-backup.db", { createPreRestoreBackup: false }) ).rejects.toThrow("Backup file not found"); }); it("should create pre-restore backup by default", async () => { const backup = await backupManager.createBackup(); // Advance time to ensure different timestamp for pre-restore backup vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z")); // Restore with default options (should create pre-restore backup) await backupManager.restoreBackup(backup.filename); // Check for pre-restore backup const backups = await backupManager.listBackups(); const preRestoreBackup = backups.find((b) => b.filename.includes("pre-restore")); expect(preRestoreBackup).toBeDefined(); expect(preRestoreBackup!.filename).toMatch(/^fusion-pre-restore-/); }); }); }); describe("generateBackupFilename", () => { it("should generate filename with correct pattern", () => { const filename = generateBackupFilename(); expect(filename).toMatch(/^fusion-\d{4}-\d{2}-\d{2}-\d{6}\.db$/); }); it("should generate unique filenames for different timestamps", () => { // Use fake timers for deterministic time control vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const filename1 = generateBackupFilename(); vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z")); const filename2 = generateBackupFilename(); expect(filename1).not.toBe(filename2); vi.useRealTimers(); }); }); describe("validateBackupSchedule", () => { it("should return true for valid cron expressions", () => { expect(validateBackupSchedule("0 2 * * *")).toBe(true); // Daily at 2 AM expect(validateBackupSchedule("0 * * * *")).toBe(true); // Hourly expect(validateBackupSchedule("*/15 * * * *")).toBe(true); // Every 15 minutes expect(validateBackupSchedule("0 0 * * 0")).toBe(true); // Weekly on Sunday }); it("should return false for invalid cron expressions", () => { expect(validateBackupSchedule("invalid")).toBe(false); expect(validateBackupSchedule("")).toBe(false); expect(validateBackupSchedule(" ")).toBe(false); expect(validateBackupSchedule("* *")).toBe(false); // Too few fields expect(validateBackupSchedule("99 99 99 99 99")).toBe(false); // Out of range }); }); describe("validateBackupRetention", () => { it("should return true for valid retention values", () => { expect(validateBackupRetention(1)).toBe(true); expect(validateBackupRetention(7)).toBe(true); expect(validateBackupRetention(100)).toBe(true); }); it("should return false for invalid retention values", () => { expect(validateBackupRetention(0)).toBe(false); expect(validateBackupRetention(-1)).toBe(false); expect(validateBackupRetention(101)).toBe(false); expect(validateBackupRetention(1.5)).toBe(false); // Not an integer expect(validateBackupRetention(NaN)).toBe(false); }); }); describe("validateBackupDir", () => { it("should return true for valid relative paths", () => { expect(validateBackupDir(".fusion/backups")).toBe(true); expect(validateBackupDir("backups")).toBe(true); expect(validateBackupDir("data/backups/kb")).toBe(true); }); it("should return false for absolute paths", () => { expect(validateBackupDir("/absolute/path")).toBe(false); expect(validateBackupDir("/home/user/backups")).toBe(false); }); it("should return false for paths with parent traversal", () => { expect(validateBackupDir("../backups")).toBe(false); expect(validateBackupDir(".fusion/../backups")).toBe(false); expect(validateBackupDir("data/../../backups")).toBe(false); }); it("should return false for Windows absolute paths", () => { expect(validateBackupDir("C:\\backups")).toBe(false); expect(validateBackupDir("D:\\data\\backups")).toBe(false); }); }); describe("createBackupManager", () => { it("should create manager with default options when no settings provided", () => { const manager = createBackupManager("/tmp/.fusion"); expect(manager).toBeInstanceOf(BackupManager); }); it("should use settings when provided", async () => { // Use fake timers for deterministic time control vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-")); const kbDir = join(tempDir, ".fusion"); await mkdir(kbDir, { recursive: true }); writeFileSync(join(kbDir, "fusion.db"), "test"); const settings: Partial = { autoBackupDir: "custom/backups", autoBackupRetention: 2, }; const manager = createBackupManager(kbDir, settings); // Create 4 backups by advancing time for (let i = 0; i < 4; i++) { vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`)); await manager.createBackup(); } // Cleanup should leave only 2 const deleted = await manager.cleanupOldBackups(); expect(deleted).toBe(2); vi.useRealTimers(); await rm(tempDir, { recursive: true, force: true }); }); }); describe("runBackupCommand", () => { let tempDir: string; let kbDir: string; beforeEach(async () => { // Use fake timers for deterministic timestamp control vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-")); kbDir = join(tempDir, ".fusion"); await mkdir(kbDir, { recursive: true }); writeFileSync(join(kbDir, "fusion.db"), "dummy database content"); }); afterEach(async () => { vi.useRealTimers(); await rm(tempDir, { recursive: true, force: true }); }); it("should create backup regardless of autoBackupEnabled setting", async () => { const settings: ProjectSettings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, autoBackupEnabled: false, // Disabled, but should still work when called manually }; const result = await runBackupCommand(kbDir, settings); // Should succeed even when autoBackupEnabled is false expect(result.success).toBe(true); expect(result.backupPath).toBeDefined(); }); it("should create backup when enabled", async () => { const settings: ProjectSettings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, autoBackupEnabled: true, autoBackupRetention: 7, }; const result = await runBackupCommand(kbDir, settings); expect(result.success).toBe(true); expect(result.backupPath).toBeDefined(); expect(result.output).toContain("Backup created"); }); it("should return failure for invalid schedule", async () => { const settings: ProjectSettings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, autoBackupEnabled: true, autoBackupSchedule: "invalid-cron", }; const result = await runBackupCommand(kbDir, settings); expect(result.success).toBe(false); expect(result.output).toContain("Invalid backup schedule"); }); it("should cleanup old backups after creation", async () => { const settings: ProjectSettings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, autoBackupEnabled: true, autoBackupRetention: 2, }; // Create 3 backups first (manually to test cleanup) by advancing time const manager = createBackupManager(kbDir, settings); for (let i = 0; i < 3; i++) { vi.setSystemTime(new Date(`2026-01-01T00:00:0${i}.000Z`)); await manager.createBackup(); } // Now run backup command vi.setSystemTime(new Date("2026-01-01T00:00:03.000Z")); const result = await runBackupCommand(kbDir, settings); expect(result.success).toBe(true); expect(result.deletedCount).toBeGreaterThanOrEqual(1); }); it("should return failure when database file is missing", async () => { // Remove the database await rm(join(kbDir, "fusion.db")); const settings: ProjectSettings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, autoBackupEnabled: true, }; const result = await runBackupCommand(kbDir, settings); expect(result.success).toBe(false); expect(result.output).toContain("failed"); }); });