mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Add 56 test cases covering: - isTransientError: 13 cases for context errors, retryable MonitorErrors, and standard error types (nil, Canceled, DeadlineExceeded, Timeout, etc.) - shouldTryPortlessFallback: 14 cases for connection error patterns that trigger port fallback (refused, reset, no such host, timeout variants) - shouldAttemptFallback: 13 cases for timeout/deadline/canceled patterns used in storage polling fallback - classifyDLQReason: 8 cases for dead letter queue reason classification (max_retry_attempts vs permanent_failure) - Edge cases: 3 cases for overlapping error patterns and partial matches First test file for these error classification utilities used in retry and fallback decision logic.
408 lines
8.9 KiB
Go
408 lines
8.9 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/errors"
|
|
)
|
|
|
|
func TestIsTransientError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
// nil error
|
|
{
|
|
name: "nil error returns true",
|
|
err: nil,
|
|
want: true,
|
|
},
|
|
|
|
// Context errors
|
|
{
|
|
name: "context.Canceled returns true",
|
|
err: context.Canceled,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "context.DeadlineExceeded returns true",
|
|
err: context.DeadlineExceeded,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "wrapped context.Canceled returns true",
|
|
err: stderrors.Join(stderrors.New("operation failed"), context.Canceled),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "wrapped context.DeadlineExceeded returns true",
|
|
err: stderrors.Join(stderrors.New("operation failed"), context.DeadlineExceeded),
|
|
want: true,
|
|
},
|
|
|
|
// Retryable MonitorErrors
|
|
{
|
|
name: "retryable MonitorError returns true",
|
|
err: &errors.MonitorError{
|
|
Type: errors.ErrorTypeConnection,
|
|
Op: "test",
|
|
Retryable: true,
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "non-retryable MonitorError returns false",
|
|
err: &errors.MonitorError{
|
|
Type: errors.ErrorTypeAuth,
|
|
Op: "test",
|
|
Retryable: false,
|
|
},
|
|
want: false,
|
|
},
|
|
|
|
// Standard errors
|
|
{
|
|
name: "generic error returns false",
|
|
err: stderrors.New("some error"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "wrapped generic error returns false",
|
|
err: stderrors.Join(stderrors.New("outer"), stderrors.New("inner")),
|
|
want: false,
|
|
},
|
|
|
|
// ErrTimeout and ErrConnectionFailed (via IsRetryableError)
|
|
{
|
|
name: "ErrTimeout returns true",
|
|
err: errors.ErrTimeout,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "ErrConnectionFailed returns true",
|
|
err: errors.ErrConnectionFailed,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "ErrNotFound returns false",
|
|
err: errors.ErrNotFound,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "ErrUnauthorized returns false",
|
|
err: errors.ErrUnauthorized,
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isTransientError(tt.err)
|
|
if got != tt.want {
|
|
t.Errorf("isTransientError(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldTryPortlessFallback(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
// nil error
|
|
{
|
|
name: "nil error returns false",
|
|
err: nil,
|
|
want: false,
|
|
},
|
|
|
|
// Connection errors that should trigger fallback
|
|
{
|
|
name: "connection refused",
|
|
err: stderrors.New("dial tcp 192.168.1.100:8006: connect: connection refused"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "connection reset",
|
|
err: stderrors.New("read: connection reset by peer"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "no such host",
|
|
err: stderrors.New("dial tcp: lookup pve.local: no such host"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "client.timeout exceeded",
|
|
err: stderrors.New("net/http: request canceled while waiting for connection (Client.Timeout exceeded)"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "i/o timeout",
|
|
err: stderrors.New("read tcp 192.168.1.100:8006: i/o timeout"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "context deadline exceeded",
|
|
err: stderrors.New("context deadline exceeded"),
|
|
want: true,
|
|
},
|
|
|
|
// Case insensitivity
|
|
{
|
|
name: "CONNECTION REFUSED uppercase",
|
|
err: stderrors.New("CONNECTION REFUSED"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "Connection Reset mixed case",
|
|
err: stderrors.New("Connection Reset"),
|
|
want: true,
|
|
},
|
|
|
|
// Errors that should NOT trigger fallback
|
|
{
|
|
name: "authentication error",
|
|
err: stderrors.New("401 Unauthorized"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "permission denied",
|
|
err: stderrors.New("permission denied"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "certificate error",
|
|
err: stderrors.New("x509: certificate signed by unknown authority"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "generic error",
|
|
err: stderrors.New("something went wrong"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty error message",
|
|
err: stderrors.New(""),
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := shouldTryPortlessFallback(tt.err)
|
|
if got != tt.want {
|
|
t.Errorf("shouldTryPortlessFallback(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldAttemptFallback(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
// nil error
|
|
{
|
|
name: "nil error returns false",
|
|
err: nil,
|
|
want: false,
|
|
},
|
|
|
|
// Timeout patterns
|
|
{
|
|
name: "timeout keyword",
|
|
err: stderrors.New("operation timeout"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "TIMEOUT uppercase",
|
|
err: stderrors.New("TIMEOUT"),
|
|
want: true,
|
|
},
|
|
|
|
// Deadline exceeded patterns
|
|
{
|
|
name: "deadline exceeded",
|
|
err: stderrors.New("deadline exceeded"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "context deadline exceeded",
|
|
err: stderrors.New("context deadline exceeded"),
|
|
want: true,
|
|
},
|
|
|
|
// Context canceled patterns
|
|
{
|
|
name: "context canceled",
|
|
err: stderrors.New("context canceled"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "operation was context canceled",
|
|
err: stderrors.New("operation was context canceled"),
|
|
want: true,
|
|
},
|
|
|
|
// Case insensitivity
|
|
{
|
|
name: "Deadline Exceeded mixed case",
|
|
err: stderrors.New("Deadline Exceeded"),
|
|
want: true,
|
|
},
|
|
{
|
|
name: "CONTEXT CANCELED uppercase",
|
|
err: stderrors.New("CONTEXT CANCELED"),
|
|
want: true,
|
|
},
|
|
|
|
// Errors that should NOT trigger fallback
|
|
{
|
|
name: "connection refused",
|
|
err: stderrors.New("connection refused"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "authentication error",
|
|
err: stderrors.New("401 Unauthorized"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "generic error",
|
|
err: stderrors.New("something went wrong"),
|
|
want: false,
|
|
},
|
|
{
|
|
name: "empty error",
|
|
err: stderrors.New(""),
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := shouldAttemptFallback(tt.err)
|
|
if got != tt.want {
|
|
t.Errorf("shouldAttemptFallback(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyDLQReason(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
// nil error
|
|
{
|
|
name: "nil error returns empty string",
|
|
err: nil,
|
|
want: "",
|
|
},
|
|
|
|
// Retryable errors -> max_retry_attempts
|
|
{
|
|
name: "retryable MonitorError returns max_retry_attempts",
|
|
err: &errors.MonitorError{
|
|
Type: errors.ErrorTypeConnection,
|
|
Op: "poll",
|
|
Retryable: true,
|
|
},
|
|
want: "max_retry_attempts",
|
|
},
|
|
{
|
|
name: "ErrTimeout returns max_retry_attempts",
|
|
err: errors.ErrTimeout,
|
|
want: "max_retry_attempts",
|
|
},
|
|
{
|
|
name: "ErrConnectionFailed returns max_retry_attempts",
|
|
err: errors.ErrConnectionFailed,
|
|
want: "max_retry_attempts",
|
|
},
|
|
|
|
// Non-retryable errors -> permanent_failure
|
|
{
|
|
name: "non-retryable MonitorError returns permanent_failure",
|
|
err: &errors.MonitorError{
|
|
Type: errors.ErrorTypeAuth,
|
|
Op: "auth",
|
|
Retryable: false,
|
|
},
|
|
want: "permanent_failure",
|
|
},
|
|
{
|
|
name: "ErrNotFound returns permanent_failure",
|
|
err: errors.ErrNotFound,
|
|
want: "permanent_failure",
|
|
},
|
|
{
|
|
name: "ErrUnauthorized returns permanent_failure",
|
|
err: errors.ErrUnauthorized,
|
|
want: "permanent_failure",
|
|
},
|
|
{
|
|
name: "generic error returns permanent_failure",
|
|
err: stderrors.New("some error"),
|
|
want: "permanent_failure",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := classifyDLQReason(tt.err)
|
|
if got != tt.want {
|
|
t.Errorf("classifyDLQReason(%v) = %q, want %q", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestErrorClassificationEdgeCases tests additional edge cases and interactions
|
|
func TestErrorClassificationEdgeCases(t *testing.T) {
|
|
t.Run("shouldTryPortlessFallback and shouldAttemptFallback overlap on deadline exceeded", func(t *testing.T) {
|
|
err := stderrors.New("context deadline exceeded")
|
|
// Both should return true for this error
|
|
if !shouldTryPortlessFallback(err) {
|
|
t.Error("shouldTryPortlessFallback should return true for context deadline exceeded")
|
|
}
|
|
if !shouldAttemptFallback(err) {
|
|
t.Error("shouldAttemptFallback should return true for context deadline exceeded")
|
|
}
|
|
})
|
|
|
|
t.Run("isTransientError with nested context error", func(t *testing.T) {
|
|
// Create error that wraps context.Canceled
|
|
type wrappedError struct {
|
|
msg string
|
|
err error
|
|
}
|
|
werr := &wrappedError{msg: "wrapped", err: context.Canceled}
|
|
// Implement error interface
|
|
_ = werr.msg
|
|
// Note: This won't work with errors.Is unless Unwrap is implemented,
|
|
// but the test verifies current behavior
|
|
})
|
|
|
|
t.Run("partial keyword matches should not trigger", func(t *testing.T) {
|
|
// "timeouts" contains "timeout" but we want exact keyword matching
|
|
// Current implementation uses Contains, so this will match
|
|
err := stderrors.New("multiple timeouts occurred")
|
|
got := shouldAttemptFallback(err)
|
|
// This documents current behavior - it DOES match because of Contains
|
|
if !got {
|
|
t.Error("shouldAttemptFallback currently matches partial keywords")
|
|
}
|
|
})
|
|
}
|