Files
Pulse/internal/tempproxy/client_test.go
2025-12-29 17:25:21 +00:00

523 lines
12 KiB
Go

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: "case insensitive upper substr",
s: "connection refused",
substrs: []string{"REFUSED"},
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 }