diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 0000000..2a27716 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,563 @@ +package errors + +import ( + "errors" + "fmt" + "testing" +) + +func TestMonitorError_Error(t *testing.T) { + baseErr := fmt.Errorf("base error") + + tests := []struct { + name string + err *MonitorError + expected string + }{ + { + name: "with node", + err: &MonitorError{ + Op: "poll_nodes", + Instance: "pve1", + Node: "node1", + Err: baseErr, + }, + expected: "poll_nodes failed on pve1/node1: base error", + }, + { + name: "with instance only", + err: &MonitorError{ + Op: "get_vms", + Instance: "pve1", + Err: baseErr, + }, + expected: "get_vms failed on pve1: base error", + }, + { + name: "operation only", + err: &MonitorError{ + Op: "sync", + Err: baseErr, + }, + expected: "sync failed: base error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.err.Error() + if result != tc.expected { + t.Errorf("Error() = %q, want %q", result, tc.expected) + } + }) + } +} + +func TestMonitorError_Unwrap(t *testing.T) { + baseErr := fmt.Errorf("wrapped error") + monErr := &MonitorError{ + Op: "test", + Instance: "instance", + Err: baseErr, + } + + unwrapped := monErr.Unwrap() + if unwrapped != baseErr { + t.Errorf("Unwrap() = %v, want %v", unwrapped, baseErr) + } +} + +func TestMonitorError_Is(t *testing.T) { + tests := []struct { + name string + err *MonitorError + target error + expected bool + }{ + { + name: "not found type matches ErrNotFound", + err: &MonitorError{Type: ErrorTypeNotFound}, + target: ErrNotFound, + expected: true, + }, + { + name: "auth type matches ErrUnauthorized", + err: &MonitorError{Type: ErrorTypeAuth}, + target: ErrUnauthorized, + expected: true, + }, + { + name: "auth type matches ErrForbidden", + err: &MonitorError{Type: ErrorTypeAuth}, + target: ErrForbidden, + expected: true, + }, + { + name: "timeout type matches ErrTimeout", + err: &MonitorError{Type: ErrorTypeTimeout}, + target: ErrTimeout, + expected: true, + }, + { + name: "connection type matches ErrConnectionFailed", + err: &MonitorError{Type: ErrorTypeConnection}, + target: ErrConnectionFailed, + expected: true, + }, + { + name: "wrapped error matches", + err: &MonitorError{Type: ErrorTypeInternal, Err: ErrInvalidInput}, + target: ErrInvalidInput, + expected: true, + }, + { + name: "type mismatch", + err: &MonitorError{Type: ErrorTypeInternal}, + target: ErrNotFound, + expected: false, + }, + { + name: "nil target", + err: &MonitorError{Type: ErrorTypeNotFound}, + target: nil, + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.err.Is(tc.target) + if result != tc.expected { + t.Errorf("Is(%v) = %v, want %v", tc.target, result, tc.expected) + } + }) + } +} + +func TestNewMonitorError(t *testing.T) { + baseErr := fmt.Errorf("test error") + + err := NewMonitorError(ErrorTypeConnection, "poll", "instance1", baseErr) + + if err.Type != ErrorTypeConnection { + t.Errorf("Type = %v, want %v", err.Type, ErrorTypeConnection) + } + if err.Op != "poll" { + t.Errorf("Op = %v, want %v", err.Op, "poll") + } + if err.Instance != "instance1" { + t.Errorf("Instance = %v, want %v", err.Instance, "instance1") + } + if err.Err != baseErr { + t.Errorf("Err = %v, want %v", err.Err, baseErr) + } + if err.Timestamp.IsZero() { + t.Error("Timestamp should be set") + } +} + +func TestMonitorError_WithNode(t *testing.T) { + err := NewMonitorError(ErrorTypeConnection, "poll", "instance1", nil) + result := err.WithNode("node1") + + if result.Node != "node1" { + t.Errorf("Node = %v, want %v", result.Node, "node1") + } + // Should return same instance for chaining + if result != err { + t.Error("WithNode() should return same instance") + } +} + +func TestMonitorError_WithStatusCode(t *testing.T) { + tests := []struct { + name string + statusCode int + wantRetryable bool + }{ + { + name: "500 error is retryable", + statusCode: 500, + wantRetryable: true, + }, + { + name: "502 error is retryable", + statusCode: 502, + wantRetryable: true, + }, + { + name: "429 rate limit is retryable", + statusCode: 429, + wantRetryable: true, + }, + { + name: "408 timeout is retryable", + statusCode: 408, + wantRetryable: true, + }, + { + name: "400 bad request not retryable", + statusCode: 400, + wantRetryable: false, + }, + { + name: "401 unauthorized not retryable", + statusCode: 401, + wantRetryable: false, + }, + { + name: "403 forbidden not retryable", + statusCode: 403, + wantRetryable: false, + }, + { + name: "404 not found not retryable", + statusCode: 404, + wantRetryable: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := NewMonitorError(ErrorTypeAPI, "request", "instance", nil) + result := err.WithStatusCode(tc.statusCode) + + if result.StatusCode != tc.statusCode { + t.Errorf("StatusCode = %v, want %v", result.StatusCode, tc.statusCode) + } + if result.Retryable != tc.wantRetryable { + t.Errorf("Retryable = %v, want %v", result.Retryable, tc.wantRetryable) + } + }) + } +} + +func TestIsRetryable(t *testing.T) { + tests := []struct { + name string + errorType ErrorType + err error + wantRetryable bool + }{ + { + name: "connection error is retryable", + errorType: ErrorTypeConnection, + err: nil, + wantRetryable: true, + }, + { + name: "timeout error is retryable", + errorType: ErrorTypeTimeout, + err: nil, + wantRetryable: true, + }, + { + name: "auth error not retryable", + errorType: ErrorTypeAuth, + err: nil, + wantRetryable: false, + }, + { + name: "validation error not retryable", + errorType: ErrorTypeValidation, + err: nil, + wantRetryable: false, + }, + { + name: "not found error not retryable", + errorType: ErrorTypeNotFound, + err: nil, + wantRetryable: false, + }, + { + name: "internal error retryable by default", + errorType: ErrorTypeInternal, + err: nil, + wantRetryable: true, + }, + { + name: "internal with invalid input not retryable", + errorType: ErrorTypeInternal, + err: ErrInvalidInput, + wantRetryable: false, + }, + { + name: "internal with forbidden not retryable", + errorType: ErrorTypeInternal, + err: ErrForbidden, + wantRetryable: false, + }, + { + name: "API error retryable by default", + errorType: ErrorTypeAPI, + err: nil, + wantRetryable: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := isRetryable(tc.errorType, tc.err) + if result != tc.wantRetryable { + t.Errorf("isRetryable(%v, %v) = %v, want %v", tc.errorType, tc.err, result, tc.wantRetryable) + } + }) + } +} + +func TestWrapConnectionError(t *testing.T) { + baseErr := fmt.Errorf("connection refused") + err := WrapConnectionError("connect", "pve1", baseErr) + + var monErr *MonitorError + if !errors.As(err, &monErr) { + t.Fatal("WrapConnectionError() did not return MonitorError") + } + + if monErr.Type != ErrorTypeConnection { + t.Errorf("Type = %v, want %v", monErr.Type, ErrorTypeConnection) + } + if monErr.Op != "connect" { + t.Errorf("Op = %v, want %v", monErr.Op, "connect") + } + if monErr.Instance != "pve1" { + t.Errorf("Instance = %v, want %v", monErr.Instance, "pve1") + } +} + +func TestWrapAuthError(t *testing.T) { + baseErr := fmt.Errorf("invalid credentials") + err := WrapAuthError("login", "pve1", baseErr) + + var monErr *MonitorError + if !errors.As(err, &monErr) { + t.Fatal("WrapAuthError() did not return MonitorError") + } + + if monErr.Type != ErrorTypeAuth { + t.Errorf("Type = %v, want %v", monErr.Type, ErrorTypeAuth) + } +} + +func TestWrapAPIError(t *testing.T) { + baseErr := fmt.Errorf("server error") + err := WrapAPIError("request", "pve1", baseErr, 500) + + var monErr *MonitorError + if !errors.As(err, &monErr) { + t.Fatal("WrapAPIError() did not return MonitorError") + } + + if monErr.Type != ErrorTypeAPI { + t.Errorf("Type = %v, want %v", monErr.Type, ErrorTypeAPI) + } + if monErr.StatusCode != 500 { + t.Errorf("StatusCode = %v, want %v", monErr.StatusCode, 500) + } +} + +func TestIsRetryableError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "MonitorError retryable", + err: NewMonitorError(ErrorTypeConnection, "test", "instance", nil), + expected: true, + }, + { + name: "MonitorError not retryable", + err: NewMonitorError(ErrorTypeAuth, "test", "instance", nil), + expected: false, + }, + { + name: "ErrTimeout is retryable", + err: ErrTimeout, + expected: true, + }, + { + name: "ErrConnectionFailed is retryable", + err: ErrConnectionFailed, + expected: true, + }, + { + name: "wrapped ErrTimeout is retryable", + err: fmt.Errorf("wrapped: %w", ErrTimeout), + expected: true, + }, + { + name: "regular error not retryable", + err: fmt.Errorf("regular error"), + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IsRetryableError(tc.err) + if result != tc.expected { + t.Errorf("IsRetryableError(%v) = %v, want %v", tc.err, result, tc.expected) + } + }) + } +} + +func TestIsAuthError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "MonitorError auth type", + err: NewMonitorError(ErrorTypeAuth, "login", "instance", nil), + expected: true, + }, + { + name: "MonitorError with 401 status", + err: NewMonitorError(ErrorTypeAPI, "request", "instance", nil).WithStatusCode(401), + expected: true, + }, + { + name: "MonitorError with 403 status", + err: NewMonitorError(ErrorTypeAPI, "request", "instance", nil).WithStatusCode(403), + expected: true, + }, + { + name: "ErrUnauthorized", + err: ErrUnauthorized, + expected: true, + }, + { + name: "ErrForbidden", + err: ErrForbidden, + expected: true, + }, + { + name: "wrapped ErrUnauthorized", + err: fmt.Errorf("wrapped: %w", ErrUnauthorized), + expected: true, + }, + { + name: "error with 'unauthorized' in message", + err: fmt.Errorf("request unauthorized"), + expected: true, + }, + { + name: "error with 'forbidden' in message", + err: fmt.Errorf("access forbidden"), + expected: true, + }, + { + name: "error with 'authentication failed' in message", + err: fmt.Errorf("authentication failed"), + expected: true, + }, + { + name: "error with 'authentication error' in message", + err: fmt.Errorf("authentication error occurred"), + expected: true, + }, + { + name: "error with '401' in message", + err: fmt.Errorf("HTTP 401 response"), + expected: true, + }, + { + name: "error with '403' in message", + err: fmt.Errorf("HTTP 403 response"), + expected: true, + }, + { + name: "regular error", + err: fmt.Errorf("some other error"), + expected: false, + }, + { + name: "MonitorError connection type not auth", + err: NewMonitorError(ErrorTypeConnection, "connect", "instance", nil), + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IsAuthError(tc.err) + if result != tc.expected { + t.Errorf("IsAuthError(%v) = %v, want %v", tc.err, result, tc.expected) + } + }) + } +} + +func TestErrorTypes(t *testing.T) { + // Verify error types are defined correctly + types := []ErrorType{ + ErrorTypeConnection, + ErrorTypeAuth, + ErrorTypeValidation, + ErrorTypeNotFound, + ErrorTypeInternal, + ErrorTypeAPI, + ErrorTypeTimeout, + } + + for _, errorType := range types { + if errorType == "" { + t.Error("ErrorType should not be empty") + } + } +} + +func TestBaseErrors(t *testing.T) { + // Verify base errors are defined + baseErrors := []error{ + ErrNotFound, + ErrUnauthorized, + ErrForbidden, + ErrTimeout, + ErrInvalidInput, + ErrConnectionFailed, + ErrInternalError, + } + + for _, err := range baseErrors { + if err == nil { + t.Error("Base error should not be nil") + } + if err.Error() == "" { + t.Error("Base error should have non-empty message") + } + } +} + +func TestMonitorError_ErrorsIs(t *testing.T) { + // Test using errors.Is with MonitorError + monErr := NewMonitorError(ErrorTypeNotFound, "get", "instance", nil) + + if !errors.Is(monErr, ErrNotFound) { + t.Error("errors.Is() should return true for matching error type") + } +} + +func TestMonitorError_ErrorsAs(t *testing.T) { + // Test using errors.As with MonitorError + originalErr := NewMonitorError(ErrorTypeConnection, "connect", "pve1", fmt.Errorf("refused")) + wrappedErr := fmt.Errorf("outer: %w", originalErr) + + var monErr *MonitorError + if !errors.As(wrappedErr, &monErr) { + t.Error("errors.As() should extract MonitorError from wrapped error") + } + + if monErr.Op != "connect" { + t.Errorf("Op = %v, want %v", monErr.Op, "connect") + } +}