package httpclient import ( "fmt" "io" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/tmaxmax/go-sse" ) func TestHttpClientImpl_Do(t *testing.T) { tests := []struct { name string request *Request serverResponse func(w http.ResponseWriter, r *http.Request) wantErr bool wantErrReg *regexp.Regexp validate func(*Response) bool }{ { name: "successful request", request: &Request{ Method: http.MethodPost, Headers: http.Header{ "Content-Type": []string{"application/json"}, }, Body: []byte(`{"test": "data"}`), }, serverResponse: func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"response": "success"}`)) }, wantErr: false, validate: func(resp *Response) bool { return resp.StatusCode == http.StatusOK && string(resp.Body) == `{"response": "success"}` }, }, { name: "request with authentication", request: &Request{ Method: http.MethodPost, Headers: http.Header{ "Content-Type": []string{"application/json"}, }, Body: []byte(`{"test": "data"}`), Auth: &AuthConfig{ Type: "bearer", APIKey: "test-token", }, }, serverResponse: func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if auth != "Bearer test-token" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error": "unauthorized"}`)) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"response": "authenticated"}`)) }, wantErr: false, validate: func(resp *Response) bool { return resp.StatusCode == http.StatusOK && string(resp.Body) == `{"response": "authenticated"}` }, }, { name: "HTTP error response", request: &Request{ Method: http.MethodPost, Headers: http.Header{ "Content-Type": []string{"application/json"}, }, Body: []byte(`{"test": "data"}`), }, serverResponse: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "bad request"}`)) }, wantErr: true, wantErrReg: regexp.MustCompile("POST - http://127.0.0.1:\\d+ with status 400 Bad Request"), validate: func(resp *Response) bool { return resp == nil }, }, { name: "request with query parameters", request: &Request{ Method: http.MethodGet, Query: url.Values{ "param1": []string{"value1"}, "param2": []string{"value2"}, }, }, serverResponse: func(w http.ResponseWriter, r *http.Request) { // Verify query parameters if r.URL.Query().Get("param1") != "value1" || r.URL.Query().Get("param2") != "value2" { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "missing query parameters"}`)) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"query_params": "received"}`)) }, wantErr: false, validate: func(resp *Response) bool { return resp.StatusCode == http.StatusOK && string(resp.Body) == `{"query_params": "received"}` }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create test server server := httptest.NewServer(http.HandlerFunc(tt.serverResponse)) defer server.Close() // Update request URL to point to test server tt.request.URL = server.URL // Create client client := NewHttpClient() // Execute request result, err := client.Do(t.Context(), tt.request) if tt.wantErr { require.Error(t, err) if tt.wantErrReg != nil && !tt.wantErrReg.MatchString(err.Error()) { t.Errorf("Do() error = %v, want error containing %v", err, tt.wantErrReg) } return } require.NoError(t, err) require.NotNil(t, result) if tt.validate != nil && !tt.validate(result) { t.Errorf("Do() validation failed for result: %+v", result) } }) } } func TestHttpClientImpl_DoStream(t *testing.T) { tests := []struct { name string request *Request serverResponse func(w http.ResponseWriter, r *http.Request) wantErr bool wantErrContains string validate func(stream any) bool }{ { name: "successful streaming request", request: &Request{ Method: http.MethodPost, Headers: http.Header{ "Content-Type": []string{"application/json"}, }, Body: []byte(`{"stream": true}`), }, serverResponse: func(w http.ResponseWriter, r *http.Request) { // Check streaming headers if r.Header.Get("Accept") != "text/event-stream" { t.Errorf( "Expected Accept header to be text/event-stream, got %s", r.Header.Get("Accept"), ) } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) // Write SSE events flusher, ok := w.(http.Flusher) if !ok { t.Error("ResponseWriter does not support flushing") return } events := []string{ "data: {\"id\": \"1\", \"content\": \"Hello\"}\n\n", "data: {\"id\": \"2\", \"content\": \"World\"}\n\n", "data: [DONE]\n\n", } for _, event := range events { fmt.Fprint(w, event) flusher.Flush() time.Sleep(10 * time.Millisecond) // Small delay between events } }, wantErr: false, validate: func(stream any) bool { // This is a basic validation - in a real test we'd iterate through the stream return stream != nil }, }, { name: "HTTP error in streaming request", request: &Request{ Method: http.MethodPost, Headers: http.Header{ "Content-Type": []string{"application/json"}, }, Body: []byte(`{"stream": true}`), }, serverResponse: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error": "unauthorized"}`)) }, wantErr: true, validate: func(stream any) bool { return stream == nil }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create test server server := httptest.NewServer(http.HandlerFunc(tt.serverResponse)) defer server.Close() // Update request URL to point to test server tt.request.URL = server.URL // Create client client := NewHttpClient() // Execute streaming request result, err := client.DoStream(t.Context(), tt.request) if tt.wantErr { require.ErrorContains(t, err, tt.wantErrContains) return } require.NoError(t, err) require.NotNil(t, result) if tt.validate != nil && !tt.validate(result) { t.Errorf("DoStream() validation failed for result: %+v", result) } // Clean up stream if result != nil { result.Close() } }) } } func TestHttpClientImpl_buildHttpRequest(t *testing.T) { client := &HttpClient{ client: &http.Client{Timeout: 5 * time.Second}, } tests := []struct { name string request *Request wantErr bool errContains string validate func(*http.Request) bool }{ { name: "basic request", request: &Request{ Method: http.MethodPost, URL: "https://api.example.com/test", Headers: http.Header{ "Content-Type": []string{"application/json"}, }, Body: []byte(`{"test": "data"}`), }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodPost && req.URL.String() == "https://api.example.com/test" && req.Header.Get("Content-Type") == "application/json" }, }, { name: "request with bearer auth", request: &Request{ Method: http.MethodPost, URL: "https://api.example.com/test", Auth: &AuthConfig{ Type: "bearer", APIKey: "test-token", }, }, wantErr: false, validate: func(req *http.Request) bool { return req.Header.Get("Authorization") == "Bearer test-token" }, }, { name: "request with api_key auth", request: &Request{ Method: http.MethodPost, URL: "https://api.example.com/test", Auth: &AuthConfig{ Type: "api_key", APIKey: "test-key", HeaderKey: "X-API-Key", }, }, wantErr: false, validate: func(req *http.Request) bool { return req.Header.Get("X-Api-Key") == "test-key" }, }, { name: "invalid URL", request: &Request{ Method: http.MethodPost, URL: "://invalid-url", }, wantErr: true, errContains: "", }, { name: "request with query parameters", request: &Request{ Method: http.MethodGet, URL: "https://api.example.com/test", Query: url.Values{ "param1": []string{"value1"}, "param2": []string{"value2"}, }, }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodGet && req.URL.String() == "https://api.example.com/test?param1=value1¶m2=value2" }, }, { name: "request with query parameters and existing query in URL", request: &Request{ Method: http.MethodGet, URL: "https://api.example.com/test?existing=param", Query: url.Values{ "new1": []string{"value1"}, "new2": []string{"value2"}, }, }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodGet && req.URL.RawQuery == "existing=param&new1=value1&new2=value2" }, }, { name: "request with multiple values for same query parameter", request: &Request{ Method: http.MethodGet, URL: "https://api.example.com/test", Query: url.Values{ "tags": []string{"tag1", "tag2", "tag3"}, "filter": []string{"active"}, }, }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodGet && req.URL.RawQuery == "filter=active&tags=tag1&tags=tag2&tags=tag3" }, }, { name: "request with empty query parameters", request: &Request{ Method: http.MethodGet, URL: "https://api.example.com/test", Query: url.Values{}, }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodGet && req.URL.String() == "https://api.example.com/test" }, }, { name: "request with nil query parameters", request: &Request{ Method: http.MethodGet, URL: "https://api.example.com/test", Query: nil, }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodGet && req.URL.String() == "https://api.example.com/test" }, }, { name: "request with URL-encoded query parameters", request: &Request{ Method: http.MethodGet, URL: "https://api.example.com/test", Query: url.Values{ "search": []string{"hello world"}, "filter": []string{"status=active&priority=high"}, }, }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodGet && req.URL.RawQuery == "filter=status%3Dactive%26priority%3Dhigh&search=hello+world" }, }, { name: "request with query parameters and body", request: &Request{ Method: http.MethodPost, URL: "https://api.example.com/test", Query: url.Values{ "version": []string{"v1"}, "format": []string{"json"}, }, Headers: http.Header{ "Content-Type": []string{"application/json"}, }, Body: []byte(`{"data": "test"}`), }, wantErr: false, validate: func(req *http.Request) bool { return req.Method == http.MethodPost && req.URL.RawQuery == "format=json&version=v1" && req.Header.Get("Content-Type") == "application/json" }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := client.BuildHttpRequest(t.Context(), tt.request) if tt.wantErr { require.ErrorContains(t, err, tt.errContains) return } require.NoError(t, err) require.NotNil(t, result) if tt.validate != nil && !tt.validate(result) { t.Errorf("buildHttpRequest() validation failed for result: %+v", result) } }) } } func Test_applyAuth(t *testing.T) { tests := []struct { name string auth *AuthConfig wantErr bool wantErrString string validate func(*http.Request) bool }{ { name: "bearer auth", auth: &AuthConfig{ Type: "bearer", APIKey: "test-token", }, wantErr: false, validate: func(req *http.Request) bool { return req.Header.Get("Authorization") == "Bearer test-token" }, }, { name: "api_key auth", auth: &AuthConfig{ Type: "api_key", APIKey: "test-key", HeaderKey: "X-API-Key", }, wantErr: false, validate: func(req *http.Request) bool { return req.Header.Get("X-Api-Key") == "test-key" }, }, { name: "bearer auth without token", auth: &AuthConfig{ Type: "bearer", }, wantErr: true, wantErrString: "bearer token is required", }, { name: "api_key auth without header key", auth: &AuthConfig{ Type: "api_key", APIKey: "test-key", }, wantErr: true, wantErrString: "header key is required", }, { name: "unsupported auth type", auth: &AuthConfig{ Type: "oauth", }, wantErr: true, wantErrString: "unsupported auth type", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, "https://example.com", nil) err := applyAuth(req.Header, tt.auth) if tt.wantErr { require.ErrorContains(t, err, tt.wantErrString) return } require.NoError(t, err) if tt.validate != nil && !tt.validate(req) { t.Errorf("applyAuth() validation failed for request: %+v", req.Header) } }) } } func TestHttpClientImpl_extractHeaders(t *testing.T) { client := &HttpClient{} headers := http.Header{ "Content-Type": []string{"application/json"}, "Authorization": []string{"Bearer token"}, "X-Custom": []string{"value1", "value2"}, // Multiple values "Empty-Header": []string{}, // Empty values } result := client.extractHeaders(headers) expected := map[string]string{ "Content-Type": "application/json", "Authorization": "Bearer token", "X-Custom": "value1", // Should take first value } for key, expectedValue := range expected { if result[key] != expectedValue { t.Errorf("extractHeaders() key %s = %v, want %v", key, result[key], expectedValue) } } // Empty-Header should not be in result if _, exists := result["Empty-Header"]; exists { t.Errorf("extractHeaders() should not include headers with empty values") } } // Test SSE Stream implementation. func TestSSEStream(t *testing.T) { // Create a mock response body with SSE data sseData := `data: {"id": "1", "content": "Hello"} data: {"id": "2", "content": "World"} data: [DONE] ` body := io.NopCloser(strings.NewReader(sseData)) stream := &defaultSSEDecoder{ ctx: t.Context(), sseStream: sse.NewStream(body), } // Test that we can close the stream err := stream.Close() if err != nil { t.Errorf("Close() unexpected error = %v", err) } // Test that closing again doesn't error err = stream.Close() if err != nil { t.Errorf("Close() second call unexpected error = %v", err) } // Test Current() and Err() methods if stream.Current() != nil { t.Errorf("Current() should return nil when no event has been read") } if stream.Err() != nil { t.Errorf("Err() should return nil initially") } }