From 79385df87b57d6a84345262cd70843a622cd4193 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 15 Jan 2026 18:13:09 +0100 Subject: [PATCH] Implement comprehensive error types and handling Add complete error handling system for Kanboard API client: Sentinel errors: - Network: ErrConnectionFailed, ErrTimeout - Auth: ErrUnauthorized, ErrForbidden - Resources: ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound - Logic: ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen - Validation: ErrEmptyTitle, ErrInvalidProjectID Helper functions: - IsNotFound() - checks all not-found error variants - IsUnauthorized() - checks auth errors - IsAPIError() - checks for API errors via errors.As All errors support errors.Is/errors.As for proper error wrapping and context preservation. Closes: kanboard-api-s7k --- .beads/issues.jsonl | 2 +- errors.go | 69 ++++++++++++++ errors_test.go | 219 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 errors_test.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 170d356..4e96556 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -17,7 +17,7 @@ {"id":"kanboard-api-l72","title":"Implement global task search with parallel execution","description":"Implement cross-project task search with parallel execution.\n\n## Method to implement\n- SearchTasksGlobally(ctx, query string) ([]Task, error)\n\n## Workflow\n1. Get all projects via getAllProjects\n2. Execute searchTasks for each project in parallel (errgroup)\n3. Aggregate and return all results\n4. Respect context cancellation\n\n## Implementation details\n- Use golang.org/x/sync/errgroup for parallel execution\n- Results channel to collect tasks\n- Handle errors from any project search\n- Context propagation for cancellation\n\n## Files to modify\n- tasks.go\n\n## Acceptance criteria\n- Parallel execution improves performance\n- Context cancellation stops all goroutines\n- Single project failure cancels remaining searches","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.533649255+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:29.533649255+01:00","dependencies":[{"issue_id":"kanboard-api-l72","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:45:32.11473352+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-l72","depends_on_id":"kanboard-api-fue","type":"blocks","created_at":"2026-01-15T17:45:32.178153305+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-l9b","title":"Implement Column API methods","description":"Implement direct API methods for column operations.\n\n## Methods to implement\n- GetColumns(ctx, projectID int) ([]Column, error) - getColumns\n- GetColumn(ctx, columnID int) (*Column, error) - getColumn\n\n## Files to create\n- columns.go\n\n## Acceptance criteria\n- Columns returned sorted by position\n- Returns ErrColumnNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.23369865+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.23369865+01:00","dependencies":[{"issue_id":"kanboard-api-l9b","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.022754817+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-l9b","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.094484504+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-ob2","title":"Add MIT license","description":"Add MIT license file to the repository.\n\n## Files to create\n- LICENSE\n\n## Content\nStandard MIT license text with appropriate copyright holder.\n\n## Acceptance criteria\n- Valid MIT license\n- Proper copyright attribution","status":"open","priority":3,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.955909551+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.955909551+01:00"} -{"id":"kanboard-api-s7k","title":"Implement error types and handling","description":"Implement comprehensive error types for the Kanboard API client.\n\n## Sentinel Errors\n- ErrConnectionFailed, ErrTimeout (network)\n- ErrUnauthorized, ErrForbidden (auth)\n- ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound (resources)\n- ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen (logic)\n- ErrEmptyTitle, ErrInvalidProjectID (validation)\n- ErrAPIError (API)\n\n## APIError struct\n- Code int\n- Message string\n- Error() string method\n\n## Helper functions\n- IsNotFound(err error) bool\n- IsUnauthorized(err error) bool\n- IsAPIError(err error) bool\n\n## Files to create\n- errors.go\n\n## Acceptance criteria\n- All errors properly support errors.Is/errors.As\n- Error wrapping with %w for context preservation","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.484116412+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:54.484116412+01:00"} +{"id":"kanboard-api-s7k","title":"Implement error types and handling","description":"Implement comprehensive error types for the Kanboard API client.\n\n## Sentinel Errors\n- ErrConnectionFailed, ErrTimeout (network)\n- ErrUnauthorized, ErrForbidden (auth)\n- ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound (resources)\n- ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen (logic)\n- ErrEmptyTitle, ErrInvalidProjectID (validation)\n- ErrAPIError (API)\n\n## APIError struct\n- Code int\n- Message string\n- Error() string method\n\n## Helper functions\n- IsNotFound(err error) bool\n- IsUnauthorized(err error) bool\n- IsAPIError(err error) bool\n\n## Files to create\n- errors.go\n\n## Acceptance criteria\n- All errors properly support errors.Is/errors.As\n- Error wrapping with %w for context preservation","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.484116412+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:13:03.195687148+01:00","closed_at":"2026-01-15T18:13:03.195687148+01:00","close_reason":"Closed"} {"id":"kanboard-api-t36","title":"Implement MoveToNextColumn with column logic","description":"Implement TaskScope.MoveToNextColumn with automatic column position logic.\n\n## Method to implement\n- TaskScope.MoveToNextColumn(ctx) error\n\n## Workflow\n1. Get task via getTask\n2. Get columns for task's project via getColumns\n3. Sort columns by position\n4. Find current column position\n5. If last column: return ErrAlreadyInLastColumn\n6. Move to next column via moveTaskPosition\n\n## Files to modify\n- task_scope.go\n\n## Acceptance criteria\n- Correctly identifies next column by position\n- Returns ErrAlreadyInLastColumn for last column\n- Handles column gaps (positions 1, 3, 5 etc.)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.900647957+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:29.900647957+01:00","dependencies":[{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:45:32.256714732+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:45:32.319227723+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-ukc","title":"Set up Mage build system","description":"Set up Mage build system for development automation.\n\n## Directory structure\n```\nmagefiles/\n├── go.mod\n├── go.sum\n└── magefile.go\n```\n\n## Targets to implement\n- Test() - Run all tests with race detection and coverage\n- Coverage() - Run tests and open HTML coverage report\n- Lint() - Run golangci-lint\n- Build() - Verify module compiles\n\n## Files to create\n- magefiles/go.mod\n- magefiles/magefile.go\n\n## Acceptance criteria\n- `mage test` runs tests with -race and -coverprofile\n- `mage lint` runs golangci-lint\n- `mage build` verifies compilation","status":"open","priority":2,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:30.260235504+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:30.260235504+01:00"} {"id":"kanboard-api-uls","title":"Implement Client struct with fluent configuration","description":"Implement the main Client struct with fluent builder pattern for configuration.\n\n## Requirements\n- Client struct with baseURL, httpClient, auth, logger, requestID\n- NewClient(baseURL string) constructor\n- Fluent configuration methods:\n - WithAPIToken(token string) *Client\n - WithBasicAuth(username, password string) *Client\n - WithHTTPClient(client *http.Client) *Client\n - WithTimeout(timeout time.Duration) *Client\n - WithLogger(logger *slog.Logger) *Client\n- Default timeout of 30 seconds\n- Thread-safe for concurrent use\n\n## Files to create\n- client.go\n\n## Acceptance criteria\n- Client is immutable after configuration\n- Thread-safe concurrent usage\n- Configurable HTTP client and logger","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.051926732+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:54.051926732+01:00","dependencies":[{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-2g1","type":"blocks","created_at":"2026-01-15T17:42:48.457362117+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-k33","type":"blocks","created_at":"2026-01-15T17:42:48.576622508+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-s7k","type":"blocks","created_at":"2026-01-15T17:42:48.749342195+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/errors.go b/errors.go index e9bd2ef..211d79d 100644 --- a/errors.go +++ b/errors.go @@ -5,10 +5,17 @@ import ( "fmt" ) +// Network errors var ( // ErrConnectionFailed indicates a connection to the Kanboard server failed. ErrConnectionFailed = errors.New("connection to Kanboard server failed") + // ErrTimeout indicates a request timed out. + ErrTimeout = errors.New("request timed out") +) + +// Authentication errors +var ( // ErrUnauthorized indicates authentication failed. ErrUnauthorized = errors.New("authentication failed: invalid credentials") @@ -16,6 +23,48 @@ var ( ErrForbidden = errors.New("access forbidden: insufficient permissions") ) +// Resource errors +var ( + // ErrNotFound indicates a resource was not found. + ErrNotFound = errors.New("resource not found") + + // ErrProjectNotFound indicates the specified project was not found. + ErrProjectNotFound = errors.New("project not found") + + // ErrTaskNotFound indicates the specified task was not found. + ErrTaskNotFound = errors.New("task not found") + + // ErrColumnNotFound indicates the specified column was not found. + ErrColumnNotFound = errors.New("column not found") + + // ErrCommentNotFound indicates the specified comment was not found. + ErrCommentNotFound = errors.New("comment not found") +) + +// Logic errors +var ( + // ErrAlreadyInLastColumn indicates a task is already in the last column. + ErrAlreadyInLastColumn = errors.New("task is already in the last column") + + // ErrAlreadyInFirstColumn indicates a task is already in the first column. + ErrAlreadyInFirstColumn = errors.New("task is already in the first column") + + // ErrTaskClosed indicates a task is already closed. + ErrTaskClosed = errors.New("task is already closed") + + // ErrTaskOpen indicates a task is already open. + ErrTaskOpen = errors.New("task is already open") +) + +// Validation errors +var ( + // ErrEmptyTitle indicates a task title cannot be empty. + ErrEmptyTitle = errors.New("task title cannot be empty") + + // ErrInvalidProjectID indicates an invalid project ID was provided. + ErrInvalidProjectID = errors.New("invalid project ID") +) + // APIError represents an error returned by the Kanboard API. type APIError struct { Code int @@ -26,3 +75,23 @@ type APIError struct { func (e *APIError) Error() string { return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message) } + +// IsNotFound returns true if the error indicates a resource was not found. +func IsNotFound(err error) bool { + return errors.Is(err, ErrNotFound) || + errors.Is(err, ErrProjectNotFound) || + errors.Is(err, ErrTaskNotFound) || + errors.Is(err, ErrColumnNotFound) || + errors.Is(err, ErrCommentNotFound) +} + +// IsUnauthorized returns true if the error indicates an authentication failure. +func IsUnauthorized(err error) bool { + return errors.Is(err, ErrUnauthorized) +} + +// IsAPIError returns true if the error is an APIError from the Kanboard API. +func IsAPIError(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..95ce997 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,219 @@ +package kanboard + +import ( + "errors" + "fmt" + "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()) + } +}