package service import ( "context" "errors" "math" "strings" "testing" "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, "a@example.com") 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, "a@example.com") 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 } 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 TestListGardensOwnedOnly(t *testing.T) { s := newTestService(t, openConfig()) alice := seedUser(t, s, "alice@example.com") bob := seedUser(t, s, "bob@example.com") 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, "carol@example.com") 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, "a@example.com") 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, "a@example.com") 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, "a@example.com") 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, "alice@example.com") bob := seedUser(t, s, "bob@example.com") 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, "a@example.com") 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) } }