Files
web/httputil/bind_test.go
T
Rene Nochebuena a778227fc2 feat(httputil): add Bind/BindEmpty request binding from path and query; v1.6.0
Bind and BindEmpty fill Req from path/query/json struct tags and validate
once, extending the typed decode->validate->call->encode pipeline to routes
with identifiers and filters. Conversion via builtins + encoding.TextUnmarshaler
(uuid.UUID, time.Time); malformed value -> 400 naming the parameter; default:
applies only when absent; repeated query -> slice; mis-tagged struct panics at
wiring. Purely additive; existing adapters unchanged. Coordinated lockstep v1.6.0.
2026-08-13 23:11:23 -06:00

344 lines
11 KiB
Go

package httputil
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"code.nochebuena.dev/einherjar/core/valid"
)
// withPath attaches a chi route context carrying the given key/value path params,
// mirroring what the router injects before a handler runs.
func withPath(r *http.Request, kv ...string) *http.Request {
rctx := chi.NewRouteContext()
for i := 0; i+1 < len(kv); i += 2 {
rctx.URLParams.Add(kv[i], kv[i+1])
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// AC1 — Bind fills path, query and json fields on one struct.
func TestBind_FillsAllThreeSources(t *testing.T) {
type req struct {
RoleID string `path:"roleID"`
Page int `query:"page"`
Name string `json:"name"`
}
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{ID: r.RoleID}, nil
})
rec := httptest.NewRecorder()
r := withPath(httptest.NewRequest(http.MethodPatch, "/roles/abc?page=7", strings.NewReader(`{"name":"turno"}`)), "roleID", "abc")
h(rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got.RoleID != "abc" || got.Page != 7 || got.Name != "turno" {
t.Fatalf("bound = %+v, want {abc 7 turno}", got)
}
}
// AC1 + AC3 — BindEmpty writes a body-less success and needs no request body.
func TestBindEmpty_PathOnly_NoBody(t *testing.T) {
type req struct {
RoleID string `path:"roleID"`
}
called := ""
h := BindEmpty(valid.New(), discardLogger(), func(_ context.Context, r req) error {
called = r.RoleID
return nil
})
rec := httptest.NewRecorder()
// DELETE with a nil body — the HandleEmpty io.EOF trap must not fire.
h(rec, withPath(httptest.NewRequest(http.MethodDelete, "/roles/xyz", nil), "roleID", "xyz"))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
if called != "xyz" {
t.Errorf("path not bound: got %q", called)
}
}
// AC3 — a bodiless GET succeeds through Bind (query only, no io.EOF).
func TestBind_NoBody_QueryOnly(t *testing.T) {
type req struct {
Q string `query:"q"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
return tRes{ID: r.Q}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/roles?q=hola", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"id":"hola"}` {
t.Errorf("body = %q", got)
}
}
// AC2 — a field with two source tags fails at wiring (Bind panics at registration).
func TestBind_TwoSourceTags_PanicsAtWiring(t *testing.T) {
type req struct {
Bad string `path:"id" query:"id"`
}
defer func() {
if recover() == nil {
t.Fatal("Bind did not panic on a two-source-tag field")
}
}()
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
}
// AC2 (companion) — an unsupported field type and a bad default also fail at wiring.
func TestBind_UnsupportedType_PanicsAtWiring(t *testing.T) {
type req struct {
Ch chan int `query:"ch"`
}
defer func() {
if recover() == nil {
t.Fatal("Bind did not panic on an unsupported field type")
}
}()
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) { return tRes{}, nil })
}
func TestBind_BadDefault_PanicsAtWiring(t *testing.T) {
type req struct {
Page int `query:"page" default:"not-a-number"`
}
defer func() {
if recover() == nil {
t.Fatal("Bind did not panic on an invalid default tag")
}
}()
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) { return tRes{}, nil })
}
// AC4 — uuid.UUID and time.Time bind from path and query via TextUnmarshaler.
func TestBind_TextUnmarshaler_UUIDAndTime(t *testing.T) {
type req struct {
ID uuid.UUID `path:"id"`
From time.Time `query:"from"`
}
id := uuid.New()
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{ID: r.ID.String()}, nil
})
rec := httptest.NewRecorder()
r := withPath(httptest.NewRequest(http.MethodGet, "/x/"+id.String()+"?from=2026-01-02T03:04:05Z", nil), "id", id.String())
h(rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got.ID != id {
t.Errorf("uuid = %s, want %s", got.ID, id)
}
if !got.From.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
t.Errorf("time = %s, want 2026-01-02T03:04:05Z", got.From)
}
}
// AC5 — a malformed value answers 400 and names the parameter, never 500.
func TestBind_MalformedParam_400WithName(t *testing.T) {
type req struct {
Page int `query:"page"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/roles?page=abc", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "page") {
t.Errorf("error body %q does not name the parameter", rec.Body.String())
}
}
// AC4/AC5 — a malformed uuid path parameter is also a 400, not a 500.
func TestBind_MalformedUUID_400(t *testing.T) {
type req struct {
ID uuid.UUID `path:"id"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, withPath(httptest.NewRequest(http.MethodGet, "/x/nope", nil), "id", "nope"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// AC6 — default applies when the parameter is absent, and NOT when present-and-empty.
func TestBind_Default_AbsentOnly(t *testing.T) {
type req struct {
Page int `query:"page" default:"1"`
Q string `query:"q" default:"all"`
}
// Absent → defaults applied.
var absent req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
absent = r
return tRes{}, nil
})
h(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/roles", nil))
if absent.Page != 1 || absent.Q != "all" {
t.Fatalf("absent defaults = %+v, want {1 all}", absent)
}
// Present-but-empty (?q=) → the caller is clearing the filter; default must NOT win.
var present req
h2 := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
present = r
return tRes{}, nil
})
rec := httptest.NewRecorder()
h2(rec, httptest.NewRequest(http.MethodGet, "/roles?q=", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if present.Q != "" {
t.Errorf("present-empty q = %q, want \"\" (default must not override)", present.Q)
}
}
// AC7 — repeated query parameters bind to a slice; a comma inside a scalar survives.
func TestBind_RepeatedQuery_Slice(t *testing.T) {
type req struct {
Kind []string `query:"kind"`
Q string `query:"q"`
}
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/x?kind=POS&kind=KDS&q=a,b,c", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if len(got.Kind) != 2 || got.Kind[0] != "POS" || got.Kind[1] != "KDS" {
t.Errorf("kind = %v, want [POS KDS]", got.Kind)
}
if got.Q != "a,b,c" {
t.Errorf("q = %q, want verbatim a,b,c (no comma splitting)", got.Q)
}
}
// AC7 (companion) — a typed slice ([]uuid.UUID) binds each repeated value.
func TestBind_RepeatedQuery_TypedSlice(t *testing.T) {
type req struct {
IDs []uuid.UUID `query:"id"`
}
a, b := uuid.New(), uuid.New()
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/x?id="+a.String()+"&id="+b.String(), nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if len(got.IDs) != 2 || got.IDs[0] != a || got.IDs[1] != b {
t.Errorf("ids = %v, want [%s %s]", got.IDs, a, b)
}
}
// WithStatus composes with Bind exactly as with Handle (201 on create).
func TestBind_WithStatus(t *testing.T) {
type req struct {
Name string `json:"name" validate:"required"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
return tRes{ID: r.Name}, nil
}, WithStatus(http.StatusCreated))
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/roles", strings.NewReader(`{"name":"cajero"}`)))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201", rec.Code)
}
}
// Validation runs on the assembled struct — a query bound value is validated too.
func TestBind_ValidatesAssembledStruct(t *testing.T) {
type req struct {
PerPage int `query:"per_page" default:"50" validate:"min=1,max=200"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/roles?per_page=99999", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (per_page over max should fail validation)", rec.Code)
}
}
// AC9 — the per-type plan is reflected once and cached (same pointer across calls).
func TestBind_PlanCachedPerType(t *testing.T) {
type req struct {
A string `query:"a"`
B int `query:"b"`
}
rt := reflect.TypeOf((*req)(nil)).Elem()
if planFor(rt) != planFor(rt) {
t.Fatal("planFor returned a different plan for the same type — cache not effective")
}
}
// AC9 — per-request work does not re-reflect the type; the plan metadata is
// parsed once and reused. Run with -benchmem to see allocations stay flat
// regardless of how many tagged fields the struct declares.
func BenchmarkBind_ManyFields(b *testing.B) {
type req struct {
ID uuid.UUID `path:"id"`
Page int `query:"page" default:"1"`
PerPage int `query:"per_page" default:"50"`
Q string `query:"q"`
Sort string `query:"sort" default:"name"`
Order string `query:"order" default:"asc"`
Kind []string `query:"kind"`
}
id := uuid.New()
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
target := "/x/" + id.String() + "?page=2&per_page=25&q=turno&sort=name&order=desc&kind=POS&kind=KDS"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
rec := httptest.NewRecorder()
h(rec, withPath(httptest.NewRequest(http.MethodGet, target, nil), "id", id.String()))
}
}