Plant catalog backend: CRUD + seeded built-ins (#12)
Build image / build-and-push (push) Successful in 5s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #31.
This commit is contained in:
2026-07-19 01:48:22 +00:00
committed by steve
parent b79bfcf7a9
commit 293532f021
11 changed files with 912 additions and 4 deletions
+56 -4
View File
@@ -2,9 +2,12 @@ package store
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// openTestDB opens a fresh file-backed DB in a temp dir and migrates it.
@@ -38,12 +41,19 @@ func TestMigrateCreatesSchema(t *testing.T) {
}
}
// 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 != 2 {
t.Errorf("schema version = %d, want 2", version)
if version != wantVersion {
t.Errorf("schema version = %d, want %d", version, wantVersion)
}
}
@@ -54,12 +64,16 @@ func TestMigrateIsIdempotent(t *testing.T) {
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 != 2 {
t.Errorf("schema_migrations rows = %d, want 2", count)
if count != len(migs) {
t.Errorf("schema_migrations rows = %d, want %d", count, len(migs))
}
}
@@ -77,6 +91,44 @@ func TestForeignKeysEnforced(t *testing.T) {
}
}
func TestDeletePlantForeignKeyBackstop(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
// Seed a user → garden → object → custom plant, then a planting referencing
// the plant. DeletePlant must surface the ON DELETE RESTRICT FK as
// ErrPlantInUse (not a raw 500-worthy error) — this pins isForeignKeyViolation's
// string-match so a driver-message change is caught here rather than in prod.
// The service normally blocks this via a count pre-check; here we hit the
// store backstop directly (the lost-race path).
insert := func(q string, args ...any) int64 {
res, err := db.SQL().ExecContext(ctx, q, args...)
if err != nil {
t.Fatalf("seed exec %q: %v", q, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
return id
}
uid := insert(`INSERT INTO users (email, display_name) VALUES ('[email protected]', 'A')`)
gid := insert(`INSERT INTO gardens (owner_id, name, width_cm, height_cm) VALUES (?, 'G', 100, 100)`, uid)
oid := insert(`INSERT INTO garden_objects (garden_id, kind, x_cm, y_cm, width_cm, height_cm) VALUES (?, 'bed', 0, 0, 10, 10)`, gid)
pid := insert(`INSERT INTO plants (owner_id, name, category, spacing_cm, color, icon) VALUES (?, 'Basil', 'herb', 25, '#4a7c3f', '🌿')`, uid)
insert(`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (?, ?, 0, 0, 10)`, oid, pid)
if err := db.DeletePlant(ctx, pid); !errors.Is(err, domain.ErrPlantInUse) {
t.Fatalf("DeletePlant on referenced plant = %v, want ErrPlantInUse", err)
}
// Clearing the reference lets the delete succeed.
insert(`DELETE FROM plantings WHERE plant_id = ?`, pid)
if err := db.DeletePlant(ctx, pid); err != nil {
t.Errorf("DeletePlant after clearing reference: %v", err)
}
}
func TestCheckConstraintRejectsBadEnum(t *testing.T) {
db := openTestDB(t)