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())) } }