Address Gadfly review on seed lots
Build image / build-and-push (push) Successful in 15s

Three real bugs, two of which would have quietly corrupted inventory numbers.

CopyGarden carried seed_lot_id into the copied plantings, so copying a garden
double-counted the original lot's usage — the copy is a plan, not a second
planting out of the same packet. It also quietly attached a private lot to a
garden that may later be shared, contradicting the "lots are never shared with a
garden" invariant this feature was built on. The copy now drops the link.

RestorePlanting restored a seed_lot_id verbatim from a history snapshot. Live
rows get their link nulled by ON DELETE SET NULL when a lot is deleted, but the
snapshot still holds the old id, so undoing a planting deletion after retiring
its lot would violate the foreign key and abort the whole revert. The revert now
drops a dangling link before restoring: the plant really was in the ground,
which is the part worth restoring.

CreateSeedLot returned a lot with Used/Remaining at their zero values, so a
packet you just bought reported "0 remaining" — the exact opposite of the truth.
The version-conflict path had the same gap, and that row is what the client
rebases on, so a 409 reporting remaining=0 is worse than no number at all. Both
fill them now.

Remaining can go negative and that is deliberate — it means more was planted
than the lot recorded buying, which happens (a miscounted packet, a lot entered
after the fact). Clamping to zero would hide the discrepancy at exactly the
moment it is worth seeing. Now documented and tested rather than incidental.

Performance and tidiness: SeedLotUsage is scoped to the lots being filled
instead of scanning every planting the owner has, so reading one lot is one
lot's work; ListSeedLotsForOwner gained the LIMIT every other list read has;
parseNullable moved out of seed_lots.go into the shared request helpers, since
every nullable field in the API needs that three-way absent/null/value
distinction; finalizePlant validates against its own maxPlantVendorLen rather
than borrowing a constant named for seed lots; seedLotForPlanting became
checkSeedLotForPlanting, since it validates rather than fetches; and the
duplicate intPtr test helper gave way to the existing ptrInt.

Declined: recording seed-lot mutations in the revision history. change_sets are
anchored to a garden and lots are user-scoped, deliberately — they belong to
you, not to any one garden, so there is no garden to record them against.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 01:38:54 -04:00
co-authored by Claude Opus 4.8
parent 5eedcaf945
commit 94aed26e14
9 changed files with 284 additions and 47 deletions
+153 -5
View File
@@ -53,11 +53,11 @@ func TestTwoLotsOfOnePlantTrackIndependently(t *testing.T) {
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)
in.PackedForYear = ptrInt(2026)
})
fedco := seedLot(t, s, owner, plant.ID, 50, func(in *SeedLotInput) {
in.Vendor = "Fedco"
in.PackedForYear = intPtr(2025)
in.PackedForYear = ptrInt(2025)
})
// Plant 12 out of the Johnny's lot only.
@@ -342,8 +342,6 @@ func TestSeedLotUnitAndQuantityValidation(t *testing.T) {
}
}
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.
@@ -351,7 +349,7 @@ 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) })
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) {
@@ -369,3 +367,153 @@ func TestDeletingAPlantWithLotsIsRefused(t *testing.T) {
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, "[email protected]")
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, "[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
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, "[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, 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, "[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
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, "[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.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)
}
}