package api import ( "encoding/json" "net/http" "strconv" "testing" "github.com/gin-gonic/gin" ) // createGardenAPI creates a garden via the API and returns its id. func createGardenAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string) int64 { t.Helper() w := doJSON(t, r, http.MethodPost, "/api/v1/gardens", map[string]any{"name": name}, cookie) if w.Code != http.StatusCreated { t.Fatalf("create garden: status %d, body %s", w.Code, w.Body.String()) } return int64(decodeGarden(t, w.Body.Bytes())["id"].(float64)) } func objectPath(id int64) string { return "/api/v1/objects/" + strconv.FormatInt(id, 10) } func fullPath(id int64) string { return "/api/v1/gardens/" + strconv.FormatInt(id, 10) + "/full" } func objectsPath(id int64) string { return "/api/v1/gardens/" + strconv.FormatInt(id, 10) + "/objects" } func TestObjectCRUDAndFull(t *testing.T) { r := authEngine(t, localCfg()) cookie := registerAndCookie(t, r, "a@example.com") gid := createGardenAPI(t, r, cookie, "Yard") // Create a bed. w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "bed", "name": "Bed 1", "xCm": 300, "yCm": 300, "widthCm": 200, "heightCm": 100}, cookie) if w.Code != http.StatusCreated { t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String()) } obj := decodeGarden(t, w.Body.Bytes()) oid := int64(obj["id"].(float64)) if obj["plantable"].(bool) != true { t.Error("bed should default plantable=true") } // /full includes it, with empty plantings/plants. w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie) if w.Code != http.StatusOK { t.Fatalf("full: status %d", w.Code) } var full struct { Garden map[string]any `json:"garden"` Objects []map[string]any `json:"objects"` Plantings []map[string]any `json:"plantings"` Plants []map[string]any `json:"plants"` } if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil { t.Fatalf("decode full: %v (body %s)", err, w.Body.String()) } if full.Garden == nil || len(full.Objects) != 1 { t.Errorf("full shape wrong: %+v", full) } if full.Plantings == nil || full.Plants == nil || len(full.Plantings) != 0 || len(full.Plants) != 0 { t.Errorf("plantings/plants should be present + empty: %+v %+v", full.Plantings, full.Plants) } // Move it (PATCH with version). w = doJSON(t, r, http.MethodPatch, objectPath(oid), map[string]any{"xCm": 500, "yCm": 500, "version": 1}, cookie) if w.Code != http.StatusOK { t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String()) } patched := decodeGarden(t, w.Body.Bytes()) if patched["xCm"].(float64) != 500 || patched["version"].(float64) != 2 { t.Errorf("patch didn't apply/bump: %+v", patched) } // Delete → 204, then /full has none. if w := doJSON(t, r, http.MethodDelete, objectPath(oid), nil, cookie); w.Code != http.StatusNoContent { t.Fatalf("delete: status %d", w.Code) } w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie) json.Unmarshal(w.Body.Bytes(), &full) if len(full.Objects) != 0 { t.Errorf("object still in /full after delete: %d", len(full.Objects)) } } func TestObjectVersionConflict(t *testing.T) { r := authEngine(t, localCfg()) cookie := registerAndCookie(t, r, "a@example.com") gid := createGardenAPI(t, r, cookie, "Yard") w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "bed", "xCm": 300, "yCm": 300, "widthCm": 100, "heightCm": 100}, cookie) oid := int64(decodeGarden(t, w.Body.Bytes())["id"].(float64)) body := map[string]any{"xCm": 400, "version": 1} if w := doJSON(t, r, http.MethodPatch, objectPath(oid), body, cookie); w.Code != http.StatusOK { t.Fatalf("first patch: %d", w.Code) } w = doJSON(t, r, http.MethodPatch, objectPath(oid), body, cookie) // stale version 1 if w.Code != http.StatusConflict { t.Fatalf("stale patch: status %d, want 409", w.Code) } var env struct { Error struct{ Code string } `json:"error"` Current map[string]any `json:"current"` } json.Unmarshal(w.Body.Bytes(), &env) if env.Error.Code != "VERSION_CONFLICT" || env.Current["version"].(float64) != 2 { t.Errorf("conflict envelope wrong: %+v", env) } } func TestObjectCrossUserIsNotFound(t *testing.T) { r := authEngine(t, localCfg()) alice := registerAndCookie(t, r, "alice@example.com") bob := registerAndCookie(t, r, "bob@example.com") gid := createGardenAPI(t, r, alice, "Alice's") w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "bed", "xCm": 300, "yCm": 300, "widthCm": 100, "heightCm": 100}, alice) oid := int64(decodeGarden(t, w.Body.Bytes())["id"].(float64)) // Bob can't see the garden or its objects — all 404. if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, bob); w.Code != http.StatusNotFound { t.Errorf("bob full = %d, want 404", w.Code) } if w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "bed", "xCm": 1, "yCm": 1, "widthCm": 10, "heightCm": 10}, bob); w.Code != http.StatusNotFound { t.Errorf("bob create object = %d, want 404", w.Code) } if w := doJSON(t, r, http.MethodPatch, objectPath(oid), map[string]any{"xCm": 1, "version": 1}, bob); w.Code != http.StatusNotFound { t.Errorf("bob patch = %d, want 404", w.Code) } if w := doJSON(t, r, http.MethodDelete, objectPath(oid), nil, bob); w.Code != http.StatusNotFound { t.Errorf("bob delete = %d, want 404", w.Code) } } func TestObjectValidationRejects(t *testing.T) { r := authEngine(t, localCfg()) cookie := registerAndCookie(t, r, "a@example.com") gid := createGardenAPI(t, r, cookie, "Yard") // Polygon shape is reserved → 400. if w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "bed", "shape": "polygon", "xCm": 300, "yCm": 300, "widthCm": 100, "heightCm": 100}, cookie); w.Code != http.StatusBadRequest { t.Errorf("polygon create = %d, want 400", w.Code) } // Missing kind → 400. if w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"xCm": 1, "yCm": 1, "widthCm": 10, "heightCm": 10}, cookie); w.Code != http.StatusBadRequest { t.Errorf("no-kind create = %d, want 400", w.Code) } // props accepts a JSON object and round-trips. w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{"kind": "tree", "xCm": 300, "yCm": 300, "widthCm": 100, "heightCm": 100, "props": map[string]any{"species": "oak"}}, cookie) if w.Code != http.StatusCreated { t.Fatalf("props create = %d, body %s", w.Code, w.Body.String()) } if props, _ := decodeGarden(t, w.Body.Bytes())["props"].(string); props == "" { t.Error("props should be stored and returned as a JSON string") } }