Build image / build-and-push (push) Successful in 7s
attachSeedRemaining summed Remaining across lots in different units and then dropped only the unit LABEL, leaving a bare number that reads as a quantity and isn't one — 50 seeds plus 50 grams reported as "100". Worse for a model than for a person, since it has nothing else to go on. The total is now omitted with the label, and SeedLots reports how many lots there are, so "several lots, no single total" stays distinguishable from "no seed at all". Also folded TestToolboxScenario's inline setup into the newAgentTestService helper the new tests use, rather than leaving two copies of the same six lines. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
375 lines
13 KiB
Go
375 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
func ptrInt(n int) *int { return &n }
|
|
|
|
// validPlant is a well-formed PlantInput for tests to tweak.
|
|
func validPlant(name string) PlantInput {
|
|
return PlantInput{Name: name, Category: domain.CategoryHerb, SpacingCM: 25, Color: "#4a7c3f", Icon: "🌿"}
|
|
}
|
|
|
|
func TestListPlantsIncludesSeededBuiltins(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
|
|
plants, err := s.ListPlants(context.Background(), owner)
|
|
if err != nil {
|
|
t.Fatalf("ListPlants: %v", err)
|
|
}
|
|
// The 0003 seed ships a catalog; assert it's there and all built-ins.
|
|
if len(plants) < 30 {
|
|
t.Fatalf("seeded catalog = %d plants, want >= 30", len(plants))
|
|
}
|
|
for _, p := range plants {
|
|
if p.OwnerID != nil {
|
|
t.Fatalf("unexpected non-builtin in a fresh catalog: %+v", p)
|
|
}
|
|
}
|
|
// Find a known one.
|
|
var found bool
|
|
for _, p := range plants {
|
|
if p.Name == "Garlic" {
|
|
found = true
|
|
if p.SpacingCM != 15 || p.Icon != "🧄" {
|
|
t.Errorf("Garlic seeded wrong: %+v", p)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected seeded Garlic in the catalog")
|
|
}
|
|
}
|
|
|
|
func TestCreatePlantIsOwnedAndTrimmed(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
|
|
in := validPlant(" Purple basil ")
|
|
in.DaysToMaturity = ptrInt(60)
|
|
p, err := s.CreatePlant(context.Background(), owner, in)
|
|
if err != nil {
|
|
t.Fatalf("CreatePlant: %v", err)
|
|
}
|
|
if p.OwnerID == nil || *p.OwnerID != owner {
|
|
t.Errorf("owner = %v, want %d", p.OwnerID, owner)
|
|
}
|
|
if p.Name != "Purple basil" {
|
|
t.Errorf("name = %q, want trimmed", p.Name)
|
|
}
|
|
if p.Version != 1 || p.DaysToMaturity == nil || *p.DaysToMaturity != 60 {
|
|
t.Errorf("unexpected plant: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestPlantVisibilityAcrossUsers(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
alice := seedUser(t, s, "[email protected]")
|
|
bob := seedUser(t, s, "[email protected]")
|
|
|
|
p, err := s.CreatePlant(context.Background(), alice, validPlant("Alice's mint"))
|
|
if err != nil {
|
|
t.Fatalf("CreatePlant: %v", err)
|
|
}
|
|
|
|
// Bob's catalog: built-ins only, none of Alice's.
|
|
bobList, err := s.ListPlants(context.Background(), bob)
|
|
if err != nil {
|
|
t.Fatalf("ListPlants(bob): %v", err)
|
|
}
|
|
for _, bp := range bobList {
|
|
if bp.ID == p.ID {
|
|
t.Fatal("bob can see alice's custom plant")
|
|
}
|
|
}
|
|
|
|
// Bob can neither edit nor delete it — invisibility masks it as ErrNotFound.
|
|
nm := "hijack"
|
|
if _, err := s.UpdatePlant(context.Background(), bob, p.ID, PlantPatch{Name: &nm}, p.Version); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("bob update err = %v, want ErrNotFound", err)
|
|
}
|
|
if err := s.DeletePlant(context.Background(), bob, p.ID); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("bob delete err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestBuiltinPlantsAreReadOnly(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
|
|
plants, _ := s.ListPlants(context.Background(), owner)
|
|
builtin := plants[0] // seeded, owner_id nil
|
|
if builtin.OwnerID != nil {
|
|
t.Fatalf("expected a built-in, got %+v", builtin)
|
|
}
|
|
|
|
nm := "tweaked"
|
|
if _, err := s.UpdatePlant(context.Background(), owner, builtin.ID, PlantPatch{Name: &nm}, builtin.Version); !errors.Is(err, domain.ErrForbidden) {
|
|
t.Errorf("edit built-in err = %v, want ErrForbidden", err)
|
|
}
|
|
if err := s.DeletePlant(context.Background(), owner, builtin.ID); !errors.Is(err, domain.ErrForbidden) {
|
|
t.Errorf("delete built-in err = %v, want ErrForbidden", err)
|
|
}
|
|
}
|
|
|
|
func TestCloneBuiltinThenEdit(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
|
|
plants, _ := s.ListPlants(context.Background(), owner)
|
|
src := plants[0]
|
|
|
|
// "Clone" = POST a copy; it's now owned and editable.
|
|
clone, err := s.CreatePlant(context.Background(), owner, PlantInput{
|
|
Name: src.Name + " (mine)", Category: src.Category, SpacingCM: src.SpacingCM, Color: src.Color, Icon: src.Icon,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("clone: %v", err)
|
|
}
|
|
sp := 12.0
|
|
updated, err := s.UpdatePlant(context.Background(), owner, clone.ID, PlantPatch{SpacingCM: &sp}, clone.Version)
|
|
if err != nil {
|
|
t.Fatalf("edit clone: %v", err)
|
|
}
|
|
if updated.SpacingCM != 12 || updated.Version != clone.Version+1 {
|
|
t.Errorf("clone edit didn't apply: %+v", updated)
|
|
}
|
|
}
|
|
|
|
func TestUpdatePlantClearsDays(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
in := validPlant("Dill")
|
|
in.DaysToMaturity = ptrInt(50)
|
|
p, _ := s.CreatePlant(context.Background(), owner, in)
|
|
|
|
// SetDays with nil clears; absent (SetDays=false) would leave it.
|
|
cleared, err := s.UpdatePlant(context.Background(), owner, p.ID, PlantPatch{SetDays: true, DaysToMaturity: nil}, p.Version)
|
|
if err != nil {
|
|
t.Fatalf("clear days: %v", err)
|
|
}
|
|
if cleared.DaysToMaturity != nil {
|
|
t.Errorf("daysToMaturity = %v, want nil", *cleared.DaysToMaturity)
|
|
}
|
|
}
|
|
|
|
func TestPlantVersionConflict(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
p, _ := s.CreatePlant(context.Background(), owner, validPlant("Sage"))
|
|
|
|
sp := 30.0
|
|
if _, err := s.UpdatePlant(context.Background(), owner, p.ID, PlantPatch{SpacingCM: &sp}, p.Version); err != nil {
|
|
t.Fatalf("first update: %v", err)
|
|
}
|
|
// Stale version → conflict + current row.
|
|
current, err := s.UpdatePlant(context.Background(), owner, p.ID, PlantPatch{SpacingCM: &sp}, p.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 TestCreatePlantValidation(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
|
|
bad := []PlantInput{
|
|
{Name: "", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // empty name
|
|
{Name: "x", Category: "spaceship", SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // bad category
|
|
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 0, Color: "#4a7c3f", Icon: "🌿"}, // zero spacing
|
|
{Name: "x", Category: domain.CategoryHerb, SpacingCM: -5, Color: "#4a7c3f", Icon: "🌿"}, // negative spacing
|
|
{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: ptrInt(0)}, // days must be >= 1
|
|
}
|
|
for i, in := range bad {
|
|
if _, err := s.CreatePlant(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
|
t.Errorf("case %d: err = %v, want ErrInvalidInput", i, err)
|
|
}
|
|
}
|
|
|
|
// A shorthand hex (#rgb) and a set days value pass.
|
|
ok := validPlant("Thyme")
|
|
ok.Color = "#0a0"
|
|
ok.DaysToMaturity = ptrInt(90)
|
|
if _, err := s.CreatePlant(context.Background(), owner, ok); err != nil {
|
|
t.Errorf("valid plant rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeletePlantInUseIsBlocked(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{
|
|
Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200,
|
|
})
|
|
plant, _ := s.CreatePlant(context.Background(), owner, validPlant("Basil"))
|
|
|
|
// Plantings CRUD is #14; insert a plop directly so the plant is referenced.
|
|
if _, err := s.store.SQL().ExecContext(context.Background(),
|
|
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (?, ?, 0, 0, 10)`,
|
|
bed.ID, plant.ID,
|
|
); err != nil {
|
|
t.Fatalf("seed planting: %v", err)
|
|
}
|
|
|
|
if err := s.DeletePlant(context.Background(), owner, plant.ID); !errors.Is(err, domain.ErrPlantInUse) {
|
|
t.Fatalf("delete in-use plant err = %v, want ErrPlantInUse", err)
|
|
}
|
|
|
|
// Clearing the reference frees the delete.
|
|
if _, err := s.store.SQL().ExecContext(context.Background(), `DELETE FROM plantings WHERE plant_id = ?`, plant.ID); err != nil {
|
|
t.Fatalf("clear planting: %v", err)
|
|
}
|
|
if err := s.DeletePlant(context.Background(), owner, plant.ID); err != nil {
|
|
t.Errorf("delete freed plant: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestFindPlantsRanksAndDisambiguates — FindPlants deliberately returns several
|
|
// candidates rather than one guess, because "garlic" against a catalog holding
|
|
// both "Garlic" and "German Red Garlic" is genuinely ambiguous and a caller with
|
|
// the surrounding context is better placed to choose than a heuristic here.
|
|
func TestFindPlantsRanksAndDisambiguates(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
ctx := context.Background()
|
|
|
|
custom, err := s.CreatePlant(ctx, owner, PlantInput{
|
|
Name: "German Red Garlic", Category: domain.CategoryVegetable, SpacingCM: 15,
|
|
Color: "#8a5a8a", Icon: "🧄",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePlant: %v", err)
|
|
}
|
|
|
|
got, err := s.FindPlants(ctx, owner, "garlic")
|
|
if err != nil {
|
|
t.Fatalf("FindPlants: %v", err)
|
|
}
|
|
names := map[string]bool{}
|
|
for _, m := range got {
|
|
names[m.Name] = true
|
|
}
|
|
if !names["Garlic"] || !names[custom.Name] {
|
|
t.Errorf("got %v, want both the built-in and the custom variety", names)
|
|
}
|
|
// Exact match first, so a caller taking [0] gets the least surprising one.
|
|
if got[0].Name != "Garlic" {
|
|
t.Errorf("first match = %q, want the exact name", got[0].Name)
|
|
}
|
|
|
|
// Prefix beats substring: "german" should surface the custom one first.
|
|
pre, err := s.FindPlants(ctx, owner, "german")
|
|
if err != nil {
|
|
t.Fatalf("FindPlants: %v", err)
|
|
}
|
|
if len(pre) == 0 || pre[0].Name != custom.Name {
|
|
t.Errorf("FindPlants(german) = %+v, want the custom variety first", pre)
|
|
}
|
|
|
|
// Category search works, and a query matching nothing says so plainly.
|
|
if herbs, _ := s.FindPlants(ctx, owner, "herb"); len(herbs) == 0 {
|
|
t.Error("category search found nothing")
|
|
}
|
|
if none, _ := s.FindPlants(ctx, owner, "zzzzz"); len(none) != 0 {
|
|
t.Errorf("FindPlants(zzzzz) = %+v, want nothing", none)
|
|
}
|
|
}
|
|
|
|
// TestFindPlantsReportsSeedRemaining — so a caller can say "you only have enough
|
|
// for half that bed" instead of confidently planting seed that doesn't exist.
|
|
func TestFindPlantsReportsSeedRemaining(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
other := seedUser(t, s, "[email protected]")
|
|
ctx := context.Background()
|
|
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
|
|
PlantID: plant.ID, Quantity: 100, Unit: domain.UnitSeeds,
|
|
}); err != nil {
|
|
t.Fatalf("CreateSeedLot: %v", err)
|
|
}
|
|
|
|
got, err := s.FindPlants(ctx, owner, plant.Name)
|
|
if err != nil {
|
|
t.Fatalf("FindPlants: %v", err)
|
|
}
|
|
if len(got) == 0 || got[0].SeedRemaining == nil || *got[0].SeedRemaining != 100 {
|
|
t.Fatalf("seedRemaining = %+v, want 100", got[0].SeedRemaining)
|
|
}
|
|
if got[0].SeedUnit != domain.UnitSeeds {
|
|
t.Errorf("seedUnit = %q", got[0].SeedUnit)
|
|
}
|
|
|
|
// A plant with no lots reports nothing rather than a misleading zero.
|
|
builtin, err := s.FindPlants(ctx, owner, "Garlic")
|
|
if err != nil {
|
|
t.Fatalf("FindPlants: %v", err)
|
|
}
|
|
if len(builtin) > 0 && builtin[0].SeedRemaining != nil {
|
|
t.Errorf("a plant with no lots reported %v remaining", *builtin[0].SeedRemaining)
|
|
}
|
|
|
|
// And lots are private: another user searching the same plant sees no seed.
|
|
// (They can't see the custom plant at all, so search a built-in instead.)
|
|
if _, err := s.CreateSeedLot(ctx, other, SeedLotInput{
|
|
PlantID: builtinPlantID(t, s, other), Quantity: 50, Unit: domain.UnitSeeds,
|
|
}); err != nil {
|
|
t.Fatalf("CreateSeedLot(other): %v", err)
|
|
}
|
|
mine, _ := s.FindPlants(ctx, owner, "Garlic")
|
|
for _, m := range mine {
|
|
if m.SeedRemaining != nil {
|
|
t.Errorf("saw another user's seed count on %q", m.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFindPlantsOmitsATotalItCannotHonestlyGive — lots in different units can't
|
|
// be summed. Dropping only the unit LABEL would leave a number that reads as a
|
|
// quantity and isn't one; the count of lots still says there is seed here.
|
|
func TestFindPlantsOmitsATotalItCannotHonestlyGive(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
ctx := context.Background()
|
|
|
|
for _, u := range []string{domain.UnitSeeds, domain.UnitGrams} {
|
|
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: plant.ID, Quantity: 50, Unit: u}); err != nil {
|
|
t.Fatalf("CreateSeedLot(%s): %v", u, err)
|
|
}
|
|
}
|
|
|
|
got, err := s.FindPlants(ctx, owner, plant.Name)
|
|
if err != nil {
|
|
t.Fatalf("FindPlants: %v", err)
|
|
}
|
|
if len(got) == 0 {
|
|
t.Fatal("plant not found")
|
|
}
|
|
if got[0].SeedRemaining != nil {
|
|
t.Errorf("reported %v across mismatched units; that number means nothing", *got[0].SeedRemaining)
|
|
}
|
|
if got[0].SeedUnit != "" {
|
|
t.Errorf("seedUnit = %q, want empty", got[0].SeedUnit)
|
|
}
|
|
// But "several lots, no single total" must stay distinguishable from "none".
|
|
if got[0].SeedLots != 2 {
|
|
t.Errorf("seedLots = %d, want 2", got[0].SeedLots)
|
|
}
|
|
}
|