package store import ( "context" "path/filepath" "strings" "testing" ) // openTestDB opens a fresh file-backed DB in a temp dir and migrates it. func openTestDB(t *testing.T) *DB { t.Helper() db, err := Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { db.Close() }) if err := db.Migrate(context.Background()); err != nil { t.Fatalf("Migrate: %v", err) } return db } func TestMigrateCreatesSchema(t *testing.T) { db := openTestDB(t) want := []string{ "users", "sessions", "gardens", "garden_shares", "garden_objects", "plants", "plantings", "schema_migrations", } for _, table := range want { var name string err := db.SQL().QueryRow( `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table, ).Scan(&name) if err != nil { t.Errorf("expected table %q to exist: %v", table, err) } } // Derive the expected head version from the embedded files so adding a // migration doesn't break this test. migs, err := loadMigrations() if err != nil { t.Fatalf("loadMigrations: %v", err) } wantVersion := migs[len(migs)-1].version var version int if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil { t.Fatalf("read schema_migrations: %v", err) } if version != wantVersion { t.Errorf("schema version = %d, want %d", version, wantVersion) } } func TestMigrateIsIdempotent(t *testing.T) { db := openTestDB(t) // A second Migrate must be a no-op and leave exactly one row per migration. if err := db.Migrate(context.Background()); err != nil { t.Fatalf("second Migrate: %v", err) } migs, err := loadMigrations() if err != nil { t.Fatalf("loadMigrations: %v", err) } var count int if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil { t.Fatalf("count schema_migrations: %v", err) } if count != len(migs) { t.Errorf("schema_migrations rows = %d, want %d", count, len(migs)) } } func TestForeignKeysEnforced(t *testing.T) { db := openTestDB(t) // The DSN sets foreign_keys(1) per connection; a planting referencing a // nonexistent object must be rejected. This guards the pragma actually // reaching the app's pooled connections, not just the sqlite3 CLI. _, err := db.SQL().Exec( `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`, ) if err == nil { t.Fatal("expected foreign-key violation, got nil") } } func TestCheckConstraintRejectsBadEnum(t *testing.T) { db := openTestDB(t) // Seed a user + garden so the FK is satisfied and only the CHECK can fail. res, err := db.SQL().Exec( `INSERT INTO users (email, display_name) VALUES ('a@b.c', 'A')`) if err != nil { t.Fatalf("insert user: %v", err) } uid, _ := res.LastInsertId() res, err = db.SQL().Exec( `INSERT INTO gardens (owner_id, name, width_cm, height_cm) VALUES (?, 'G', 100, 100)`, uid) if err != nil { t.Fatalf("insert garden: %v", err) } gid, _ := res.LastInsertId() // kind='invalid' violates the CHECK constraint. _, err = db.SQL().Exec( `INSERT INTO garden_objects (garden_id, kind, x_cm, y_cm, width_cm, height_cm) VALUES (?, 'invalid', 0, 0, 10, 10)`, gid) if err == nil { t.Fatal("expected CHECK constraint violation for bad kind, got nil") } } func TestForeignKeysEnforcedForFileURIDSN(t *testing.T) { // A file: URI (or any path with query params) must still get foreign_keys(1): // the earlier passthrough silently dropped the pragmas for these DSNs. p := filepath.Join(t.TempDir(), "uri.db") db, err := Open("file:" + p + "?_pragma=synchronous(1)") if err != nil { t.Fatalf("Open file: URI: %v", err) } t.Cleanup(func() { db.Close() }) if err := db.Migrate(context.Background()); err != nil { t.Fatalf("Migrate: %v", err) } _, err = db.SQL().Exec( `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`, ) if err == nil { t.Fatal("expected foreign-key violation for file: URI DSN, got nil") } } func TestBuildDSNAlwaysIncludesPragmas(t *testing.T) { for _, in := range []string{ "./pansy.db", "/data/pansy.db", "file:/data/pansy.db", "file:/data/pansy.db?cache=shared", "/tmp/weird?name.db", } { dsn, _ := buildDSN(in) for _, want := range []string{"busy_timeout", "journal_mode", "foreign_keys"} { if !strings.Contains(dsn, want) { t.Errorf("buildDSN(%q) = %q, missing %s pragma", in, dsn, want) } } } } func TestBuildDSNDoesNotDuplicateUserPragma(t *testing.T) { // A user-supplied busy_timeout must not be duplicated by our default. dsn, _ := buildDSN("file:/data/x.db?_pragma=busy_timeout(9000)") if strings.Count(dsn, "busy_timeout") != 1 { t.Errorf("buildDSN kept both user and default busy_timeout: %q", dsn) } } func TestBuildDSNMemoryDetection(t *testing.T) { if _, mem := buildDSN(":memory:"); !mem { t.Error(":memory: not detected as in-memory") } if _, mem := buildDSN("file:x.db?mode=memory"); !mem { t.Error("mode=memory not detected as in-memory") } if _, mem := buildDSN("/var/lib/pansy/pansy.db"); mem { t.Error("file path wrongly detected as in-memory") } } func TestInMemoryDBIsCoherentAcrossQueries(t *testing.T) { // A bare ":memory:" DSN gives each connection its own DB; Open must pin the // pool so the migrated schema is visible to subsequent queries. db, err := Open(":memory:") if err != nil { t.Fatalf("Open(:memory:): %v", err) } defer db.Close() if err := db.Migrate(context.Background()); err != nil { t.Fatalf("Migrate: %v", err) } var name string if err := db.SQL().QueryRow( `SELECT name FROM sqlite_master WHERE type='table' AND name='gardens'`, ).Scan(&name); err != nil { t.Fatalf("in-memory schema not visible: %v", err) } }