package api import ( "encoding/json" "net/http" "strconv" "testing" "github.com/gin-gonic/gin" ) func seedLotPath(id int64) string { return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10) } // decodeList decodes a bare JSON array body. Seed lot listing returns the array // directly rather than wrapping it (unlike /journal's {"entries": …}), so a // helper that assumed an object would quietly read nothing. func decodeList(t *testing.T, body []byte) []any { t.Helper() var out []any if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("decode list: %v (%s)", err, body) } return out } // createPlantAPI makes a custom plant and returns its id. func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 { t.Helper() w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{ "name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱", }, cookie) if w.Code != http.StatusCreated { t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String()) } return int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) } // TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP. // // It exists for the reason CLAUDE.md gives: service tests cannot see a route // that was never registered, or one registered with the wrong :param name. Every // other handler file had a sibling API test; this group did not, which is the // state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and // completely unreachable. func TestSeedLotCrudAPI(t *testing.T) { r := authEngine(t, localCfg()) cookie := registerAndCookie(t, r, "lots@example.com") plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15) // Create. w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{ "plantId": plantID, "vendor": "Johnny's", "sku": "2761", "quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495, }, cookie) if w.Code != http.StatusCreated { t.Fatalf("create: status %d, body %s", w.Code, w.Body.String()) } lot := decodeMap(t, w.Body.Bytes()) id := int64(lot["id"].(float64)) if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" { t.Errorf("unexpected lot: %+v", lot) } // GET by id — the route most likely to be missing or mis-registered. w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie) if w.Code != http.StatusOK { t.Fatalf("get: status %d, body %s", w.Code, w.Body.String()) } if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id { t.Errorf("get returned id %v, want %d", got["id"], id) } // List, and the ?plantId= filter. w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("list: status %d, body %s", w.Code, w.Body.String()) } if n := len(decodeList(t, w.Body.Bytes())); n != 1 { t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String()) } other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45) w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie) if w.Code != http.StatusOK { t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String()) } if n := len(decodeList(t, w.Body.Bytes())); n != 0 { t.Errorf("filter by a plant with no lots returned %d, want 0", n) } // A bad plantId filter is 400, whether non-numeric or out of range — the // handler rejects id < 1, not just unparseable strings. for _, bad := range []string{"nope", "0", "-1"} { if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest { t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code) } } // PATCH with the current version. w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{ "vendor": "Fedco", "version": lot["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["vendor"] != "Fedco" { t.Errorf("vendor = %v, want Fedco", updated["vendor"]) } if updated["version"].(float64) != lot["version"].(float64)+1 { t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"]) } // A stale version conflicts and carries the current row back, so the client // can rebase without a second request. w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{ "vendor": "stale", "version": lot["version"], }, cookie) if w.Code != http.StatusConflict { t.Fatalf("stale patch: status %d, want 409", w.Code) } if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" { t.Errorf("409 body missing the current row: %s", w.Body.String()) } // DELETE. if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent { t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String()) } if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound { t.Errorf("get after delete = %d, want 404", w.Code) } } // TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings // through the HTTP surface, not just in the service. // // DESIGN.md makes derivation load-bearing — "a decremented column drifts the // moment a planting is edited behind its back" — so a route that returned a // stored or stale figure would break the invariant silently, and the number is // the whole reason anyone opens the seed shelf. func TestSeedLotRemainingIsDerivedAPI(t *testing.T) { r := authEngine(t, localCfg()) cookie := registerAndCookie(t, r, "remaining@example.com") plantID := createPlantAPI(t, r, cookie, "Beans", 10) w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{ "plantId": plantID, "quantity": 50, "unit": "seeds", }, cookie) if w.Code != http.StatusCreated { t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String()) } lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) gid := createGardenAPI(t, r, cookie, "G") w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{ "kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true, }, cookie) if w.Code != http.StatusCreated { t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String()) } oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) // Plant 12 of them against the lot. w = doJSON(t, r, http.MethodPost, objectPath(oid)+"/plantings", map[string]any{ "plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID, }, cookie) if w.Code != http.StatusCreated { t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String()) } w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie) if w.Code != http.StatusOK { t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String()) } got := decodeMap(t, w.Body.Bytes()) if rem, ok := got["remaining"].(float64); !ok || rem != 38 { t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String()) } // Over-plant it: 45 more (57 total against 50 bought) drives remaining // NEGATIVE. That's deliberate — the number is a derived truth about what // you've committed, not a floor clamped at zero, and "you've planted more than // you bought" is exactly the signal a gardener wants rather than a hidden -7. w = doJSON(t, r, http.MethodPost, objectPath(oid)+"/plantings", map[string]any{ "plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID, }, cookie) if w.Code != http.StatusCreated { t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String()) } w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie) if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 { t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem) } } // TestSeedLotsArePrivateAPI checks the ACL through the router. // // Lots are private to the buyer and deliberately never travel with a shared // garden, so another user must not be able to read or edit one. Per the // project's convention, no-access is ErrNotFound rather than ErrForbidden — // existence is masked — so every one of these is a 404, not a 403. func TestSeedLotsArePrivateAPI(t *testing.T) { r := authEngine(t, localCfg()) alice := registerAndCookie(t, r, "alice-lots@example.com") bob := registerAndCookie(t, r, "bob-lots@example.com") plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15) w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{ "plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs", }, alice) if w.Code != http.StatusCreated { t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String()) } lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) for _, tc := range []struct { name string method string body any }{ {"get", http.MethodGet, nil}, {"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}}, {"delete", http.MethodDelete, nil}, } { if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound { t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code) } } // Bob's own listing must not include it either. w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob) if w.Code != http.StatusOK { t.Fatalf("bob list: status %d", w.Code) } if n := len(decodeList(t, w.Body.Bytes())); n != 0 { t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String()) } // And it's still intact for alice. if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK { t.Errorf("alice lost access to her own lot: %d", w.Code) } } // TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an // unauthenticated caller gets 401 rather than an empty list. func TestSeedLotsRequireAuthAPI(t *testing.T) { r := authEngine(t, localCfg()) for _, tc := range []struct { method, path string }{ {http.MethodGet, "/api/v1/seed-lots"}, {http.MethodPost, "/api/v1/seed-lots"}, {http.MethodGet, seedLotPath(1)}, {http.MethodPatch, seedLotPath(1)}, {http.MethodDelete, seedLotPath(1)}, } { if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized { t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code) } } }