Grow journal: time-stamped notes on gardens, beds and plantings (#52)
"Take notes through the season on each bed and plant." There is already free-text notes on gardens, objects and plants, but it is a single mutable field: writing "powdery mildew on the west bed" overwrites what you wrote in June. The distinction worth keeping is that notes says what this thing IS, while a journal entry says what HAPPENED, and when. Both stay. garden_id is NOT NULL even when an entry is about a bed or a single plop, and that is the whole trick: permission checks reuse requireGardenRole unchanged and no second ACL path is invented. object_id/planting_id narrow the target; the garden always anchors it. Because the garden anchors permission, the target is checked to actually live in that garden — otherwise a valid object id from someone else's garden would be storable here and then leak through the list read. A plop-level entry that also names an object must name the right one, or the two filters would disagree about what an entry is about. observed_at is distinct from created_at: you write up Saturday's observations on Sunday, and Saturday is the date that matters. Listing orders by observation, newest first, with id breaking ties inside a day. Roles follow the existing shape. Editors write, viewers read, strangers get ErrNotFound. An author may edit their own entries; the garden owner may DELETE any entry in their garden but may not edit one — rewriting somebody else's observation under their name is a different act from removing it. Deliberately not wired into the revision history, per the decision on #52: entries are already append-shaped and individually versioned, and undoing a note is just deleting it. There's a test asserting journal writes produce no change sets, so that decision can't quietly reverse itself later. Closes #52 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user