package store_test import ( "context" "errors" "os" "path/filepath" "strconv" "testing" "time" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/store" ) // openTemp opens a file-backed store in a temporary directory, which is the only // kind worth snapshotting: an in-memory database has nothing to preserve. func openTemp(t *testing.T) *store.Store { t.Helper() path := filepath.Join(t.TempDir(), "live.db") st, err := store.Open(context.Background(), store.Options{Path: path}) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = st.Close() }) return st } // TestSnapshotProducesAReadableDatabase is the property the backup exists for: the // file that comes out must be a database, not merely a file with the right size. func TestSnapshotProducesAReadableDatabase(t *testing.T) { st := openTemp(t) dest := filepath.Join(t.TempDir(), "snapshots", "go-bananas.db") if err := st.Snapshot(context.Background(), dest); err != nil { t.Fatalf("Snapshot: %v", err) } info, err := os.Stat(dest) if err != nil { t.Fatalf("stat snapshot: %v", err) } if info.Size() == 0 { t.Fatal("snapshot is empty") } // Reopening is the check that matters. A truncated or torn copy often fails // here rather than at the point of use, which is exactly the failure a restore // would hit at the worst possible moment. reopened, err := store.Open(context.Background(), store.Options{Path: dest, ReadOnly: true}) if err != nil { t.Fatalf("snapshot is not a readable database: %v", err) } defer func() { _ = reopened.Close() }() // The schema must have come across, not just the bytes: an empty database with // no tables would also open cleanly. var count int if err := reopened.DB().QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_master WHERE type = 'table'").Scan(&count); err != nil { t.Fatalf("query snapshot schema: %v", err) } if count == 0 { t.Error("snapshot has no tables; the schema did not come across") } } // TestSnapshotRefusesToOverwrite guards the failure this feature is easiest to get // wrong: a snapshot that silently overwrites the previous good one turns a failed // run into two data-loss events instead of one. func TestSnapshotRefusesToOverwrite(t *testing.T) { st := openTemp(t) dest := filepath.Join(t.TempDir(), "snap.db") if err := st.Snapshot(context.Background(), dest); err != nil { t.Fatalf("first snapshot: %v", err) } first, err := os.Stat(dest) if err != nil { t.Fatalf("stat: %v", err) } if err := st.Snapshot(context.Background(), dest); err == nil { t.Fatal("expected the second snapshot to be refused") } second, err := os.Stat(dest) if err != nil { t.Fatalf("stat: %v", err) } if !first.ModTime().Equal(second.ModTime()) { t.Error("the existing snapshot was modified by the refused write") } } // TestSnapshotCreatesTheParentDirectory covers the manual-invocation path, where // the operator names a directory that does not exist yet. func TestSnapshotCreatesTheParentDirectory(t *testing.T) { st := openTemp(t) base := t.TempDir() dest := filepath.Join(base, "a", "b", "c", "snap.db") if err := st.Snapshot(context.Background(), dest); err != nil { t.Fatalf("Snapshot: %v", err) } if _, err := os.Stat(dest); err != nil { t.Errorf("snapshot not written: %v", err) } } // TestSnapshotRejectsEmptyDestination checks the argument validation rather than // letting SQLite interpret an empty filename as the current directory. func TestSnapshotRejectsEmptyDestination(t *testing.T) { st := openTemp(t) if err := st.Snapshot(context.Background(), " "); err == nil { t.Error("expected an error for a blank destination") } else if !errors.Is(err, domain.ErrInvalid) { t.Errorf("got %v, want an invalid-input error", err) } } // TestSnapshotEscapesQuotesInThePath guards the SQL-literal construction. A path // containing a single quote must not be able to terminate the literal and append // statements of its own. func TestSnapshotEscapesQuotesInThePath(t *testing.T) { st := openTemp(t) dest := filepath.Join(t.TempDir(), "it's a snap.db") if err := st.Snapshot(context.Background(), dest); err != nil { t.Fatalf("Snapshot: %v", err) } if _, err := os.Stat(dest); err != nil { t.Errorf("snapshot not written to the quoted path: %v", err) } } // TestSnapshotOfAnInMemoryDatabaseIsRefused documents that the store-level guard // exists, rather than leaving it to the command line. func TestIsPersistentPath(t *testing.T) { for _, path := range []string{":memory:", "file::memory:?cache=shared", "file:x?mode=memory"} { if store.IsPersistentPath(path) { t.Errorf("%q should be reported as ephemeral", path) } } for _, path := range []string{"/srv/data/go-bananas.db", "./data/db.sqlite"} { if !store.IsPersistentPath(path) { t.Errorf("%q should be reported as persistent", path) } } } // TestSnapshotUnderLoadStaysConsistent is a smoke test for the reason VACUUM INTO // is used rather than a file copy: writing while the snapshot is taken must not // corrupt the result. func TestSnapshotUnderLoadStaysConsistent(t *testing.T) { st := openTemp(t) ctx := context.Background() done := make(chan struct{}) go func() { defer close(done) for i := 0; i < 20; i++ { if _, err := st.DB().ExecContext(ctx, `INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)`, "concurrent.key", strconv.Itoa(i), time.Now().UTC().Format(time.RFC3339Nano), "test"); err != nil { return } } }() dest := filepath.Join(t.TempDir(), "under-load.db") if err := st.Snapshot(ctx, dest); err != nil { t.Fatalf("Snapshot during writes: %v", err) } <-done reopened, err := store.Open(ctx, store.Options{Path: dest, ReadOnly: true}) if err != nil { t.Fatalf("snapshot taken during writes is unreadable: %v", err) } defer func() { _ = reopened.Close() }() if err := reopened.DB().PingContext(ctx); err != nil { t.Errorf("snapshot failed an integrity check: %v", err) } }