Files
fusion/packages/core/src/backup.test.ts
gsxdsm 32c5b30648 refactor(FN-846): rename filename prefixes from kb to fusion
- Switch settings export filename prefix from 'kb' to 'fusion' (e.g. fusion-settings-*.json)
- Switch backup filename prefix from 'kb' to 'fusion' with legacy-safe parsing for existing kb-* backups
- Remove unused ntfy deep-link code and dashboard routing for /project/:id
- Remove dead notifier tests and App test assertions for removed features
- Clean up dashboard command and bin.ts unused variables
- Add changeset for the published package rename
2026-04-03 21:39:33 -07:00

509 lines
18 KiB
TypeScript

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<ProjectSettings> = {
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");
});
});