-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathhandler_test.go
More file actions
487 lines (423 loc) · 15.2 KB
/
Copy pathhandler_test.go
File metadata and controls
487 lines (423 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
package mid
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type User struct{ Name string }
type RequiredUser struct {
Name string `validate:"required"`
}
func UserHandler(u User) (any, error) { return User{Name: "Goodbye"}, nil }
func UserHandlerWithError(u User) (any, error) {
return nil, errors.New("simulated handler error")
}
func UserHandlerNilResponse(u User) (any, error) {
return nil, nil
}
// serve runs h against a GET /user request carrying body and returns the recorder.
func serve(h http.Handler, body string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/user", bytes.NewBufferString(body))
h.ServeHTTP(recorder, request)
return recorder
}
// Here is an example of a struct with one or more handlers
type UserController struct {
// Perhaps you have logging or database connections to share with handlers
value string
}
// implements Handler[T any]
func (uc *UserController) IndexHandler(u User) (any, error) {
u.Name = uc.value
return u, nil
}
func TestHandlerStruct(t *testing.T) {
h := UserController{value: "demo"}
handler := Handler(h.IndexHandler)
recorder := serve(handler, `{"name":"input"}`)
if recorder.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != `{"Name":"demo"}`+"\n" {
t.Errorf("unexpected response: %s", recorder.Body.String())
}
}
// TestHandlerResponses covers the request -> (status, body) contract across
// the happy path and the JSONDecoder/StructValidator error branches that
// surface a response body directly.
func TestHandlerResponses(t *testing.T) {
cases := []struct {
name string
body string
wantCode int
wantBody string
}{
{
// Full happy path: decode, validate, call handler, encode.
name: "happyPath",
body: `{"name":"example"}`,
wantCode: http.StatusOK,
wantBody: `{"Name":"Goodbye"}` + "\n",
},
{
// Malformed JSON hits JSONDecoder's default branch, masked as
// the generic ErrJSONInvalid message.
name: "invalidJSON",
body: `{invalid JSON}`,
wantCode: http.StatusBadRequest,
wantBody: `{"error":"invalid JSON"}` + "\n",
},
{
// Wrong type for a field hits JSONDecoder's UnmarshalTypeError
// branch, which reports a descriptive message.
name: "unmarshalTypeError",
body: `{"name": 123}`,
wantCode: http.StatusBadRequest,
wantBody: `{"error":"unexpected type 'number' for field 'Name': invalid JSON"}` + "\n",
},
}
handler := Handler(UserHandler)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
recorder := serve(handler, c.body)
if recorder.Code != c.wantCode {
t.Errorf("expected status %d, got %d: %s", c.wantCode, recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != c.wantBody {
t.Errorf("unexpected response: %s", recorder.Body.String())
}
})
}
}
// TestHandlerWithNilResponse covers a handler that returns a nil response
// value, which json.Encode renders as JSON null.
func TestHandlerWithNilResponse(t *testing.T) {
handler := Handler(UserHandlerNilResponse)
recorder := serve(handler, `{"name":"example"}`)
if recorder.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != "null\n" {
t.Errorf("unexpected response: %s", recorder.Body.String())
}
}
// TestHandlerSetsContentType verifies Content-Type is set to application/json
// before decoding even begins (here decoding fails on malformed JSON).
func TestHandlerSetsContentType(t *testing.T) {
handler := Handler(UserHandler)
recorder := serve(handler, `{bad json}`)
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json" {
t.Errorf("expected Content-Type 'application/json', got '%s'", contentType)
}
}
func BenchmarkHandlerWithType(b *testing.B) {
handler := Handler(UserHandler)
for i := 0; i < b.N; i++ {
recorder := serve(handler, `{"name":"example"}`)
if recorder.Body.String() != `{"Name":"Goodbye"}`+"\n" {
b.Log(recorder.Body.String())
b.Fail()
}
}
}
// TestJSONDecoderWithInvalidUnmarshalError tests JSONDecoder's
// InvalidUnmarshalError switch branch (a nil destination pointer), which
// leaves the original error message intact instead of masking it as
// ErrJSONInvalid.
func TestJSONDecoderWithInvalidUnmarshalError(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/user", bytes.NewBufferString(`{"name":"example"}`))
var nilInput *User
err := JSONDecoder(request, nilInput)
if err == nil {
t.Fatal("expected JSONDecoder to return an error for a nil destination pointer")
}
if err.Error() != "json: Unmarshal(nil *mid.User)" {
t.Errorf("unexpected error: %v", err)
}
}
// TestStructValidatorNonStruct covers StructValidator's non-ValidationErrors
// branch: validating a non-struct yields *InvalidValidationError, which is
// returned as-is instead of a ValidationErrors.
func TestStructValidatorNonStruct(t *testing.T) {
// note int "5" is the decode input
err := StructValidator(5)
if err == nil {
t.Fatal("expected StructValidator to return an error for a non-struct input")
}
var ve ValidationErrors
if errors.As(err, &ve) {
t.Errorf("expected a non-ValidationErrors error, got %v", err)
}
if err.Error() != "validator: (nil int)" {
t.Errorf("unexpected error: %v", err)
}
}
// TestValidatorRejectsInput tests that when StructValidator rejects input the
// handler is never called and the response projects each validator.FieldError
// into a client-usable {field, tag, message} object (the raw FieldError
// interface exposes no JSON fields, so it would otherwise marshal to {}).
func TestValidatorRejectsInput(t *testing.T) {
handlerCalled := false
handlerFunc := func(u RequiredUser) (any, error) {
handlerCalled = true
return u, nil
}
handler := Handler(handlerFunc)
recorder := serve(handler, `{}`)
if handlerCalled {
t.Error("expected handler to NOT be called when validator rejects input")
}
if recorder.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
want := `{"errors":[{"field":"RequiredUser.Name","tag":"required","message":"failed 'required' validation"}]}` + "\n"
if recorder.Body.String() != want {
t.Errorf("unexpected response: %s", recorder.Body.String())
}
}
// TestErrorHandlerReceivesDecodeAndValidationErrors is the regression guard for
// the DI fix: a custom WithErrorHandler must handle decode and validation
// failures too, not just handler-returned errors. Previously JSONDecoder and
// StructValidator hard-coded the default JSONErrorHandler, so the override was
// silently bypassed for those two branches.
func TestErrorHandlerReceivesDecodeAndValidationErrors(t *testing.T) {
cases := []struct {
name string
body string
}{
{"decodeError", `{invalid json}`},
{"validationError", `{}`}, // RequiredUser.Name is required
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
called := false
var got error
customErrHandler := ErrorHandler[RequiredUser](func(w http.ResponseWriter, r *http.Request, input RequiredUser, err error) {
called = true
got = err
w.WriteHeader(http.StatusTeapot)
})
h := func(u RequiredUser) (any, error) { return u, nil }
handler := Handler(h, WithErrorHandler(customErrHandler))
recorder := serve(handler, c.body)
if !called {
t.Fatalf("expected custom ErrorHandler to be called for %s", c.name)
}
if got == nil {
t.Error("expected a non-nil error passed to the ErrorHandler")
}
if recorder.Code != http.StatusTeapot {
t.Errorf("expected custom status %d, got %d", http.StatusTeapot, recorder.Code)
}
})
}
}
// AddressBook exercises a nested struct and a parameterized rule so the
// validation response is checked for namespaced field paths and the param
// field.
type AddressBook struct {
Street string `validate:"required"`
Zip string `validate:"len=5"`
}
// TestValidatorRejectsNestedInput verifies the response reports the fully
// namespaced field path for nested structs and includes the constraint param.
func TestValidatorRejectsNestedInput(t *testing.T) {
handlerFunc := func(a AddressBook) (any, error) { return a, nil }
handler := Handler(handlerFunc)
recorder := serve(handler, `{"Street":"","Zip":"12"}`)
if recorder.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
want := `{"errors":[` +
`{"field":"AddressBook.Street","tag":"required","message":"failed 'required' validation"},` +
`{"field":"AddressBook.Zip","tag":"len","message":"failed 'len' validation (5)"}` +
`]}` + "\n"
if recorder.Body.String() != want {
t.Errorf("unexpected response: %s", recorder.Body.String())
}
}
// TestHandlerShortCircuits tests that when the decoder or validator returns an
// error, the handler is never called and the error is routed through the
// configured ErrorHandler (here a custom onErr that writes a distinct status
// and plain-text body, proving DI reaches decode/validation failures).
func TestHandlerShortCircuits(t *testing.T) {
failingDecoder := func(r *http.Request, input *User) error {
return errors.New("decoder rejected")
}
failingValidator := func(input User) error {
return errors.New("validation failed")
}
onErr := func(status int) ErrorHandler[User] {
return func(w http.ResponseWriter, r *http.Request, input User, err error) {
http.Error(w, err.Error(), status)
}
}
cases := []struct {
name string
build func(HandlerFunc[User]) http.Handler
wantCode int
wantBody string
}{
{
"failingDecoder",
func(h HandlerFunc[User]) http.Handler {
return Handler(h, WithDecoder(failingDecoder), WithErrorHandler(onErr(http.StatusUnauthorized)))
},
http.StatusUnauthorized, "decoder rejected\n",
},
{
"failingValidator",
func(h HandlerFunc[User]) http.Handler {
return Handler(h, WithValidator(failingValidator), WithErrorHandler(onErr(http.StatusForbidden)))
},
http.StatusForbidden, "validation failed\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
handlerCalled := false
handlerFunc := func(u User) (any, error) {
handlerCalled = true
return u, nil
}
recorder := serve(c.build(handlerFunc), `{"name":"example"}`)
if handlerCalled {
t.Errorf("expected handler to NOT be called when %s fails", c.name)
}
if recorder.Code != c.wantCode {
t.Errorf("expected status %d, got %d", c.wantCode, recorder.Code)
}
if recorder.Body.String() != c.wantBody {
t.Errorf("unexpected response: %s", recorder.Body.String())
}
})
}
}
// TestHandlerReturnsError tests that when the handler function returns a
// non-nil error, the ErrorHandler is called and no 200 OK is sent. The body
// is empty because this ErrorHandler only writes a status code.
func TestHandlerReturnsError(t *testing.T) {
errorHandlerCalled := false
var capturedError error
customErrHandler := ErrorHandler[User](func(w http.ResponseWriter, r *http.Request, input User, err error) {
errorHandlerCalled = true
capturedError = err
w.WriteHeader(http.StatusInternalServerError)
})
handler := Handler(UserHandlerWithError, WithErrorHandler(customErrHandler))
recorder := serve(handler, `{"name":"example"}`)
if !errorHandlerCalled {
t.Error("expected ErrorHandler to be called when handler returns error")
}
if capturedError == nil {
t.Error("expected captured error to be non-nil")
}
if recorder.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, recorder.Code)
}
if recorder.Body.String() != "" {
t.Errorf("unexpected response: %s", recorder.Body.String())
}
}
// errorWriter simulates a client disconnect / broken pipe by returning an
// error on Write and refusing to record WriteHeader.
type errorWriter struct {
httptest.ResponseRecorder
errToReturn error
}
func (e *errorWriter) Write([]byte) (int, error) {
if e.errToReturn != nil {
return 0, e.errToReturn
}
return 0, nil
}
func (e *errorWriter) WriteHeader(statusCode int) {
if e.errToReturn != nil {
return // simulate broken connection
}
e.ResponseRecorder.WriteHeader(statusCode)
}
// TestHandlerWithWriteErrorDuringResponse tests that when json.Encode fails
// while writing the response body (e.g. client disconnect), the failure is
// logged rather than routed to the ErrorHandler: the 200 status line is already
// on the wire, so switching to an error response would be a superfluous
// WriteHeader. The handler must not panic and must not invoke the ErrorHandler.
func TestHandlerWithWriteErrorDuringResponse(t *testing.T) {
writeErr := errors.New("broken pipe: write failed")
errorHandlerCalled := false
customErrHandler := ErrorHandler[User](func(w http.ResponseWriter, r *http.Request, input User, err error) {
errorHandlerCalled = true
})
recorder := &errorWriter{
ResponseRecorder: *httptest.NewRecorder(),
errToReturn: writeErr,
}
request := httptest.NewRequest(http.MethodGet, "/user", bytes.NewBufferString(`{"name":"example"}`))
handler := Handler(UserHandler, WithErrorHandler(customErrHandler))
handler.ServeHTTP(recorder, request) // must not panic
if errorHandlerCalled {
t.Error("expected ErrorHandler NOT to be called once the 200 status is already sent")
}
}
// headerCountWriter wraps a ResponseRecorder and tracks how many times
// WriteHeader is called.
type headerCountWriter struct {
httptest.ResponseRecorder
headerCount int
}
func (h *headerCountWriter) WriteHeader(statusCode int) {
h.headerCount++
h.ResponseRecorder.WriteHeader(statusCode)
}
// TestHandlerWriteHeaderOnce tests that WriteHeader is only called once, both
// on the success path and when the handler returns an error.
func TestHandlerWriteHeaderOnce(t *testing.T) {
cases := []struct {
name string
handler HandlerFunc[User]
}{
{"success", UserHandler},
{"handlerError", UserHandlerWithError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
recorder := &headerCountWriter{ResponseRecorder: *httptest.NewRecorder()}
request := httptest.NewRequest(http.MethodGet, "/user", bytes.NewBufferString(`{"name":"example"}`))
handler := Handler(c.handler)
handler.ServeHTTP(recorder, request)
if recorder.headerCount != 1 {
t.Errorf("expected WriteHeader to be called exactly once, got %d", recorder.headerCount)
}
})
}
}
type SampleInput struct {
Name string
Title string `valid:"alphanum,required"`
Email string `valid:"email,required"`
Message string `valid:"ascii,required"`
Date string `valid:"-"`
}
func BenchmarkMid(b *testing.B) {
handler := Handler(func(in SampleInput) (any, error) {
in.Name = "mid"
return in, nil
})
data := `{"Title":"FooBar","Email":"email@example.com","Message":"Hello there","Date":"yes"}`
for n := 0; n < b.N; n++ {
rr := httptest.NewRecorder()
req, err := http.NewRequest("POST", "/", strings.NewReader(data))
if err != nil {
b.Fatal(err)
}
handler.ServeHTTP(rr, req)
expected := `{"Name":"mid","Title":"FooBar","Email":"email@example.com","Message":"Hello there","Date":"yes"}`
if expected != strings.TrimSpace(rr.Body.String()) {
b.Errorf("expected %s, got %s", expected, strings.TrimSpace(rr.Body.String()))
}
}
}