package backup import ( "context" "os" "path/filepath" "testing" "time" "jarl/internal/store" ) func newDB(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "jarl.db") st, err := store.Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) return path } func TestWriteVerifyInspect(t *testing.T) { ctx := context.Background() src := newDB(t) dir := filepath.Join(t.TempDir(), "backups") now := time.Date(2026, 10, 8, 3, 15, 12, 0, time.UTC) f, err := Write(ctx, src, dir, KindJarl, now) if err != nil { t.Fatal(err) } if filepath.Base(f.Path) != "jarl-20261008T031512Z.db" || f.Size == 0 { t.Errorf("backup = %+v", f) } if fi, _ := os.Stat(f.Path); fi.Mode().Perm() != 0o600 { t.Errorf("mode %v", fi.Mode()) } if err := Verify(ctx, f.Path); err != nil { t.Error(err) } in, err := Inspect(ctx, f.Path) if err != nil || in.Schema != store.SchemaVersion() { t.Errorf("inspect: %+v %v", in, err) } // The same second again is refused rather than overwritten. if _, err := Write(ctx, src, dir, KindJarl, now); err == nil { t.Error("overwrote a backup") } // No temporary files are left. ents, _ := os.ReadDir(dir) if len(ents) != 1 { t.Errorf("files: %v", ents) } // A missing database is an error, and isn't created. missing := filepath.Join(t.TempDir(), "nope.db") if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second)); err == nil { t.Error("backed up a missing database") } if _, err := os.Stat(missing); err == nil { t.Error("created the missing database") } } func TestVerifyCorrupt(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "bad.db") os.WriteFile(path, []byte("SQLite format 3\x00 but not really a database, just text that goes on"), 0o600) if err := Verify(ctx, path); err == nil { t.Error("a corrupt file passed") } if _, err := Inspect(ctx, path); err == nil { t.Error("inspected a corrupt file") } } func TestPrune(t *testing.T) { dir := t.TempDir() now := time.Date(2026, 10, 8, 3, 0, 0, 0, time.UTC) touch := func(name string) { os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600) } // Daily backups from 0 to 39 days old, plus files that aren't ours. for d := 0; d < 40; d++ { touch(Name(KindJarl, now.Add(-time.Duration(d)*24*time.Hour))) } touch(Name(KindStats, now.Add(-100*24*time.Hour))) touch("jarl-before-upgrade.db") touch("jarl-20200101T000000Z.db.bak") touch("notes.txt") removed, err := Prune(dir, KindJarl, now, 30*24*time.Hour, 7) if err != nil { t.Fatal(err) } if len(removed) != 9 { // 31..39 days old t.Errorf("removed %d: %v", len(removed), removed) } files, _ := List(dir) jarl := 0 for _, f := range files { if f.Kind == KindJarl { jarl++ if now.Sub(f.Time) > 30*24*time.Hour { t.Errorf("kept %s", f.Path) } } } if jarl != 31 { t.Errorf("%d jarl backups left", jarl) } for _, name := range []string{"jarl-before-upgrade.db", "jarl-20200101T000000Z.db.bak", "notes.txt", Name(KindStats, now.Add(-100*24*time.Hour))} { if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Errorf("%s was deleted", name) } } // After a long outage every backup is old: the newest keepAtLeast stay. removed, _ = Prune(dir, KindJarl, now.Add(365*24*time.Hour), 30*24*time.Hour, 7) files, _ = List(dir) n := 0 for _, f := range files { if f.Kind == KindJarl { n++ } } if n != 7 || len(removed) != 24 { t.Errorf("after a year: %d left, %d removed", n, len(removed)) } // Retention 0 keeps everything. if removed, _ := Prune(dir, KindJarl, now.Add(3650*24*time.Hour), 0, 1); len(removed) != 0 { t.Errorf("retention 0 removed %v", removed) } } func TestPruneTempFiles(t *testing.T) { dir := t.TempDir() now := time.Now() old := filepath.Join(dir, "."+Name(KindJarl, now.Add(-48*time.Hour))+".tmp") fresh := filepath.Join(dir, "."+Name(KindJarl, now)+".tmp") os.WriteFile(old, nil, 0o600) os.WriteFile(fresh, nil, 0o600) os.Chtimes(old, now.Add(-48*time.Hour), now.Add(-48*time.Hour)) Prune(dir, KindJarl, now, 24*time.Hour, 1) if _, err := os.Stat(old); err == nil { t.Error("old temporary file kept") } if _, err := os.Stat(fresh); err != nil { t.Error("a running backup's temporary file was deleted") } }