package service import ( "context" "errors" "testing" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // seedLot records a purchase of plantID with the given quantity in seeds. func seedLot(t *testing.T, s *Service, owner, plantID int64, qty float64, over func(*SeedLotInput)) *domain.SeedLot { t.Helper() in := SeedLotInput{PlantID: plantID, Quantity: qty, Unit: domain.UnitSeeds} if over != nil { over(&in) } l, err := s.CreateSeedLot(context.Background(), owner, in) if err != nil { t.Fatalf("CreateSeedLot: %v", err) } return l } // builtinPlantID returns a seeded built-in's id — the case that proves a lot can // reference a plant it doesn't own. func builtinPlantID(t *testing.T, s *Service, actor int64) int64 { t.Helper() plants, err := s.ListPlants(context.Background(), actor) if err != nil { t.Fatalf("ListPlants: %v", err) } for _, p := range plants { if p.OwnerID == nil { return p.ID } } t.Fatal("no built-in plants seeded") return 0 } // TestTwoLotsOfOnePlantTrackIndependently — the reason inventory belongs to a // purchase and not to a variety: the same garlic from two vendors in two years // is two different things. func TestTwoLotsOfOnePlantTrackIndependently(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) ctx := context.Background() johnnys := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.Vendor = "Johnny's" in.SourceURL = "https://www.johnnyseeds.com/vegetables/garlic/german-red-garlic" in.PackedForYear = ptrInt(2026) }) fedco := seedLot(t, s, owner, plant.ID, 50, func(in *SeedLotInput) { in.Vendor = "Fedco" in.PackedForYear = ptrInt(2025) }) // Plant 12 out of the Johnny's lot only. twelve := 12 if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &twelve, SeedLotID: &johnnys.ID, }); err != nil { t.Fatalf("CreatePlanting: %v", err) } lots, err := s.ListSeedLots(ctx, owner, nil) if err != nil { t.Fatalf("ListSeedLots: %v", err) } byID := map[int64]domain.SeedLot{} for _, l := range lots { byID[l.ID] = l } if got := byID[johnnys.ID]; got.Used != 12 || got.Remaining != 88 { t.Errorf("Johnny's lot: used=%v remaining=%v, want 12/88", got.Used, got.Remaining) } if got := byID[fedco.ID]; got.Used != 0 || got.Remaining != 50 { t.Errorf("Fedco lot: used=%v remaining=%v, want 0/50", got.Used, got.Remaining) } if byID[johnnys.ID].Vendor != "Johnny's" || byID[johnnys.ID].SourceURL == "" { t.Errorf("provenance lost: %+v", byID[johnnys.ID]) } } // TestRemainingReturnsWhenAPlantingIsRemoved — the acceptance criterion, and the // reason `remaining` is derived rather than decremented on planting: removing a // plop has to give the seed back, without anything having to remember to. func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, nil) ctx := context.Background() ten := 10 pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } if got, _ := s.GetSeedLot(ctx, owner, lot.ID); got.Remaining != 90 { t.Fatalf("remaining after planting = %v, want 90", got.Remaining) } // Soft-remove ("cleared the bed"): only ACTIVE plantings count against a lot. today := "2026-08-01" if _, err := s.UpdatePlanting(ctx, owner, pl.ID, PlantingPatch{ SetRemovedAt: true, RemovedAt: &today, }, pl.Version); err != nil { t.Fatalf("UpdatePlanting: %v", err) } if got, _ := s.GetSeedLot(ctx, owner, lot.ID); got.Remaining != 100 || got.Used != 0 { t.Errorf("after removal: used=%v remaining=%v, want 0/100", got.Used, got.Remaining) } } // TestLotOnBuiltinPlantIsPrivate — you can buy generic Garlic from Johnny's, so // a lot may point at a plant it doesn't own; the LOT is still yours alone. func TestLotOnBuiltinPlantIsPrivate(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") other := seedUser(t, s, "b@example.com") ctx := context.Background() builtin := builtinPlantID(t, s, owner) lot := seedLot(t, s, owner, builtin, 25, func(in *SeedLotInput) { in.Vendor = "Johnny's" }) if _, err := s.GetSeedLot(ctx, owner, lot.ID); err != nil { t.Fatalf("owner can't read their own lot: %v", err) } // Another user gets ErrNotFound, not Forbidden: existence stays masked. if _, err := s.GetSeedLot(ctx, other, lot.ID); !errors.Is(err, domain.ErrNotFound) { t.Errorf("other user read err = %v, want ErrNotFound", err) } if _, err := s.UpdateSeedLot(ctx, other, lot.ID, SeedLotPatch{Notes: strPtr("mine now")}, lot.Version); !errors.Is(err, domain.ErrNotFound) { t.Errorf("other user update err = %v, want ErrNotFound", err) } if err := s.DeleteSeedLot(ctx, other, lot.ID); !errors.Is(err, domain.ErrNotFound) { t.Errorf("other user delete err = %v, want ErrNotFound", err) } if lots, _ := s.ListSeedLots(ctx, other, nil); len(lots) != 0 { t.Errorf("other user sees %d lots", len(lots)) } } // TestSourceURLValidation — the field renders as a clickable link, so the scheme // check is what stops a stored javascript: URL from becoming script execution. func TestSourceURLValidation(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") plant := seedOwnPlant(t, s, owner, 15) ctx := context.Background() ok := []string{ "", "https://www.johnnyseeds.com/x", "http://example.com/seeds?sku=123", } for _, u := range ok { if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{ PlantID: plant.ID, Unit: domain.UnitSeeds, SourceURL: u, }); err != nil { t.Errorf("SourceURL %q rejected: %v", u, err) } } bad := []string{ "javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,", "//evil.example.com", // schemeless: would resolve against pansy's origin "/seeds/garlic", // relative, same reason "file:///etc/passwd", "https://", // no host } for _, u := range bad { if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{ PlantID: plant.ID, Unit: domain.UnitSeeds, SourceURL: u, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("SourceURL %q accepted (err=%v)", u, err) } } // The same rule applies to the plant's own source link. if _, err := s.CreatePlant(ctx, owner, PlantInput{ Name: "X", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿", SourceURL: "javascript:alert(1)", }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("plant SourceURL accepted a javascript: URL (err=%v)", err) } } // TestPlantCarriesProvenance — the headline: "German Red Garlic" links back to // the page it came from, and the built-ins keep working untouched. func TestPlantCarriesProvenance(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") ctx := context.Background() p, err := s.CreatePlant(ctx, owner, PlantInput{ Name: "German Red Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#8a5a8a", Icon: "🧄", SourceURL: "https://www.johnnyseeds.com/vegetables/garlic/german-red-garlic", Vendor: "Johnny's Selected Seeds", }) if err != nil { t.Fatalf("CreatePlant: %v", err) } if p.Vendor != "Johnny's Selected Seeds" || p.SourceURL == "" { t.Errorf("provenance not stored: %+v", p) } // The built-ins predate these columns and must still read cleanly. plants, _ := s.ListPlants(ctx, owner) for _, b := range plants { if b.OwnerID == nil && (b.SourceURL != "" || b.Vendor != "") { t.Errorf("built-in %q gained provenance from nowhere: %+v", b.Name, b) } } } // TestPlantingRejectsMismatchedLot — attributing a garlic planting to a tomato // lot would silently corrupt every remaining count downstream. func TestPlantingRejectsMismatchedLot(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") other := seedUser(t, s, "b@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) garlic := seedOwnPlant(t, s, owner, 15) ctx := context.Background() tomato, err := s.CreatePlant(ctx, owner, PlantInput{ Name: "Tomato", Category: domain.CategoryVegetable, SpacingCM: 45, Color: "#c0392b", Icon: "🍅", }) if err != nil { t.Fatalf("CreatePlant: %v", err) } tomatoLot := seedLot(t, s, owner, tomato.ID, 20, nil) if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: garlic.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &tomatoLot.ID, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("a garlic planting was attributed to a tomato lot (err=%v)", err) } // Someone else's lot is an invalid reference, not a leak of its existence. othersPlant := seedOwnPlant(t, s, other, 15) othersLot := seedLot(t, s, other, othersPlant.ID, 10, nil) if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: garlic.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &othersLot.ID, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("another user's lot was accepted (err=%v)", err) } } // TestDeletingALotKeepsPlantingHistory — the plants really were in the ground, // whatever happened to the record of the packet. func TestDeletingALotKeepsPlantingHistory(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, nil) ctx := context.Background() pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &lot.ID, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } if err := s.DeleteSeedLot(ctx, owner, lot.ID); err != nil { t.Fatalf("DeleteSeedLot: %v", err) } survived, err := s.store.GetPlanting(ctx, pl.ID) if err != nil { t.Fatalf("planting was destroyed with its lot: %v", err) } if survived.SeedLotID != nil { t.Errorf("seedLotId = %v, want null after the lot was deleted", *survived.SeedLotID) } } // TestSeedLotVersionGuard — lots are version-guarded like every other mutable row. func TestSeedLotVersionGuard(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, nil) ctx := context.Background() updated, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("half used")}, lot.Version) if err != nil { t.Fatalf("UpdateSeedLot: %v", err) } if updated.Notes != "half used" || updated.Version != lot.Version+1 { t.Errorf("unexpected update: %+v", updated) } // Stale version → conflict, carrying the current row. current, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("stale")}, lot.Version) if !errors.Is(err, domain.ErrVersionConflict) { t.Fatalf("err = %v, want ErrVersionConflict", err) } if current == nil || current.Notes != "half used" { t.Errorf("conflict should carry the current row, got %+v", current) } } // TestSeedLotUnitAndQuantityValidation func TestSeedLotUnitAndQuantityValidation(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") plant := seedOwnPlant(t, s, owner, 15) ctx := context.Background() if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: plant.ID, Unit: "handfuls"}); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("unknown unit accepted: %v", err) } if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{ PlantID: plant.ID, Unit: domain.UnitSeeds, Quantity: -1, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("negative quantity accepted: %v", err) } pct := 140.0 if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{ PlantID: plant.ID, Unit: domain.UnitSeeds, GerminationPct: &pct, }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("140%% germination accepted: %v", err) } if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{ PlantID: plant.ID, Unit: domain.UnitSeeds, PurchasedAt: strPtr("not-a-date"), }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("malformed purchase date accepted: %v", err) } } // TestDeletingAPlantWithLotsIsRefused — seed_lots.plant_id is ON DELETE CASCADE, // so without a guard, deleting a plant would silently destroy the purchase // records attached to it: vendor, cost, germination rate, gone with no warning. func TestDeletingAPlantWithLotsIsRefused(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.CostCents = ptrInt(499) }) ctx := context.Background() if err := s.DeletePlant(ctx, owner, plant.ID); !errors.Is(err, domain.ErrPlantInUse) { t.Fatalf("DeletePlant err = %v, want ErrPlantInUse", err) } if _, err := s.GetSeedLot(ctx, owner, lot.ID); err != nil { t.Errorf("the lot was destroyed anyway: %v", err) } // Deleting the lot deliberately then frees the plant. if err := s.DeleteSeedLot(ctx, owner, lot.ID); err != nil { t.Fatalf("DeleteSeedLot: %v", err) } if err := s.DeletePlant(ctx, owner, plant.ID); err != nil { t.Errorf("DeletePlant after clearing lots: %v", err) } } // TestFreshLotReportsItsFullQuantity — an unfilled Remaining reads as "none // left" on a packet you just bought, which is the opposite of the truth. func TestFreshLotReportsItsFullQuantity(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, nil) if lot.Remaining != 100 || lot.Used != 0 { t.Errorf("fresh lot: used=%v remaining=%v, want 0/100", lot.Used, lot.Remaining) } } // TestVersionConflictCarriesRemaining — the 409 body is what the client rebases // on, so it needs the computed fields too. func TestVersionConflictCarriesRemaining(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, nil) ctx := context.Background() ten := 10 if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, }); err != nil { t.Fatalf("CreatePlanting: %v", err) } if _, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("first")}, lot.Version); err != nil { t.Fatalf("first update: %v", err) } current, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("stale")}, lot.Version) if !errors.Is(err, domain.ErrVersionConflict) { t.Fatalf("err = %v, want ErrVersionConflict", err) } if current == nil || current.Remaining != 90 || current.Used != 10 { t.Errorf("conflict row: used=%v remaining=%v, want 10/90", current.Used, current.Remaining) } } // TestRemainingGoesNegativeRatherThanHidingIt — planting more than you recorded // buying is a real thing; clamping to zero would hide the discrepancy at exactly // the moment it is worth seeing. func TestRemainingGoesNegativeRatherThanHidingIt(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 5, nil) ctx := context.Background() twenty := 20 if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &twenty, SeedLotID: &lot.ID, }); err != nil { t.Fatalf("CreatePlanting: %v", err) } got, err := s.GetSeedLot(ctx, owner, lot.ID) if err != nil { t.Fatalf("GetSeedLot: %v", err) } if got.Remaining != -15 { t.Errorf("remaining = %v, want -15 (20 planted from a lot of 5)", got.Remaining) } } // TestCopiedGardenDoesNotInheritSeedLots — a copy is a plan, not a second // planting out of the same packet. Carrying the link would double-count the // original lot's usage and quietly attach a private lot to a garden that may // later be shared. func TestCopiedGardenDoesNotInheritSeedLots(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, nil) ctx := context.Background() ten := 10 if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, }); err != nil { t.Fatalf("CreatePlanting: %v", err) } copied, err := s.CopyGarden(ctx, owner, g.ID, "Copy") if err != nil { t.Fatalf("CopyGarden: %v", err) } full, err := s.GardenFull(ctx, owner, copied.ID) if err != nil { t.Fatalf("GardenFull: %v", err) } if len(full.Plantings) != 1 { t.Fatalf("copy has %d plantings, want 1", len(full.Plantings)) } if full.Plantings[0].SeedLotID != nil { t.Errorf("the copy inherited seed lot %v", *full.Plantings[0].SeedLotID) } // And the original lot's usage is unchanged: still 10, not 20. got, _ := s.GetSeedLot(ctx, owner, lot.ID) if got.Used != 10 { t.Errorf("copying double-counted the lot: used = %v, want 10", got.Used) } } // TestRevertRestoresAPlantingWhoseLotIsGone — a snapshot can name a lot that has // since been deleted; restoring it verbatim would violate the FK and abort the // whole revert. func TestRevertRestoresAPlantingWhoseLotIsGone(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) lot := seedLot(t, s, owner, plant.ID, 100, nil) ctx := context.Background() pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &lot.ID, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } if err := s.DeletePlanting(ctx, owner, pl.ID); err != nil { t.Fatalf("DeletePlanting: %v", err) } del := history(t, s, owner, g.ID)[0] // The lot goes away while the deletion sits in history. if err := s.DeleteSeedLot(ctx, owner, lot.ID); err != nil { t.Fatalf("DeleteSeedLot: %v", err) } if _, conflicts, err := s.RevertChangeSet(ctx, owner, del.ID, domain.SourceUI); err != nil || len(conflicts) != 0 { t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts) } restored, err := s.store.GetPlanting(ctx, pl.ID) if err != nil { t.Fatalf("planting not restored: %v", err) } if restored.SeedLotID != nil { t.Errorf("a dangling lot reference was restored: %v", *restored.SeedLotID) } }