Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
185 lines
5.9 KiB
Go
185 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|