fix(landscape): accept an empty run body; pin the builder's Alpine release

POST /landscape/runs documents an optional body, but DecodeJSON returned
io.EOF on an empty one and the handler turned that into 400 — breaking the
most common call, an all-defaults run. decodeRunRequest now treats io.EOF as
the zero request; a malformed body is still 400.

The Dockerfile's Alpine ABI coupling was enforced only by a comment: a
floating golang:1.26-alpine builder against a pinned alpine:3.24 runtime
would build green and die at startup on a libgdal soname mismatch as soon as
the Go image rolls forward. Both stages are now pinned to 3.24.

Also: the route-mount test walks the router so all six routes are asserted
(GET /slots and GET /results included), and the spec documents the 404 those
two return when migration 00011's seeded groups are absent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 14:22:26 +05:00
parent ad3c9fbdcd
commit 6aec00ee29
4 changed files with 129 additions and 26 deletions

View File

@@ -1,6 +1,8 @@
package http
import (
"errors"
"io"
"net/http"
"gis/internal/landscape"
@@ -89,12 +91,26 @@ func (h *LandscapeHandler) uploadSlot(w http.ResponseWriter, r *http.Request) {
httputil.WriteJSON(w, http.StatusCreated, dataset)
}
// decodeRunRequest reads the run body. The whole body is optional — every field
// has a default — so an empty body is the zero request (a default incremental
// run), not an error. A malformed body writes a 400 and reports false.
func decodeRunRequest(w http.ResponseWriter, r *http.Request) (runRequest, bool) {
req, err := httputil.DecodeJSON[runRequest](w, r)
switch {
case errors.Is(err, io.EOF):
return runRequest{}, true
case err != nil:
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
return runRequest{}, false
}
return req, true
}
// createRun queues a Landscape Program run and returns it with 202 Accepted;
// the worker executes it asynchronously.
func (h *LandscapeHandler) createRun(w http.ResponseWriter, r *http.Request) {
req, err := httputil.DecodeJSON[runRequest](w, r)
if err != nil {
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
req, ok := decodeRunRequest(w, r)
if !ok {
return
}
if err := h.validate.Struct(req); err != nil {

View File

@@ -3,6 +3,7 @@ package http
import (
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
@@ -90,31 +91,101 @@ func TestGetRunInvalidID(t *testing.T) {
}
}
// TestRegisterMountsRoutes checks the route table itself: every path reaches a
// handler (405/404 would mean it is not mounted). Each request is one that is
// rejected before the service is used.
// TestDecodeRunRequestEmptyBody pins the "all defaults" call: POST
// /landscape/runs with no body at all is a valid request, not a 400. Tested at
// the helper level because the 202 path needs a live service.
func TestDecodeRunRequestEmptyBody(t *testing.T) {
for _, body := range []string{"", "{}"} {
req := httptest.NewRequest(http.MethodPost, "/landscape/runs", strings.NewReader(body))
rec := httptest.NewRecorder()
got, ok := decodeRunRequest(rec, req)
if !ok {
t.Fatalf("body %q: decodeRunRequest reported failure with status %d", body, rec.Code)
}
if got.FromStage != nil || len(got.Overrides) != 0 || got.Params != nil {
t.Fatalf("body %q: got %+v, want the zero request", body, got)
}
if rec.Code != http.StatusOK || rec.Body.Len() != 0 {
t.Fatalf("body %q: helper wrote a response (status %d, body %s)", body, rec.Code, rec.Body.String())
}
// The zero request must also survive validation, or an empty body
// would still be rejected one step later.
if err := validator.New(validator.WithRequiredStructEnabled()).Struct(got); err != nil {
t.Fatalf("body %q: zero request fails validation: %v", body, err)
}
}
}
func TestDecodeRunRequestMalformedBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/landscape/runs", strings.NewReader(`{`))
rec := httptest.NewRecorder()
if _, ok := decodeRunRequest(rec, req); ok {
t.Fatal("decodeRunRequest accepted a truncated body")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
// TestRegisterMountsRoutes walks the route table of a router with the handler
// registered, so every one of the six routes is checked — including GET /slots
// and GET /results, which cannot be exercised without a live service.
func TestRegisterMountsRoutes(t *testing.T) {
r := chi.NewRouter()
r.Route("/landscape", newLandscapeTestHandler().Register)
cases := []struct {
method, path, body, contentType string
want int
}{
{http.MethodPost, "/landscape/runs", `{`, "application/json", http.StatusBadRequest},
{http.MethodPost, "/landscape/slots/lp-z-lcr", `{}`, "application/json", http.StatusBadRequest},
{http.MethodGet, "/landscape/runs?page=0", "", "", http.StatusBadRequest},
{http.MethodGet, "/landscape/runs/nope", "", "", http.StatusBadRequest},
got := map[string]bool{}
err := chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
got[method+" "+strings.TrimSuffix(route, "/")] = true
return nil
})
if err != nil {
t.Fatalf("walk routes: %v", err)
}
for _, c := range cases {
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
if c.contentType != "" {
req.Header.Set("Content-Type", c.contentType)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s %s: status = %d, want %d (body %s)", c.method, c.path, rec.Code, c.want, rec.Body.String())
want := []string{
"GET /landscape/slots",
"POST /landscape/slots/{code}",
"GET /landscape/runs",
"POST /landscape/runs",
"GET /landscape/runs/{id}",
"GET /landscape/results",
}
for _, w := range want {
if !got[w] {
t.Errorf("route %q is not mounted; mounted routes: %v", w, keys(got))
}
}
if len(got) != len(want) {
t.Errorf("mounted %d routes, want %d: %v", len(got), len(want), keys(got))
}
}
// TestRoutedRequestReachesHandler sends a real request through the router so the
// URL parameter wiring (chi.URLParam) is exercised, not just the pattern.
func TestRoutedRequestReachesHandler(t *testing.T) {
r := chi.NewRouter()
r.Route("/landscape", newLandscapeTestHandler().Register)
req := httptest.NewRequest(http.MethodGet, "/landscape/runs/nope", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid id") {
t.Fatalf("body %s does not report the bad id", rec.Body.String())
}
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}