From cf45a62c123f34b7478c53e158184f08255f9e3c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 26 Nov 2025 14:26:59 +0000 Subject: [PATCH] test: add unit tests for internal/tempproxy Add 24 tests covering: - ErrorType enum constants - ProxyError Error() and Unwrap() methods - calculateBackoff exponential backoff with jitter - contains case-insensitive substring matching - classifyError error classification for node, auth, SSH, sensor, rate limit, timeout, transport - RPCRequest and RPCResponse struct fields - Client struct fields --- internal/tempproxy/client_test.go | 516 ++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 internal/tempproxy/client_test.go diff --git a/internal/tempproxy/client_test.go b/internal/tempproxy/client_test.go new file mode 100644 index 0000000..54c0c0b --- /dev/null +++ b/internal/tempproxy/client_test.go @@ -0,0 +1,516 @@ +package tempproxy + +import ( + "errors" + "net" + "testing" + "time" +) + +func TestErrorType_Constants(t *testing.T) { + // Verify enum values are distinct + values := map[ErrorType]bool{} + errorTypes := []ErrorType{ + ErrorTypeUnknown, + ErrorTypeTransport, + ErrorTypeAuth, + ErrorTypeSSH, + ErrorTypeSensor, + ErrorTypeTimeout, + ErrorTypeNode, + } + + for _, et := range errorTypes { + if values[et] { + t.Errorf("Duplicate ErrorType value: %d", et) + } + values[et] = true + } + + // ErrorTypeUnknown should be 0 (default) + if ErrorTypeUnknown != 0 { + t.Errorf("ErrorTypeUnknown = %d, want 0", ErrorTypeUnknown) + } +} + +func TestProxyError_Error(t *testing.T) { + tests := []struct { + name string + err ProxyError + contains string + }{ + { + name: "with wrapped error", + err: ProxyError{ + Message: "connection failed", + Wrapped: errors.New("dial tcp: connection refused"), + }, + contains: "connection failed: dial tcp: connection refused", + }, + { + name: "without wrapped error", + err: ProxyError{ + Message: "operation timed out", + }, + contains: "operation timed out", + }, + { + name: "with type and retryable", + err: ProxyError{ + Type: ErrorTypeSSH, + Message: "SSH connectivity issue", + Retryable: true, + Wrapped: errors.New("ssh: handshake failed"), + }, + contains: "SSH connectivity issue: ssh: handshake failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.err.Error() + if result != tc.contains { + t.Errorf("Error() = %q, want %q", result, tc.contains) + } + }) + } +} + +func TestProxyError_Unwrap(t *testing.T) { + inner := errors.New("inner error") + err := &ProxyError{ + Message: "outer error", + Wrapped: inner, + } + + unwrapped := err.Unwrap() + if unwrapped != inner { + t.Errorf("Unwrap() = %v, want %v", unwrapped, inner) + } +} + +func TestProxyError_Unwrap_Nil(t *testing.T) { + err := &ProxyError{ + Message: "error without wrapped", + } + + unwrapped := err.Unwrap() + if unwrapped != nil { + t.Errorf("Unwrap() = %v, want nil", unwrapped) + } +} + +func TestCalculateBackoff(t *testing.T) { + tests := []struct { + name string + attempt int + minExpected time.Duration + maxExpected time.Duration + }{ + { + name: "attempt 0", + attempt: 0, + minExpected: 90 * time.Millisecond, // initialBackoff - 10% jitter + maxExpected: 110 * time.Millisecond, // initialBackoff + 10% jitter + }, + { + name: "attempt 1", + attempt: 1, + minExpected: 180 * time.Millisecond, // 200ms * 0.9 + maxExpected: 220 * time.Millisecond, // 200ms * 1.1 + }, + { + name: "attempt 2", + attempt: 2, + minExpected: 360 * time.Millisecond, // 400ms * 0.9 + maxExpected: 440 * time.Millisecond, // 400ms * 1.1 + }, + { + name: "negative attempt (treated as 0)", + attempt: -1, + minExpected: 90 * time.Millisecond, + maxExpected: 110 * time.Millisecond, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Run multiple times to account for jitter + for i := 0; i < 10; i++ { + result := calculateBackoff(tc.attempt) + if result < tc.minExpected || result > tc.maxExpected { + t.Errorf("calculateBackoff(%d) = %v, want between %v and %v", + tc.attempt, result, tc.minExpected, tc.maxExpected) + } + } + }) + } +} + +func TestCalculateBackoff_CappedAtMax(t *testing.T) { + // High attempt number should be capped at maxBackoff (10s) + jitter + result := calculateBackoff(100) + + maxWithJitter := maxBackoff + time.Duration(float64(maxBackoff)*jitterFraction) + if result > maxWithJitter { + t.Errorf("calculateBackoff(100) = %v, should be capped at ~%v", result, maxBackoff) + } +} + +func TestContains(t *testing.T) { + tests := []struct { + name string + s string + substrs []string + expected bool + }{ + { + name: "single match", + s: "connection refused", + substrs: []string{"refused"}, + expected: true, + }, + { + name: "multiple substrs first match", + s: "ssh connection failed", + substrs: []string{"ssh", "timeout"}, + expected: true, + }, + { + name: "multiple substrs second match", + s: "operation timeout", + substrs: []string{"ssh", "timeout"}, + expected: true, + }, + { + name: "no match", + s: "success", + substrs: []string{"error", "failed"}, + expected: false, + }, + { + name: "case insensitive upper", + s: "CONNECTION REFUSED", + substrs: []string{"connection"}, + expected: true, + }, + { + name: "case insensitive mixed", + s: "SSH Error", + substrs: []string{"ssh error"}, + expected: true, + }, + { + name: "empty string", + s: "", + substrs: []string{"test"}, + expected: false, + }, + { + name: "empty substrs", + s: "test", + substrs: []string{}, + expected: false, + }, + { + name: "exact match", + s: "error", + substrs: []string{"error"}, + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := contains(tc.s, tc.substrs...) + if result != tc.expected { + t.Errorf("contains(%q, %v) = %v, want %v", tc.s, tc.substrs, result, tc.expected) + } + }) + } +} + +func TestClassifyError_NodeErrors(t *testing.T) { + tests := []struct { + name string + respError string + expectType ErrorType + retryable bool + }{ + { + name: "rejected by validator", + respError: "rejected by validator: invalid node", + expectType: ErrorTypeNode, + retryable: false, + }, + { + name: "not in allowlist", + respError: "node not in allowlist", + expectType: ErrorTypeNode, + retryable: false, + }, + { + name: "node quote pattern", + respError: "node \"server1\" not found", + expectType: ErrorTypeNode, + retryable: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := classifyError(nil, tc.respError) + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != tc.expectType { + t.Errorf("Type = %v, want %v", result.Type, tc.expectType) + } + if result.Retryable != tc.retryable { + t.Errorf("Retryable = %v, want %v", result.Retryable, tc.retryable) + } + }) + } +} + +func TestClassifyError_AuthErrors(t *testing.T) { + tests := []struct { + respError string + }{ + {"unauthorized"}, + {"method requires host-level privileges"}, + {"method requires admin capability"}, + {"missing admin capability for operation"}, + } + + for _, tc := range tests { + t.Run(tc.respError, func(t *testing.T) { + result := classifyError(nil, tc.respError) + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != ErrorTypeAuth { + t.Errorf("Type = %v, want ErrorTypeAuth", result.Type) + } + if result.Retryable { + t.Error("Auth errors should not be retryable") + } + }) + } +} + +func TestClassifyError_SSHErrors(t *testing.T) { + tests := []struct { + respError string + }{ + {"ssh: handshake failed"}, + {"connection reset by peer"}, + {"operation timeout exceeded"}, + } + + for _, tc := range tests { + t.Run(tc.respError, func(t *testing.T) { + result := classifyError(nil, tc.respError) + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != ErrorTypeSSH { + t.Errorf("Type = %v, want ErrorTypeSSH", result.Type) + } + if !result.Retryable { + t.Error("SSH errors should be retryable") + } + }) + } +} + +func TestClassifyError_SensorErrors(t *testing.T) { + tests := []struct { + respError string + }{ + {"sensor command failed"}, + {"temperature sensor unavailable"}, + } + + for _, tc := range tests { + t.Run(tc.respError, func(t *testing.T) { + result := classifyError(nil, tc.respError) + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != ErrorTypeSensor { + t.Errorf("Type = %v, want ErrorTypeSensor", result.Type) + } + if result.Retryable { + t.Error("Sensor errors should not be retryable") + } + }) + } +} + +func TestClassifyError_RateLimitErrors(t *testing.T) { + result := classifyError(nil, "rate limit exceeded") + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", result.Type) + } + if result.Retryable { + t.Error("Rate limit errors should not be retryable") + } +} + +func TestClassifyError_TimeoutNetError(t *testing.T) { + // Create a mock timeout error + timeoutErr := &mockNetError{timeout: true} + + result := classifyError(timeoutErr, "") + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != ErrorTypeTimeout { + t.Errorf("Type = %v, want ErrorTypeTimeout", result.Type) + } + if !result.Retryable { + t.Error("Timeout errors should be retryable") + } +} + +func TestClassifyError_OpError(t *testing.T) { + opErr := &net.OpError{ + Op: "dial", + Net: "unix", + Err: errors.New("connection refused"), + } + + result := classifyError(opErr, "") + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != ErrorTypeTransport { + t.Errorf("Type = %v, want ErrorTypeTransport", result.Type) + } + if !result.Retryable { + t.Error("Transport errors should be retryable") + } +} + +func TestClassifyError_NilInputs(t *testing.T) { + result := classifyError(nil, "") + if result != nil { + t.Errorf("classifyError(nil, \"\") = %v, want nil", result) + } +} + +func TestClassifyError_UnknownError(t *testing.T) { + unknownErr := errors.New("some unknown error") + + result := classifyError(unknownErr, "") + if result == nil { + t.Fatal("classifyError returned nil") + } + if result.Type != ErrorTypeUnknown { + t.Errorf("Type = %v, want ErrorTypeUnknown", result.Type) + } + if result.Retryable { + t.Error("Unknown errors should not be retryable") + } +} + +func TestRPCRequest_Fields(t *testing.T) { + req := RPCRequest{ + Method: "get_temperature", + Params: map[string]interface{}{ + "node": "server1", + }, + } + + if req.Method != "get_temperature" { + t.Errorf("Method = %q, want get_temperature", req.Method) + } + if req.Params["node"] != "server1" { + t.Errorf("Params[node] = %v, want server1", req.Params["node"]) + } +} + +func TestRPCResponse_Fields(t *testing.T) { + resp := RPCResponse{ + Success: true, + Data: map[string]interface{}{ + "temperature": "45.0", + }, + Error: "", + } + + if !resp.Success { + t.Error("Success should be true") + } + if resp.Data["temperature"] != "45.0" { + t.Errorf("Data[temperature] = %v, want 45.0", resp.Data["temperature"]) + } + if resp.Error != "" { + t.Errorf("Error = %q, want empty", resp.Error) + } +} + +func TestRPCResponse_ErrorCase(t *testing.T) { + resp := RPCResponse{ + Success: false, + Error: "unauthorized", + } + + if resp.Success { + t.Error("Success should be false") + } + if resp.Error != "unauthorized" { + t.Errorf("Error = %q, want unauthorized", resp.Error) + } +} + +func TestClient_Fields(t *testing.T) { + client := &Client{ + socketPath: "/test/socket.sock", + timeout: 60 * time.Second, + } + + if client.socketPath != "/test/socket.sock" { + t.Errorf("socketPath = %q, want /test/socket.sock", client.socketPath) + } + if client.timeout != 60*time.Second { + t.Errorf("timeout = %v, want 60s", client.timeout) + } +} + +func TestProxyError_AllFields(t *testing.T) { + wrapped := errors.New("wrapped error") + err := ProxyError{ + Type: ErrorTypeSSH, + Message: "SSH connectivity issue", + Retryable: true, + Wrapped: wrapped, + } + + if err.Type != ErrorTypeSSH { + t.Errorf("Type = %v, want ErrorTypeSSH", err.Type) + } + if err.Message != "SSH connectivity issue" { + t.Errorf("Message = %q, want 'SSH connectivity issue'", err.Message) + } + if !err.Retryable { + t.Error("Retryable should be true") + } + if err.Wrapped != wrapped { + t.Errorf("Wrapped = %v, want %v", err.Wrapped, wrapped) + } +} + +// mockNetError implements net.Error for testing +type mockNetError struct { + timeout bool + temporary bool +} + +func (e *mockNetError) Error() string { return "mock network error" } +func (e *mockNetError) Timeout() bool { return e.timeout } +func (e *mockNetError) Temporary() bool { return e.temporary }