diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go new file mode 100644 index 0000000..afaa00a --- /dev/null +++ b/internal/api/middleware_test.go @@ -0,0 +1,443 @@ +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) + } + }) +}