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, "journal@example.com") 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, "filters@example.com") 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, "jowner@example.com") gid := createGardenAPI(t, r, owner, "G") stranger := registerAndCookie(t, r, "jstranger@example.com") 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) } }