344 lines
11 KiB
Go
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()))
|
||
|
|
}
|
||
|
|
}
|