-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patherror_handler_test.go
More file actions
550 lines (464 loc) · 15.9 KB
/
error_handler_test.go
File metadata and controls
550 lines (464 loc) · 15.9 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
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
package wo
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/gowool/hook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ErrorHandlerTestEvent is a simple implementation of hook.Resolver for testing
type ErrorHandlerTestEvent struct {
req *http.Request
res http.ResponseWriter
hook.Resolver
}
func (e *ErrorHandlerTestEvent) SetRequest(r *http.Request) {
e.req = r
}
func (e *ErrorHandlerTestEvent) Request() *http.Request {
return e.req
}
func (e *ErrorHandlerTestEvent) SetResponse(w http.ResponseWriter) {
e.res = w
}
func (e *ErrorHandlerTestEvent) Response() http.ResponseWriter {
return e.res
}
// NewErrorHandlerTestEvent creates a new ErrorHandlerTestEvent for testing
func NewErrorHandlerTestEvent(req *http.Request, res http.ResponseWriter) *ErrorHandlerTestEvent {
e := &ErrorHandlerTestEvent{}
e.SetRequest(req)
e.SetResponse(res)
return e
}
func TestErrorHandler_DefaultValues(t *testing.T) {
// Test with nil logger and nil mapper
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
assert.NotNil(t, handler)
}
func TestErrorHandler_AfterResponseWritten(t *testing.T) {
// Create a response that's already written
req := httptest.NewRequest(http.MethodGet, "/", nil)
res := &Response{ResponseWriter: httptest.NewRecorder()}
res.WriteHeader(http.StatusOK) // Mark as written
event := NewErrorHandlerTestEvent(req, res)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuffer, nil))
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, logger)
handler(event, errors.New("test error"))
// Should log warning but not attempt to write response
logOutput := logBuffer.String()
assert.Contains(t, logOutput, "error handler: called after response written")
}
func TestErrorHandler_RedirectError(t *testing.T) {
tests := []struct {
name string
redirect *RedirectError
wantCode int
wantLoc string
}{
{
name: "301 redirect",
redirect: &RedirectError{Status: http.StatusMovedPermanently, URL: "/new-location"},
wantCode: http.StatusMovedPermanently,
wantLoc: "/new-location",
},
{
name: "302 redirect",
redirect: &RedirectError{Status: http.StatusFound, URL: "/temporary"},
wantCode: http.StatusFound,
wantLoc: "/temporary",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuffer, nil))
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, logger)
handler(event, tt.redirect)
assert.Equal(t, tt.wantCode, rec.Code)
assert.Equal(t, tt.wantLoc, rec.Header().Get(HeaderLocation))
assert.Empty(t, rec.Body.String())
})
}
}
func TestErrorHandler_HTTPErrorWithMapper(t *testing.T) {
customErr := errors.New("custom error")
mapper := func(err error) *HTTPError {
if err == customErr {
return NewHTTPError(http.StatusTeapot, "mapped error")
}
return nil
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, MIMEApplicationJSON) // Force JSON response
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, mapper, nil)
handler(event, customErr)
assert.Equal(t, http.StatusTeapot, rec.Code)
body := rec.Body.String()
// Parse JSON to check the detail field
var response map[string]interface{}
err := json.Unmarshal([]byte(body), &response)
require.NoError(t, err)
assert.Equal(t, "mapped error", response["detail"])
}
func TestErrorHandler_HTTPErrorMapperReturnsNil(t *testing.T) {
mapper := func(err error) *HTTPError {
return nil // Always returns nil
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuffer, nil))
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, mapper, logger)
handler(event, errors.New("test error"))
// Should default to internal server error
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestErrorHandler_WithCustomRender(t *testing.T) {
renderCalled := false
customRender := func(e *ErrorHandlerTestEvent, httpErr *HTTPError) {
renderCalled = true
e.Response().WriteHeader(httpErr.Status)
_, _ = e.Response().Write([]byte("custom rendered"))
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](customRender, nil, nil)
handler(event, ErrBadRequest)
assert.True(t, renderCalled)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "custom rendered", rec.Body.String())
}
func TestErrorHandler_HeadRequestMethod(t *testing.T) {
req := httptest.NewRequest(http.MethodHead, "/", nil)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
handler(event, ErrNotFound)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Empty(t, rec.Body.String()) // HEAD requests shouldn't have body
}
func TestErrorHandler_ContentNegotiation(t *testing.T) {
tests := []struct {
name string
acceptHeader string
expectedType string
expectedBody string
}{
{
name: "JSON response",
acceptHeader: "application/json",
expectedType: MIMEApplicationJSON,
expectedBody: "Bad Request",
},
{
name: "HTML response",
acceptHeader: "text/html",
expectedType: MIMETextHTMLCharsetUTF8,
expectedBody: "<!DOCTYPE html>",
},
{
name: "Text response (default)",
acceptHeader: "text/plain",
expectedType: MIMETextPlainCharsetUTF8,
expectedBody: "Bad Request",
},
{
name: "Unknown accept type falls back to plain text",
acceptHeader: "application/xml",
expectedType: "", // Content type not set when negotiation fails
expectedBody: "Bad Request",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, tt.acceptHeader)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
handler(event, ErrBadRequest)
contentType := rec.Header().Get(HeaderContentType)
if tt.expectedType != "" {
assert.Equal(t, tt.expectedType, contentType)
}
assert.Contains(t, rec.Body.String(), tt.expectedBody)
})
}
}
func TestErrorHandler_JSONResponse(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, MIMEApplicationJSON)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
// Create a custom error with specific message
customErr := NewHTTPError(http.StatusUnauthorized, "custom message")
customErr.Debug = true
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
handler(event, customErr)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, MIMEApplicationJSON, rec.Header().Get(HeaderContentType))
// Parse JSON response
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "custom message", response["detail"]) // detail field contains the custom message
assert.Equal(t, float64(http.StatusUnauthorized), response["status"])
// Debug field might not be included in all response formats
if debug, ok := response["debug"]; ok {
assert.Equal(t, true, debug)
}
}
func TestErrorHandler_HTMLResponse(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, MIMETextHTMLCharsetUTF8)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
handler(event, ErrNotFound)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Equal(t, MIMETextHTMLCharsetUTF8, rec.Header().Get(HeaderContentType))
body := rec.Body.String()
assert.Contains(t, body, "<!DOCTYPE html>")
assert.Contains(t, body, "Not Found!")
assert.Contains(t, body, "Code 404")
}
func TestErrorHandler_PlainTextResponse(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, MIMETextPlainCharsetUTF8)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
handler(event, ErrNotFound)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Equal(t, MIMETextPlainCharsetUTF8, rec.Header().Get(HeaderContentType))
assert.Equal(t, "Not Found", rec.Body.String())
}
func TestErrorHandler_DebugContext(t *testing.T) {
tests := []struct {
name string
debugCtx bool
}{
{
name: "debug enabled",
debugCtx: true,
},
{
name: "debug disabled",
debugCtx: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, MIMEApplicationJSON)
req = req.WithContext(WithDebug(req.Context(), tt.debugCtx))
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
handler(event, ErrInternalServerError)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
// Debug field is only included in ToMap() when Debug is true
if tt.debugCtx {
// When debug is enabled, debug field should be present
if debug, exists := response["debug"]; exists {
assert.Equal(t, true, debug)
} else {
// Debug field might be absent from JSON response if not part of standard format
t.Log("Debug field not found in JSON response - this may be expected behavior")
}
} else {
// When debug is disabled, debug field should not be present or should be false
if debug, exists := response["debug"]; exists {
assert.Equal(t, false, debug)
}
}
})
}
}
func TestErrorHandler_RequestLogging(t *testing.T) {
tests := []struct {
name string
requestLogged bool
}{
{
name: "request not logged",
requestLogged: false,
},
{
name: "request already logged",
requestLogged: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(WithRequestLogged(req.Context(), tt.requestLogged))
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuffer, nil))
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, logger)
handler(event, ErrBadRequest)
logOutput := logBuffer.String()
if !tt.requestLogged {
assert.Contains(t, logOutput, "request failed")
} else {
assert.Empty(t, logOutput)
}
})
}
}
func TestErrorHandler_WriteErrors(t *testing.T) {
tests := []struct {
name string
acceptHeader string
setupFailing bool
}{
{
name: "JSON write error",
acceptHeader: MIMEApplicationJSON,
setupFailing: true,
},
{
name: "HTML write error",
acceptHeader: MIMETextHTMLCharsetUTF8,
setupFailing: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, tt.acceptHeader)
// Create a failing response writer
rec := httptest.NewRecorder()
failingWriter := &failingResponseWriter{ResponseWriter: rec}
res := &Response{ResponseWriter: failingWriter}
event := NewErrorHandlerTestEvent(req, res)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuffer, nil))
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, logger)
handler(event, ErrBadRequest)
// Should log write error
logOutput := logBuffer.String()
assert.Contains(t, logOutput, "write response")
})
}
}
func TestErrorHandler_ErrorTemplate(t *testing.T) {
// Test that the error template is valid and can be executed
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(HeaderAccept, MIMETextHTMLCharsetUTF8)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
// Use an error with a custom message
customErr := NewHTTPError(http.StatusBadRequest, "Custom error message")
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, nil, nil)
handler(event, customErr)
body := rec.Body.String()
// HTML template uses title field (HTTP status text), not custom message
assert.Contains(t, body, "Bad Request!") // title field shows HTTP status text
assert.Contains(t, body, "Code 400")
}
// failingResponseWriter is a mock that fails on Write operations
type failingResponseWriter struct {
http.ResponseWriter
}
func (f *failingResponseWriter) Write([]byte) (int, error) {
return 0, errors.New("write failed")
}
// Test helper to check if error handler handles different error types correctly
func TestErrorHandler_ErrorTypes(t *testing.T) {
tests := []struct {
name string
err error
expected int
}{
{
name: "standard HTTPError",
err: ErrNotFound,
expected: http.StatusNotFound,
},
{
name: "custom error",
err: errors.New("custom error"),
expected: http.StatusInternalServerError,
},
{
name: "wrapped HTTPError",
err: NewHTTPError(http.StatusInternalServerError, "wrapper error").WithInternal(errors.New("inner")),
expected: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, AsHTTPError, nil)
handler(event, tt.err)
assert.Equal(t, tt.expected, rec.Code)
})
}
}
// TestErrorHandler_Integration tests the error handler with a more realistic scenario
func TestErrorHandler_Integration(t *testing.T) {
// Test a complete error handling scenario with all features
req := httptest.NewRequest(http.MethodPost, "/api/users", nil)
req.Header.Set(HeaderAccept, MIMEApplicationJSON)
req.Header.Set("Content-Type", MIMEApplicationJSON)
req = req.WithContext(WithDebug(req.Context(), true))
rec := httptest.NewRecorder()
res := &Response{ResponseWriter: rec}
event := NewErrorHandlerTestEvent(req, res)
var logBuffer bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&logBuffer, nil))
// Custom mapper that adds context
mapper := func(err error) *HTTPError {
if errors.Is(err, ErrUnauthorized) {
return ErrUnauthorized.WithInternal(err)
}
return AsHTTPError(err)
}
handler := ErrorHandler[*ErrorHandlerTestEvent](nil, mapper, logger)
handler(event, ErrUnauthorized)
// Verify response
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, MIMEApplicationJSON, rec.Header().Get(HeaderContentType))
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, float64(http.StatusUnauthorized), response["status"])
// Debug field might not be included in standard JSON response
if debug, exists := response["debug"]; exists {
assert.Equal(t, true, debug)
}
}