Implements the Pansy v1 scaffold (epic #20, phase 0). #1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config, gin server with slog + recovery + /api/v1/healthz, modernc SQLite store with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration runner, full initial schema (users, sessions, gardens, garden_shares, garden_objects, plants, plantings), domain structs + sentinel errors, graceful shutdown. #2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack Router/Query, typed /api/v1 fetch wrapper (ApiError carries status + body so later issues can read 409 conflict rows), dev proxy /api -> :8080, responsive app shell with stub pages for all five routes. #3 Single binary: //go:embed of the web build with a committed placeholder, SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched /api and missing assets), Makefile (web/build/dev/test), README quickstart + env var table. Verified: go build/vet/test clean (CGO off); binary migrates idempotently and serves healthz; web tsc + build clean; integrated binary serves the SPA, deep links, and correct cache headers; app mounts and navigates all five routes at desktop and 375px widths. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
123 lines
3.5 KiB
Go
123 lines
3.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
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 != 1 {
|
|
t.Errorf("schema version = %d, want 1", version)
|
|
}
|
|
}
|
|
|
|
func TestMigrateIsIdempotent(t *testing.T) {
|
|
db := openTestDB(t)
|
|
|
|
// A second Migrate must be a no-op and leave exactly one recorded version.
|
|
if err := db.Migrate(context.Background()); err != nil {
|
|
t.Fatalf("second Migrate: %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 != 1 {
|
|
t.Errorf("schema_migrations rows = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
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 ('[email protected]', '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 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)
|
|
}
|
|
}
|