package dbsnap import ( "context" "database/sql" "errors" "fmt" "io" "log" "os" "path/filepath" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) func newStore(t *testing.T) (*store.Store, string) { t.Helper() p := filepath.Join(t.TempDir(), "hub.db") s, err := store.New(p, log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { s.Close() }) return s, p } func openRO(t *testing.T, p string) *sql.DB { t.Helper() db, err := sql.Open("sqlite", "file:"+p+"?mode=ro") if err != nil { t.Fatalf("open %s: %v", p, err) } t.Cleanup(func() { db.Close() }) return db } // rowCounts returns count(*) of every table. func rowCounts(t *testing.T, db *sql.DB) map[string]int { t.Helper() rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) if err != nil { t.Fatalf("list tables: %v", err) } var names []string for rows.Next() { var n string _ = rows.Scan(&n) names = append(names, n) } rows.Close() out := map[string]int{} for _, n := range names { var c int if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM "%s"`, n)).Scan(&c); err != nil { t.Fatalf("count %s: %v", n, err) } out[n] = c } return out } // TestSnapshot_ConsistentWithLiveDBIncludingWAL is the consequence test: the snapshot passes integrity_check and has // the live database's row count in EVERY table — including rows that so far live only in hub.db-wal. Precondition // asserted in-test: copying hub.db alone (the naive file backup) LOSES those rows, so this test can tell the two apart. func TestSnapshot_ConsistentWithLiveDBIncludingWAL(t *testing.T) { s, live := newStore(t) for i := 0; i < 150; i++ { if err := s.UpsertHost(&store.Host{HostID: fmt.Sprintf("h%03d", i), CustomerID: "c1", APIKey: fmt.Sprintf("k%03d", i)}); err != nil { t.Fatalf("UpsertHost: %v", err) } } if fi, err := os.Stat(live + "-wal"); err != nil || fi.Size() == 0 { t.Fatalf("precondition: want a non-empty WAL, got %v", err) } // Precondition: a plain copy of hub.db does not hold the WAL rows. naive := filepath.Join(t.TempDir(), "naive.db") b, _ := os.ReadFile(live) _ = os.WriteFile(naive, b, 0o600) if c := rowCounts(t, openRO(t, naive))["hosts"]; c == 150 { t.Fatalf("precondition: a plain file copy already holds all 150 hosts — the test cannot tell a WAL-blind copy apart") } dir := filepath.Join(t.TempDir(), "snapshots") m := &Maker{Store: s, Dir: dir} res, err := m.Make(context.Background()) if err != nil { t.Fatalf("Make: %v", err) } snap := openRO(t, filepath.Join(dir, res.Name)) var ic string if err := snap.QueryRow(`PRAGMA integrity_check`).Scan(&ic); err != nil || ic != "ok" { t.Fatalf("integrity_check = %q, %v", ic, err) } want, got := rowCounts(t, openRO(t, live)), rowCounts(t, snap) if len(want) < 10 || want["hosts"] != 150 { t.Fatalf("live counts look wrong: %d tables, hosts=%d", len(want), want["hosts"]) } for tbl, n := range want { if got[tbl] != n { t.Errorf("table %s: snapshot %d rows, live %d", tbl, got[tbl], n) } } if fi, _ := os.Stat(filepath.Join(dir, res.Name)); fi == nil || fi.Mode().Perm() != 0o600 || res.Bytes != fi.Size() { t.Errorf("snapshot file mode/size wrong: %+v res.Bytes=%d", fi, res.Bytes) } if _, err := os.Stat(filepath.Join(dir, res.Name+".tmp")); !os.IsNotExist(err) { t.Errorf("the .tmp file is left behind") } } // TestSnapshot_KeepsNewestTwo pins Keep: three runs leave the two newest. func TestSnapshot_KeepsNewestTwo(t *testing.T) { s, _ := newStore(t) dir := filepath.Join(t.TempDir(), "snapshots") base := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) i := 0 m := &Maker{Store: s, Dir: dir, Now: func() time.Time { i++; return base.Add(time.Duration(i) * 24 * time.Hour) }} for k := 0; k < 3; k++ { if _, err := m.Make(context.Background()); err != nil { t.Fatalf("Make %d: %v", k, err) } } got := List(dir) want := []string{"hub-20261007T000000Z.db", "hub-20261008T000000Z.db"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("kept %v, want %v", got, want) } } type blockingStore struct { in, release chan struct{} once *sync.Once } func (b blockingStore) SnapshotInto(ctx context.Context, path string) error { b.once.Do(func() { close(b.in) }) <-b.release return os.WriteFile(path, []byte("x"), 0o600) } // TestSnapshot_NeverTwoAtOnce: a second Make while the first runs returns ErrBusy and writes nothing. func TestSnapshot_NeverTwoAtOnce(t *testing.T) { bs := blockingStore{in: make(chan struct{}), release: make(chan struct{}), once: &sync.Once{}} dir := t.TempDir() m := &Maker{Store: bs, Dir: dir} done := make(chan error, 1) go func() { _, err := m.Make(context.Background()); done <- err }() <-bs.in second := make(chan error, 1) go func() { _, err := m.Make(context.Background()); second <- err }() select { case err := <-second: if !errors.Is(err, ErrBusy) { t.Fatalf("second Make = %v, want ErrBusy", err) } case <-time.After(2 * time.Second): close(bs.release) t.Fatalf("second Make did not return ErrBusy at once: it ran alongside the first") } close(bs.release) if err := <-done; err != nil { t.Fatalf("first Make: %v", err) } if n := len(List(dir)); n != 1 { t.Fatalf("%d snapshots, want 1", n) } m.Now = func() time.Time { return time.Now().Add(time.Hour) } // a distinct name if _, err := m.Make(context.Background()); err != nil { t.Fatalf("Make after the first ended: %v", err) } } type failStore struct{} func (failStore) SnapshotInto(ctx context.Context, path string) error { _ = os.WriteFile(path, []byte("half"), 0o600) return errors.New("disk full") } // TestSnapshot_FailureLeavesNoFile: a failed VACUUM INTO leaves neither a snapshot nor a .tmp for DooPlex to pick up. func TestSnapshot_FailureLeavesNoFile(t *testing.T) { dir := t.TempDir() if _, err := (&Maker{Store: failStore{}, Dir: dir}).Make(context.Background()); err == nil { t.Fatal("Make succeeded over a failing store") } if e, _ := os.ReadDir(dir); len(e) != 0 { t.Fatalf("left %d file(s) behind", len(e)) } } func TestNeedsCatchUp(t *testing.T) { dir := t.TempDir() now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) if !NeedsCatchUp(dir, now, 24*time.Hour) { t.Error("empty dir: want catch-up") } _ = os.WriteFile(filepath.Join(dir, "hub-20261005T000000Z.db"), nil, 0o600) if NeedsCatchUp(dir, now, 24*time.Hour) { t.Error("12 h old: want no catch-up") } if !NeedsCatchUp(dir, now.Add(13*time.Hour), 24*time.Hour) { t.Error("25 h old: want catch-up") } }