package service import ( "context" "errors" "testing" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) func writeEntry(t *testing.T, s *Service, actor, gardenID int64, in JournalInput) *domain.JournalEntry { t.Helper() e, err := s.CreateJournalEntry(context.Background(), actor, gardenID, in) if err != nil { t.Fatalf("CreateJournalEntry: %v", err) } return e } func listJournal(t *testing.T, s *Service, actor, gardenID int64, q JournalQuery) []domain.JournalEntry { t.Helper() entries, _, err := s.ListJournal(context.Background(), actor, gardenID, q) if err != nil { t.Fatalf("ListJournal: %v", err) } return entries } // TestEntriesAttachToGardenBedOrPlanting — the three targets, each listing under // itself and under the garden that anchors it. func TestEntriesAttachToGardenBedOrPlanting(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) ctx := context.Background() plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } gardenEntry := writeEntry(t, s, owner, g.ID, JournalInput{Body: "First frost tonight"}) bedEntry := writeEntry(t, s, owner, g.ID, JournalInput{ObjectID: &bed.ID, Body: "Powdery mildew on the west bed"}) plopEntry := writeEntry(t, s, owner, g.ID, JournalInput{ ObjectID: &bed.ID, PlantingID: &plop.ID, Body: "Scapes forming", }) // The garden sees all three: it anchors every entry. if all := listJournal(t, s, owner, g.ID, JournalQuery{}); len(all) != 3 { t.Errorf("garden journal has %d entries, want 3", len(all)) } byBed := listJournal(t, s, owner, g.ID, JournalQuery{ObjectID: &bed.ID}) if len(byBed) != 2 { t.Errorf("bed journal has %d entries, want 2 (the bed's and the plop's)", len(byBed)) } byPlop := listJournal(t, s, owner, g.ID, JournalQuery{PlantingID: &plop.ID}) if len(byPlop) != 1 || byPlop[0].ID != plopEntry.ID { t.Errorf("plop journal = %+v, want just the plop entry", byPlop) } if gardenEntry.ObjectID != nil || bedEntry.PlantingID != nil { t.Error("targets leaked between entries") } if gardenEntry.AuthorID != owner { t.Error("author not recorded") } // The author's name is resolved for display without a lookup per row. if all := listJournal(t, s, owner, g.ID, JournalQuery{}); all[0].AuthorName == "" { t.Error("author name not resolved on the list read") } } // TestDeletingABedRemovesItsEntriesOnly — the cascade is scoped: losing a bed // must not take the garden's own season notes with it. func TestDeletingABedRemovesItsEntriesOnly(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) ctx := context.Background() writeEntry(t, s, owner, g.ID, JournalInput{Body: "Garden-level note"}) writeEntry(t, s, owner, g.ID, JournalInput{ObjectID: &bed.ID, Body: "Bed-level note"}) if err := s.DeleteObject(ctx, owner, bed.ID); err != nil { t.Fatalf("DeleteObject: %v", err) } left := listJournal(t, s, owner, g.ID, JournalQuery{}) if len(left) != 1 || left[0].Body != "Garden-level note" { t.Errorf("after deleting the bed the journal is %+v, want just the garden note", left) } } // TestBackdatedEntriesOrderByObservationNotWriting — you write up Saturday's // observations on Sunday, and Saturday is the date that matters. func TestBackdatedEntriesOrderByObservationNotWriting(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) // Written second, observed first. writeEntry(t, s, owner, g.ID, JournalInput{Body: "Sunday", ObservedAt: strPtr("2026-06-14")}) writeEntry(t, s, owner, g.ID, JournalInput{Body: "Saturday", ObservedAt: strPtr("2026-06-13")}) writeEntry(t, s, owner, g.ID, JournalInput{Body: "Monday", ObservedAt: strPtr("2026-06-15")}) got := listJournal(t, s, owner, g.ID, JournalQuery{}) want := []string{"Monday", "Sunday", "Saturday"} for i, w := range want { if got[i].Body != w { t.Errorf("position %d = %q, want %q (ordered by observation, newest first)", i, got[i].Body, w) } } // A date range filters on observation too. from, to := "2026-06-14", "2026-06-14" only := listJournal(t, s, owner, g.ID, JournalQuery{From: &from, To: &to}) if len(only) != 1 || only[0].Body != "Sunday" { t.Errorf("date range returned %+v, want just Sunday", only) } } // TestObservedAtDefaultsToToday func TestObservedAtDefaultsToToday(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "No date given"}) if e.ObservedAt != s.now().UTC().Format(dateLayout) { t.Errorf("observedAt = %q, want today", e.ObservedAt) } if _, err := s.CreateJournalEntry(context.Background(), owner, g.ID, JournalInput{ Body: "Bad date", ObservedAt: strPtr("14/06/2026"), }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("malformed observedAt accepted: %v", err) } if _, err := s.CreateJournalEntry(context.Background(), owner, g.ID, JournalInput{Body: " "}); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("empty body accepted: %v", err) } } // TestJournalPermissions — viewer reads and cannot write; stranger sees nothing; // an author owns their own text, and the garden owner can remove anything. func TestJournalPermissions(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") editor := seedUser(t, s, "e@example.com") viewer := seedUser(t, s, "v@example.com") stranger := seedUser(t, s, "s@example.com") g := seedGarden(t, s, owner) ctx := context.Background() if _, err := s.AddShare(ctx, owner, g.ID, "e@example.com", domain.RoleEditor); err != nil { t.Fatalf("AddShare editor: %v", err) } if _, err := s.AddShare(ctx, owner, g.ID, "v@example.com", domain.RoleViewer); err != nil { t.Fatalf("AddShare viewer: %v", err) } byEditor := writeEntry(t, s, editor, g.ID, JournalInput{Body: "Editor's observation"}) // Viewer: reads, cannot write. if entries := listJournal(t, s, viewer, g.ID, JournalQuery{}); len(entries) != 1 { t.Errorf("viewer sees %d entries, want 1", len(entries)) } if _, err := s.CreateJournalEntry(ctx, viewer, g.ID, JournalInput{Body: "nope"}); !errors.Is(err, domain.ErrForbidden) { t.Errorf("viewer write err = %v, want ErrForbidden", err) } // Stranger: existence stays masked. if _, _, err := s.ListJournal(ctx, stranger, g.ID, JournalQuery{}); !errors.Is(err, domain.ErrNotFound) { t.Errorf("stranger read err = %v, want ErrNotFound", err) } if err := s.DeleteJournalEntry(ctx, stranger, byEditor.ID); !errors.Is(err, domain.ErrNotFound) { t.Errorf("stranger delete err = %v, want ErrNotFound", err) } // The author edits their own. updated, err := s.UpdateJournalEntry(ctx, editor, byEditor.ID, JournalPatch{Body: strPtr("Revised")}, byEditor.Version) if err != nil { t.Fatalf("author edit: %v", err) } if updated.Body != "Revised" { t.Errorf("body = %q", updated.Body) } // The garden OWNER may not rewrite somebody else's observation under their // name — that's a different act from removing it. if _, err := s.UpdateJournalEntry(ctx, owner, byEditor.ID, JournalPatch{Body: strPtr("Owner rewrote this")}, updated.Version); !errors.Is(err, domain.ErrForbidden) { t.Errorf("owner edit of another's entry err = %v, want ErrForbidden", err) } // But may delete it: the owner is the backstop for content in their garden. if err := s.DeleteJournalEntry(ctx, owner, byEditor.ID); err != nil { t.Errorf("owner delete of another's entry: %v", err) } } // TestEntryTargetMustBelongToTheGarden — the garden anchors permission, so an // object id from somebody else's garden must not be storable against this one. func TestEntryTargetMustBelongToTheGarden(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") other := seedUser(t, s, "b@example.com") mine := seedGarden(t, s, owner) theirs := seedGarden(t, s, other) theirBed := seedBed(t, s, other, theirs.ID) myBed := seedBed(t, s, owner, mine.ID) ctx := context.Background() if _, err := s.CreateJournalEntry(ctx, owner, mine.ID, JournalInput{ ObjectID: &theirBed.ID, Body: "about someone else's bed", }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("foreign object accepted: %v", err) } // A plop-level entry naming the wrong object is refused too, or the object // and planting filters would disagree about what an entry is about. plant := seedOwnPlant(t, s, owner, 15) otherBed, err := s.CreateObject(ctx, owner, mine.ID, ObjectInput{ Kind: domain.KindBed, Name: "Other", XCM: 200, YCM: 200, WidthCM: 100, HeightCM: 100, }) if err != nil { t.Fatalf("CreateObject: %v", err) } plop, err := s.CreatePlanting(ctx, owner, myBed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } if _, err := s.CreateJournalEntry(ctx, owner, mine.ID, JournalInput{ ObjectID: &otherBed.ID, PlantingID: &plop.ID, Body: "mismatched", }); !errors.Is(err, domain.ErrInvalidInput) { t.Errorf("mismatched object/planting pair accepted: %v", err) } } // TestJournalVersionGuardAndPaging func TestJournalVersionGuardAndPaging(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) ctx := context.Background() e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "One"}) if _, err := s.UpdateJournalEntry(ctx, owner, e.ID, JournalPatch{Body: strPtr("Two")}, e.Version); err != nil { t.Fatalf("first update: %v", err) } current, err := s.UpdateJournalEntry(ctx, owner, e.ID, JournalPatch{Body: strPtr("Three")}, e.Version) if !errors.Is(err, domain.ErrVersionConflict) { t.Fatalf("stale update err = %v, want ErrVersionConflict", err) } if current == nil || current.Body != "Two" { t.Errorf("conflict should carry the current row, got %+v", current) } for i := 0; i < 5; i++ { writeEntry(t, s, owner, g.ID, JournalInput{Body: "filler"}) } page, hasMore, err := s.ListJournal(ctx, owner, g.ID, JournalQuery{Limit: 2}) if err != nil { t.Fatalf("ListJournal: %v", err) } if len(page) != 2 || !hasMore { t.Errorf("page = %d entries, hasMore = %v; want 2 and true", len(page), hasMore) } last, hasMore, err := s.ListJournal(ctx, owner, g.ID, JournalQuery{Limit: 50, Offset: 0}) if err != nil { t.Fatalf("ListJournal: %v", err) } if len(last) != 6 || hasMore { t.Errorf("full page = %d entries, hasMore = %v; want 6 and false", len(last), hasMore) } } // TestJournalStaysOutOfRevisionHistory — decided deliberately (#52): entries are // append-shaped and individually versioned, and undoing a note is deleting it. func TestJournalStaysOutOfRevisionHistory(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) before := len(history(t, s, owner, g.ID)) e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "An observation"}) if _, err := s.UpdateJournalEntry(context.Background(), owner, e.ID, JournalPatch{Body: strPtr("Revised")}, e.Version); err != nil { t.Fatalf("update: %v", err) } if got := len(history(t, s, owner, g.ID)); got != before { t.Errorf("journal writes produced %d change sets; they should produce none", got-before) } } // TestPlopEntryIsVisibleUnderItsBed — an entry about a plant IN a bed is an entry // about that bed. Without deriving the parent object, "notes about this bed" // would silently exclude every note written about something growing in it. func TestPlopEntryIsVisibleUnderItsBed(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") g := seedGarden(t, s, owner) bed := seedBed(t, s, owner, g.ID) plant := seedOwnPlant(t, s, owner, 15) ctx := context.Background() plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, }) if err != nil { t.Fatalf("CreatePlanting: %v", err) } // Only the plop is named — no objectId. e := writeEntry(t, s, owner, g.ID, JournalInput{PlantingID: &plop.ID, Body: "Scapes forming"}) if e.ObjectID == nil || *e.ObjectID != bed.ID { t.Fatalf("objectId = %v, want the plop's parent bed %d", e.ObjectID, bed.ID) } if byBed := listJournal(t, s, owner, g.ID, JournalQuery{ObjectID: &bed.ID}); len(byBed) != 1 { t.Errorf("the bed filter found %d entries, want the plop's", len(byBed)) } if byPlop := listJournal(t, s, owner, g.ID, JournalQuery{PlantingID: &plop.ID}); len(byPlop) != 1 { t.Errorf("the plop filter found %d entries, want 1", len(byPlop)) } }