Seed provenance + inventory: source links and seed lots (#50) #64

Merged
steve merged 3 commits from feat/seed-lots into main 2026-07-21 05:39:25 +00:00
3 changed files with 51 additions and 0 deletions
Showing only changes of commit 5eedcaf945 - Show all commits
+13
View File
@@ -129,6 +129,12 @@ func (s *Service) UpdatePlant(ctx context.Context, actorID, plantID int64, patch
// planting (even a removed one — the FK is RESTRICT) is refused with // planting (even a removed one — the FK is RESTRICT) is refused with
// ErrPlantInUse rather than orphaning history; the client clones-and-edits or // ErrPlantInUse rather than orphaning history; the client clones-and-edits or
// clears the plantings first. // clears the plantings first.
//
// Seed lots are refused for a different reason: seed_lots.plant_id is ON DELETE
// CASCADE, so without this check deleting a plant would silently destroy the
// purchase records attached to it — vendor, cost, germination rate, all gone
// with no warning. Refusing lets the owner delete the lots deliberately if that
// is really what they meant.
func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error { func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error {
if _, err := s.writablePlant(ctx, actorID, plantID); err != nil { if _, err := s.writablePlant(ctx, actorID, plantID); err != nil {
return err return err
@@ -140,6 +146,13 @@ func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error
if n > 0 { if n > 0 {
return domain.ErrPlantInUse return domain.ErrPlantInUse
} }
lots, err := s.store.CountSeedLotsForPlant(ctx, plantID)
if err != nil {
return err
}
if lots > 0 {
return domain.ErrPlantInUse
}
return s.store.DeletePlant(ctx, plantID) return s.store.DeletePlant(ctx, plantID)
} }
+26
View File
@@ -343,3 +343,29 @@ func TestSeedLotUnitAndQuantityValidation(t *testing.T) {
} }
func intPtr(v int) *int { return &v } func intPtr(v int) *int { return &v }
Outdated
Review

🟡 Duplicate intPtr test helper when ptrInt already exists in same package

maintainability · flagged by 3 models

  • internal/service/seed_lots_test.go:345func intPtr(v int) *int duplicates ptrInt in plants_test.go:12 (same package, different name). Having two names for the same helper is needless churn for future readers. Fix: delete intPtr and use ptrInt everywhere in the package.

🪰 Gadfly · advisory

🟡 **Duplicate intPtr test helper when ptrInt already exists in same package** _maintainability · flagged by 3 models_ - **`internal/service/seed_lots_test.go:345`** — `func intPtr(v int) *int` duplicates `ptrInt` in `plants_test.go:12` (same package, different name). Having two names for the same helper is needless churn for future readers. **Fix:** delete `intPtr` and use `ptrInt` everywhere in the package. <sub>🪰 Gadfly · advisory</sub>
// 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)
}
}
+12
View File
3
@@ -140,6 +140,18 @@ func (d *DB) DeleteSeedLot(ctx context.Context, id int64) error {
return nil return nil
} }
// CountSeedLotsForPlant returns how many lots reference a plant. Used to refuse
// deleting a plant that has purchase records: the FK is ON DELETE CASCADE, so
// without this check the delete would silently destroy them.
func (d *DB) CountSeedLotsForPlant(ctx context.Context, plantID int64) (int, error) {
var n int
if err := d.sql.QueryRowContext(ctx,
`SELECT COUNT(*) FROM seed_lots WHERE plant_id = ?`, plantID).Scan(&n); err != nil {
return 0, fmt.Errorf("store: count seed lots for plant: %w", err)
}
return n, nil
}
// SeedLotUse is one active planting attributed to a lot, carrying just enough to // SeedLotUse is one active planting attributed to a lot, carrying just enough to
// compute its effective plant count. // compute its effective plant count.
type SeedLotUse struct { type SeedLotUse struct {