package api import ( "encoding/json" "net/http" "strconv" "testing" ) func plantPath(id int64) string { return "/api/v1/plants/" + strconv.FormatInt(id, 10) } // firstBuiltinID returns the id of a seeded built-in (no ownerId key) from a // /plants list response, plus the total count. func firstBuiltinID(t *testing.T, body []byte) (int64, int) { t.Helper() var list []map[string]any if err := json.Unmarshal(body, &list); err != nil { t.Fatalf("decode plants list: %v (body %s)", err, body) } for _, p := range list { if _, owned := p["ownerId"]; !owned { return int64(p["id"].(float64)), len(list) } } t.Fatal("no built-in plant in list") return 0, 0 } func TestPlantCatalogFlow(t *testing.T) { r := authEngine(t, localCfg()) cookie := registerAndCookie(t, r, "a@example.com") // List → seeded built-ins present. w := doJSON(t, r, http.MethodGet, "/api/v1/plants", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("list status = %d, body %s", w.Code, w.Body.String()) } builtinID, count := firstBuiltinID(t, w.Body.Bytes()) if count < 30 { t.Errorf("catalog = %d plants, want >= 30 seeded", count) } // Create a custom plant → 201. w = doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{"name": "My tomato", "category": "vegetable", "spacingCm": 55, "color": "#e53935", "icon": "🍅"}, cookie) if w.Code != http.StatusCreated { t.Fatalf("create status = %d, body %s", w.Code, w.Body.String()) } created := decodeMap(t, w.Body.Bytes()) id := int64(created["id"].(float64)) if created["ownerId"] == nil { t.Error("custom plant should carry ownerId") } // Patch the custom plant with its version → 200, bumped. w = doJSON(t, r, http.MethodPatch, plantPath(id), map[string]any{"spacingCm": 50, "version": 1}, cookie) if w.Code != http.StatusOK { t.Fatalf("patch status = %d, body %s", w.Code, w.Body.String()) } if decodeMap(t, w.Body.Bytes())["version"].(float64) != 2 { t.Error("patch didn't bump version") } // A built-in is read-only: patch and delete both → 403. if w := doJSON(t, r, http.MethodPatch, plantPath(builtinID), map[string]any{"spacingCm": 99, "version": 1}, cookie); w.Code != http.StatusForbidden { t.Errorf("patch built-in = %d, want 403", w.Code) } if w := doJSON(t, r, http.MethodDelete, plantPath(builtinID), nil, cookie); w.Code != http.StatusForbidden { t.Errorf("delete built-in = %d, want 403", w.Code) } // Delete the custom plant → 204. if w := doJSON(t, r, http.MethodDelete, plantPath(id), nil, cookie); w.Code != http.StatusNoContent { t.Errorf("delete custom = %d, want 204", w.Code) } } func TestPlantVisibilityAcrossUsersAPI(t *testing.T) { r := authEngine(t, localCfg()) alice := registerAndCookie(t, r, "alice@example.com") bob := registerAndCookie(t, r, "bob@example.com") w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{"name": "Alice mint", "category": "herb", "spacingCm": 30, "color": "#66bb6a", "icon": "🌿"}, alice) id := int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) // Bob can't see it → editing masks as 404, not 403. if w := doJSON(t, r, http.MethodPatch, plantPath(id), map[string]any{"name": "x", "version": 1}, bob); w.Code != http.StatusNotFound { t.Errorf("bob patch alice's plant = %d, want 404", w.Code) } } func TestPlantsRequireAuth(t *testing.T) { r := authEngine(t, localCfg()) if w := doJSON(t, r, http.MethodGet, "/api/v1/plants", nil, nil); w.Code != http.StatusUnauthorized { t.Errorf("unauthenticated list = %d, want 401", w.Code) } }