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