Seed provenance + inventory: source links and seed lots (#50) #64
@@ -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
|
||||
// ErrPlantInUse rather than orphaning history; the client clones-and-edits or
|
||||
// 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 {
|
||||
if _, err := s.writablePlant(ctx, actorID, plantID); err != nil {
|
||||
return err
|
||||
@@ -140,6 +146,13 @@ func (s *Service) DeletePlant(ctx context.Context, actorID, plantID int64) error
|
||||
if n > 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -343,3 +343,29 @@ 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,18 @@ func (d *DB) DeleteSeedLot(ctx context.Context, id int64) error {
|
||||
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
|
||||
// compute its effective plant count.
|
||||
type SeedLotUse struct {
|
||||
|
||||
Reference in New Issue
Block a user
🟡 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) *intduplicatesptrIntinplants_test.go:12(same package, different name). Having two names for the same helper is needless churn for future readers. Fix: deleteintPtrand useptrInteverywhere in the package.🪰 Gadfly · advisory