go-bananas/internal/store/backup_test.go

186 lines
5.9 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
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)
}
}