Implement MoveToNextColumn and MoveToPreviousColumn

- MoveToNextColumn: moves task to next column in workflow
- MoveToPreviousColumn: moves task to previous column
- Gets columns sorted by position, finds current, moves to adjacent
- Returns ErrAlreadyInLastColumn when at workflow end
- Returns ErrAlreadyInFirstColumn when at workflow start
- Handles column gaps (non-sequential positions)
- Comprehensive test coverage for all edge cases
This commit is contained in:
Oliver Jakoubek 2026-01-15 18:42:02 +01:00
commit 4907a7caad
3 changed files with 285 additions and 1 deletions

View file

@ -18,7 +18,7 @@
{"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":"closed","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-15T18:23:40.255170515+01:00","closed_at":"2026-01-15T18:23:40.255170515+01:00","close_reason":"Closed","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":"closed","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-15T18:23:40.255170515+01:00","closed_at":"2026-01-15T18:23:40.255170515+01:00","close_reason":"Closed","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":"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-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":"closed","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-15T18:41:56.137534164+01:00","closed_at":"2026-01-15T18:41:56.137534164+01:00","close_reason":"Closed","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":"closed","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-15T18:14:16.530243958+01:00","closed_at":"2026-01-15T18:14:16.530243958+01:00","close_reason":"Closed","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":"closed","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-15T18:14:16.530243958+01:00","closed_at":"2026-01-15T18:14:16.530243958+01:00","close_reason":"Closed","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-una","title":"Implement TaskScope fluent builder (core methods)","description":"Implement TaskScope for fluent task-scoped operations (core methods).\n\n## Requirements\n- TaskScope struct with client and taskID\n- Client.Task(taskID int) *TaskScope method\n- Core TaskScope methods:\n - Get(ctx) (*Task, error)\n - Close(ctx) error\n - Open(ctx) error\n - MoveToColumn(ctx, columnID int) error\n - MoveToProject(ctx, projectID int) error\n - Update(ctx, params *TaskUpdateParams) error\n\n## Files to create\n- task_scope.go\n\n## Example usage\n```go\ntask, _ := client.Task(123).Get(ctx)\nclient.Task(123).Close(ctx)\nclient.Task(123).Update(ctx, kanboard.NewTaskUpdate().SetTitle(\"New\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:41.173930396+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:34:32.369975567+01:00","closed_at":"2026-01-15T18:34:32.369975567+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.198935686+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-5fb","type":"blocks","created_at":"2026-01-15T17:43:31.261453756+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-una","title":"Implement TaskScope fluent builder (core methods)","description":"Implement TaskScope for fluent task-scoped operations (core methods).\n\n## Requirements\n- TaskScope struct with client and taskID\n- Client.Task(taskID int) *TaskScope method\n- Core TaskScope methods:\n - Get(ctx) (*Task, error)\n - Close(ctx) error\n - Open(ctx) error\n - MoveToColumn(ctx, columnID int) error\n - MoveToProject(ctx, projectID int) error\n - Update(ctx, params *TaskUpdateParams) error\n\n## Files to create\n- task_scope.go\n\n## Example usage\n```go\ntask, _ := client.Task(123).Get(ctx)\nclient.Task(123).Close(ctx)\nclient.Task(123).Update(ctx, kanboard.NewTaskUpdate().SetTitle(\"New\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:41.173930396+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:34:32.369975567+01:00","closed_at":"2026-01-15T18:34:32.369975567+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.198935686+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-5fb","type":"blocks","created_at":"2026-01-15T17:43:31.261453756+01:00","created_by":"Oliver Jakoubek"}]}

View file

