Add garden objects backend: polymorphic CRUD + /full (#10)
Garden objects (beds, bags, containers, in-ground, trees, paths, structures) in one polymorphic table, following the #7 service conventions. - store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden (z_index order), version-guarded Update (RETURNING, returns current row on conflict), Delete (plantings cascade via FK). - store/plantings.go + plants.go: the read side /full needs now — ListActivePlantingsForGarden (removed_at IS NULL) and ListReferencedPlants (distinct plants used by active plantings). Both return empty until #14 adds plantings; #12/#14 extend these files. - service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all (ctx, actorID, ...) through requireGardenRole(editor for mutations, viewer for /full). finalizeObject validates kind + shape (rect/circle; polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose bbox-overlaps-garden placement (partial overhang OK), hex color, valid JSON props, name/notes caps; normalizes rotation to [0,360). Plantable defaults by kind, overridable. - api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id, GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any JSON value stored as text; PATCH is partial + required version, 409 returns the current row. Tests: service (defaults, plantable-by-kind, rotation normalize, validation rejects incl. off-field/polygon/bad-color/bad-props, partial patch + version conflict, cross-user ErrNotFound, delete, /full shape) and api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400, props round-trip). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// seedGarden creates a garden owned by owner and returns it.
|
||||
func seedGarden(t *testing.T, s *Service, owner int64) *domain.Garden {
|
||||
t.Helper()
|
||||
g, err := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Yard", WidthCM: 1000, HeightCM: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("seed garden: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func TestCreateObjectDefaults(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
|
||||
// A bed at the garden center; no shape/plantable given.
|
||||
o, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, Name: " Bed 1 ", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateObject: %v", err)
|
||||
}
|
||||
if o.Shape != domain.ShapeRect {
|
||||
t.Errorf("shape = %q, want default rect", o.Shape)
|
||||
}
|
||||
if !o.Plantable {
|
||||
t.Error("a bed should default to plantable")
|
||||
}
|
||||
if o.Name != "Bed 1" {
|
||||
t.Errorf("name = %q, want trimmed", o.Name)
|
||||
}
|
||||
if o.Version != 1 || o.GardenID != g.ID {
|
||||
t.Errorf("unexpected object: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateObjectPlantableByKind(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
|
||||
tree, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindTree, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create tree: %v", err)
|
||||
}
|
||||
if tree.Plantable {
|
||||
t.Error("a tree should default to not plantable")
|
||||
}
|
||||
|
||||
// Override: a plantable path.
|
||||
path, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindPath, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Plantable: ptrBool(true),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create path: %v", err)
|
||||
}
|
||||
if !path.Plantable {
|
||||
t.Error("plantable override should win")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateObjectRotationNormalized(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
|
||||
o, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, RotationDeg: 450,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateObject: %v", err)
|
||||
}
|
||||
if o.RotationDeg != 90 {
|
||||
t.Errorf("rotation 450 normalized to %v, want 90", o.RotationDeg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateObjectValidation(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner) // 1000×1000
|
||||
|
||||
bad := []ObjectInput{
|
||||
{Kind: "spaceship", XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100}, // bad kind
|
||||
{Kind: domain.KindBed, Shape: domain.ShapePolygon, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100}, // polygon reserved
|
||||
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 0, HeightCM: 100}, // zero width
|
||||
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: -5, HeightCM: 100}, // negative
|
||||
{Kind: domain.KindBed, XCM: 5000, YCM: 5000, WidthCM: 100, HeightCM: 100}, // fully off-field
|
||||
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Color: strPtr("nope")}, // bad color
|
||||
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Props: strPtr("{not json")}, // bad props
|
||||
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Name: strings.Repeat("x", maxObjectNameLen+1)}, // name too long
|
||||
}
|
||||
for i, in := range bad {
|
||||
if _, err := s.CreateObject(context.Background(), owner, g.ID, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("case %d: err = %v, want ErrInvalidInput", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Partial overhang IS allowed (center near the edge).
|
||||
if _, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, XCM: 990, YCM: 990, WidthCM: 200, HeightCM: 200,
|
||||
}); err != nil {
|
||||
t.Errorf("overhang at the edge should be allowed: %v", err)
|
||||
}
|
||||
|
||||
// A valid hex color and valid props JSON pass.
|
||||
if _, err := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
|
||||
Color: strPtr("#3f8f4f"), Props: strPtr(`{"heightCm":40}`),
|
||||
}); err != nil {
|
||||
t.Errorf("valid color+props should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateObjectPartialAndVersion(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
o, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Bed", XCM: 100, YCM: 100, WidthCM: 100, HeightCM: 100,
|
||||
})
|
||||
|
||||
// Move + rotate only; name/dimensions untouched.
|
||||
nx, ny, rot := 300.0, 400.0, -90.0
|
||||
updated, err := s.UpdateObject(context.Background(), owner, o.ID, ObjectPatch{XCM: &nx, YCM: &ny, RotationDeg: &rot}, o.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
if updated.XCM != 300 || updated.YCM != 400 {
|
||||
t.Errorf("move didn't apply: %+v", updated)
|
||||
}
|
||||
if updated.RotationDeg != 270 {
|
||||
t.Errorf("rotation -90 normalized to %v, want 270", updated.RotationDeg)
|
||||
}
|
||||
if updated.Name != "Bed" || updated.WidthCM != 100 {
|
||||
t.Errorf("untouched fields changed: %+v", updated)
|
||||
}
|
||||
if updated.Version != o.Version+1 {
|
||||
t.Errorf("version = %d, want %d", updated.Version, o.Version+1)
|
||||
}
|
||||
|
||||
// Stale version → conflict + current row.
|
||||
current, err := s.UpdateObject(context.Background(), owner, o.ID, ObjectPatch{XCM: &nx}, o.Version)
|
||||
if !errors.Is(err, domain.ErrVersionConflict) {
|
||||
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
|
||||
}
|
||||
if current == nil || current.Version != 2 {
|
||||
t.Errorf("conflict didn't return current row: %+v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectCrossUserIsNotFound(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
alice := seedUser(t, s, "[email protected]")
|
||||
bob := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, alice)
|
||||
o, _ := s.CreateObject(context.Background(), alice, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
|
||||
})
|
||||
|
||||
nx := 1.0
|
||||
if _, err := s.CreateObject(context.Background(), bob, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100}); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("bob create err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := s.UpdateObject(context.Background(), bob, o.ID, ObjectPatch{XCM: &nx}, o.Version); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("bob update err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("bob delete err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := s.GardenFull(context.Background(), bob, g.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("bob /full err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
o, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||||
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
|
||||
})
|
||||
|
||||
if err := s.DeleteObject(context.Background(), owner, o.ID); err != nil {
|
||||
t.Fatalf("DeleteObject: %v", err)
|
||||
}
|
||||
full, err := s.GardenFull(context.Background(), owner, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull: %v", err)
|
||||
}
|
||||
if len(full.Objects) != 0 {
|
||||
t.Errorf("object still present after delete: %d", len(full.Objects))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenFullShape(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 300, YCM: 300, WidthCM: 100, HeightCM: 100})
|
||||
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindContainer, Shape: domain.ShapeCircle, XCM: 600, YCM: 600, WidthCM: 60, HeightCM: 60})
|
||||
|
||||
full, err := s.GardenFull(context.Background(), owner, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GardenFull: %v", err)
|
||||
}
|
||||
if full.Garden == nil || full.Garden.ID != g.ID {
|
||||
t.Error("full.Garden missing")
|
||||
}
|
||||
if len(full.Objects) != 2 {
|
||||
t.Errorf("objects = %d, want 2", len(full.Objects))
|
||||
}
|
||||
// Plantings/plants are empty (non-nil) until #14.
|
||||
if full.Plantings == nil || len(full.Plantings) != 0 {
|
||||
t.Errorf("plantings = %v, want empty non-nil", full.Plantings)
|
||||
}
|
||||
if full.Plants == nil || len(full.Plants) != 0 {
|
||||
t.Errorf("plants = %v, want empty non-nil", full.Plants)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user