Fix Close/Invalidate/Reopen to handle API array response

The Checkvist API returns an array of tasks (containing the modified task
and potentially its subtasks) for close, reopen, and invalidate operations.
The code was incorrectly trying to decode into a single Task struct.

Changes:
- Decode response into []Task instead of Task
- Return first element (the modified task)
- Add defensive error handling for empty arrays
- Update tests to mock array responses

Fixes: checkvist-api-2zr
This commit is contained in:
Oliver Jakoubek 2026-01-15 10:48:38 +01:00
commit b716d4d0fe
3 changed files with 37 additions and 18 deletions

View file

@ -3,6 +3,7 @@
{"id":"checkvist-api-1ki","title":"Write GoDoc examples","description":"Create example_test.go with runnable examples:\n- Example_basicUsage\n- ExampleNewClient\n- ExampleClient_Authenticate\n- ExampleChecklistService_List\n- ExampleTaskService_Create\n- ExampleNewTask (builder pattern)\n- ExampleNewFilter (filter builder)\n- ExampleFilter_Apply\nAll examples should be runnable and appear in GoDoc.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:38.443075101+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T14:35:43.090941735+01:00","closed_at":"2026-01-14T14:35:43.090941735+01:00","close_reason":"GoDoc examples für alle geforderten Funktionen implementiert","dependencies":[{"issue_id":"checkvist-api-1ki","depends_on_id":"checkvist-api-c2k","type":"blocks","created_at":"2026-01-14T12:33:14.730667505+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-1ki","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:15.039931257+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-1ki","depends_on_id":"checkvist-api-1ze","type":"blocks","created_at":"2026-01-14T12:33:15.388830036+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-1ze","title":"Implement client-side Filter builder","description":"Create filter.go with Filter builder (since Checkvist API has no server-side filtering):\n- NewFilter(tasks []Task) *Filter\n- WithTag(tag string) *Filter\n- WithTags(tags ...string) *Filter (AND logic)\n- WithStatus(status TaskStatus) *Filter\n- WithDueBefore(t time.Time) *Filter\n- WithDueAfter(t time.Time) *Filter\n- WithDueOn(t time.Time) *Filter\n- WithOverdue() *Filter\n- WithSearch(query string) *Filter (searches in Content)\n- Apply() []Task\nPerformance target: \u003c10ms for 1000+ tasks\nFilters are combined with AND logic.","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:56.24379077+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T14:33:38.687033358+01:00","closed_at":"2026-01-14T14:33:38.687033358+01:00","close_reason":"Filter builder mit allen geforderten Methoden implementiert","dependencies":[{"issue_id":"checkvist-api-1ze","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:02.543121262+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-1zf","title":"Write unit tests for Filter","description":"Create filter_test.go with tests:\n- TestFilter_WithTag\n- TestFilter_WithMultipleTags\n- TestFilter_WithStatus\n- TestFilter_WithDueBefore\n- TestFilter_WithDueAfter\n- TestFilter_WithDueOn\n- TestFilter_WithOverdue\n- TestFilter_WithSearch\n- TestFilter_Combined\n- TestFilter_Performance_1000Tasks\nUse table-driven tests.","status":"closed","priority":1,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:38.136650557+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T14:34:50.361097261+01:00","closed_at":"2026-01-14T14:34:50.361097261+01:00","close_reason":"Alle geforderten Tests implementiert und bestanden","dependencies":[{"issue_id":"checkvist-api-1zf","depends_on_id":"checkvist-api-1ze","type":"blocks","created_at":"2026-01-14T12:33:14.4378645+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-2zr","title":"Fix Close/Invalidate/Reopen to handle array response","description":"## Description\n\nThe `Close()`, `Invalidate()`, and `Reopen()` methods on `TaskService` fail with a JSON decoding error because the Checkvist API returns an array of tasks, not a single task object.\n\n## Error\n\n```go\ntask, err := client.Tasks(945445).Close(ctx, taskID)\nif err != nil {\n panic(err)\n}\n```\n\nResults in:\n```\npanic: decoding response: json: cannot unmarshal array into Go value of type checkvist.Task\n```\n\n## Root Cause\n\nIn `tasks.go` (lines 204-240), all three methods decode the API response into a single `Task`:\n\n```go\nvar task Task\nif err := s.client.doPost(ctx, path, nil, \u0026task); err != nil {\n return nil, err\n}\n```\n\nBut the Checkvist API returns an array containing the modified task and potentially its subtasks.\n\n## Affected Methods\n\n- `TaskService.Close()` (tasks.go:204)\n- `TaskService.Reopen()` (tasks.go:217)\n- `TaskService.Invalidate()` (tasks.go:230)\n\n## Acceptance Criteria\n\n- [ ] All three methods correctly decode the array response\n- [ ] Return the first element (the modified task) as `*Task`\n- [ ] Update tests to use array response fixtures\n- [ ] Add integration test notes in code comments","status":"closed","priority":2,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T10:26:22.308158727+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T10:48:33.012621346+01:00","closed_at":"2026-01-15T10:48:33.012621346+01:00","close_reason":"Closed"}
{"id":"checkvist-api-347","title":"Write unit tests for Checklists","description":"Create checklists_test.go with tests:\n- TestChecklists_List\n- TestChecklists_ListArchived\n- TestChecklists_Get\n- TestChecklists_Get_NotFound\n- TestChecklists_Create\n- TestChecklists_Update\n- TestChecklists_Delete\nUse table-driven tests. Create testdata/checklists/ fixtures.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:37.243525209+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:41:58.076714398+01:00","closed_at":"2026-01-14T13:41:58.076714398+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-347","depends_on_id":"checkvist-api-c2k","type":"blocks","created_at":"2026-01-14T12:33:13.512887479+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-47y","title":"Quality \u0026 Documentation","description":"Phase 4: Complete unit tests, GoDoc examples, README, and CHANGELOG. Target \u003e80% test coverage. All public functions documented with examples.","status":"closed","priority":0,"issue_type":"epic","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:36.655509387+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T14:37:52.12671299+01:00","closed_at":"2026-01-14T14:37:52.12671299+01:00","close_reason":"Alle zugehörigen Features und Tasks abgeschlossen"}
{"id":"checkvist-api-4pz","title":"Fix DueDate not applied when creating tasks with WithDueDate()","description":"## Problem\n\nWhen creating tasks with `WithDueDate()`, the due date is not set on the created task, even though other parameters (position, priority, tags) work correctly.\n\n## Example Code\n\n```go\ntask, err := client.Tasks(945445).Create(ctx,\n checkvist.NewTask(\"Buy milk\").\n WithPosition(1).\n WithDueDate(checkvist.DueTomorrow).\n WithPriority(2).\n WithTags(\"Shopping\"),\n)\n// Result: task has position=1, priority=2, tags=\"Shopping\", but DueDate is nil\n```\n\n## Analysis\n\nThe JSON being sent to the API looks correct:\n```json\n{\n \"task\": {\n \"content\": \"Buy milk\",\n \"position\": 1,\n \"due_date\": \"^Tomorrow\",\n \"priority\": 2,\n \"tags\": \"Shopping\"\n }\n}\n```\n\nThe task wrapper format is correct (other fields work). The issue is likely with the due_date format:\n- Current format: `\"^Tomorrow\"` (with caret prefix)\n- API might expect: `\"tomorrow\"` (without caret) or different syntax\n\n## Investigation Needed\n\n1. Check Checkvist API documentation for exact `due_date` format\n2. Test with different formats:\n - `\"tomorrow\"` (without ^)\n - `\"Tomorrow\"` (capitalized, without ^)\n - `\"2026-01-16\"` (ISO date)\n3. Check if the `^` prefix is only used in task content, not in API parameters\n\n## Affected Code\n\n- `models.go:178-188` - DueDate constants with `^` prefix\n- `models.go:190-205` - DueAt(), DueString(), DueInDays() functions\n\n## Acceptance Criteria\n\n- [ ] `WithDueDate(DueTomorrow)` creates task with correct due date\n- [ ] `WithDueDate(DueAt(time))` creates task with correct due date\n- [ ] All DueDate constants work correctly","status":"closed","priority":2,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T10:11:00.062239196+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T10:17:38.380280674+01:00","closed_at":"2026-01-15T10:17:38.380280674+01:00","close_reason":"Closed"}

View file

@ -201,42 +201,57 @@ func (s *TaskService) Delete(ctx context.Context, taskID int) error {
}
// Close marks a task as completed.
// The API returns an array containing the modified task and potentially its subtasks.
func (s *TaskService) Close(ctx context.Context, taskID int) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d/close.json", s.checklistID, taskID)
var task Task
if err := s.client.doPost(ctx, path, nil, &task); err != nil {
var tasks []Task
if err := s.client.doPost(ctx, path, nil, &tasks); err != nil {
return nil, err
}
parseDueDate(&task)
return &task, nil
if len(tasks) == 0 {
return nil, fmt.Errorf("close task: unexpected empty response")
}
parseDueDate(&tasks[0])
return &tasks[0], nil
}
// Reopen reopens a closed or invalidated task.
// The API returns an array containing the modified task and potentially its subtasks.
func (s *TaskService) Reopen(ctx context.Context, taskID int) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d/reopen.json", s.checklistID, taskID)
var task Task
if err := s.client.doPost(ctx, path, nil, &task); err != nil {
var tasks []Task
if err := s.client.doPost(ctx, path, nil, &tasks); err != nil {
return nil, err
}
parseDueDate(&task)
return &task, nil
if len(tasks) == 0 {
return nil, fmt.Errorf("reopen task: unexpected empty response")
}
parseDueDate(&tasks[0])
return &tasks[0], nil
}
// Invalidate marks a task as invalidated (strikethrough).
// The API returns an array containing the modified task and potentially its subtasks.
func (s *TaskService) Invalidate(ctx context.Context, taskID int) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d/invalidate.json", s.checklistID, taskID)
var task Task
if err := s.client.doPost(ctx, path, nil, &task); err != nil {
var tasks []Task
if err := s.client.doPost(ctx, path, nil, &tasks); err != nil {
return nil, err
}
parseDueDate(&task)
return &task, nil
if len(tasks) == 0 {
return nil, fmt.Errorf("invalidate task: unexpected empty response")
}
parseDueDate(&tasks[0])
return &tasks[0], nil
}
// parseDueDate attempts to parse the DueDateRaw string into a time.Time.

View file

@ -277,10 +277,11 @@ func TestTasks_Close(t *testing.T) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
response := Task{
// API returns an array containing the modified task and potentially subtasks
response := []Task{{
ID: 101,
Status: StatusClosed,
}
}}
json.NewEncoder(w).Encode(response)
default:
t.Errorf("unexpected path: %s", r.URL.Path)
@ -310,10 +311,11 @@ func TestTasks_Reopen(t *testing.T) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
response := Task{
// API returns an array containing the modified task and potentially subtasks
response := []Task{{
ID: 101,
Status: StatusOpen,
}
}}
json.NewEncoder(w).Encode(response)
default:
t.Errorf("unexpected path: %s", r.URL.Path)
@ -343,10 +345,11 @@ func TestTasks_Invalidate(t *testing.T) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
response := Task{
// API returns an array containing the modified task and potentially subtasks
response := []Task{{
ID: 101,
Status: StatusInvalidated,
}
}}
json.NewEncoder(w).Encode(response)
default:
t.Errorf("unexpected path: %s", r.URL.Path)