Files
pansy/internal/service/gardens_test.go
T
steveandClaude Opus 4.8 f4c5db587f Season view: filter the editor to a year (#54)
"Change the garlic bed to cucumbers this year" has a year in it, and pansy had
no notion of one — the editor showed whatever is currently planted, full stop.

The data has always supported seasons: plantings carry planted_at and
removed_at, and "clear bed" soft-removes rather than deleting. A season is a
date range over data that already exists. No schema change, and specifically no
seasons table — it would duplicate what the dates already say and create a
second source of truth about when something was in the ground.

?year=YYYY on /full returns every plop whose [planted_at, removed_at] interval
overlapped that calendar year, so garlic planted in October and pulled the
following July appears in BOTH years, which is what actually happened. Undated
plantings appear in every year: everything that predates this feature has a null
planted_at, and a rule that excluded them would empty every existing garden the
moment a year was selected. Without the param /full behaves exactly as before —
the existing tests pass unchanged, which was the point of doing it this way.

Widening to past plops means widening the referenced-plant lookup with it.
A plant pulled last July isn't active, but its plops still have to render with
the right icon and colour, so ListReferencedPlants takes an includeRemoved flag
that tracks the same switch.

A past season is READ-ONLY, gated at the single canEdit the palette, inspector,
nudging, placement and drag handles all key off. Editing the past by accident is
the failure mode this feature introduces, so the state is stated in a banner
rather than implied by a dropdown you set a while ago, with the way back to the
live garden next to it.

The season is a separate query under its own key. The optimistic mutations all
patch gardenFullKey(gardenId); folding a year into that key would let them write
into whichever season happened to be on screen. Read-only views never need that
machinery, and keeping them out of it means they can't accidentally join it.

The year selector offers only years the garden holds data for, plus the current
one. A free numeric field invites a typo, and a typo'd year produces a
confidently empty garden that reads as data loss rather than a mistake — the
server bounds the year for the same reason.

Closes #54

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:40:26 -04:00

531 lines
19 KiB
Go

package service
import (
"context"
"errors"
"math"
"strings"
"testing"
"unicode/utf8"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// seedUser registers a local user and returns its id.
func seedUser(t *testing.T, s *Service, email string) int64 {
t.Helper()
u := mustRegister(t, s, email, email, "password123")
return u.ID
}
func TestCreateGardenAppliesDefaults(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(context.Background(), owner, GardenInput{Name: " Backyard "})
if err != nil {
t.Fatalf("CreateGarden: %v", err)
}
if g.Name != "Backyard" {
t.Errorf("name = %q, want trimmed 'Backyard'", g.Name)
}
if g.WidthCM != defaultGardenCM || g.HeightCM != defaultGardenCM {
t.Errorf("dimensions = %vx%v, want %d default", g.WidthCM, g.HeightCM, defaultGardenCM)
}
if g.UnitPref != domain.UnitMetric {
t.Errorf("unit = %q, want metric", g.UnitPref)
}
if g.OwnerID != owner {
t.Errorf("owner = %d, want %d", g.OwnerID, owner)
}
if g.Version != 1 {
t.Errorf("version = %d, want 1", g.Version)
}
}
func TestCreateGardenValidation(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
cases := []GardenInput{
{Name: " "}, // blank name
{Name: strings.Repeat("a", maxGardenNameLen+1)}, // name too long
{Name: "X", Notes: strings.Repeat("b", maxGardenNotesLen+1)}, // notes too long
{Name: "X", UnitPref: "furlongs"}, // bad unit
{Name: "X", WidthCM: -5}, // negative (defaults only fill exact 0)
{Name: "X", WidthCM: maxGardenCM + 1}, // over the cap
{Name: "X", HeightCM: maxGardenCM * 10}, // over the cap
{Name: "X", WidthCM: math.NaN()}, // NaN slips past naive < / >
{Name: "X", HeightCM: math.Inf(1)}, // +Inf
{Name: "X", WidthCM: math.SmallestNonzeroFloat64}, // subnormal, > 0 but < 1 cm
{Name: "X", GridSizeCM: maxGardenCM + 1}, // grid over the cap
}
for i, in := range cases {
if _, err := s.CreateGarden(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("case %d: err = %v, want ErrInvalidInput", i, err)
}
}
// A dimension exactly at the cap is valid.
if _, err := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Big", WidthCM: maxGardenCM, HeightCM: maxGardenCM}); err != nil {
t.Errorf("dimension at cap should be valid: %v", err)
}
}
func TestGardenGridSettings(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
ctx := context.Background()
// An omitted grid size lands the default; snap defaults off.
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "G"})
if err != nil {
t.Fatalf("CreateGarden: %v", err)
}
if g.GridSizeCM != defaultGardenGridCM {
t.Errorf("grid = %v, want default %d", g.GridSizeCM, defaultGardenGridCM)
}
if g.SnapToGrid {
t.Error("snap should default off")
}
// An update sets an explicit grid and turns snap on; both persist.
up, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{
Name: "G", WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref,
GridSizeCM: 25, SnapToGrid: true,
}, g.Version)
if err != nil {
t.Fatalf("UpdateGarden: %v", err)
}
got, err := s.GetGarden(ctx, owner, g.ID)
if err != nil {
t.Fatalf("GetGarden: %v", err)
}
if got.GridSizeCM != 25 || !got.SnapToGrid {
t.Errorf("persisted grid=%v snap=%v, want 25/true", got.GridSizeCM, got.SnapToGrid)
}
// An update omitting the grid size (0) is re-defaulted, not stored as 0.
up2, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{
Name: "G", WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref,
}, up.Version)
if err != nil {
t.Fatalf("UpdateGarden(reset): %v", err)
}
if up2.GridSizeCM != defaultGardenGridCM || up2.SnapToGrid {
t.Errorf("after omit grid=%v snap=%v, want default/false", up2.GridSizeCM, up2.SnapToGrid)
}
}
func TestListGardensOwnedOnly(t *testing.T) {
s := newTestService(t, openConfig())
alice := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
if _, err := s.CreateGarden(context.Background(), alice, GardenInput{Name: "Alice A"}); err != nil {
t.Fatal(err)
}
if _, err := s.CreateGarden(context.Background(), alice, GardenInput{Name: "Alice B"}); err != nil {
t.Fatal(err)
}
if _, err := s.CreateGarden(context.Background(), bob, GardenInput{Name: "Bob A"}); err != nil {
t.Fatal(err)
}
aliceGardens, err := s.ListGardens(context.Background(), alice)
if err != nil {
t.Fatalf("ListGardens: %v", err)
}
if len(aliceGardens) != 2 {
t.Errorf("alice sees %d gardens, want 2", len(aliceGardens))
}
for _, g := range aliceGardens {
if g.OwnerID != alice {
t.Errorf("alice's list contains a garden owned by %d", g.OwnerID)
}
}
// A user with no gardens gets a non-nil empty list.
carol := seedUser(t, s, "[email protected]")
if gs, err := s.ListGardens(context.Background(), carol); err != nil || gs == nil || len(gs) != 0 {
t.Errorf("carol list = (%v, %v), want empty non-nil", gs, err)
}
}
func TestUpdateGardenHappyPathBumpsVersion(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Yard"})
updated, err := s.UpdateGarden(context.Background(), owner, g.ID,
GardenInput{Name: "Front Yard", WidthCM: 200, HeightCM: 400, UnitPref: domain.UnitImperial, Notes: "sunny"}, g.Version)
if err != nil {
t.Fatalf("UpdateGarden: %v", err)
}
if updated.Name != "Front Yard" || updated.WidthCM != 200 || updated.UnitPref != domain.UnitImperial {
t.Errorf("update didn't persist: %+v", updated)
}
if updated.Version != g.Version+1 {
t.Errorf("version = %d, want %d", updated.Version, g.Version+1)
}
}
func TestUpdateGardenVersionConflictReturnsCurrent(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Yard"})
// First update succeeds and bumps the version to 2.
if _, err := s.UpdateGarden(context.Background(), owner, g.ID,
GardenInput{Name: "Yard v2", WidthCM: 1000, HeightCM: 1000, UnitPref: domain.UnitMetric}, g.Version); err != nil {
t.Fatalf("first update: %v", err)
}
// A second update at the stale version 1 conflicts and returns the current row.
current, err := s.UpdateGarden(context.Background(), owner, g.ID,
GardenInput{Name: "Yard v3", WidthCM: 1000, HeightCM: 1000, UnitPref: domain.UnitMetric}, g.Version)
if !errors.Is(err, domain.ErrVersionConflict) {
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
}
if current == nil || current.Name != "Yard v2" || current.Version != 2 {
t.Errorf("conflict didn't return the current row: %+v", current)
}
// Retrying with the fresh version succeeds.
if _, err := s.UpdateGarden(context.Background(), owner, g.ID,
GardenInput{Name: "Yard v3", WidthCM: 1000, HeightCM: 1000, UnitPref: domain.UnitMetric}, current.Version); err != nil {
t.Errorf("retry with fresh version failed: %v", err)
}
}
func TestUpdateGardenRejectsMissingDimensions(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Yard"})
// Update states every field; a 0 dimension is invalid (no defaulting on update).
if _, err := s.UpdateGarden(context.Background(), owner, g.ID,
GardenInput{Name: "Yard", UnitPref: domain.UnitMetric}, g.Version); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("update with 0 dims err = %v, want ErrInvalidInput", err)
}
}
func TestCrossUserAccessIsNotFound(t *testing.T) {
s := newTestService(t, openConfig())
alice := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(context.Background(), alice, GardenInput{Name: "Alice's"})
// Bob must not learn Alice's garden exists: every access is ErrNotFound.
if _, err := s.GetGarden(context.Background(), bob, g.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob get err = %v, want ErrNotFound", err)
}
if _, err := s.UpdateGarden(context.Background(), bob, g.ID,
GardenInput{Name: "hijack", WidthCM: 100, HeightCM: 100, UnitPref: domain.UnitMetric}, g.Version); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob update err = %v, want ErrNotFound", err)
}
if err := s.DeleteGarden(context.Background(), bob, g.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob delete err = %v, want ErrNotFound", err)
}
// Alice's garden is untouched.
if still, err := s.GetGarden(context.Background(), alice, g.ID); err != nil || still.Name != "Alice's" {
t.Errorf("alice's garden was affected: %+v %v", still, err)
}
}
func TestDeleteGarden(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(context.Background(), owner, GardenInput{Name: "Yard"})
if err := s.DeleteGarden(context.Background(), owner, g.ID); err != nil {
t.Fatalf("DeleteGarden: %v", err)
}
if _, err := s.GetGarden(context.Background(), owner, g.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("garden still present after delete: %v", err)
}
// Deleting again is ErrNotFound.
if err := s.DeleteGarden(context.Background(), owner, g.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("re-delete err = %v, want ErrNotFound", err)
}
}
func TestCopyGardenDuplicatesContents(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
src, err := s.CreateGarden(ctx, owner, GardenInput{
Name: "Home", WidthCM: 732, HeightCM: 732, UnitPref: domain.UnitImperial,
Notes: "the real one", GridSizeCM: 30, SnapToGrid: true,
})
if err != nil {
t.Fatalf("CreateGarden: %v", err)
}
bed := seedBed(t, s, owner, src.ID)
plant := seedOwnPlant(t, s, owner, 20)
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 10, YCM: -20, RadiusCM: 15})
if err != nil {
t.Fatalf("CreatePlanting: %v", err)
}
dup, err := s.CopyGarden(ctx, owner, src.ID, "")
if err != nil {
t.Fatalf("CopyGarden: %v", err)
}
if dup.ID == src.ID {
t.Fatal("copy reused the source id")
}
if dup.Name != "Home (copy)" {
t.Errorf("name = %q, want 'Home (copy)'", dup.Name)
}
if dup.OwnerID != owner {
t.Errorf("owner = %d, want %d", dup.OwnerID, owner)
}
if dup.Version != 1 {
t.Errorf("version = %d, want a fresh 1", dup.Version)
}
if dup.WidthCM != src.WidthCM || dup.HeightCM != src.HeightCM ||
dup.UnitPref != src.UnitPref || dup.Notes != src.Notes ||
dup.GridSizeCM != src.GridSizeCM || dup.SnapToGrid != src.SnapToGrid {
t.Errorf("settings not carried over: %+v vs source %+v", dup, src)
}
full, err := s.GardenFull(ctx, owner, dup.ID, nil)
if err != nil {
t.Fatalf("GardenFull(copy): %v", err)
}
if len(full.Objects) != 1 {
t.Fatalf("copy has %d objects, want 1", len(full.Objects))
}
got := full.Objects[0]
if got.ID == bed.ID {
t.Error("copied object reused the source object id")
}
if got.GardenID != dup.ID {
t.Errorf("copied object garden = %d, want the copy %d", got.GardenID, dup.ID)
}
if got.Kind != bed.Kind || got.XCM != bed.XCM || got.YCM != bed.YCM ||
got.WidthCM != bed.WidthCM || got.HeightCM != bed.HeightCM || got.Plantable != bed.Plantable {
t.Errorf("copied object differs: %+v vs source %+v", got, bed)
}
if len(full.Plantings) != 1 {
t.Fatalf("copy has %d plantings, want 1", len(full.Plantings))
}
gotPlop := full.Plantings[0]
if gotPlop.ID == plop.ID {
t.Error("copied planting reused the source planting id")
}
if gotPlop.ObjectID != got.ID {
t.Errorf("copied planting parent = %d, want the copied object %d", gotPlop.ObjectID, got.ID)
}
if gotPlop.PlantID != plop.PlantID || gotPlop.XCM != plop.XCM || gotPlop.YCM != plop.YCM ||
gotPlop.RadiusCM != plop.RadiusCM {
t.Errorf("copied planting differs: %+v vs source %+v", gotPlop, plop)
}
// The source is untouched by the copy.
srcFull, err := s.GardenFull(ctx, owner, src.ID, nil)
if err != nil {
t.Fatalf("GardenFull(source): %v", err)
}
if len(srcFull.Objects) != 1 || len(srcFull.Plantings) != 1 {
t.Errorf("source changed: %d objects, %d plantings", len(srcFull.Objects), len(srcFull.Plantings))
}
}
func TestCopyGardenSkipsRemovedPlantings(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
src := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, src.ID)
plant := seedOwnPlant(t, s, owner, 20)
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, RadiusCM: 10}); err != nil {
t.Fatalf("CreatePlanting(first): %v", err)
}
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 50, RadiusCM: 10}); err != nil {
t.Fatalf("CreatePlanting(cleared): %v", err)
}
// Clear the bed, then re-plant one plop: the copy should carry the active plop
// only, not the soft-removed history.
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("ClearObject: %v", err)
}
replanted, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 25, RadiusCM: 10})
if err != nil {
t.Fatalf("CreatePlanting(replanted): %v", err)
}
dup, err := s.CopyGarden(ctx, owner, src.ID, "")
if err != nil {
t.Fatalf("CopyGarden: %v", err)
}
full, err := s.GardenFull(ctx, owner, dup.ID, nil)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
if len(full.Plantings) != 1 {
t.Fatalf("copy has %d plantings, want only the 1 active one", len(full.Plantings))
}
if full.Plantings[0].XCM != replanted.XCM {
t.Errorf("copied plop x = %v, want the active %v", full.Plantings[0].XCM, replanted.XCM)
}
}
func TestCopyGardenNaming(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Yard"})
if err != nil {
t.Fatalf("CreateGarden: %v", err)
}
// An explicit name wins, and is trimmed like any other garden name.
dup, err := s.CopyGarden(ctx, owner, g.ID, " Experiment ")
if err != nil {
t.Fatalf("CopyGarden: %v", err)
}
if dup.Name != "Experiment" {
t.Errorf("name = %q, want trimmed 'Experiment'", dup.Name)
}
// An over-long explicit name is rejected rather than silently truncated.
if _, err := s.CopyGarden(ctx, owner, g.ID, strings.Repeat("a", maxGardenNameLen+1)); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("long name err = %v, want ErrInvalidInput", err)
}
// A source already at the cap still yields a valid derived name.
long, err := s.CreateGarden(ctx, owner, GardenInput{Name: strings.Repeat("b", maxGardenNameLen)})
if err != nil {
t.Fatalf("CreateGarden(long): %v", err)
}
dupLong, err := s.CopyGarden(ctx, owner, long.ID, "")
if err != nil {
t.Fatalf("CopyGarden(long): %v", err)
}
if len(dupLong.Name) > maxGardenNameLen {
t.Errorf("derived name is %d bytes, over the %d cap", len(dupLong.Name), maxGardenNameLen)
}
if !strings.HasSuffix(dupLong.Name, " (copy)") {
t.Errorf("derived name = %q, want a ' (copy)' suffix", dupLong.Name)
}
}
func TestCopyGardenNameRuneBoundary(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
// A name at the cap made of multi-byte runes: truncating for the suffix must
// not split one and leave invalid UTF-8.
name := strings.Repeat("é", maxGardenNameLen/2) // 2 bytes each == the cap
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: name})
if err != nil {
t.Fatalf("CreateGarden: %v", err)
}
dup, err := s.CopyGarden(ctx, owner, g.ID, "")
if err != nil {
t.Fatalf("CopyGarden: %v", err)
}
if !utf8.ValidString(dup.Name) {
t.Errorf("derived name %q is not valid UTF-8", dup.Name)
}
if len(dup.Name) > maxGardenNameLen {
t.Errorf("derived name is %d bytes, over the %d cap", len(dup.Name), maxGardenNameLen)
}
}
func TestCopyGardenPermissions(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
alice := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, alice)
// A stranger can't see the garden at all, let alone copy it.
if _, err := s.CopyGarden(ctx, bob, g.ID, ""); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("stranger copy err = %v, want ErrNotFound", err)
}
// An editor may edit contents but not duplicate the garden (see CopyGarden's
// note on cross-owner plant references).
if _, err := s.AddShare(ctx, alice, g.ID, "[email protected]", domain.RoleEditor); err != nil {
t.Fatalf("AddShare: %v", err)
}
if _, err := s.CopyGarden(ctx, bob, g.ID, ""); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("editor copy err = %v, want ErrForbidden", err)
}
// A missing garden is ErrNotFound.
if _, err := s.CopyGarden(ctx, alice, g.ID+9999, ""); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("missing garden err = %v, want ErrNotFound", err)
}
}
func TestCopyGardenDoesNotInheritPublicLink(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
link, err := s.EnablePublicShareLink(ctx, owner, g.ID, false)
if err != nil {
t.Fatalf("EnablePublicShareLink: %v", err)
}
if link.Token == "" {
t.Fatal("no token issued")
}
dup, err := s.CopyGarden(ctx, owner, g.ID, "")
if err != nil {
t.Fatalf("CopyGarden: %v", err)
}
// The copy must start private: no token of its own...
dupLink, err := s.GetPublicShareLink(ctx, owner, dup.ID)
if err != nil {
t.Fatalf("GetPublicShareLink(copy): %v", err)
}
if dupLink.Token != "" {
t.Errorf("copy inherited a public token %q", dupLink.Token)
}
// ...and the source's token still resolves to the source, not the copy.
pub, err := s.PublicGarden(ctx, link.Token)
if err != nil {
t.Fatalf("PublicGarden: %v", err)
}
if pub.Garden.ID != g.ID {
t.Errorf("source token resolves to garden %d, want the source %d", pub.Garden.ID, g.ID)
}
}
func TestCopyGardenDoesNotInheritShares(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
alice := seedUser(t, s, "[email protected]")
bob := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, alice)
if _, err := s.AddShare(ctx, alice, g.ID, "[email protected]", domain.RoleEditor); err != nil {
t.Fatalf("AddShare: %v", err)
}
dup, err := s.CopyGarden(ctx, alice, g.ID, "")
if err != nil {
t.Fatalf("CopyGarden: %v", err)
}
// Bob keeps his access to the original but gets none to the copy.
if _, err := s.GetGarden(ctx, bob, g.ID); err != nil {
t.Errorf("bob lost access to the source: %v", err)
}
if _, err := s.GetGarden(ctx, bob, dup.ID); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("bob can see the copy: err = %v, want ErrNotFound", err)
}
}