Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
326 lines
13 KiB
Go
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))
|
|
}
|
|
}
|