Files
pansy/internal/service/seed_lots_test.go
T
steveandClaude Opus 4.8 5eedcaf945
Build image / build-and-push (push) Successful in 4s
Refuse deleting a plant that has seed lots
seed_lots.plant_id is ON DELETE CASCADE, so deleting a plant would have silently
destroyed the purchase records attached to it — vendor, cost, germination rate,
gone with no warning. Plantings were already guarded with ErrPlantInUse because
their FK is RESTRICT; lots needed the same guard for the opposite reason, since
their FK would have obliged rather than refused.

Refusing lets the owner delete the lots deliberately if that is really what they
meant, which is the same shape as the existing clones-and-edits path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:29:24 -04:00

372 lines
14 KiB
Go

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, "[email protected]")
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 = intPtr(2026)
})
fedco := seedLot(t, s, owner, plant.ID, 50, func(in *SeedLotInput) {
in.Vendor = "Fedco"
in.PackedForYear = intPtr(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, "[email protected]")
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, "[email protected]")
other := seedUser(t, s, "[email protected]")
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, "[email protected]")
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,<script>alert(1)</script>",
"//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, "[email protected]")
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, "[email protected]")
other := seedUser(t, s, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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)
}
}
func intPtr(v int) *int { return &v }
// 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, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
lot := seedLot(t, s, owner, plant.ID, 100, func(in *SeedLotInput) { in.CostCents = intPtr(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)
}
}