- internal/backup: weekly (configurable) snapshots via the existing VACUUM INTO + blob-strip path; restart-safe (only backs up if newest is older than the interval), retains BACKUP_KEEP most recent, prunes the rest. Runs in a background goroutine; failures are logged, never fatal. - Config: BACKUP_DIR (./data/backups), BACKUP_INTERVAL (168h; 0 disables), BACKUP_KEEP (8). - Fix: blob-strip now clears attachments.image_data too, not just receipts. - Fix: STORAGE_DIR is the storage ROOT (default ./data) — receipts/ and attachments/ live under it; corrects the doubled-nesting from item 10. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
2.7 KiB
Go
100 lines
2.7 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeSnap writes a tiny file so backup files exist on disk for the tests, and
|
|
// records how many times it ran.
|
|
type fakeSnap struct{ calls int }
|
|
|
|
func (f *fakeSnap) SnapshotTo(dest string, includeBlobs bool) error {
|
|
f.calls++
|
|
return os.WriteFile(dest, []byte("snap"), 0o600)
|
|
}
|
|
|
|
func TestWriteBackupAndNewest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := &fakeSnap{}
|
|
now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC)
|
|
|
|
if err := writeBackup(s, dir, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files := backupFiles(dir)
|
|
if len(files) != 1 || files[0] != "hsa-backup-2026-06-10-090000.db" {
|
|
t.Fatalf("files = %v", files)
|
|
}
|
|
if got := newestBackup(dir); !got.Equal(now) {
|
|
t.Errorf("newestBackup = %v, want %v", got, now)
|
|
}
|
|
}
|
|
|
|
func TestUntilNext(t *testing.T) {
|
|
dir := t.TempDir()
|
|
every := 168 * time.Hour
|
|
now := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC)
|
|
|
|
// No backups yet → back up immediately.
|
|
if d := untilNext(dir, every, now); d != 0 {
|
|
t.Errorf("empty dir: untilNext = %v, want 0", d)
|
|
}
|
|
|
|
// A fresh backup → wait nearly the full interval.
|
|
_ = writeBackup(&fakeSnap{}, dir, now.Add(-1*time.Hour))
|
|
if d := untilNext(dir, every, now); d <= 0 || d > every {
|
|
t.Errorf("fresh backup: untilNext = %v, want ~167h", d)
|
|
}
|
|
|
|
// An old backup → back up now.
|
|
dir2 := t.TempDir()
|
|
_ = writeBackup(&fakeSnap{}, dir2, now.Add(-200*time.Hour))
|
|
if d := untilNext(dir2, every, now); d != 0 {
|
|
t.Errorf("stale backup: untilNext = %v, want 0", d)
|
|
}
|
|
}
|
|
|
|
func TestPruneKeepsMostRecent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := &fakeSnap{}
|
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
for i := 0; i < 5; i++ {
|
|
_ = writeBackup(s, dir, base.AddDate(0, 0, i))
|
|
}
|
|
if err := prune(dir, 2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files := backupFiles(dir)
|
|
if len(files) != 2 {
|
|
t.Fatalf("after prune: %v", files)
|
|
}
|
|
// The two newest (Jan 04, Jan 05) survive.
|
|
if !strings.Contains(files[0], "2026-01-04") || !strings.Contains(files[1], "2026-01-05") {
|
|
t.Errorf("wrong files kept: %v", files)
|
|
}
|
|
}
|
|
|
|
func TestScheduleRunsOnceThenStops(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := &fakeSnap{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
// A real store would be passed in production; here the fake is enough.
|
|
go Schedule(ctx, s, dir, time.Hour, 4, func() time.Time {
|
|
return time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC)
|
|
})
|
|
// Wait for the immediate first backup to land.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(filepath.Join(dir, "hsa-backup-2026-06-10-090000.db")); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("Schedule did not write an initial backup")
|
|
}
|