package appstate import ( "archive/zip" "os" "path/filepath" "strings" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) { temp := t.TempDir() dbPath := filepath.Join(temp, "qfs.db") cfgPath := filepath.Join(temp, "config.yaml") if err := writeTestSQLiteDB(dbPath); err != nil { t.Fatalf("write sqlite db: %v", err) } if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { t.Fatalf("write config: %v", err) } prevNow := backupNow defer func() { backupNow = prevNow }() backupNow = func() time.Time { return time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) } created, err := EnsureRotatingLocalBackup(dbPath, cfgPath) if err != nil { t.Fatalf("backup: %v", err) } if len(created) == 0 { t.Fatalf("expected backup to be created") } dailyArchive := filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-11.zip") if _, err := os.Stat(dailyArchive); err != nil { t.Fatalf("daily archive missing: %v", err) } assertZipContains(t, dailyArchive, "qfs.db", "config.yaml") backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) } created, err = EnsureRotatingLocalBackup(dbPath, cfgPath) if err != nil { t.Fatalf("backup rotate: %v", err) } if len(created) == 0 { t.Fatalf("expected backup to be created for new day") } dailyArchive = filepath.Join(temp, "backups", "daily", "qfs-backp-2026-02-12.zip") if _, err := os.Stat(dailyArchive); err != nil { t.Fatalf("daily archive missing after rotate: %v", err) } } func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) { temp := t.TempDir() dbPath := filepath.Join(temp, "qfs.db") cfgPath := filepath.Join(temp, "config.yaml") if err := writeTestSQLiteDB(dbPath); err != nil { t.Fatalf("write sqlite db: %v", err) } if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { t.Fatalf("write config: %v", err) } backupRoot := filepath.Join(temp, "custom_backups") t.Setenv(envBackupDir, backupRoot) if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil { t.Fatalf("backup with env: %v", err) } if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil { t.Fatalf("expected backup in custom dir: %v", err) } t.Setenv(envBackupDisable, "1") if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil { t.Fatalf("backup disabled: %v", err) } if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil { t.Fatalf("backup should remain from previous run: %v", err) } } func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) { temp := t.TempDir() repoRoot := filepath.Join(temp, "repo") if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0755); err != nil { t.Fatalf("mkdir git dir: %v", err) } dbPath := filepath.Join(repoRoot, "data", "qfs.db") cfgPath := filepath.Join(repoRoot, "data", "config.yaml") if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { t.Fatalf("mkdir data dir: %v", err) } if err := writeTestSQLiteDB(dbPath); err != nil { t.Fatalf("write sqlite db: %v", err) } if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { t.Fatalf("write cfg: %v", err) } _, err := EnsureRotatingLocalBackup(dbPath, cfgPath) if err == nil { t.Fatal("expected git worktree backup root to be rejected") } if !strings.Contains(err.Error(), "outside git worktree") { t.Fatalf("unexpected error: %v", err) } } func writeTestSQLiteDB(path string) error { db, err := gorm.Open(sqlite.Open(path), &gorm.Config{}) if err != nil { return err } sqlDB, err := db.DB() if err != nil { return err } defer sqlDB.Close() return db.Exec(` CREATE TABLE sample_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL ); INSERT INTO sample_items(name) VALUES ('backup'); `).Error } func assertZipContains(t *testing.T, archivePath string, expected ...string) { t.Helper() reader, err := zip.OpenReader(archivePath) if err != nil { t.Fatalf("open archive: %v", err) } defer reader.Close() found := make(map[string]bool, len(reader.File)) for _, file := range reader.File { found[file.Name] = true } for _, name := range expected { if !found[name] { t.Fatalf("archive %s missing %s", archivePath, name) } } }