Plant catalog backend: CRUD + seeded built-ins (#12) #31

Merged
steve merged 2 commits from phase-4-plants-api into main 2026-07-19 01:48:23 +00:00
2 changed files with 46 additions and 5 deletions
Showing only changes of commit 02e66f462f - Show all commits
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
func intPtr(n int) *int { return &n }
func ptrInt(n int) *int { return &n }
Outdated
Review

🟡 Duplicate pointer helper intPtr should be consolidated with existing ptrBool/strPtr in same package

maintainability · flagged by 2 models

  • internal/service/plants_test.go:12intPtr duplicates the pointer-helper pattern already present in internal/service/objects_test.go (ptrBool, strPtr). Three tiny one-liners are now scattered across test files in the same package with inconsistent naming (ptrBool vs strPtr vs intPtr). This makes the helpers hard to discover and invites further duplication. Fix: consolidate them into a shared helpers_test.go in internal/service/ with a consistent scheme (e.g. `ptrBo…

🪰 Gadfly · advisory

🟡 **Duplicate pointer helper intPtr should be consolidated with existing ptrBool/strPtr in same package** _maintainability · flagged by 2 models_ - **`internal/service/plants_test.go:12`** — `intPtr` duplicates the pointer-helper pattern already present in `internal/service/objects_test.go` (`ptrBool`, `strPtr`). Three tiny one-liners are now scattered across test files in the same package with inconsistent naming (`ptrBool` vs `strPtr` vs `intPtr`). This makes the helpers hard to discover and invites further duplication. **Fix:** consolidate them into a shared `helpers_test.go` in `internal/service/` with a consistent scheme (e.g. `ptrBo… <sub>🪰 Gadfly · advisory</sub>
// validPlant is a well-formed PlantInput for tests to tweak.
func validPlant(name string) PlantInput {
@@ -53,7 +53,7 @@ func TestCreatePlantIsOwnedAndTrimmed(t *testing.T) {
owner := seedUser(t, s, "[email protected]")
in := validPlant(" Purple basil ")
in.DaysToMaturity = intPtr(60)
in.DaysToMaturity = ptrInt(60)
p, err := s.CreatePlant(context.Background(), owner, in)
if err != nil {
t.Fatalf("CreatePlant: %v", err)
@@ -147,7 +147,7 @@ func TestUpdatePlantClearsDays(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
in := validPlant("Dill")
in.DaysToMaturity = intPtr(50)
in.DaysToMaturity = ptrInt(50)
p, _ := s.CreatePlant(context.Background(), owner, in)
// SetDays with nil clears; absent (SetDays=false) would leave it.
@@ -191,7 +191,7 @@ func TestCreatePlantValidation(t *testing.T) {
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "nope", Icon: "🌿"}, // bad color
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: ""}, // empty icon
{Name: strings.Repeat("x", maxPlantNameLen+1), Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // name too long
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿", DaysToMaturity: intPtr(0)}, // days must be >= 1
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿", DaysToMaturity: ptrInt(0)}, // days must be >= 1
}
for i, in := range bad {
if _, err := s.CreatePlant(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) {
@@ -202,7 +202,7 @@ func TestCreatePlantValidation(t *testing.T) {
// A shorthand hex (#rgb) and a set days value pass.
ok := validPlant("Thyme")
ok.Color = "#0a0"
ok.DaysToMaturity = intPtr(90)
ok.DaysToMaturity = ptrInt(90)
if _, err := s.CreatePlant(context.Background(), owner, ok); err != nil {
t.Errorf("valid plant rejected: %v", err)
}
+41
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.
@@ -88,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)