@ -42,6 +42,86 @@ func (t *TaskScope) MoveToColumn(ctx context.Context, columnID int) error {
return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, columnID, 0, int(task.SwimlaneID)) return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, columnID, 0, int(task.SwimlaneID))
} }
// MoveToNextColumn moves the task to the next column in the workflow.
// Columns are ordered by their position field.
// Returns ErrAlreadyInLastColumn if the task is already in the last column.
func (t *TaskScope) MoveToNextColumn(ctx context.Context) error {
// Get task to find current column and project
task, err := t.Get(ctx)
if err != nil {
return err
}
// Get all columns for the project (already sorted by position)
columns, err := t.client.GetColumns(ctx, int(task.ProjectID))
if err != nil {
return err
}
if len(columns) == 0 {
return ErrAlreadyInLastColumn
}
// Find the current column index
currentColumnID := int(task.ColumnID)
currentIndex := -1
for i, col := range columns {
if int(col.ID) == currentColumnID {
currentIndex = i
break
}
}
// If current column not found or is the last column
if currentIndex == -1 || currentIndex >= len(columns)-1 {
return ErrAlreadyInLastColumn
}
// Move to the next column
nextColumn := columns[currentIndex+1]
return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, int(nextColumn.ID), 0, int(task.SwimlaneID))
}
// MoveToPreviousColumn moves the task to the previous column in the workflow.
// Columns are ordered by their position field.
// Returns ErrAlreadyInFirstColumn if the task is already in the first column.
func (t *TaskScope) MoveToPreviousColumn(ctx context.Context) error {
// Get task to find current column and project
task, err := t.Get(ctx)
if err != nil {
return err
}
// Get all columns for the project (already sorted by position)
columns, err := t.client.GetColumns(ctx, int(task.ProjectID))
if err != nil {
return err
}
if len(columns) == 0 {
return ErrAlreadyInFirstColumn
}
// Find the current column index
currentColumnID := int(task.ColumnID)
currentIndex := -1
for i, col := range columns {
if int(col.ID) == currentColumnID {
currentIndex = i
break
}
}
// If current column not found or is the first column
if currentIndex <= 0 {
return ErrAlreadyInFirstColumn
}
// Move to the previous column
prevColumn := columns[currentIndex-1]
return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, int(prevColumn.ID), 0, int(task.SwimlaneID))
}
// MoveToProject moves the task to a different project. // MoveToProject moves the task to a different project.
func (t *TaskScope) MoveToProject(ctx context.Context, projectID int) error { func (t *TaskScope) MoveToProject(ctx context.Context, projectID int) error {
return t.client.MoveTaskToProject(ctx, t.taskID, projectID) return t.client.MoveTaskToProject(ctx, t.taskID, projectID)

View file

@ -3,6 +3,7 @@ package kanboard
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -180,6 +181,209 @@ func TestTaskScope_MoveToColumn(t *testing.T) {
} }
} }
func TestTaskScope_MoveToNextColumn(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest
json.NewDecoder(r.Body).Decode(&req)
callCount++
switch callCount {
case 1:
// First call: getTask
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "2", "swimlane_id": "0", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// Second call: getColumns
if req.Method != "getColumns" {
t.Errorf("expected method=getColumns, got %s", req.Method)
}
// Return columns in position order
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`[
{"id": "1", "title": "Backlog", "position": "1", "project_id": "1"},
{"id": "2", "title": "In Progress", "position": "2", "project_id": "1"},
{"id": "3", "title": "Done", "position": "3", "project_id": "1"}
]`),
}
json.NewEncoder(w).Encode(resp)
case 3:
// Third call: moveTaskPosition
if req.Method != "moveTaskPosition" {
t.Errorf("expected method=moveTaskPosition, got %s", req.Method)
}
params := req.Params.(map[string]any)
if params["column_id"].(float64) != 3 {
t.Errorf("expected column_id=3 (Done), got %v", params["column_id"])
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`true`),
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
err := client.Task(42).MoveToNextColumn(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_MoveToNextColumn_AlreadyInLastColumn(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest
json.NewDecoder(r.Body).Decode(&req)
callCount++
switch callCount {
case 1:
// Task is already in column 3 (Done)
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "3", "swimlane_id": "0", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// getColumns
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`[
{"id": "1", "title": "Backlog", "position": "1", "project_id": "1"},
{"id": "2", "title": "In Progress", "position": "2", "project_id": "1"},
{"id": "3", "title": "Done", "position": "3", "project_id": "1"}
]`),
}
json.NewEncoder(w).Encode(resp)
default:
t.Error("moveTaskPosition should not be called when already in last column")
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
err := client.Task(42).MoveToNextColumn(context.Background())
if err == nil {
t.Fatal("expected error for task already in last column")
}
if !errors.Is(err, ErrAlreadyInLastColumn) {
t.Errorf("expected ErrAlreadyInLastColumn, got %v", err)
}
}
func TestTaskScope_MoveToPreviousColumn(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest
json.NewDecoder(r.Body).Decode(&req)
callCount++
switch callCount {
case 1:
// Task in column 2 (In Progress)
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "2", "swimlane_id": "0", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// getColumns
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`[
{"id": "1", "title": "Backlog", "position": "1", "project_id": "1"},
{"id": "2", "title": "In Progress", "position": "2", "project_id": "1"},
{"id": "3", "title": "Done", "position": "3", "project_id": "1"}
]`),
}
json.NewEncoder(w).Encode(resp)
case 3:
// moveTaskPosition
params := req.Params.(map[string]any)
if params["column_id"].(float64) != 1 {
t.Errorf("expected column_id=1 (Backlog), got %v", params["column_id"])
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`true`),
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
err := client.Task(42).MoveToPreviousColumn(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_MoveToPreviousColumn_AlreadyInFirstColumn(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest
json.NewDecoder(r.Body).Decode(&req)
callCount++
switch callCount {
case 1:
// Task is already in column 1 (Backlog)
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "1", "swimlane_id": "0", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// getColumns
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`[
{"id": "1", "title": "Backlog", "position": "1", "project_id": "1"},
{"id": "2", "title": "In Progress", "position": "2", "project_id": "1"},
{"id": "3", "title": "Done", "position": "3", "project_id": "1"}
]`),
}
json.NewEncoder(w).Encode(resp)
default:
t.Error("moveTaskPosition should not be called when already in first column")
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
err := client.Task(42).MoveToPreviousColumn(context.Background())
if err == nil {
t.Fatal("expected error for task already in first column")
}
if !errors.Is(err, ErrAlreadyInFirstColumn) {
t.Errorf("expected ErrAlreadyInFirstColumn, got %v", err)
}
}
func TestTaskScope_MoveToProject(t *testing.T) { func TestTaskScope_MoveToProject(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest var req JSONRPCRequest