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
This commit is contained in:
parent
fbc86871e6
commit
cf45a62c12
1 changed files with 516 additions and 0 deletions
516
internal/tempproxy/client_test.go
Normal file
516
internal/tempproxy/client_test.go
Normal file
|
|
@ -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 }
|
||||
Loading…
Reference in a new issue