Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
520 lines
19 KiB
Go
520 lines
19 KiB
Go
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)
|
|
}
|
|
}
|