kanboard-api/errors_test.go
Oliver Jakoubek 449cd2626c feat: add OperationFailedError with actionable hints
The Kanboard API returns only true/false for many operations without
explaining why they failed. Added OperationFailedError type that
includes operation details and hints about possible causes.

Updated MoveTaskPosition and MoveTaskToProject to use this new error
type, providing users with actionable debugging information instead
of generic "failed to move task" messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:12:25 +01:00

288 lines
7.8 KiB
Go

package kanboard
import (
"errors"
"fmt"
"strings"
"testing"
)
func TestAPIError_Error(t *testing.T) {
tests := []struct {
name string
err *APIError
expected string
}{
{
name: "invalid request",
err: &APIError{Code: -32600, Message: "Invalid Request"},
expected: "Kanboard API error (code -32600): Invalid Request",
},
{
name: "method not found",
err: &APIError{Code: -32601, Message: "Method not found"},
expected: "Kanboard API error (code -32601): Method not found",
},
{
name: "custom error",
err: &APIError{Code: 1001, Message: "Task not found"},
expected: "Kanboard API error (code 1001): Task not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.expected {
t.Errorf("APIError.Error() = %q, want %q", got, tt.expected)
}
})
}
}
func TestIsNotFound(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"ErrNotFound", ErrNotFound, true},
{"ErrProjectNotFound", ErrProjectNotFound, true},
{"ErrTaskNotFound", ErrTaskNotFound, true},
{"ErrColumnNotFound", ErrColumnNotFound, true},
{"ErrCommentNotFound", ErrCommentNotFound, true},
{"wrapped ErrNotFound", fmt.Errorf("context: %w", ErrNotFound), true},
{"wrapped ErrTaskNotFound", fmt.Errorf("getting task: %w", ErrTaskNotFound), true},
{"ErrUnauthorized", ErrUnauthorized, false},
{"ErrConnectionFailed", ErrConnectionFailed, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNotFound(tt.err); got != tt.expected {
t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func TestIsUnauthorized(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"ErrUnauthorized", ErrUnauthorized, true},
{"wrapped ErrUnauthorized", fmt.Errorf("auth failed: %w", ErrUnauthorized), true},
{"ErrForbidden", ErrForbidden, false},
{"ErrNotFound", ErrNotFound, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsUnauthorized(tt.err); got != tt.expected {
t.Errorf("IsUnauthorized(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func TestIsAPIError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"APIError", &APIError{Code: -32600, Message: "Invalid"}, true},
{"wrapped APIError", fmt.Errorf("call failed: %w", &APIError{Code: -32600, Message: "Invalid"}), true},
{"ErrUnauthorized", ErrUnauthorized, false},
{"ErrNotFound", ErrNotFound, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsAPIError(tt.err); got != tt.expected {
t.Errorf("IsAPIError(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func TestErrorsIs(t *testing.T) {
// Test that errors.Is works correctly with sentinel errors
tests := []struct {
name string
err error
target error
expected bool
}{
{"direct match", ErrTaskNotFound, ErrTaskNotFound, true},
{"wrapped match", fmt.Errorf("ctx: %w", ErrTaskNotFound), ErrTaskNotFound, true},
{"double wrapped", fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", ErrTaskNotFound)), ErrTaskNotFound, true},
{"different error", ErrTaskNotFound, ErrProjectNotFound, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errors.Is(tt.err, tt.target); got != tt.expected {
t.Errorf("errors.Is(%v, %v) = %v, want %v", tt.err, tt.target, got, tt.expected)
}
})
}
}
func TestErrorsAs(t *testing.T) {
// Test that errors.As works correctly with APIError
apiErr := &APIError{Code: -32600, Message: "Invalid Request"}
wrappedErr := fmt.Errorf("call failed: %w", apiErr)
var target *APIError
// Direct APIError
if !errors.As(apiErr, &target) {
t.Error("errors.As should match direct APIError")
}
if target.Code != -32600 {
t.Errorf("expected Code=-32600, got %d", target.Code)
}
// Wrapped APIError
target = nil
if !errors.As(wrappedErr, &target) {
t.Error("errors.As should match wrapped APIError")
}
if target.Message != "Invalid Request" {
t.Errorf("expected Message='Invalid Request', got %s", target.Message)
}
// Non-APIError
target = nil
if errors.As(ErrNotFound, &target) {
t.Error("errors.As should not match non-APIError")
}
}
func TestSentinelErrorMessages(t *testing.T) {
// Ensure all sentinel errors have meaningful messages
sentinels := []struct {
name string
err error
}{
{"ErrConnectionFailed", ErrConnectionFailed},
{"ErrTimeout", ErrTimeout},
{"ErrUnauthorized", ErrUnauthorized},
{"ErrForbidden", ErrForbidden},
{"ErrNotFound", ErrNotFound},
{"ErrProjectNotFound", ErrProjectNotFound},
{"ErrTaskNotFound", ErrTaskNotFound},
{"ErrColumnNotFound", ErrColumnNotFound},
{"ErrCommentNotFound", ErrCommentNotFound},
{"ErrAlreadyInLastColumn", ErrAlreadyInLastColumn},
{"ErrAlreadyInFirstColumn", ErrAlreadyInFirstColumn},
{"ErrTaskClosed", ErrTaskClosed},
{"ErrTaskOpen", ErrTaskOpen},
{"ErrEmptyTitle", ErrEmptyTitle},
{"ErrInvalidProjectID", ErrInvalidProjectID},
}
for _, s := range sentinels {
t.Run(s.name, func(t *testing.T) {
if s.err == nil {
t.Errorf("%s should not be nil", s.name)
}
if s.err.Error() == "" {
t.Errorf("%s should have a non-empty error message", s.name)
}
})
}
}
func TestErrorWrapping(t *testing.T) {
// Test error wrapping preserves context
originalErr := ErrTaskNotFound
wrappedOnce := fmt.Errorf("getting task %d: %w", 42, originalErr)
wrappedTwice := fmt.Errorf("in board scope: %w", wrappedOnce)
// Should preserve original error
if !errors.Is(wrappedTwice, ErrTaskNotFound) {
t.Error("wrapped error should match original with errors.Is")
}
// Should include context in message
if wrappedTwice.Error() != "in board scope: getting task 42: task not found" {
t.Errorf("unexpected error message: %s", wrappedTwice.Error())
}
}
func TestOperationFailedError(t *testing.T) {
tests := []struct {
name string
err *OperationFailedError
expectedSubstr []string
}{
{
name: "with hints",
err: &OperationFailedError{
Operation: "moveTaskPosition(task=42, column=5, project=1)",
Hints: []string{"task may not exist", "column may not belong to project"},
},
expectedSubstr: []string{
"moveTaskPosition",
"operation failed",
"possible causes",
"task may not exist",
"column may not belong to project",
},
},
{
name: "without hints",
err: &OperationFailedError{
Operation: "someOperation",
Hints: nil,
},
expectedSubstr: []string{"someOperation", "operation failed"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errMsg := tt.err.Error()
for _, substr := range tt.expectedSubstr {
if !containsSubstr(errMsg, substr) {
t.Errorf("error message %q should contain %q", errMsg, substr)
}
}
})
}
}
func TestIsOperationFailed(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"OperationFailedError", &OperationFailedError{Operation: "test"}, true},
{"wrapped OperationFailedError", fmt.Errorf("call failed: %w", &OperationFailedError{Operation: "test"}), true},
{"ErrUnauthorized", ErrUnauthorized, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsOperationFailed(tt.err); got != tt.expected {
t.Errorf("IsOperationFailed(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func containsSubstr(s, substr string) bool {
return strings.Contains(s, substr)
}