From dadbc180e3dbd6d65335dbe0628eafac323121dc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 21:18:14 +0000 Subject: [PATCH] Add unit tests for HTTPClient (internal/tempproxy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 23 test cases covering NewHTTPClient, IsAvailable, GetTemperature, and HealthCheck including success paths, error handling (401, 403, 429, 5xx), JSON parsing, URL encoding, retry behavior, and connection errors. Coverage: 22.4% → 47.6% --- internal/tempproxy/http_client_test.go | 495 +++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 internal/tempproxy/http_client_test.go diff --git a/internal/tempproxy/http_client_test.go b/internal/tempproxy/http_client_test.go new file mode 100644 index 000000000..afe55c152 --- /dev/null +++ b/internal/tempproxy/http_client_test.go @@ -0,0 +1,495 @@ +package tempproxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNewHTTPClient(t *testing.T) { + client := NewHTTPClient("https://example.com/api", "test-token") + + if client.baseURL != "https://example.com/api" { + t.Errorf("baseURL = %q, want https://example.com/api", client.baseURL) + } + if client.authToken != "test-token" { + t.Errorf("authToken = %q, want test-token", client.authToken) + } + if client.httpClient == nil { + t.Error("httpClient should not be nil") + } + if client.timeout != defaultTimeout { + t.Errorf("timeout = %v, want %v", client.timeout, defaultTimeout) + } +} + +func TestNewHTTPClient_TrimsTrailingSlash(t *testing.T) { + client := NewHTTPClient("https://example.com/api/", "token") + + if client.baseURL != "https://example.com/api" { + t.Errorf("baseURL = %q, want trailing slash removed", client.baseURL) + } +} + +func TestHTTPClient_IsAvailable(t *testing.T) { + tests := []struct { + name string + baseURL string + token string + expected bool + }{ + { + name: "both configured", + baseURL: "https://example.com", + token: "token", + expected: true, + }, + { + name: "empty baseURL", + baseURL: "", + token: "token", + expected: false, + }, + { + name: "empty token", + baseURL: "https://example.com", + token: "", + expected: false, + }, + { + name: "both empty", + baseURL: "", + token: "", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := NewHTTPClient(tc.baseURL, tc.token) + if client.IsAvailable() != tc.expected { + t.Errorf("IsAvailable() = %v, want %v", client.IsAvailable(), tc.expected) + } + }) + } +} + +func TestHTTPClient_GetTemperature_Success(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != http.MethodGet { + t.Errorf("Method = %q, want GET", r.Method) + } + if !strings.HasPrefix(r.URL.Path, "/temps") { + t.Errorf("Path = %q, want /temps", r.URL.Path) + } + if r.URL.Query().Get("node") != "node1" { + t.Errorf("node query param = %q, want node1", r.URL.Query().Get("node")) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("Authorization = %q, want Bearer test-token", r.Header.Get("Authorization")) + } + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept")) + } + + // Return success response + resp := struct { + Node string `json:"node"` + Temperature string `json:"temperature"` + }{ + Node: "node1", + Temperature: `{"cpu": 45.0}`, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "test-token") + temp, err := client.GetTemperature("node1") + + if err != nil { + t.Fatalf("GetTemperature() error = %v", err) + } + if temp != `{"cpu": 45.0}` { + t.Errorf("GetTemperature() = %q, want {\"cpu\": 45.0}", temp) + } +} + +func TestHTTPClient_GetTemperature_NotConfigured(t *testing.T) { + client := NewHTTPClient("", "") + _, err := client.GetTemperature("node1") + + if err == nil { + t.Fatal("Expected error for unconfigured client") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", proxyErr.Type) + } + if proxyErr.Retryable { + t.Error("Should not be retryable") + } +} + +func TestHTTPClient_GetTemperature_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "bad-token") + _, err := client.GetTemperature("node1") + + if err == nil { + t.Fatal("Expected error for 401 response") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeAuth { + t.Errorf("Type = %v, want ErrorTypeAuth", proxyErr.Type) + } + if proxyErr.Retryable { + t.Error("Auth errors should not be retryable") + } +} + +func TestHTTPClient_GetTemperature_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + _, err := client.GetTemperature("node1") + + if err == nil { + t.Fatal("Expected error for 403 response") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeAuth { + t.Errorf("Type = %v, want ErrorTypeAuth", proxyErr.Type) + } + if proxyErr.Message != "node not allowed by proxy" { + t.Errorf("Message = %q, want 'node not allowed by proxy'", proxyErr.Message) + } +} + +func TestHTTPClient_GetTemperature_RateLimit(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + _, err := client.GetTemperature("node1") + + if err == nil { + t.Fatal("Expected error for 429 response") + } + + // Should have retried + if attempts < 2 { + t.Errorf("Expected retries, got %d attempts", attempts) + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", proxyErr.Type) + } +} + +func TestHTTPClient_GetTemperature_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + _, err := client.GetTemperature("node1") + + if err == nil { + t.Fatal("Expected error for 500 response") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", proxyErr.Type) + } + // 5xx errors should be retryable + if !proxyErr.Retryable { + t.Error("5xx errors should be retryable") + } +} + +func TestHTTPClient_GetTemperature_ClientError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("bad request")) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + _, err := client.GetTemperature("node1") + + if err == nil { + t.Fatal("Expected error for 400 response") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + // 4xx errors (except 401, 403, 429) should not be retryable + if proxyErr.Retryable { + t.Error("4xx errors should not be retryable") + } +} + +func TestHTTPClient_GetTemperature_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not valid json")) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + _, err := client.GetTemperature("node1") + + if err == nil { + t.Fatal("Expected error for invalid JSON") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", proxyErr.Type) + } + if !strings.Contains(proxyErr.Message, "parse response JSON") { + t.Errorf("Message = %q, should mention JSON parsing", proxyErr.Message) + } +} + +func TestHTTPClient_GetTemperature_URLEncoding(t *testing.T) { + receivedNode := "" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedNode = r.URL.Query().Get("node") + resp := struct { + Node string `json:"node"` + Temperature string `json:"temperature"` + }{ + Node: receivedNode, + Temperature: "{}", + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + + // Test with special characters that need URL encoding + nodeWithSpaces := "node with spaces" + _, err := client.GetTemperature(nodeWithSpaces) + + if err != nil { + t.Fatalf("GetTemperature() error = %v", err) + } + if receivedNode != nodeWithSpaces { + t.Errorf("Received node = %q, want %q", receivedNode, nodeWithSpaces) + } +} + +func TestHTTPClient_GetTemperature_RetryOnTransportError(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts < 3 { + // Force connection close to simulate transport error + hj, ok := w.(http.Hijacker) + if ok { + conn, _, _ := hj.Hijack() + conn.Close() + return + } + } + // Success on third attempt + resp := struct { + Node string `json:"node"` + Temperature string `json:"temperature"` + }{ + Node: "node1", + Temperature: "{}", + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + _, _ = client.GetTemperature("node1") + + // Should have made multiple attempts + if attempts < 2 { + t.Errorf("Expected retries, got %d attempts", attempts) + } +} + +func TestHTTPClient_HealthCheck_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + t.Errorf("Path = %q, want /health", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("Authorization header missing or incorrect") + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "test-token") + err := client.HealthCheck() + + if err != nil { + t.Errorf("HealthCheck() error = %v", err) + } +} + +func TestHTTPClient_HealthCheck_NotConfigured(t *testing.T) { + client := NewHTTPClient("", "") + err := client.HealthCheck() + + if err == nil { + t.Fatal("Expected error for unconfigured client") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", proxyErr.Type) + } +} + +func TestHTTPClient_HealthCheck_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("service unavailable")) + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + err := client.HealthCheck() + + if err == nil { + t.Fatal("Expected error for 503 response") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", proxyErr.Type) + } + // 5xx errors should be retryable + if !proxyErr.Retryable { + t.Error("5xx errors should be retryable") + } +} + +func TestHTTPClient_HealthCheck_ConnectionRefused(t *testing.T) { + // Use a URL that will refuse connection + client := NewHTTPClient("http://127.0.0.1:1", "token") + client.httpClient.Timeout = 100 * time.Millisecond + + err := client.HealthCheck() + + if err == nil { + t.Fatal("Expected error for refused connection") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + if proxyErr.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", proxyErr.Type) + } + if !proxyErr.Retryable { + t.Error("Connection errors should be retryable") + } +} + +func TestHTTPClient_HealthCheck_LongBody(t *testing.T) { + // Server returns a very long body that should be limited + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + // Write more than 1024 bytes + for i := 0; i < 200; i++ { + w.Write([]byte("error message ")) + } + })) + defer server.Close() + + client := NewHTTPClient(server.URL, "token") + err := client.HealthCheck() + + if err == nil { + t.Fatal("Expected error") + } + + proxyErr, ok := err.(*ProxyError) + if !ok { + t.Fatalf("Expected *ProxyError, got %T", err) + } + // Body should be limited to 1024 bytes + if len(proxyErr.Message) > 1100 { // Some overhead for HTTP status prefix + t.Errorf("Message too long: %d bytes", len(proxyErr.Message)) + } +} + +func TestHTTPClient_Fields(t *testing.T) { + client := &HTTPClient{ + baseURL: "https://test.example.com", + authToken: "secret", + timeout: 60 * time.Second, + } + + if client.baseURL != "https://test.example.com" { + t.Errorf("baseURL = %q, want https://test.example.com", client.baseURL) + } + if client.authToken != "secret" { + t.Errorf("authToken = %q, want secret", client.authToken) + } + if client.timeout != 60*time.Second { + t.Errorf("timeout = %v, want 60s", client.timeout) + } +}