Pulse/internal/tempproxy/http_client_test.go
rcourtman cb939c3532 Add unit tests for HTTPClient (internal/tempproxy)
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%
2025-11-30 21:18:14 +00:00

495 lines
13 KiB
Go

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)
}
}