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
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -343,3 +343,29 @@ func TestSeedLotUnitAndQuantityValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func intPtr(v int) *int { return &v }
|
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
|
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 {
|
||||||
|
|||||||
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