Files
pansy/internal/service/journal_test.go
T
steveandClaude Opus 4.8 6638696b00
Build image / build-and-push (push) Successful in 5s
Address Gadfly review on the grow journal
PATCH and DELETE /journal/:id were never registered. Both handlers existed, were
covered by service-level tests, and were completely unreachable — my edit to the
router anchored on a string that only exists on another branch, so it silently
did nothing. Registered, and covered by an API-level test that walks the whole
lifecycle through the router, because that is the only kind of test that could
have caught it. Anything addressed by its own id now has one.

An entry about a plop was invisible under its bed's filter. Naming only a
planting left object_id null, so "notes about this bed" silently excluded every
note written about something growing IN the bed — the two filters disagreed
about what an entry is about, which is precisely the question the reader is
asking. The parent object is now derived from the planting.

checkJournalTarget flattened every store error to ErrInvalidInput, so a real
database failure surfaced as a 400 telling the caller their request was bad, and
never reached the logs. Only "no such row" is a bad reference now; anything else
passes through.

ListJournalEntries repeated the column scan order inline, one column different
from scanJournalEntry — the classic way for a shared column list to drift out of
step with its readers. Both now build from journalScanTargets, with the list
appending the joined author name.

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

326 lines
13 KiB
Go

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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
editor := seedUser(t, s, "[email protected]")
viewer := seedUser(t, s, "[email protected]")
stranger := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
ctx := context.Background()
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleEditor); err != nil {
t.Fatalf("AddShare editor: %v", err)
}
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", 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, "[email protected]")
other := seedUser(t, s, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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))
}
}