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>
288 lines
7.8 KiB
Go
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)
|
|
}
|