Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
379 lines
14 KiB
Go
379 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
|
||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||
)
|
||
|
||
// seedBed creates a plantable 200×200 bed centered in a 1000×1000 garden.
|
||
func seedBed(t *testing.T, s *Service, owner, gardenID int64) *domain.GardenObject {
|
||
t.Helper()
|
||
o, err := s.CreateObject(context.Background(), owner, gardenID, ObjectInput{
|
||
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed bed: %v", err)
|
||
}
|
||
return o
|
||
}
|
||
|
||
// seedOwnPlant creates a custom plant owned by owner with the given spacing.
|
||
func seedOwnPlant(t *testing.T, s *Service, owner int64, spacingCM float64) *domain.Plant {
|
||
t.Helper()
|
||
p, err := s.CreatePlant(context.Background(), owner, PlantInput{
|
||
Name: "Testroot", Category: domain.CategoryHerb, SpacingCM: spacingCM, Color: "#4a7c3f", Icon: "🌿",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed plant: %v", err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func TestDerivedCountFormula(t *testing.T) {
|
||
cases := []struct {
|
||
r, sp float64
|
||
want int
|
||
}{
|
||
{0.1, 10, 1}, // tiny radius floors to 1
|
||
{0, 10, 1}, // zero radius → 1
|
||
{10, 0, 1}, // zero spacing → 1
|
||
{10, 10, 3}, // π·100/100 = 3.14 → 3
|
||
{50, 10, 79}, // π·2500/100 = 78.5 → 79
|
||
{30, 15, 13}, // π·900/225 = 12.57 → 13
|
||
}
|
||
for i, c := range cases {
|
||
if got := derivedCount(c.r, c.sp); got != c.want {
|
||
t.Errorf("case %d: derivedCount(%v, %v) = %d, want %d", i, c.r, c.sp, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDerivedCountCapped(t *testing.T) {
|
||
// A huge radius with a tiny spacing would produce an absurd (32-bit
|
||
// overflowing) count; it's capped at the same ceiling as a manual override.
|
||
if got := derivedCount(10_000, 0.1); got != maxExplicitCount {
|
||
t.Errorf("derivedCount(10000, 0.1) = %d, want cap %d", got, maxExplicitCount)
|
||
}
|
||
}
|
||
|
||
func TestCreatePlantingDefaultsAndDerivedCount(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 10, YCM: -20, RadiusCM: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreatePlanting: %v", err)
|
||
}
|
||
if pl.Count != nil {
|
||
t.Errorf("count = %v, want nil (derived)", *pl.Count)
|
||
}
|
||
if pl.DerivedCount != 3 {
|
||
t.Errorf("derivedCount = %d, want 3", pl.DerivedCount)
|
||
}
|
||
if pl.PlantedAt == nil {
|
||
t.Error("plantedAt should default to today, got nil")
|
||
}
|
||
if pl.Version != 1 || pl.ObjectID != bed.ID {
|
||
t.Errorf("unexpected planting: %+v", pl)
|
||
}
|
||
}
|
||
|
||
func TestPlantingCountOverrideWins(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
cnt := 12
|
||
pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 50, Count: &cnt,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreatePlanting: %v", err)
|
||
}
|
||
if pl.Count == nil || *pl.Count != 12 {
|
||
t.Errorf("count override = %v, want 12", pl.Count)
|
||
}
|
||
// derivedCount is still computed alongside the override (79 for r=50, sp=10).
|
||
if pl.DerivedCount != 79 {
|
||
t.Errorf("derivedCount = %d, want 79", pl.DerivedCount)
|
||
}
|
||
}
|
||
|
||
func TestUpdatePlantingMoveResizeAndClearCount(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
cnt := 5
|
||
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, Count: &cnt,
|
||
})
|
||
|
||
// Move + grow radius, and clear the count override back to derived.
|
||
nx, ny, nr := 30.0, -40.0, 50.0
|
||
updated, err := s.UpdatePlanting(context.Background(), owner, pl.ID,
|
||
PlantingPatch{XCM: &nx, YCM: &ny, RadiusCM: &nr, SetCount: true, Count: nil}, pl.Version)
|
||
if err != nil {
|
||
t.Fatalf("UpdatePlanting: %v", err)
|
||
}
|
||
if updated.XCM != 30 || updated.YCM != -40 || updated.RadiusCM != 50 {
|
||
t.Errorf("move/resize didn't apply: %+v", updated)
|
||
}
|
||
if updated.Count != nil {
|
||
t.Errorf("count should be cleared, got %v", *updated.Count)
|
||
}
|
||
if updated.DerivedCount != 79 { // r=50, sp=10
|
||
t.Errorf("derivedCount after grow = %d, want 79", updated.DerivedCount)
|
||
}
|
||
if updated.Version != pl.Version+1 {
|
||
t.Errorf("version = %d, want %d", updated.Version, pl.Version+1)
|
||
}
|
||
}
|
||
|
||
func TestPlantingNonPlantableObjectRejected(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
tree, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
||
Kind: domain.KindTree, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100,
|
||
})
|
||
if _, err := s.CreatePlanting(context.Background(), owner, tree.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("planting into a tree err = %v, want ErrInvalidInput", err)
|
||
}
|
||
}
|
||
|
||
func TestPlantingForeignPlantRejectedBuiltinAllowed(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
bob := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
|
||
// Bob's custom plant is invisible to owner → invalid reference.
|
||
bobPlant := seedOwnPlant(t, s, bob, 10)
|
||
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: bobPlant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("foreign plant err = %v, want ErrInvalidInput", err)
|
||
}
|
||
// An unknown plant id is likewise invalid.
|
||
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: 999999, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("unknown plant err = %v, want ErrInvalidInput", err)
|
||
}
|
||
// A built-in plant is plantable by anyone.
|
||
builtins, _ := s.ListPlants(context.Background(), owner)
|
||
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: builtins[0].ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
}); err != nil {
|
||
t.Errorf("planting a built-in should work: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPlantingBoundsCheck(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID) // 200×200 → local bounds ±100
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
// Center outside the object's local bounds → rejected (radius may overhang,
|
||
// but the center may not).
|
||
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 150, YCM: 0, RadiusCM: 10,
|
||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("out-of-bounds center err = %v, want ErrInvalidInput", err)
|
||
}
|
||
// On the edge is allowed.
|
||
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 100, YCM: -100, RadiusCM: 30,
|
||
}); err != nil {
|
||
t.Errorf("edge-of-bounds center should be allowed: %v", err)
|
||
}
|
||
// Non-positive radius rejected.
|
||
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0,
|
||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("zero radius err = %v, want ErrInvalidInput", err)
|
||
}
|
||
}
|
||
|
||
func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
})
|
||
|
||
// It shows in /full, with a derived count.
|
||
full, _ := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||
if len(full.Plantings) != 1 {
|
||
t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings))
|
||
}
|
||
if full.Plantings[0].DerivedCount != 3 {
|
||
t.Errorf("full derivedCount = %d, want 3", full.Plantings[0].DerivedCount)
|
||
}
|
||
if len(full.Plants) != 1 {
|
||
t.Errorf("full.Plants = %d, want 1 referenced plant", len(full.Plants))
|
||
}
|
||
|
||
// Soft-remove → it leaves /full. (On/after today's default planted_at.)
|
||
rm := "2030-06-01"
|
||
if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID,
|
||
PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil {
|
||
t.Fatalf("soft-remove: %v", err)
|
||
}
|
||
full, _ = s.GardenFull(context.Background(), owner, g.ID, nil)
|
||
if len(full.Plantings) != 0 {
|
||
t.Errorf("removed plop still in /full: %d", len(full.Plantings))
|
||
}
|
||
}
|
||
|
||
func TestPlantingRemovedBeforePlantedRejected(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
// planted_at defaults to today; a removed_at in the distant past is invalid.
|
||
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
})
|
||
past := "2000-01-01"
|
||
if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID,
|
||
PlantingPatch{SetRemovedAt: true, RemovedAt: &past}, pl.Version); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("removed-before-planted err = %v, want ErrInvalidInput", err)
|
||
}
|
||
}
|
||
|
||
func TestUpdatePlantingEditableAfterObjectShrinks(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID) // 200×200 → local bounds ±100
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
|
||
// A plop near the bed's edge.
|
||
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 90, YCM: 0, RadiusCM: 10,
|
||
})
|
||
|
||
// Shrink the bed to 100×100 (local bounds ±50): the plop's center (90) is now
|
||
// outside the object.
|
||
w, h := 100.0, 100.0
|
||
if _, err := s.UpdateObject(context.Background(), owner, bed.ID, ObjectPatch{WidthCM: &w, HeightCM: &h}, bed.Version); err != nil {
|
||
t.Fatalf("shrink bed: %v", err)
|
||
}
|
||
|
||
// Editing the out-of-bounds plop without moving it (radius here) must still
|
||
// work — otherwise it'd be un-removable.
|
||
nr := 15.0
|
||
updated, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version)
|
||
if err != nil {
|
||
t.Fatalf("radius edit on orphaned plop should work: %v", err)
|
||
}
|
||
// But actually moving it still enforces the new bounds.
|
||
outside := 90.0
|
||
if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{XCM: &outside}, updated.Version); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("moving to out-of-bounds should be rejected: %v", err)
|
||
}
|
||
// Moving it back inside works.
|
||
inside := 10.0
|
||
if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{XCM: &inside}, updated.Version); err != nil {
|
||
t.Errorf("moving in-bounds should work: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPlantingVersionConflict(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
})
|
||
|
||
nr := 20.0
|
||
if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); err != nil {
|
||
t.Fatalf("first update: %v", err)
|
||
}
|
||
current, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version)
|
||
if !errors.Is(err, domain.ErrVersionConflict) {
|
||
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
|
||
}
|
||
if current == nil || current.Version != 2 {
|
||
t.Fatalf("conflict didn't return current row: %+v", current)
|
||
}
|
||
if current.DerivedCount < 1 {
|
||
t.Errorf("conflict current row should carry a derived count, got %d", current.DerivedCount)
|
||
}
|
||
}
|
||
|
||
func TestPlantingCrossUserIsNotFound(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
bob := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
})
|
||
|
||
nr := 5.0
|
||
if _, err := s.UpdatePlanting(context.Background(), bob, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); !errors.Is(err, domain.ErrNotFound) {
|
||
t.Errorf("bob update err = %v, want ErrNotFound", err)
|
||
}
|
||
if err := s.DeletePlanting(context.Background(), bob, pl.ID); !errors.Is(err, domain.ErrNotFound) {
|
||
t.Errorf("bob delete err = %v, want ErrNotFound", err)
|
||
}
|
||
// Bob can't place into owner's bed either.
|
||
if _, err := s.CreatePlanting(context.Background(), bob, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
}); !errors.Is(err, domain.ErrNotFound) {
|
||
t.Errorf("bob create err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
func TestDeletePlanting(t *testing.T) {
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10,
|
||
})
|
||
|
||
if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil {
|
||
t.Fatalf("DeletePlanting: %v", err)
|
||
}
|
||
full, _ := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||
if len(full.Plantings) != 0 {
|
||
t.Errorf("planting still present after delete: %d", len(full.Plantings))
|
||
}
|
||
}
|