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
This commit is contained in:
parent
ba942f3b52
commit
79385df87b
3 changed files with 289 additions and 1 deletions
|
|
@ -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-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-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-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-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-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"}]}
|
{"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"}]}
|
||||||
|
|
|
||||||
69
errors.go
69
errors.go
|
|
@ -5,10 +5,17 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Network errors
|
||||||
var (
|
var (
|
||||||
// ErrConnectionFailed indicates a connection to the Kanboard server failed.
|
// ErrConnectionFailed indicates a connection to the Kanboard server failed.
|
||||||
ErrConnectionFailed = errors.New("connection to 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 indicates authentication failed.
|
||||||
ErrUnauthorized = errors.New("authentication failed: invalid credentials")
|
ErrUnauthorized = errors.New("authentication failed: invalid credentials")
|
||||||
|
|
||||||
|
|
@ -16,6 +23,48 @@ var (
|
||||||
ErrForbidden = errors.New("access forbidden: insufficient permissions")
|
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.
|
// APIError represents an error returned by the Kanboard API.
|
||||||
type APIError struct {
|
type APIError struct {
|
||||||
Code int
|
Code int
|
||||||
|
|
@ -26,3 +75,23 @@ type APIError struct {
|
||||||
func (e *APIError) Error() string {
|
func (e *APIError) Error() string {
|
||||||
return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
219
errors_test.go
Normal file
219
errors_test.go
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue