mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Tests the error logging path when json.Encode fails due to a write error on the ResponseWriter.
511 lines
13 KiB
Go
511 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestAPIError_Error(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
apiError APIError
|
|
want string
|
|
}{
|
|
{
|
|
name: "simple error message",
|
|
apiError: APIError{ErrorMessage: "something went wrong"},
|
|
want: "something went wrong",
|
|
},
|
|
{
|
|
name: "empty error message",
|
|
apiError: APIError{ErrorMessage: ""},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "error with all fields",
|
|
apiError: APIError{
|
|
ErrorMessage: "unauthorized",
|
|
Code: "AUTH_FAILED",
|
|
StatusCode: 401,
|
|
Timestamp: 1234567890,
|
|
RequestID: "req-123",
|
|
Details: map[string]string{"reason": "invalid token"},
|
|
},
|
|
want: "unauthorized",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tt.apiError.Error()
|
|
if got != tt.want {
|
|
t.Errorf("APIError.Error() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIError_ImplementsError(t *testing.T) {
|
|
var _ error = &APIError{}
|
|
}
|
|
|
|
func TestResponseWriter_WriteHeader(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
codes []int
|
|
wantStatusCode int
|
|
wantWrittenCount int
|
|
wantUnderlyingCode int
|
|
}{
|
|
{
|
|
name: "single write",
|
|
codes: []int{http.StatusOK},
|
|
wantStatusCode: http.StatusOK,
|
|
wantWrittenCount: 1,
|
|
wantUnderlyingCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "first write wins",
|
|
codes: []int{http.StatusCreated, http.StatusBadRequest, http.StatusInternalServerError},
|
|
wantStatusCode: http.StatusCreated,
|
|
wantWrittenCount: 1,
|
|
wantUnderlyingCode: http.StatusCreated,
|
|
},
|
|
{
|
|
name: "error code",
|
|
codes: []int{http.StatusNotFound},
|
|
wantStatusCode: http.StatusNotFound,
|
|
wantWrittenCount: 1,
|
|
wantUnderlyingCode: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "server error",
|
|
codes: []int{http.StatusInternalServerError},
|
|
wantStatusCode: http.StatusInternalServerError,
|
|
wantWrittenCount: 1,
|
|
wantUnderlyingCode: http.StatusInternalServerError,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
for _, code := range tt.codes {
|
|
rw.WriteHeader(code)
|
|
}
|
|
|
|
if rw.statusCode != tt.wantStatusCode {
|
|
t.Errorf("statusCode = %d, want %d", rw.statusCode, tt.wantStatusCode)
|
|
}
|
|
|
|
if rec.Code != tt.wantUnderlyingCode {
|
|
t.Errorf("underlying Code = %d, want %d", rec.Code, tt.wantUnderlyingCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponseWriter_WriteHeader_WrittenFlag(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
if rw.written {
|
|
t.Error("written should be false initially")
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusCreated)
|
|
|
|
if !rw.written {
|
|
t.Error("written should be true after WriteHeader")
|
|
}
|
|
}
|
|
|
|
func TestResponseWriter_Write(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
preWriteHeader bool
|
|
preWriteHeaderCode int
|
|
writeData []byte
|
|
wantStatusCode int
|
|
wantWritten bool
|
|
}{
|
|
{
|
|
name: "write without prior WriteHeader",
|
|
preWriteHeader: false,
|
|
writeData: []byte("hello"),
|
|
wantStatusCode: http.StatusOK,
|
|
wantWritten: true,
|
|
},
|
|
{
|
|
name: "write with prior WriteHeader",
|
|
preWriteHeader: true,
|
|
preWriteHeaderCode: http.StatusCreated,
|
|
writeData: []byte("created"),
|
|
wantStatusCode: http.StatusCreated,
|
|
wantWritten: true,
|
|
},
|
|
{
|
|
name: "empty write without prior WriteHeader",
|
|
preWriteHeader: false,
|
|
writeData: []byte{},
|
|
wantStatusCode: http.StatusOK,
|
|
wantWritten: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
if tt.preWriteHeader {
|
|
rw.WriteHeader(tt.preWriteHeaderCode)
|
|
}
|
|
|
|
n, err := rw.Write(tt.writeData)
|
|
if err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
|
|
if n != len(tt.writeData) {
|
|
t.Errorf("Write() = %d bytes, want %d", n, len(tt.writeData))
|
|
}
|
|
|
|
if rw.statusCode != tt.wantStatusCode {
|
|
t.Errorf("statusCode = %d, want %d", rw.statusCode, tt.wantStatusCode)
|
|
}
|
|
|
|
if rw.written != tt.wantWritten {
|
|
t.Errorf("written = %v, want %v", rw.written, tt.wantWritten)
|
|
}
|
|
|
|
if string(rec.Body.Bytes()) != string(tt.writeData) {
|
|
t.Errorf("body = %q, want %q", rec.Body.String(), string(tt.writeData))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponseWriter_Write_MultipleWrites(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
// First write triggers implicit WriteHeader
|
|
_, err := rw.Write([]byte("first"))
|
|
if err != nil {
|
|
t.Fatalf("first Write() error = %v", err)
|
|
}
|
|
|
|
// Second write should not change status
|
|
_, err = rw.Write([]byte(" second"))
|
|
if err != nil {
|
|
t.Fatalf("second Write() error = %v", err)
|
|
}
|
|
|
|
if rw.statusCode != http.StatusOK {
|
|
t.Errorf("statusCode = %d, want %d", rw.statusCode, http.StatusOK)
|
|
}
|
|
|
|
if rec.Body.String() != "first second" {
|
|
t.Errorf("body = %q, want %q", rec.Body.String(), "first second")
|
|
}
|
|
}
|
|
|
|
func TestResponseWriter_StatusCode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rw *responseWriter
|
|
wantStatusCode int
|
|
}{
|
|
{
|
|
name: "nil receiver",
|
|
rw: nil,
|
|
wantStatusCode: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "default status",
|
|
rw: &responseWriter{statusCode: http.StatusOK},
|
|
wantStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "custom status",
|
|
rw: &responseWriter{statusCode: http.StatusNotFound},
|
|
wantStatusCode: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "server error status",
|
|
rw: &responseWriter{statusCode: http.StatusBadGateway},
|
|
wantStatusCode: http.StatusBadGateway,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tt.rw.StatusCode()
|
|
if got != tt.wantStatusCode {
|
|
t.Errorf("StatusCode() = %d, want %d", got, tt.wantStatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockHijacker implements http.Hijacker for testing
|
|
type mockHijacker struct {
|
|
http.ResponseWriter
|
|
conn net.Conn
|
|
rw *bufio.ReadWriter
|
|
err error
|
|
}
|
|
|
|
func (m *mockHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
return m.conn, m.rw, m.err
|
|
}
|
|
|
|
func TestResponseWriter_Hijack_NotSupported(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
conn, brw, err := rw.Hijack()
|
|
if err == nil {
|
|
t.Error("Hijack() should return error when underlying writer doesn't support it")
|
|
}
|
|
if conn != nil {
|
|
t.Error("Hijack() should return nil conn on error")
|
|
}
|
|
if brw != nil {
|
|
t.Error("Hijack() should return nil bufio.ReadWriter on error")
|
|
}
|
|
if err.Error() != "ResponseWriter does not implement http.Hijacker" {
|
|
t.Errorf("Hijack() error = %q, want specific error message", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestResponseWriter_Hijack_Supported(t *testing.T) {
|
|
// Create a mock hijacker that supports hijacking
|
|
mockConn := &net.TCPConn{}
|
|
mockRW := bufio.NewReadWriter(bufio.NewReader(nil), bufio.NewWriter(nil))
|
|
hijacker := &mockHijacker{
|
|
ResponseWriter: httptest.NewRecorder(),
|
|
conn: mockConn,
|
|
rw: mockRW,
|
|
err: nil,
|
|
}
|
|
|
|
rw := &responseWriter{ResponseWriter: hijacker, statusCode: http.StatusOK}
|
|
|
|
conn, brw, err := rw.Hijack()
|
|
if err != nil {
|
|
t.Errorf("Hijack() error = %v, want nil", err)
|
|
}
|
|
if conn != mockConn {
|
|
t.Error("Hijack() returned unexpected conn")
|
|
}
|
|
if brw != mockRW {
|
|
t.Error("Hijack() returned unexpected bufio.ReadWriter")
|
|
}
|
|
}
|
|
|
|
// mockFlusher implements http.Flusher for testing
|
|
type mockFlusher struct {
|
|
http.ResponseWriter
|
|
flushed bool
|
|
}
|
|
|
|
func (m *mockFlusher) Flush() {
|
|
m.flushed = true
|
|
}
|
|
|
|
func TestResponseWriter_Flush_NotSupported(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
// This should not panic even though underlying doesn't support Flush
|
|
rw.Flush()
|
|
}
|
|
|
|
func TestResponseWriter_Flush_Supported(t *testing.T) {
|
|
flusher := &mockFlusher{ResponseWriter: httptest.NewRecorder()}
|
|
rw := &responseWriter{ResponseWriter: flusher, statusCode: http.StatusOK}
|
|
|
|
if flusher.flushed {
|
|
t.Error("flushed should be false initially")
|
|
}
|
|
|
|
rw.Flush()
|
|
|
|
if !flusher.flushed {
|
|
t.Error("Flush() should call underlying Flusher.Flush()")
|
|
}
|
|
}
|
|
|
|
// Note: httptest.ResponseRecorder implements http.Flusher
|
|
func TestResponseWriter_Flush_WithRecorder(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
// Write some data
|
|
rw.Write([]byte("test"))
|
|
|
|
// Flush should work with httptest.ResponseRecorder (it implements Flusher)
|
|
rw.Flush()
|
|
|
|
// If we got here without panic, the test passes
|
|
}
|
|
|
|
func TestResponseWriter_Header(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
// Set a header through the wrapper
|
|
rw.Header().Set("X-Custom-Header", "test-value")
|
|
|
|
// Verify it was set on the underlying writer
|
|
if got := rec.Header().Get("X-Custom-Header"); got != "test-value" {
|
|
t.Errorf("Header().Get() = %q, want %q", got, "test-value")
|
|
}
|
|
}
|
|
|
|
func TestResponseWriter_FullFlow(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
// Set headers
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
rw.Header().Set("X-Request-ID", "test-123")
|
|
|
|
// Write status
|
|
rw.WriteHeader(http.StatusCreated)
|
|
|
|
// Write body
|
|
n, err := rw.Write([]byte(`{"status":"created"}`))
|
|
if err != nil {
|
|
t.Fatalf("Write() error = %v", err)
|
|
}
|
|
if n != 20 {
|
|
t.Errorf("Write() = %d bytes, want 20", n)
|
|
}
|
|
|
|
// Verify all values
|
|
if rw.StatusCode() != http.StatusCreated {
|
|
t.Errorf("StatusCode() = %d, want %d", rw.StatusCode(), http.StatusCreated)
|
|
}
|
|
if rec.Code != http.StatusCreated {
|
|
t.Errorf("rec.Code = %d, want %d", rec.Code, http.StatusCreated)
|
|
}
|
|
if rec.Body.String() != `{"status":"created"}` {
|
|
t.Errorf("body = %q, want %q", rec.Body.String(), `{"status":"created"}`)
|
|
}
|
|
if rec.Header().Get("Content-Type") != "application/json" {
|
|
t.Errorf("Content-Type = %q, want %q", rec.Header().Get("Content-Type"), "application/json")
|
|
}
|
|
}
|
|
|
|
func TestResponseWriter_EdgeCases(t *testing.T) {
|
|
t.Run("zero status code preserved", func(t *testing.T) {
|
|
rw := &responseWriter{statusCode: 0}
|
|
if rw.StatusCode() != 0 {
|
|
t.Errorf("StatusCode() = %d, want 0", rw.StatusCode())
|
|
}
|
|
})
|
|
|
|
t.Run("write after write maintains written flag", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
rw.Write([]byte("a"))
|
|
if !rw.written {
|
|
t.Error("written should be true after first Write")
|
|
}
|
|
|
|
rw.Write([]byte("b"))
|
|
if !rw.written {
|
|
t.Error("written should remain true after second Write")
|
|
}
|
|
})
|
|
|
|
t.Run("WriteHeader after Write is no-op", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
rw := &responseWriter{ResponseWriter: rec, statusCode: http.StatusOK}
|
|
|
|
rw.Write([]byte("data"))
|
|
rw.WriteHeader(http.StatusNotFound)
|
|
|
|
// Status should remain 200 since Write triggered implicit WriteHeader
|
|
if rw.statusCode != http.StatusOK {
|
|
t.Errorf("statusCode = %d, want %d", rw.statusCode, http.StatusOK)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestErrorHandler_EmptyPath(t *testing.T) {
|
|
// Test that empty path is normalized to "/" (fix for issue #334)
|
|
var capturedPath string
|
|
handler := ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
capturedPath = r.URL.Path
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
|
|
req.URL.Path = "" // explicitly set empty path
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if capturedPath != "/" {
|
|
t.Errorf("expected empty path to be normalized to '/', got %q", capturedPath)
|
|
}
|
|
}
|
|
|
|
func TestErrorHandler_PanicRecovery(t *testing.T) {
|
|
// Test that panics are recovered and return 500
|
|
handler := ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
panic("test panic")
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Should not panic - ErrorHandler recovers it
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500 after panic recovery, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// failingWriter is a ResponseWriter that fails on Write
|
|
type failingWriter struct {
|
|
header http.Header
|
|
}
|
|
|
|
func (fw *failingWriter) Header() http.Header {
|
|
if fw.header == nil {
|
|
fw.header = make(http.Header)
|
|
}
|
|
return fw.header
|
|
}
|
|
|
|
func (fw *failingWriter) Write([]byte) (int, error) {
|
|
return 0, net.ErrClosed
|
|
}
|
|
|
|
func (fw *failingWriter) WriteHeader(int) {}
|
|
|
|
func TestWriteErrorResponse_EncodeFails(t *testing.T) {
|
|
// Test that writeErrorResponse logs but doesn't panic when encode fails
|
|
fw := &failingWriter{}
|
|
|
|
// Should not panic even when Write fails
|
|
writeErrorResponse(fw, http.StatusInternalServerError, "TEST_ERR", "test error", nil)
|
|
|
|
// Verify Content-Type was set before the failed write
|
|
if ct := fw.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("expected Content-Type 'application/json', got %q", ct)
|
|
}
|
|
}
|