Seed provenance + inventory: source links and seed lots (#50) (#64)
Build image / build-and-push (push) Successful in 8s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #64.
This commit is contained in:
2026-07-21 05:39:24 +00:00
committed by steve
parent 2a8fe24766
commit 78a04672b5
18 changed files with 1511 additions and 31 deletions
+17
View File
@@ -50,6 +50,9 @@ type PlantingInput struct {
Count *int
Label *string
PlantedAt *string
// SeedLotID attributes this planting to a purchase, so the lot can report
// what's left. Optional.
SeedLotID *int64
}
// PlantingPatch is a partial update. The nullable fields (Count/Label/PlantedAt/
@@ -68,6 +71,8 @@ type PlantingPatch struct {
PlantedAt *string
SetRemovedAt bool
RemovedAt *string
SetSeedLotID bool
SeedLotID *int64
}
// CreatePlanting places a plop in a plantable object the actor can edit.
@@ -93,6 +98,7 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
Count: in.Count,
Label: trimStringPtr(in.Label),
PlantedAt: in.PlantedAt,
SeedLotID: in.SeedLotID,
}
if p.PlantedAt == nil {
today := s.now().UTC().Format(dateLayout)
@@ -101,6 +107,9 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
if err := finalizePlanting(p, o, true); err != nil {
return nil, err
}
if err := s.checkSeedLotForPlanting(ctx, actorID, p.SeedLotID, p.PlantID); err != nil {
return nil, err
}
created, err := s.store.CreatePlanting(ctx, p)
if err != nil {
return nil, err
@@ -148,6 +157,11 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
if err := finalizePlanting(pl, o, checkBounds); err != nil {
return nil, err
}
// Re-checked on every update, not just when the lot changes: a plant swap can
// leave a previously-valid attribution pointing at a lot of the wrong variety.
if err := s.checkSeedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID); err != nil {
return nil, err
}
pl.Version = version
updated, err := s.store.UpdatePlanting(ctx, pl)
@@ -249,6 +263,9 @@ func applyPlantingPatch(pl *domain.Planting, p PlantingPatch) {
if p.SetRemovedAt {
pl.RemovedAt = p.RemovedAt
}
if p.SetSeedLotID {
pl.SeedLotID = p.SeedLotID
}
}
// finalizePlanting validates a built/merged plop against its parent object.
+37 -1
View File
@@ -18,6 +18,7 @@ const (
minPlantSpacingCM = 0.1
maxPlantSpacingCM = 10_000 // 100 m — headroom for large trees/shrubs
maxDaysToMaturity = 3650 // ~10 years
maxPlantVendorLen = 200
)
// plantCategories is the set of valid category enum values (mirrors the schema
@@ -40,7 +41,11 @@ type PlantInput struct {
Color string
Icon string
DaysToMaturity *int
Notes string
// Where this variety came from — the page you'd go back to to buy it again.
// What you actually bought, and how much is left, is a SeedLot instead.
SourceURL string
Vendor string
Notes string
}
// PlantPatch is a partial update: nil fields are left unchanged. DaysToMaturity
@@ -54,6 +59,8 @@ type PlantPatch struct {
Icon *string
SetDays bool
DaysToMaturity *int
SourceURL *string
Vendor *string
Notes *string
}
@@ -73,6 +80,8 @@ func (s *Service) CreatePlant(ctx context.Context, actorID int64, in PlantInput)
Color: in.Color,
Icon: strings.TrimSpace(in.Icon),
DaysToMaturity: in.DaysToMaturity,
SourceURL: strings.TrimSpace(in.SourceURL),
Vendor: strings.TrimSpace(in.Vendor),
Notes: strings.TrimSpace(in.Notes),
}
if err := finalizePlant(p); err != nil {
@@ -121,6 +130,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
@@ -132,6 +147,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)
}
@@ -155,6 +177,12 @@ func applyPlantPatch(p *domain.Plant, patch PlantPatch) {
if patch.SetDays {
p.DaysToMaturity = patch.DaysToMaturity
}
if patch.SourceURL != nil {
p.SourceURL = strings.TrimSpace(*patch.SourceURL)
}
if patch.Vendor != nil {
p.Vendor = strings.TrimSpace(*patch.Vendor)
}
if patch.Notes != nil {
p.Notes = strings.TrimSpace(*patch.Notes)
}
@@ -184,5 +212,13 @@ func finalizePlant(p *domain.Plant) error {
if len(p.Notes) > maxPlantNotesLen {
return domain.ErrInvalidInput
}
if len(p.Vendor) > maxPlantVendorLen {
return domain.ErrInvalidInput
}
// Rendered as a clickable link, so the scheme check is load-bearing (see
// validSourceURL) rather than tidiness.
if !validSourceURL(p.SourceURL) {
return domain.ErrInvalidInput
}
return nil
}
+6 -6
View File
@@ -184,12 +184,12 @@ func TestCreatePlantValidation(t *testing.T) {
owner := seedUser(t, s, "[email protected]")
bad := []PlantInput{
{Name: "", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // empty name
{Name: "x", Category: "spaceship", SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // bad category
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 0, Color: "#4a7c3f", Icon: "🌿"}, // zero spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: -5, Color: "#4a7c3f", Icon: "🌿"}, // negative spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "nope", Icon: "🌿"}, // bad color
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: ""}, // empty icon
{Name: "", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // empty name
{Name: "x", Category: "spaceship", SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // bad category
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 0, Color: "#4a7c3f", Icon: "🌿"}, // zero spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: -5, Color: "#4a7c3f", Icon: "🌿"}, // negative spacing
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "nope", Icon: "🌿"}, // bad color
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: ""}, // empty icon
{Name: strings.Repeat("x", maxPlantNameLen+1), Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // name too long
{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿", DaysToMaturity: ptrInt(0)}, // days must be >= 1
}
+24
View File
@@ -417,6 +417,9 @@ type revertOps[T any] struct {
// remove undoes a creation. It returns the changes it made, because deleting
// an object also deletes the plantings hanging off it.
remove func(ctx context.Context, row *T) ([]change, error)
// beforeRestore fixes up a snapshot whose references may have gone stale
// while it sat in history.
beforeRestore func(ctx context.Context, row *T) error
// blockRemoval optionally refuses a removal, e.g. an object that has gained
// plantings since. Returns a conflict reason, or "" to allow it.
blockRemoval func(ctx context.Context, row *T) (string, error)
@@ -445,6 +448,11 @@ func revertEntity[T any](
if err := unsnapshot(r.Before, &target); err != nil {
return nil, nil, err
}
if ops.beforeRestore != nil {
if err := ops.beforeRestore(ctx, &target); err != nil {
return nil, nil, err
}
}
restored, err := ops.restore(ctx, &target)
if err != nil {
// The id may have been taken between the check above and this insert.
@@ -547,6 +555,22 @@ func plantingRevertOps(s *Service) revertOps[domain.Planting] {
get: s.store.GetPlanting,
update: s.store.UpdatePlanting,
restore: s.store.RestorePlanting,
// A snapshot can name a seed lot that has since been deleted. The live
// rows got their link nulled by ON DELETE SET NULL, but the snapshot
// still holds the old id, and restoring it would violate the foreign key
// and abort the whole revert. Drop the dangling link instead: the plant
// really was in the ground, which is the part worth restoring.
beforeRestore: func(ctx context.Context, p *domain.Planting) error {
if p.SeedLotID == nil {
return nil
}
if _, err := s.store.GetSeedLot(ctx, *p.SeedLotID); errors.Is(err, domain.ErrNotFound) {
p.SeedLotID = nil
} else if err != nil {
return err
}
return nil
},
remove: func(ctx context.Context, p *domain.Planting) ([]change, error) {
if err := s.store.DeletePlanting(ctx, p.ID); err != nil {
return nil, err
+347
View File
@@ -0,0 +1,347 @@
package service
import (
"context"
"log/slog"
"net/url"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Seed provenance and inventory (#50). A lot is one purchase — a specific packet
// of a specific variety, from a specific vendor, in a specific year — and it is
// owned by the buyer even when it points at a built-in plant. Lots are private:
// they are never shared along with a garden, because what you paid for seed is
// not part of a garden plan.
const (
maxLotTextLen = 200
maxLotNotesLen = 10_000
maxSourceURLLen = 2000
maxLotQuantity = 1_000_000
minPackedYear = 1900
maxPackedYear = 2200
)
// lotUnits is the set of valid quantity units (mirrors the schema CHECK).
var lotUnits = map[string]struct{}{
domain.UnitSeeds: {},
domain.UnitGrams: {},
domain.UnitOunces: {},
domain.UnitPackets: {},
domain.UnitBulbs: {},
domain.UnitPlants: {},
}
// SeedLotInput is the payload for recording a purchase.
type SeedLotInput struct {
PlantID int64
Vendor string
SourceURL string
SKU string
LotCode string
PurchasedAt *string
PackedForYear *int
Quantity float64
Unit string
CostCents *int
GerminationPct *float64
Notes string
}
// SeedLotPatch is a partial update; nil fields are left unchanged. The nullable
// columns use a Set* flag to tell "clear to NULL" from "leave alone". PlantID is
// absent deliberately: re-pointing a purchase at a different variety would
// rewrite history rather than correct it.
type SeedLotPatch struct {
Vendor *string
SourceURL *string
SKU *string
LotCode *string
SetPurchasedAt bool
PurchasedAt *string
SetPackedForYear bool
PackedForYear *int
Quantity *float64
Unit *string
SetCostCents bool
CostCents *int
SetGerminationPct bool
GerminationPct *float64
Notes *string
}
// ListSeedLots returns the actor's own lots, each with Used/Remaining filled in.
// Optionally narrowed to one plant.
func (s *Service) ListSeedLots(ctx context.Context, actorID int64, plantID *int64) ([]domain.SeedLot, error) {
lots, err := s.store.ListSeedLotsForOwner(ctx, actorID, plantID)
if err != nil {
return nil, err
}
if err := s.fillRemaining(ctx, actorID, lots); err != nil {
return nil, err
}
return lots, nil
}
// fillRemaining computes Used and Remaining for a slice of the actor's lots.
//
// Deliberately derived, never stored. The alternative — decrementing a column
// when you plant — drifts the moment anything edits or removes a planting behind
// its back, and once it has drifted there is no way to tell what the true number
// was. Attribution plus arithmetic can always be recomputed and audited.
// Remaining may go NEGATIVE, and that is deliberate: it means more was planted
// than the lot recorded buying, which is a real thing that happens (a
// miscounted packet, a lot entered after the fact). Clamping it to zero would
// hide the discrepancy at exactly the moment it is worth seeing.
func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error {
if len(lots) == 0 {
return nil
}
ids := make([]int64, 0, len(lots))
for i := range lots {
ids = append(ids, lots[i].ID)
}
uses, err := s.store.SeedLotUsage(ctx, actorID, ids)
if err != nil {
return err
}
used := make(map[int64]float64, len(lots))
for _, u := range uses {
n := derivedCount(u.RadiusCM, u.SpacingCM)
if u.Count != nil {
n = *u.Count
}
used[u.LotID] += float64(n)
}
for i := range lots {
lots[i].Used = used[lots[i].ID]
lots[i].Remaining = lots[i].Quantity - lots[i].Used
}
return nil
}
// ownSeedLot loads a lot and enforces that the actor owns it. Someone else's lot
// is ErrNotFound, not ErrForbidden — existence stays masked, same as gardens and
// other users' plants. A store failure that isn't "no such row" passes through
// unchanged; it is not an authorization answer.
func (s *Service) ownSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
l, err := s.store.GetSeedLot(ctx, lotID)
if err != nil {
return nil, err // ErrNotFound for a missing row; anything else is real
}
if l.OwnerID != actorID {
return nil, domain.ErrNotFound
}
return l, nil
}
// GetSeedLot returns one of the actor's own lots, with Used/Remaining filled in.
func (s *Service) GetSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {
l, err := s.ownSeedLot(ctx, actorID, lotID)
if err != nil {
return nil, err
}
return s.withRemaining(ctx, actorID, l)
}
// CreateSeedLot records a purchase owned by the actor. The plant must be one the
// actor can see — a built-in or their own — since you can perfectly well buy
// generic Garlic from a vendor.
func (s *Service) CreateSeedLot(ctx context.Context, actorID int64, in SeedLotInput) (*domain.SeedLot, error) {
if _, err := s.visiblePlant(ctx, actorID, in.PlantID); err != nil {
return nil, err // ErrInvalidInput for an unknown or someone else's plant
}
l := &domain.SeedLot{
OwnerID: actorID,
PlantID: in.PlantID,
Vendor: strings.TrimSpace(in.Vendor),
SourceURL: strings.TrimSpace(in.SourceURL),
SKU: strings.TrimSpace(in.SKU),
LotCode: strings.TrimSpace(in.LotCode),
PurchasedAt: in.PurchasedAt,
PackedForYear: in.PackedForYear,
Quantity: in.Quantity,
Unit: in.Unit,
CostCents: in.CostCents,
GerminationPct: in.GerminationPct,
Notes: strings.TrimSpace(in.Notes),
}
if err := finalizeSeedLot(l); err != nil {
return nil, err
}
created, err := s.store.CreateSeedLot(ctx, l)
if err != nil {
return nil, err
}
// A fresh lot has used nothing, but say so explicitly rather than letting the
// zero value stand in: an unfilled Remaining reads as "none left" on a packet
// you just bought.
return s.withRemaining(ctx, actorID, created)
}
// withRemaining fills Used/Remaining on a single lot.
func (s *Service) withRemaining(ctx context.Context, actorID int64, l *domain.SeedLot) (*domain.SeedLot, error) {
one := []domain.SeedLot{*l}
if err := s.fillRemaining(ctx, actorID, one); err != nil {
return nil, err
}
return &one[0], nil
}
// UpdateSeedLot applies a partial, version-guarded patch to the actor's own lot.
func (s *Service) UpdateSeedLot(ctx context.Context, actorID, lotID int64, patch SeedLotPatch, version int64) (*domain.SeedLot, error) {
l, err := s.ownSeedLot(ctx, actorID, lotID)
if err != nil {
return nil, err
}
applySeedLotPatch(l, patch)
if err := finalizeSeedLot(l); err != nil {
return nil, err
}
l.Version = version
updated, err := s.store.UpdateSeedLot(ctx, l)
if err != nil {
// The conflict path carries the CURRENT row back to the client to rebase
// on, so it needs its computed fields too — a 409 body reporting
// remaining=0 would be worse than no number at all.
if updated != nil {
if filled, ferr := s.withRemaining(ctx, actorID, updated); ferr == nil {
updated = filled
}
}
return updated, err
}
// The write succeeded. If the follow-up read fails, return the row without
// its computed fields rather than reporting a failed update that applied.
filled, ferr := s.withRemaining(ctx, actorID, updated)
if ferr != nil {
slog.Error("service: lot updated but remaining could not be computed", "error", ferr, "lot", lotID)
return updated, nil
}
return filled, nil
}
// DeleteSeedLot removes the actor's own lot. Plantings attributed to it survive
// with a null link (ON DELETE SET NULL) — the plants really were in the ground,
// whatever happened to the record of the packet.
func (s *Service) DeleteSeedLot(ctx context.Context, actorID, lotID int64) error {
if _, err := s.ownSeedLot(ctx, actorID, lotID); err != nil {
return err
}
return s.store.DeleteSeedLot(ctx, lotID)
}
// checkSeedLotForPlanting validates a planting's lot attribution: the lot must be
// the actor's, and must be a lot OF the plant being planted. Attributing a garlic
// planting to a tomato lot is a data error that would silently corrupt every
// remaining count downstream, so it's refused rather than stored.
func (s *Service) checkSeedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {
if lotID == nil {
return nil
}
l, err := s.ownSeedLot(ctx, actorID, *lotID)
if err != nil {
return domain.ErrInvalidInput // unknown or not yours: an invalid reference
}
if l.PlantID != plantID {
return domain.ErrInvalidInput
}
return nil
}
func applySeedLotPatch(l *domain.SeedLot, p SeedLotPatch) {
if p.Vendor != nil {
l.Vendor = strings.TrimSpace(*p.Vendor)
}
if p.SourceURL != nil {
l.SourceURL = strings.TrimSpace(*p.SourceURL)
}
if p.SKU != nil {
l.SKU = strings.TrimSpace(*p.SKU)
}
if p.LotCode != nil {
l.LotCode = strings.TrimSpace(*p.LotCode)
}
if p.SetPurchasedAt {
l.PurchasedAt = p.PurchasedAt
}
if p.SetPackedForYear {
l.PackedForYear = p.PackedForYear
}
if p.Quantity != nil {
l.Quantity = *p.Quantity
}
if p.Unit != nil {
l.Unit = *p.Unit
}
if p.SetCostCents {
l.CostCents = p.CostCents
}
if p.SetGerminationPct {
l.GerminationPct = p.GerminationPct
}
if p.Notes != nil {
l.Notes = strings.TrimSpace(*p.Notes)
}
}
// finalizeSeedLot validates a built/merged lot. Shared by create and update.
func finalizeSeedLot(l *domain.SeedLot) error {
if _, ok := lotUnits[l.Unit]; !ok {
return domain.ErrInvalidInput
}
if !isFinite(l.Quantity) || l.Quantity < 0 || l.Quantity > maxLotQuantity {
return domain.ErrInvalidInput
}
if len(l.Vendor) > maxLotTextLen || len(l.SKU) > maxLotTextLen || len(l.LotCode) > maxLotTextLen {
return domain.ErrInvalidInput
}
if len(l.Notes) > maxLotNotesLen {
return domain.ErrInvalidInput
}
if !validSourceURL(l.SourceURL) {
return domain.ErrInvalidInput
}
if !validDatePtr(l.PurchasedAt) {
return domain.ErrInvalidInput
}
if l.PackedForYear != nil && (*l.PackedForYear < minPackedYear || *l.PackedForYear > maxPackedYear) {
return domain.ErrInvalidInput
}
if l.CostCents != nil && *l.CostCents < 0 {
return domain.ErrInvalidInput
}
if l.GerminationPct != nil {
g := *l.GerminationPct
if !isFinite(g) || g < 0 || g > 100 {
return domain.ErrInvalidInput
}
}
return nil
}
// validSourceURL accepts an empty string or an absolute http(s) URL.
//
// This renders as a clickable link, so it is untrusted input in the way that
// matters: without the scheme check a "javascript:" URL stored here becomes
// script execution the moment someone clicks it. Only http and https, and only
// with a host — a relative or schemeless URL would resolve against pansy's own
// origin, which is never what a vendor link means.
func validSourceURL(raw string) bool {
if raw == "" {
return true
}
if len(raw) > maxSourceURLLen {
return false
}
u, err := url.Parse(raw)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
return u.Host != ""
}
+519
View File
@@ -0,0 +1,519 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// seedLot records a purchase of plantID with the given quantity in seeds.
func seedLot(t *testing.T, s *Service, owner, plantID int64, qty float64, over func(*SeedLotInput)) *domain.SeedLot {
t.Helper()
in := SeedLotInput{PlantID: plantID, Quantity: qty, Unit: domain.UnitSeeds}
if over != nil {
over(&in)
}
l, err := s.CreateSeedLot(context.Background(), owner, in)
if err != nil {
t.Fatalf("CreateSeedLot: %v", err)
}
return l
}
// builtinPlantID returns a seeded built-in's id — the case that proves a lot can
// reference a plant it doesn't own.
func builtinPlantID(t *testing.T, s *Service, actor int64) int64 {
t.Helper()
plants, err := s.ListPlants(context.Background(), actor)
if err != nil {
t.Fatalf("ListPlants: %v", err)
}
for _, p := range plants {
if p.OwnerID == nil {
return p.ID
}
}
t.Fatal("no built-in plants seeded")
return 0
}
// TestTwoLotsOfOnePlantTrackIndependently — the reason inventory belongs to a
// purchase and not to a variety: the same garlic from two vendors in two years
// is two different things.
func TestTwoLotsOfOnePlantTrackIndependently(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)
ctx := context.Background()
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 = ptrInt(2026)
})
fedco := seedLot(t, s, owner, plant.ID, 50, func(in *SeedLotInput) {
in.Vendor = "Fedco"
in.PackedForYear = ptrInt(2025)
})
// Plant 12 out of the Johnny's lot only.
twelve := 12
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &twelve, SeedLotID: &johnnys.ID,
}); err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
lots, err := s.ListSeedLots(ctx, owner, nil)
if err != nil {
t.Fatalf("ListSeedLots: %v", err)
}
byID := map[int64]domain.SeedLot{}
for _, l := range lots {
byID[l.ID] = l
}
if got := byID[johnnys.ID]; got.Used != 12 || got.Remaining != 88 {
t.Errorf("Johnny's lot: used=%v remaining=%v, want 12/88", got.Used, got.Remaining)
}
if got := byID[fedco.ID]; got.Used != 0 || got.Remaining != 50 {
t.Errorf("Fedco lot: used=%v remaining=%v, want 0/50", got.Used, got.Remaining)
}
if byID[johnnys.ID].Vendor != "Johnny's" || byID[johnnys.ID].SourceURL == "" {
t.Errorf("provenance lost: %+v", byID[johnnys.ID])
}
}
// TestRemainingReturnsWhenAPlantingIsRemoved — the acceptance criterion, and the
// reason `remaining` is derived rather than decremented on planting: removing a
// plop has to give the seed back, without anything having to remember to.
func TestRemainingReturnsWhenAPlantingIsRemoved(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
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID,
})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
if got, _ := s.GetSeedLot(ctx, owner, lot.ID); got.Remaining != 90 {
t.Fatalf("remaining after planting = %v, want 90", got.Remaining)
}
// Soft-remove ("cleared the bed"): only ACTIVE plantings count against a lot.
today := "2026-08-01"
if _, err := s.UpdatePlanting(ctx, owner, pl.ID, PlantingPatch{
SetRemovedAt: true, RemovedAt: &today,
}, pl.Version); err != nil {
t.Fatalf("UpdatePlanting: %v", err)
}
if got, _ := s.GetSeedLot(ctx, owner, lot.ID); got.Remaining != 100 || got.Used != 0 {
t.Errorf("after removal: used=%v remaining=%v, want 0/100", got.Used, got.Remaining)
}
}
// TestLotOnBuiltinPlantIsPrivate — you can buy generic Garlic from Johnny's, so
// a lot may point at a plant it doesn't own; the LOT is still yours alone.
func TestLotOnBuiltinPlantIsPrivate(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
other := seedUser(t, s, "[email protected]")
ctx := context.Background()
builtin := builtinPlantID(t, s, owner)
lot := seedLot(t, s, owner, builtin, 25, func(in *SeedLotInput) { in.Vendor = "Johnny's" })
if _, err := s.GetSeedLot(ctx, owner, lot.ID); err != nil {
t.Fatalf("owner can't read their own lot: %v", err)
}
// Another user gets ErrNotFound, not Forbidden: existence stays masked.
if _, err := s.GetSeedLot(ctx, other, lot.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("other user read err = %v, want ErrNotFound", err)
}
if _, err := s.UpdateSeedLot(ctx, other, lot.ID, SeedLotPatch{Notes: strPtr("mine now")}, lot.Version); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("other user update err = %v, want ErrNotFound", err)
}
if err := s.DeleteSeedLot(ctx, other, lot.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("other user delete err = %v, want ErrNotFound", err)
}
if lots, _ := s.ListSeedLots(ctx, other, nil); len(lots) != 0 {
t.Errorf("other user sees %d lots", len(lots))
}
}
// TestSourceURLValidation — the field renders as a clickable link, so the scheme
// check is what stops a stored javascript: URL from becoming script execution.
func TestSourceURLValidation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
ok := []string{
"",
"https://www.johnnyseeds.com/x",
"http://example.com/seeds?sku=123",
}
for _, u := range ok {
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, SourceURL: u,
}); err != nil {
t.Errorf("SourceURL %q rejected: %v", u, err)
}
}
bad := []string{
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"//evil.example.com", // schemeless: would resolve against pansy's origin
"/seeds/garlic", // relative, same reason
"file:///etc/passwd",
"https://", // no host
}
for _, u := range bad {
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, SourceURL: u,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("SourceURL %q accepted (err=%v)", u, err)
}
}
// The same rule applies to the plant's own source link.
if _, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "X", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿",
SourceURL: "javascript:alert(1)",
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("plant SourceURL accepted a javascript: URL (err=%v)", err)
}
}
// TestPlantCarriesProvenance — the headline: "German Red Garlic" links back to
// the page it came from, and the built-ins keep working untouched.
func TestPlantCarriesProvenance(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
ctx := context.Background()
p, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "German Red Garlic", Category: domain.CategoryVegetable, SpacingCM: 15,
Color: "#8a5a8a", Icon: "🧄",
SourceURL: "https://www.johnnyseeds.com/vegetables/garlic/german-red-garlic",
Vendor: "Johnny's Selected Seeds",
})
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
if p.Vendor != "Johnny's Selected Seeds" || p.SourceURL == "" {
t.Errorf("provenance not stored: %+v", p)
}
// The built-ins predate these columns and must still read cleanly.
plants, _ := s.ListPlants(ctx, owner)
for _, b := range plants {
if b.OwnerID == nil && (b.SourceURL != "" || b.Vendor != "") {
t.Errorf("built-in %q gained provenance from nowhere: %+v", b.Name, b)
}
}
}
// TestPlantingRejectsMismatchedLot — attributing a garlic planting to a tomato
// lot would silently corrupt every remaining count downstream.
func TestPlantingRejectsMismatchedLot(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
other := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
garlic := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
tomato, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "Tomato", Category: domain.CategoryVegetable, SpacingCM: 45, Color: "#c0392b", Icon: "🍅",
})
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
tomatoLot := seedLot(t, s, owner, tomato.ID, 20, nil)
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: garlic.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &tomatoLot.ID,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("a garlic planting was attributed to a tomato lot (err=%v)", err)
}
// Someone else's lot is an invalid reference, not a leak of its existence.
othersPlant := seedOwnPlant(t, s, other, 15)
othersLot := seedLot(t, s, other, othersPlant.ID, 10, nil)
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: garlic.ID, XCM: 0, YCM: 0, RadiusCM: 20, SeedLotID: &othersLot.ID,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("another user's lot was accepted (err=%v)", err)
}
}
// TestDeletingALotKeepsPlantingHistory — the plants really were in the ground,
// whatever happened to the record of the packet.
func TestDeletingALotKeepsPlantingHistory(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.DeleteSeedLot(ctx, owner, lot.ID); err != nil {
t.Fatalf("DeleteSeedLot: %v", err)
}
survived, err := s.store.GetPlanting(ctx, pl.ID)
if err != nil {
t.Fatalf("planting was destroyed with its lot: %v", err)
}
if survived.SeedLotID != nil {
t.Errorf("seedLotId = %v, want null after the lot was deleted", *survived.SeedLotID)
}
}
// TestSeedLotVersionGuard — lots are version-guarded like every other mutable row.
func TestSeedLotVersionGuard(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)
ctx := context.Background()
updated, err := s.UpdateSeedLot(ctx, owner, lot.ID, SeedLotPatch{Notes: strPtr("half used")}, lot.Version)
if err != nil {
t.Fatalf("UpdateSeedLot: %v", err)
}
if updated.Notes != "half used" || updated.Version != lot.Version+1 {
t.Errorf("unexpected update: %+v", updated)
}
// Stale version → conflict, carrying the current row.
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.Notes != "half used" {
t.Errorf("conflict should carry the current row, got %+v", current)
}
}
// TestSeedLotUnitAndQuantityValidation
func TestSeedLotUnitAndQuantityValidation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: plant.ID, Unit: "handfuls"}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown unit accepted: %v", err)
}
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, Quantity: -1,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("negative quantity accepted: %v", err)
}
pct := 140.0
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, GerminationPct: &pct,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("140%% germination accepted: %v", err)
}
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Unit: domain.UnitSeeds, PurchasedAt: strPtr("not-a-date"),
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("malformed purchase date accepted: %v", err)
}
}
// 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 = ptrInt(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)
}
}
// 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)
}
}