Address second round of Gadfly findings on #89
Build image / build-and-push (push) Successful in 16s

- Rename fillRequest → objectFillRequest to match the package's
  <resource><Action>Request convention (objectCreateRequest, gardenCreateRequest…).
- Add the missing anonymous-clear permission case (401), mirroring anonymous fill.
- Reword the makeFillPlant comment to stop referencing external branch state,
  which won't make sense once merged — just note the consolidation follow-up.
- clearObject: send no body (undefined) rather than an empty {}, and validate
  the {cleared} response with a zod schema instead of a raw type cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 22:12:00 -04:00
co-authored by Claude Opus 4.8
parent 7a6f6963f9
commit 33e048cea9
3 changed files with 16 additions and 10 deletions
+3 -3
View File
@@ -37,14 +37,14 @@ func (r fillRect) degenerate() bool {
return r.MaxX <= r.MinX || r.MaxY <= r.MinY return r.MaxX <= r.MinX || r.MaxY <= r.MinY
} }
// fillRequest is the body for POST /objects/:id/fill. // objectFillRequest is the body for POST /objects/:id/fill.
// //
// A region is given EITHER by compass name ("ne", "south half", "all") or as an // A region is given EITHER by compass name ("ne", "south half", "all") or as an
// explicit rect in the object's local frame. The named form is what a person // explicit rect in the object's local frame. The named form is what a person
// means and what the agent uses; the rect is for a future drag-a-box affordance. // means and what the agent uses; the rect is for a future drag-a-box affordance.
// Exactly one must be supplied — accepting both and silently preferring one // Exactly one must be supplied — accepting both and silently preferring one
// would make a client bug look like a geometry bug. // would make a client bug look like a geometry bug.
type fillRequest struct { type objectFillRequest struct {
PlantID int64 `json:"plantId" binding:"required"` PlantID int64 `json:"plantId" binding:"required"`
Region string `json:"region"` Region string `json:"region"`
Rect *fillRect `json:"rect"` Rect *fillRect `json:"rect"`
@@ -58,7 +58,7 @@ func (h *handlers) fillObject(c *gin.Context) {
if !ok { if !ok {
return return
} }
var req fillRequest var req objectFillRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required") writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
return return
+7 -5
View File
@@ -10,11 +10,10 @@ import (
func fillPath(id int64) string { return objectPath(id) + "/fill" } func fillPath(id int64) string { return objectPath(id) + "/fill" }
func clearPath(id int64) string { return objectPath(id) + "/clear" } func clearPath(id int64) string { return objectPath(id) + "/clear" }
// makeFillPlant creates a custom plant and returns its id. // makeFillPlant creates a custom plant and returns its id. (A near-identical
// // createPlantAPI landed alongside the seed-lot tests; consolidating the two into
// Deliberately named apart from the seed-lot tests' helper: that one arrives on // one shared helper is a fine follow-up, kept separate here only to avoid a
// a separate branch, and two files declaring the same helper would collide on // merge collision on the shared symbol.)
// whichever merged second.
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 { func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
t.Helper() t.Helper()
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{ w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
@@ -221,4 +220,7 @@ func TestFillClearPermissionsAPI(t *testing.T) {
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized { if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous fill = %d, want 401", w.Code) t.Errorf("anonymous fill = %d, want 401", w.Code)
} }
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous clear = %d, want 401", w.Code)
}
} }
+6 -2
View File
@@ -328,6 +328,8 @@ export function useUpdatePlanting(gardenId: number) {
}) })
} }
const clearResultSchema = z.object({ cleared: z.number() })
/** Clear a bed: soft-remove every active plop in an object (#82). /** Clear a bed: soft-remove every active plop in an object (#82).
* *
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which * ONE request, and so ONE change set. This used to be a loop of PATCHes, which
@@ -341,8 +343,10 @@ export function useClearObject(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (objectId: number): Promise<number> => { mutationFn: async (objectId: number): Promise<number> => {
const res = (await api.post(`/objects/${objectId}/clear`, {})) as { cleared?: number } // No body — clear takes none; passing undefined sends none rather than an
return res?.cleared ?? 0 // empty {}. The response is just a count; validate it rather than cast.
const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
return res.cleared
}, },
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }), onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')), onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),