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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user