From d27aabe4c40b233915f321c760748af841126cf7 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 15 Jan 2026 18:26:55 +0100 Subject: [PATCH] Implement Task API methods (CRUD operations) - GetTask: retrieve single task by ID - GetAllTasks: retrieve all tasks for a project with status filter - CreateTask: create new task and return the created task - UpdateTask: partial updates via pointer fields - CloseTask/OpenTask: status operations with proper error handling - Returns ErrTaskNotFound, ErrTaskClosed, ErrTaskOpen as appropriate - Comprehensive test coverage for all methods and edge cases --- .beads/issues.jsonl | 2 +- tasks.go | 102 +++++++++ tasks_test.go | 510 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 tasks.go create mode 100644 tasks_test.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b1d2304..6f0855d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -6,7 +6,7 @@ {"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-5fb","title":"Implement TaskUpdateParams builder","description":"Implement TaskUpdateParams for fluent task update configuration.\n\n## Requirements\n- TaskUpdateParams struct with pointer fields\n- NewTaskUpdate() *TaskUpdateParams constructor\n- Fluent setter methods:\n - SetTitle(title string) *TaskUpdateParams\n - SetDescription(desc string) *TaskUpdateParams\n - SetColor(colorID string) *TaskUpdateParams\n - SetOwner(ownerID int) *TaskUpdateParams\n - SetCategory(categoryID int) *TaskUpdateParams\n - SetPriority(priority int) *TaskUpdateParams\n - SetDueDate(date time.Time) *TaskUpdateParams\n- Internal method to convert to UpdateTaskRequest\n\n## Files to create\n- task_update_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTaskUpdate().\n SetTitle(\"New Title\").\n SetPriority(2)\n```\n\n## Acceptance criteria\n- Only set fields are included in update request\n- All setters return *TaskUpdateParams for chaining","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.814955926+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.814955926+01:00","dependencies":[{"issue_id":"kanboard-api-5fb","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.134402453+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-70e","title":"Add GoDoc documentation to all public APIs","description":"Add comprehensive GoDoc comments to all exported types and functions.\n\n## Requirements\n- Package-level documentation in doc.go\n- All exported types documented\n- All exported functions documented with examples\n- All exported constants/variables documented\n\n## Documentation standards\n- Start with the name being documented\n- Include usage examples where helpful\n- Document error conditions\n- Note thread-safety characteristics\n\n## Files to create/modify\n- doc.go (package documentation)\n- All .go files with exported symbols\n\n## Acceptance criteria\n- `go doc` produces clean output\n- All exports have documentation\n- Examples are runnable","status":"open","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:54.342657373+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:54.342657373+01:00","dependencies":[{"issue_id":"kanboard-api-70e","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:46:55.508211397+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"kanboard-api-91a","title":"Implement Task API methods (CRUD)","description":"Implement direct API methods for core task operations.\n\n## Methods to implement\n- GetTask(ctx, taskID int) (*Task, error) - getTask\n- GetAllTasks(ctx, projectID int, status TaskStatus) ([]Task, error) - getAllTasks\n- CreateTask(ctx, req CreateTaskRequest) (*Task, error) - createTask\n- UpdateTask(ctx, req UpdateTaskRequest) error - updateTask\n- CloseTask(ctx, taskID int) error - closeTask\n- OpenTask(ctx, taskID int) error - openTask\n\n## Files to create\n- tasks.go\n\n## Acceptance criteria\n- CreateTask returns the created task\n- UpdateTask supports partial updates via pointer fields\n- Proper error types (ErrTaskNotFound, ErrTaskClosed, etc.)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.9962543+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.9962543+01:00","dependencies":[{"issue_id":"kanboard-api-91a","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.291442735+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-91a","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.434459365+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"kanboard-api-91a","title":"Implement Task API methods (CRUD)","description":"Implement direct API methods for core task operations.\n\n## Methods to implement\n- GetTask(ctx, taskID int) (*Task, error) - getTask\n- GetAllTasks(ctx, projectID int, status TaskStatus) ([]Task, error) - getAllTasks\n- CreateTask(ctx, req CreateTaskRequest) (*Task, error) - createTask\n- UpdateTask(ctx, req UpdateTaskRequest) error - updateTask\n- CloseTask(ctx, taskID int) error - closeTask\n- OpenTask(ctx, taskID int) error - openTask\n\n## Files to create\n- tasks.go\n\n## Acceptance criteria\n- CreateTask returns the created task\n- UpdateTask supports partial updates via pointer fields\n- Proper error types (ErrTaskNotFound, ErrTaskClosed, etc.)","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.9962543+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:26:49.591860359+01:00","closed_at":"2026-01-15T18:26:49.591860359+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-91a","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.291442735+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-91a","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.434459365+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-9k8","title":"Implement TaskParams builder (Options Pattern)","description":"Implement TaskParams for fluent task creation configuration.\n\n## Requirements\n- TaskParams struct with private fields\n- NewTask(title string) *TaskParams constructor\n- Fluent setter methods:\n - WithDescription(desc string) *TaskParams\n - InColumn(columnID int) *TaskParams\n - WithCategory(categoryID int) *TaskParams\n - WithOwner(ownerID int) *TaskParams\n - WithColor(colorID string) *TaskParams\n - WithPriority(priority int) *TaskParams\n - WithDueDate(date time.Time) *TaskParams\n - WithTags(tags ...string) *TaskParams\n - WithReference(ref string) *TaskParams\n- Internal method to convert to CreateTaskRequest\n\n## Files to create\n- task_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTask(\"My Task\").\n WithDescription(\"Details\").\n InColumn(2).\n WithTags(\"urgent\", \"backend\")\n```\n\n## Acceptance criteria\n- Pure configuration object (no I/O)\n- All setters return *TaskParams for chaining\n- Unset optional fields remain nil","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.439513879+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.439513879+01:00","dependencies":[{"issue_id":"kanboard-api-9k8","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.070100884+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-apl","title":"Implement Project API methods","description":"Implement direct API methods for project/board operations.\n\n## Methods to implement\n- GetAllProjects(ctx) ([]Project, error) - getAllProjects\n- GetProjectByID(ctx, projectID int) (*Project, error) - getProjectById\n- GetProjectByName(ctx, name string) (*Project, error) - getProjectByName (Nice-to-have)\n\n## Files to create\n- projects.go\n\n## Acceptance criteria\n- All methods use context for cancellation\n- Proper error handling and wrapping\n- Returns ErrProjectNotFound when appropriate","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:15.864764497+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:21:36.226187777+01:00","closed_at":"2026-01-15T18:21:36.226187777+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:52.850751716+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:52.946556814+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-c3i","title":"Create README.md with documentation","description":"Create comprehensive README.md documentation in English.\n\n## Sections to include\n- Project overview and features\n- Installation instructions\n- Quick start example\n- API documentation overview\n- Authentication methods (API token, user/password)\n- Fluent API examples\n- Direct API examples\n- Error handling\n- Thread-safety notes\n- Tag operations warning (non-atomic)\n- License (MIT)\n\n## Example code to include\n```go\n// Client creation\nclient := kanboard.NewClient(\"https://kanboard.example.com\").\n WithAPIToken(\"my-token\").\n WithTimeout(30 * time.Second)\n\n// Fluent API\ntask, _ := client.Board(1).CreateTask(ctx,\n kanboard.NewTask(\"My Task\").WithDescription(\"Details\"))\n\n// Direct API\ntasks, _ := client.GetAllTasks(ctx, 1, kanboard.StatusActive)\n```\n\n## Files to create\n- README.md\n\n## Acceptance criteria\n- Clear installation instructions\n- Working code examples\n- Documents all major features","status":"open","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.228407343+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.228407343+01:00","dependencies":[{"issue_id":"kanboard-api-c3i","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:46:55.445847253+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/tasks.go b/tasks.go new file mode 100644 index 0000000..47e02e2 --- /dev/null +++ b/tasks.go @@ -0,0 +1,102 @@ +package kanboard + +import ( + "context" + "fmt" +) + +// GetTask returns a task by its ID. +// Returns ErrTaskNotFound if the task does not exist. +func (c *Client) GetTask(ctx context.Context, taskID int) (*Task, error) { + params := map[string]int{"task_id": taskID} + + var result *Task + if err := c.call(ctx, "getTask", params, &result); err != nil { + return nil, fmt.Errorf("getTask: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: task %d", ErrTaskNotFound, taskID) + } + + return result, nil +} + +// GetAllTasks returns all tasks for a project with the specified status. +func (c *Client) GetAllTasks(ctx context.Context, projectID int, status TaskStatus) ([]Task, error) { + params := map[string]int{ + "project_id": projectID, + "status_id": int(status), + } + + var result []Task + if err := c.call(ctx, "getAllTasks", params, &result); err != nil { + return nil, fmt.Errorf("getAllTasks: %w", err) + } + + return result, nil +} + +// CreateTask creates a new task and returns the created task. +func (c *Client) CreateTask(ctx context.Context, req CreateTaskRequest) (*Task, error) { + var taskID int + if err := c.call(ctx, "createTask", req, &taskID); err != nil { + return nil, fmt.Errorf("createTask: %w", err) + } + + if taskID == 0 { + return nil, fmt.Errorf("createTask: failed to create task") + } + + // Fetch the created task to return full details + return c.GetTask(ctx, taskID) +} + +// UpdateTask updates an existing task. +// Only non-nil fields in the request will be updated. +func (c *Client) UpdateTask(ctx context.Context, req UpdateTaskRequest) error { + var success bool + if err := c.call(ctx, "updateTask", req, &success); err != nil { + return fmt.Errorf("updateTask: %w", err) + } + + if !success { + return fmt.Errorf("updateTask: update failed") + } + + return nil +} + +// CloseTask closes a task (sets it to inactive). +// Returns ErrTaskClosed if the task is already closed. +func (c *Client) CloseTask(ctx context.Context, taskID int) error { + params := map[string]int{"task_id": taskID} + + var success bool + if err := c.call(ctx, "closeTask", params, &success); err != nil { + return fmt.Errorf("closeTask: %w", err) + } + + if !success { + return fmt.Errorf("%w: task %d", ErrTaskClosed, taskID) + } + + return nil +} + +// OpenTask opens a task (sets it to active). +// Returns ErrTaskOpen if the task is already open. +func (c *Client) OpenTask(ctx context.Context, taskID int) error { + params := map[string]int{"task_id": taskID} + + var success bool + if err := c.call(ctx, "openTask", params, &success); err != nil { + return fmt.Errorf("openTask: %w", err) + } + + if !success { + return fmt.Errorf("%w: task %d", ErrTaskOpen, taskID) + } + + return nil +} diff --git a/tasks_test.go b/tasks_test.go new file mode 100644 index 0000000..19f57d5 --- /dev/null +++ b/tasks_test.go @@ -0,0 +1,510 @@ +package kanboard + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_GetTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getTask" { + t.Errorf("expected method=getTask, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "title": "Test Task", "project_id": "1", "column_id": "1", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + task, err := client.GetTask(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(task.ID) != 42 { + t.Errorf("expected ID=42, got %d", task.ID) + } + if task.Title != "Test Task" { + t.Errorf("expected title='Test Task', got %s", task.Title) + } + if !bool(task.IsActive) { + t.Error("expected task to be active") + } +} + +func TestClient_GetTask_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`null`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.GetTask(context.Background(), 999) + if err == nil { + t.Fatal("expected error for non-existent task") + } + + if !errors.Is(err, ErrTaskNotFound) { + t.Errorf("expected ErrTaskNotFound, got %v", err) + } +} + +func TestClient_GetAllTasks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getAllTasks" { + t.Errorf("expected method=getAllTasks, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", params["project_id"]) + } + if params["status_id"].(float64) != 1 { + t.Errorf("expected status_id=1, got %v", params["status_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "title": "Task One", "project_id": "1", "is_active": "1"}, + {"id": "2", "title": "Task Two", "project_id": "1", "is_active": "1"} + ]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + tasks, err := client.GetAllTasks(context.Background(), 1, StatusActive) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tasks) != 2 { + t.Errorf("expected 2 tasks, got %d", len(tasks)) + } + if tasks[0].Title != "Task One" { + t.Errorf("expected first task='Task One', got %s", tasks[0].Title) + } +} + +func TestClient_GetAllTasks_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + tasks, err := client.GetAllTasks(context.Background(), 1, StatusInactive) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tasks) != 0 { + t.Errorf("expected 0 tasks, got %d", len(tasks)) + } +} + +func TestClient_CreateTask(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++ + if callCount == 1 { + // First call: createTask + if req.Method != "createTask" { + t.Errorf("expected method=createTask, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["title"] != "New Task" { + t.Errorf("expected title='New Task', got %v", params["title"]) + } + if params["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", params["project_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`42`), + } + json.NewEncoder(w).Encode(resp) + } else { + // Second call: getTask to fetch created task + if req.Method != "getTask" { + t.Errorf("expected method=getTask, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "title": "New Task", "project_id": "1", "column_id": "1", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + task, err := client.CreateTask(context.Background(), CreateTaskRequest{ + Title: "New Task", + ProjectID: 1, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(task.ID) != 42 { + t.Errorf("expected ID=42, got %d", task.ID) + } + if task.Title != "New Task" { + t.Errorf("expected title='New Task', got %s", task.Title) + } +} + +func TestClient_CreateTask_WithOptions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method == "createTask" { + params := req.Params.(map[string]any) + if params["description"] != "Task description" { + t.Errorf("expected description='Task description', got %v", params["description"]) + } + if params["color_id"] != "blue" { + t.Errorf("expected color_id='blue', got %v", params["color_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`42`), + } + json.NewEncoder(w).Encode(resp) + } else { + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "title": "New Task", "project_id": "1", "column_id": "1", "is_active": "1", "description": "Task description", "color_id": "blue"}`), + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.CreateTask(context.Background(), CreateTaskRequest{ + Title: "New Task", + ProjectID: 1, + Description: "Task description", + ColorID: "blue", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_CreateTask_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + // Return 0 (false) to indicate failure + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`0`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.CreateTask(context.Background(), CreateTaskRequest{ + Title: "New Task", + ProjectID: 1, + }) + if err == nil { + t.Fatal("expected error for failed task creation") + } +} + +func TestClient_UpdateTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "updateTask" { + t.Errorf("expected method=updateTask, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["id"].(float64) != 42 { + t.Errorf("expected id=42, got %v", params["id"]) + } + if params["title"] != "Updated Title" { + t.Errorf("expected title='Updated Title', got %v", params["title"]) + } + + 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") + + title := "Updated Title" + err := client.UpdateTask(context.Background(), UpdateTaskRequest{ + ID: 42, + Title: &title, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_UpdateTask_PartialUpdate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + params := req.Params.(map[string]any) + // Only priority should be set + if _, hasTitle := params["title"]; hasTitle { + t.Error("title should not be present in partial update") + } + if params["priority"].(float64) != 2 { + t.Errorf("expected priority=2, got %v", params["priority"]) + } + + 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") + + priority := 2 + err := client.UpdateTask(context.Background(), UpdateTaskRequest{ + ID: 42, + Priority: &priority, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_UpdateTask_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + title := "Updated Title" + err := client.UpdateTask(context.Background(), UpdateTaskRequest{ + ID: 42, + Title: &title, + }) + if err == nil { + t.Fatal("expected error for failed update") + } +} + +func TestClient_CloseTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "closeTask" { + t.Errorf("expected method=closeTask, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_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.CloseTask(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_CloseTask_AlreadyClosed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + // Kanboard returns false if task is already closed + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.CloseTask(context.Background(), 42) + if err == nil { + t.Fatal("expected error for already closed task") + } + + if !errors.Is(err, ErrTaskClosed) { + t.Errorf("expected ErrTaskClosed, got %v", err) + } +} + +func TestClient_OpenTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "openTask" { + t.Errorf("expected method=openTask, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_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.OpenTask(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_OpenTask_AlreadyOpen(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + // Kanboard returns false if task is already open + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.OpenTask(context.Background(), 42) + if err == nil { + t.Fatal("expected error for already open task") + } + + if !errors.Is(err, ErrTaskOpen) { + t.Errorf("expected ErrTaskOpen, got %v", err) + } +} + +func TestClient_GetTask_ContextCanceled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select {} + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := client.GetTask(ctx, 42) + if err == nil { + t.Fatal("expected error due to canceled context") + } +}