Files
pansy/internal/api/journal_test.go
T
steveandClaude Opus 4.8 d94c967310 Journal UI: write and read season notes where you're already looking (#53)
happens. Notes get written standing in the garden holding a phone, usually
one-handed — if it takes more than a couple of taps from looking at a bed to
typing a sentence, the log stays empty.

So the journal is a tab in the rail #49 settled, and selecting a bed puts a
"📝 Add note" button in the inspector that scopes the journal to that bed and
switches to it. The composer is already open in there rather than behind an
"add" button, which makes it two taps from a selected bed to typing.

The observed date defaults to today in the VIEWER's timezone, not UTC — "today"
means the day you are standing in the garden — and stays editable, because you
write up Saturday's observations on Sunday.

Discoverability is the other half. A log you have to remember exists is a log
nobody reads, so the inspector button carries the note count for that bed and
the Journal tab carries the garden's total: a garden with a season's notes in it
no longer looks identical to an empty one. Those counts come from their own
endpoint, deliberately — the indicator has to work while the journal panel is
closed, which is exactly when the entry list hasn't loaded.

Permissions match the service: anyone who can edit the garden can write, the
author can edit their own text, and the author or the garden owner can delete.
A viewer sees the entries and the count but no composer.

The empty state says what the journal is for rather than just "no entries",
since it starts empty for a whole season's worth of gardens and the reason to
start is the thing worth saying.

Closes #53

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

201 lines
7.5 KiB
Go

package api
import (
"net/http"
"strconv"
"testing"
)
func journalPath(gardenID int64) string {
return "/api/v1/gardens/" + strconv.FormatInt(gardenID, 10) + "/journal"
}
func journalEntryPath(id int64) string {
return "/api/v1/journal/" + strconv.FormatInt(id, 10)
}
// TestJournalCrudAPI walks the whole entry lifecycle over HTTP.
//
// It exists because the service-level tests could not see that PATCH and DELETE
// were never registered on the router: the handlers were written, tested through
// the service, and completely unreachable. Anything addressed by its own id
// needs at least one test that goes through the router.
func TestJournalCrudAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"body": "First frost tonight", "observedAt": "2026-10-12",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
}
entry := decodeMap(t, w.Body.Bytes())
id := int64(entry["id"].(float64))
if entry["observedAt"] != "2026-10-12" || entry["body"] != "First frost tonight" {
t.Errorf("unexpected entry: %+v", entry)
}
// List it back.
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
}
body := decodeMap(t, w.Body.Bytes())
entries, _ := body["entries"].([]any)
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1: %s", len(entries), w.Body.String())
}
if first := entries[0].(map[string]any); first["authorName"] == "" {
t.Error("author name missing from the list read")
}
// PATCH — the route this test exists for.
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
"body": "First frost, tomatoes done", "version": entry["version"],
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
}
updated := decodeMap(t, w.Body.Bytes())
if updated["body"] != "First frost, tomatoes done" {
t.Errorf("body = %v", updated["body"])
}
// A stale version conflicts, carrying the current row.
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
"body": "stale", "version": entry["version"],
}, cookie)
if w.Code != http.StatusConflict {
t.Errorf("stale patch: status %d, want 409", w.Code)
}
if current, _ := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); current == nil {
t.Error("409 should carry the current row")
}
// DELETE — likewise.
w = doJSON(t, r, http.MethodDelete, journalEntryPath(id), nil, cookie)
if w.Code != http.StatusNoContent {
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
}
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
if entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any); len(entries) != 0 {
t.Errorf("%d entries survived the delete", len(entries))
}
}
// TestJournalFiltersAPI covers the query parameters, including a malformed one.
func TestJournalFiltersAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
}, cookie)
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"body": "garden level", "observedAt": "2026-05-01",
}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"objectId": objectID, "body": "bed level", "observedAt": "2026-06-01",
}, cookie)
countAt := func(query string) int {
t.Helper()
w := doJSON(t, r, http.MethodGet, journalPath(gid)+query, nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list%s: status %d, body %s", query, w.Code, w.Body.String())
}
entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any)
return len(entries)
}
if n := countAt(""); n != 2 {
t.Errorf("unfiltered = %d, want 2", n)
}
if n := countAt("?objectId=" + strconv.FormatInt(objectID, 10)); n != 1 {
t.Errorf("by object = %d, want 1", n)
}
if n := countAt("?from=2026-05-15&to=2026-12-31"); n != 1 {
t.Errorf("by date range = %d, want 1", n)
}
// A malformed filter is an error, not a silently ignored one that widens the
// query back to the whole garden.
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?objectId=abc", nil, cookie)
if w.Code != http.StatusBadRequest {
t.Errorf("malformed objectId: status %d, want 400", w.Code)
}
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?from=nonsense", nil, cookie)
if w.Code != http.StatusBadRequest {
t.Errorf("malformed from: status %d, want 400", w.Code)
}
}
// TestJournalRequiresAccessAPI — anonymous is 401, a stranger is 404.
func TestJournalRequiresAccessAPI(t *testing.T) {
r := authEngine(t, localCfg())
owner := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, owner, "G")
stranger := registerAndCookie(t, r, "[email protected]")
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous list: status %d, want 401", w.Code)
}
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger list: status %d, want 404", w.Code)
}
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "nope"}, stranger)
if w.Code != http.StatusNotFound {
t.Errorf("stranger write: status %d, want 404", w.Code)
}
if w := doJSON(t, r, http.MethodDelete, journalEntryPath(1), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger delete: status %d, want 404", w.Code)
}
}
// TestJournalCountsAPI — the indicator that makes the log discoverable has to
// work while the journal panel is closed, so it's its own endpoint.
func TestJournalCountsAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
}, cookie)
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
counts := func() map[string]any {
t.Helper()
w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("counts: status %d, body %s", w.Code, w.Body.String())
}
c, _ := decodeMap(t, w.Body.Bytes())["counts"].(map[string]any)
return c
}
if len(counts()) != 0 {
t.Errorf("a garden with no entries should report no counts")
}
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "garden note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "bed note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "another"}, cookie)
c := counts()
// Key "0" is the garden itself; anything else is an object id.
if c["0"] != float64(1) {
t.Errorf("garden-level count = %v, want 1", c["0"])
}
if c[strconv.FormatInt(objectID, 10)] != float64(2) {
t.Errorf("bed count = %v, want 2", c[strconv.FormatInt(objectID, 10)])
}
if w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous counts: status %d, want 401", w.Code)
}
}