From e2d0f2299c1893a6c72c64a9e6d6fb01fe59a7fb Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 14 Jan 2026 13:46:08 +0100 Subject: [PATCH] Add unit tests for Tasks Create tasks_test.go with comprehensive tests: - TestTasks_List: list all tasks in checklist - TestTasks_Get: get single task by ID - TestTasks_Create: create basic task - TestTasks_Create_WithBuilder: create task with all options - TestTasks_Update: update task properties - TestTasks_Delete: delete task - TestTasks_Close: mark task as completed - TestTasks_Reopen: reopen closed task - TestTasks_Invalidate: invalidate task - TestDueDate_Parsing: table-driven due date parsing tests - TestTaskBuilder: builder pattern validation Add testdata/tasks/ fixtures: - list.json: sample task list - single.json: single task response All 11 tests pass using httptest.Server mocking. Closes checkvist-api-v2f --- .beads/issues.jsonl | 2 +- tasks_test.go | 436 +++++++++++++++++++++++++++++++++++++ testdata/tasks/list.json | 34 +++ testdata/tasks/single.json | 16 ++ 4 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 tasks_test.go create mode 100644 testdata/tasks/list.json create mode 100644 testdata/tasks/single.json diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2a15ee4..745290f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,5 +22,5 @@ {"id":"checkvist-api-nrk","title":"Create README with quickstart","description":"Create README.md (in English) with:\n- Project description\n- Installation: go get code.beautifulmachines.dev/jakoubek/checkvist-api\n- Quick start example (init client, list checklists, create task)\n- API overview (Checklists, Tasks, Notes, Filters)\n- Builder pattern examples\n- Error handling examples\n- Configuration options\n- Link to GoDoc\n- License (MIT)","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:38.724338606+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:38.724338606+01:00","dependencies":[{"issue_id":"checkvist-api-nrk","depends_on_id":"checkvist-api-c2k","type":"blocks","created_at":"2026-01-14T12:33:15.785698203+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-nrk","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:16.125115134+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-rl9","title":"Implement Task operations","description":"Create tasks.go with TaskService:\n- client.Tasks(checklistID) returns TaskService\n- List(ctx) ([]Task, error) - GET /checklists/{id}/tasks.json\n- Get(ctx, taskID) (*Task, error) - GET /checklists/{id}/tasks/{task_id}.json (includes parent hierarchy)\n- Create(ctx, builder *TaskBuilder) (*Task, error) - POST /checklists/{id}/tasks.json\n- Update(ctx, taskID, opts) (*Task, error) - PUT /checklists/{id}/tasks/{task_id}.json\n- Delete(ctx, taskID) error - DELETE /checklists/{id}/tasks/{task_id}.json\n- Close(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/close.json\n- Reopen(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/reopen.json\n- Invalidate(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/invalidate.json\nParse DueDate from DueDateRaw when retrieving tasks.","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:53.90629838+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:39:29.443727155+01:00","closed_at":"2026-01-14T13:39:29.443727155+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:32:55.247292478+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:32:55.536931433+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-tjk","title":"Implement TaskBuilder fluent interface","description":"Enhance task creation with builder pattern:\n- NewTask(content string) *TaskBuilder\n- WithTags(tags ...string) *TaskBuilder\n- WithDueDate(due DueDate) *TaskBuilder\n- WithPriority(p int) *TaskBuilder\n- WithParent(parentID int64) *TaskBuilder\n- WithPosition(pos int) *TaskBuilder\n- Build() returns parameters for API call\nTaskBuilder should be chainable and return itself for fluent usage:\n checkvist.NewTask(\"Content\").WithTags(\"tag1\").WithDueDate(checkvist.DueTomorrow).WithPriority(1)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:55.929907579+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:55.929907579+01:00","dependencies":[{"issue_id":"checkvist-api-tjk","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:02.20339206+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"checkvist-api-v2f","title":"Write unit tests for Tasks","description":"Create tasks_test.go with tests:\n- TestTasks_List\n- TestTasks_Get\n- TestTasks_Get_WithParentHierarchy\n- TestTasks_Create\n- TestTasks_Create_WithBuilder\n- TestTasks_Update\n- TestTasks_Delete\n- TestTasks_Close\n- TestTasks_Reopen\n- TestTasks_Invalidate\n- TestDueDate_Parsing\nUse table-driven tests. Create testdata/tasks/ fixtures.","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:37.538754679+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:37.538754679+01:00","dependencies":[{"issue_id":"checkvist-api-v2f","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:13.81085058+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"checkvist-api-v2f","title":"Write unit tests for Tasks","description":"Create tasks_test.go with tests:\n- TestTasks_List\n- TestTasks_Get\n- TestTasks_Get_WithParentHierarchy\n- TestTasks_Create\n- TestTasks_Create_WithBuilder\n- TestTasks_Update\n- TestTasks_Delete\n- TestTasks_Close\n- TestTasks_Reopen\n- TestTasks_Invalidate\n- TestDueDate_Parsing\nUse table-driven tests. Create testdata/tasks/ fixtures.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:37.538754679+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:46:01.50434559+01:00","closed_at":"2026-01-14T13:46:01.50434559+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-v2f","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:13.81085058+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-ymg","title":"Implement Client struct with functional options","description":"Create client.go with:\n- Client struct (baseURL, username, remoteKey, token, tokenExp, httpClient, retryConf, logger, mu sync.RWMutex)\n- NewClient(username, remoteKey string, opts ...Option) *Client constructor\n- Create options.go with Option type and functional options:\n - WithHTTPClient(*http.Client)\n - WithTimeout(time.Duration)\n - WithRetryConfig(RetryConfig)\n - WithLogger(*slog.Logger)\n - WithBaseURL(string) for testing\n- RetryConfig struct (MaxRetries, BaseDelay, MaxDelay, Jitter)\n- Default values: 3 retries, 1s base delay, 30s max delay, jitter enabled","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.021154076+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:24:55.101093793+01:00","closed_at":"2026-01-14T13:24:55.101093793+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-ymg","depends_on_id":"checkvist-api-e9p","type":"blocks","created_at":"2026-01-14T12:32:47.077541448+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-ymg","depends_on_id":"checkvist-api-mnh","type":"blocks","created_at":"2026-01-14T12:32:47.358639632+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/tasks_test.go b/tasks_test.go new file mode 100644 index 0000000..0b07a2b --- /dev/null +++ b/tasks_test.go @@ -0,0 +1,436 @@ +package checkvist + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestTasks_List(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks.json": + w.Write(loadFixture(t, "testdata/tasks/list.json")) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + tasks, err := client.Tasks(1).List(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tasks) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(tasks)) + } + if tasks[0].ID != 101 { + t.Errorf("expected ID 101, got %d", tasks[0].ID) + } + if tasks[0].Content != "First task" { + t.Errorf("expected content 'First task', got %s", tasks[0].Content) + } + if tasks[1].Priority != 1 { + t.Errorf("expected priority 1, got %d", tasks[1].Priority) + } +} + +func TestTasks_Get(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks/101.json": + w.Write(loadFixture(t, "testdata/tasks/single.json")) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + task, err := client.Tasks(1).Get(context.Background(), 101) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task.ID != 101 { + t.Errorf("expected ID 101, got %d", task.ID) + } + if task.Content != "First task" { + t.Errorf("expected content 'First task', got %s", task.Content) + } +} + +func TestTasks_Create(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks.json": + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + var req CreateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + if req.Content != "New task" { + t.Errorf("expected content 'New task', got %s", req.Content) + } + + response := Task{ + ID: 200, + ChecklistID: 1, + Content: req.Content, + Status: StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + json.NewEncoder(w).Encode(response) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + task, err := client.Tasks(1).Create(context.Background(), NewTask("New task")) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task.ID != 200 { + t.Errorf("expected ID 200, got %d", task.ID) + } + if task.Content != "New task" { + t.Errorf("expected content 'New task', got %s", task.Content) + } +} + +func TestTasks_Create_WithBuilder(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks.json": + var req CreateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + + if req.Content != "Task with options" { + t.Errorf("expected content 'Task with options', got %s", req.Content) + } + if req.Priority != 1 { + t.Errorf("expected priority 1, got %d", req.Priority) + } + if req.Due != "^tomorrow" { + t.Errorf("expected due '^tomorrow', got %s", req.Due) + } + if req.Tags != "tag1, tag2" { + t.Errorf("expected tags 'tag1, tag2', got %s", req.Tags) + } + if req.ParentID != 100 { + t.Errorf("expected parent_id 100, got %d", req.ParentID) + } + + response := Task{ + ID: 201, + ChecklistID: 1, + ParentID: req.ParentID, + Content: req.Content, + Priority: req.Priority, + DueDateRaw: "2026-01-15", + TagsAsText: req.Tags, + } + json.NewEncoder(w).Encode(response) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + builder := NewTask("Task with options"). + WithPriority(1). + WithDueDate(DueTomorrow). + WithTags("tag1", "tag2"). + WithParent(100) + + task, err := client.Tasks(1).Create(context.Background(), builder) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task.ID != 201 { + t.Errorf("expected ID 201, got %d", task.ID) + } + if task.ParentID != 100 { + t.Errorf("expected ParentID 100, got %d", task.ParentID) + } +} + +func TestTasks_Update(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks/101.json": + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + + response := Task{ + ID: 101, + ChecklistID: 1, + Content: "Updated content", + UpdatedAt: time.Now(), + } + json.NewEncoder(w).Encode(response) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + content := "Updated content" + task, err := client.Tasks(1).Update(context.Background(), 101, UpdateTaskRequest{ + Content: &content, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task.Content != "Updated content" { + t.Errorf("expected content 'Updated content', got %s", task.Content) + } +} + +func TestTasks_Delete(t *testing.T) { + var deleteCalled bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks/101.json": + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + deleteCalled = true + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + err := client.Tasks(1).Delete(context.Background(), 101) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !deleteCalled { + t.Error("expected DELETE to be called") + } +} + +func TestTasks_Close(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks/101/close.json": + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + response := Task{ + ID: 101, + Status: StatusClosed, + } + json.NewEncoder(w).Encode(response) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + task, err := client.Tasks(1).Close(context.Background(), 101) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task.Status != StatusClosed { + t.Errorf("expected status Closed, got %v", task.Status) + } +} + +func TestTasks_Reopen(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks/101/reopen.json": + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + response := Task{ + ID: 101, + Status: StatusOpen, + } + json.NewEncoder(w).Encode(response) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + task, err := client.Tasks(1).Reopen(context.Background(), 101) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task.Status != StatusOpen { + t.Errorf("expected status Open, got %v", task.Status) + } +} + +func TestTasks_Invalidate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/auth/login.json": + json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + case "/checklists/1/tasks/101/invalidate.json": + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + response := Task{ + ID: 101, + Status: StatusInvalidated, + } + json.NewEncoder(w).Encode(response) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL)) + task, err := client.Tasks(1).Invalidate(context.Background(), 101) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if task.Status != StatusInvalidated { + t.Errorf("expected status Invalidated, got %v", task.Status) + } +} + +func TestDueDate_Parsing(t *testing.T) { + tests := []struct { + name string + dueRaw string + expected *time.Time + }{ + { + name: "ISO date", + dueRaw: "2026-01-20", + expected: timePtr(time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)), + }, + { + name: "empty string", + dueRaw: "", + expected: nil, + }, + { + name: "invalid format", + dueRaw: "tomorrow", + expected: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + task := &Task{DueDateRaw: tc.dueRaw} + parseDueDate(task) + + if tc.expected == nil { + if task.DueDate != nil { + t.Errorf("expected nil DueDate, got %v", task.DueDate) + } + } else { + if task.DueDate == nil { + t.Fatal("expected DueDate to be set") + } + if !task.DueDate.Equal(*tc.expected) { + t.Errorf("expected %v, got %v", tc.expected, task.DueDate) + } + } + }) + } +} + +func TestTaskBuilder(t *testing.T) { + builder := NewTask("Test content"). + WithParent(50). + WithPosition(3). + WithDueDate(DueNextWeek). + WithPriority(2). + WithTags("work", "urgent") + + req := builder.build() + + if req.Content != "Test content" { + t.Errorf("expected content 'Test content', got %s", req.Content) + } + if req.ParentID != 50 { + t.Errorf("expected ParentID 50, got %d", req.ParentID) + } + if req.Position != 3 { + t.Errorf("expected Position 3, got %d", req.Position) + } + if req.Due != "^next week" { + t.Errorf("expected Due '^next week', got %s", req.Due) + } + if req.Priority != 2 { + t.Errorf("expected Priority 2, got %d", req.Priority) + } + if req.Tags != "work, urgent" { + t.Errorf("expected Tags 'work, urgent', got %s", req.Tags) + } +} + +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/testdata/tasks/list.json b/testdata/tasks/list.json new file mode 100644 index 0000000..6da6fc9 --- /dev/null +++ b/testdata/tasks/list.json @@ -0,0 +1,34 @@ +[ + { + "id": 101, + "checklist_id": 1, + "parent_id": 0, + "content": "First task", + "status": 0, + "position": 1, + "priority": 0, + "tags_as_text": "", + "due": "2026-01-20", + "assignee_ids": [], + "comments_count": 0, + "update_line": "", + "updated_at": "2026-01-14T10:00:00Z", + "created_at": "2026-01-10T09:00:00Z" + }, + { + "id": 102, + "checklist_id": 1, + "parent_id": 0, + "content": "Second task", + "status": 1, + "position": 2, + "priority": 1, + "tags_as_text": "important, urgent", + "due": "", + "assignee_ids": [1, 2], + "comments_count": 3, + "update_line": "", + "updated_at": "2026-01-14T11:00:00Z", + "created_at": "2026-01-11T10:00:00Z" + } +] diff --git a/testdata/tasks/single.json b/testdata/tasks/single.json new file mode 100644 index 0000000..206a796 --- /dev/null +++ b/testdata/tasks/single.json @@ -0,0 +1,16 @@ +{ + "id": 101, + "checklist_id": 1, + "parent_id": 0, + "content": "First task", + "status": 0, + "position": 1, + "priority": 0, + "tags_as_text": "", + "due": "2026-01-20", + "assignee_ids": [], + "comments_count": 0, + "update_line": "", + "updated_at": "2026-01-14T10:00:00Z", + "created_at": "2026-01-10T09:00:00Z" +}