Implement TaskScope tag methods with read-modify-write (CRITICAL)

- GetTags: retrieve task tags as map[int]string
- SetTags: replace all tags on a task
- ClearTags: remove all tags
- AddTag: read-modify-write, idempotent (no-op if tag exists)
- RemoveTag: read-modify-write, idempotent (no-op if tag doesn't exist)
- HasTag: check if task has a specific tag

WARNING: Tag operations are NOT atomic. Concurrent modifications may
cause data loss due to the read-modify-write pattern required by
Kanboard's setTaskTags API.

Comprehensive test coverage including idempotency tests.
This commit is contained in:
Oliver Jakoubek 2026-01-15 18:36:40 +01:00
commit c8eea853e5
3 changed files with 503 additions and 1 deletions

View file

@ -23,5 +23,5 @@
{"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"}]}
{"id":"kanboard-api-xhf","title":"Create example programs","description":"Create example programs demonstrating library usage.\n\n## Examples to create\n```\nexamples/\n├── basic/\n│ └── main.go # Basic client setup and simple operations\n├── fluent/\n│ └── main.go # Fluent API demonstration\n└── search/\n └── main.go # Search functionality demo\n```\n\n## basic/main.go\n- Client creation with API token\n- Get all projects\n- Get tasks from a project\n- Create a simple task\n\n## fluent/main.go\n- Client configuration with all options\n- Task creation with TaskParams\n- Task updates with TaskUpdateParams\n- Tag operations\n\n## search/main.go\n- Project-specific search\n- Global search across all projects\n\n## Files to create\n- examples/basic/main.go\n- examples/fluent/main.go\n- examples/search/main.go\n\n## Acceptance criteria\n- Examples compile and are well-commented\n- Cover main use cases\n- Show both fluent and direct API styles","status":"open","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.604889443+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.604889443+01:00","dependencies":[{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-2ze","type":"blocks","created_at":"2026-01-15T17:46:55.571585285+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:46:55.63515762+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-xhf","title":"Create example programs","description":"Create example programs demonstrating library usage.\n\n## Examples to create\n```\nexamples/\n├── basic/\n│ └── main.go # Basic client setup and simple operations\n├── fluent/\n│ └── main.go # Fluent API demonstration\n└── search/\n └── main.go # Search functionality demo\n```\n\n## basic/main.go\n- Client creation with API token\n- Get all projects\n- Get tasks from a project\n- Create a simple task\n\n## fluent/main.go\n- Client configuration with all options\n- Task creation with TaskParams\n- Task updates with TaskUpdateParams\n- Tag operations\n\n## search/main.go\n- Project-specific search\n- Global search across all projects\n\n## Files to create\n- examples/basic/main.go\n- examples/fluent/main.go\n- examples/search/main.go\n\n## Acceptance criteria\n- Examples compile and are well-commented\n- Cover main use cases\n- Show both fluent and direct API styles","status":"open","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.604889443+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.604889443+01:00","dependencies":[{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-2ze","type":"blocks","created_at":"2026-01-15T17:46:55.571585285+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:46:55.63515762+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-zfc","title":"Implement TaskScope tag methods with read-modify-write (CRITICAL)","description":"Implement TaskScope tag methods with read-modify-write pattern. CRITICAL feature.\n\n## TaskScope methods to implement\n- GetTags(ctx) (map[int]string, error)\n- SetTags(ctx, tags ...string) error - replaces ALL tags\n- ClearTags(ctx) error - removes ALL tags\n- AddTag(ctx, tag string) error - read-modify-write\n- RemoveTag(ctx, tag string) error - read-modify-write\n- HasTag(ctx, tag string) (bool, error)\n\n## Read-Modify-Write Workflow for AddTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Check if tag already exists\n4. If not: add tag to list\n5. Call setTaskTags with updated list\n\n## Read-Modify-Write Workflow for RemoveTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Filter out the tag to remove\n4. Call setTaskTags with filtered list\n5. If tag didn't exist: no error (idempotent)\n\n## Files to modify\n- task_scope.go\n\n## IMPORTANT WARNING\nThis is NOT atomic. Concurrent tag modifications may cause data loss. Document this limitation.\n\n## Acceptance criteria\n- AddTag is idempotent (no error if tag exists)\n- RemoveTag is idempotent (no error if tag doesn't exist)\n- HasTag correctly checks tag existence","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.911429864+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.911429864+01:00","dependencies":[{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-16r","type":"blocks","created_at":"2026-01-15T17:43:49.517064988+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:43:49.593313748+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:49.690872321+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-zfc","title":"Implement TaskScope tag methods with read-modify-write (CRITICAL)","description":"Implement TaskScope tag methods with read-modify-write pattern. CRITICAL feature.\n\n## TaskScope methods to implement\n- GetTags(ctx) (map[int]string, error)\n- SetTags(ctx, tags ...string) error - replaces ALL tags\n- ClearTags(ctx) error - removes ALL tags\n- AddTag(ctx, tag string) error - read-modify-write\n- RemoveTag(ctx, tag string) error - read-modify-write\n- HasTag(ctx, tag string) (bool, error)\n\n## Read-Modify-Write Workflow for AddTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Check if tag already exists\n4. If not: add tag to list\n5. Call setTaskTags with updated list\n\n## Read-Modify-Write Workflow for RemoveTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Filter out the tag to remove\n4. Call setTaskTags with filtered list\n5. If tag didn't exist: no error (idempotent)\n\n## Files to modify\n- task_scope.go\n\n## IMPORTANT WARNING\nThis is NOT atomic. Concurrent tag modifications may cause data loss. Document this limitation.\n\n## Acceptance criteria\n- AddTag is idempotent (no error if tag exists)\n- RemoveTag is idempotent (no error if tag doesn't exist)\n- HasTag correctly checks tag existence","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.911429864+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:36:32.533937315+01:00","closed_at":"2026-01-15T18:36:32.533937315+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-16r","type":"blocks","created_at":"2026-01-15T17:43:49.517064988+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:43:49.593313748+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:49.690872321+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-zg2","title":"Implement File API methods","description":"Implement direct API methods for task file operations.\n\n## Methods to implement (Must-have)\n- GetAllTaskFiles(ctx, taskID int) ([]TaskFile, error) - getAllTaskFiles\n- CreateTaskFile(ctx, projectID, taskID int, filename string, content []byte) (int, error) - createTaskFile\n\n## Methods to implement (Nice-to-have)\n- DownloadTaskFile(ctx, fileID int) ([]byte, error) - downloadTaskFile\n- RemoveTaskFile(ctx, fileID int) error - removeTaskFile\n\n## TaskScope methods to add\n- GetFiles(ctx) ([]TaskFile, error)\n- UploadFile(ctx, filename string, content []byte) (*TaskFile, error)\n\n## Files to create\n- files.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- File content base64 encoded for upload\n- CreateTaskFile returns file ID","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.748005313+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.748005313+01:00","dependencies":[{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.984099418+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:50.049331328+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-zg2","title":"Implement File API methods","description":"Implement direct API methods for task file operations.\n\n## Methods to implement (Must-have)\n- GetAllTaskFiles(ctx, taskID int) ([]TaskFile, error) - getAllTaskFiles\n- CreateTaskFile(ctx, projectID, taskID int, filename string, content []byte) (int, error) - createTaskFile\n\n## Methods to implement (Nice-to-have)\n- DownloadTaskFile(ctx, fileID int) ([]byte, error) - downloadTaskFile\n- RemoveTaskFile(ctx, fileID int) error - removeTaskFile\n\n## TaskScope methods to add\n- GetFiles(ctx) ([]TaskFile, error)\n- UploadFile(ctx, filename string, content []byte) (*TaskFile, error)\n\n## Files to create\n- files.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- File content base64 encoded for upload\n- CreateTaskFile returns file ID","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.748005313+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.748005313+01:00","dependencies":[{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.984099418+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:50.049331328+01:00","created_by":"Oliver Jakoubek"}]}

View file

@ -51,3 +51,115 @@ func (t *TaskScope) MoveToProject(ctx context.Context, projectID int) error {
func (t *TaskScope) Update(ctx context.Context, params *TaskUpdateParams) error { func (t *TaskScope) Update(ctx context.Context, params *TaskUpdateParams) error {
return t.client.UpdateTaskFromParams(ctx, t.taskID, params) return t.client.UpdateTaskFromParams(ctx, t.taskID, params)
} }
// GetTags returns the tags assigned to this task as a map of tagID to tag name.
func (t *TaskScope) GetTags(ctx context.Context) (map[int]string, error) {
return t.client.GetTaskTags(ctx, t.taskID)
}
// SetTags sets the tags for this task, replacing all existing tags.
// Tags are specified by name. Non-existent tags will be auto-created.
//
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
func (t *TaskScope) SetTags(ctx context.Context, tags ...string) error {
task, err := t.Get(ctx)
if err != nil {
return err
}
return t.client.SetTaskTags(ctx, int(task.ProjectID), t.taskID, tags)
}
// ClearTags removes all tags from this task.
//
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
func (t *TaskScope) ClearTags(ctx context.Context) error {
return t.SetTags(ctx)
}
// AddTag adds a tag to this task using a read-modify-write pattern.
// If the tag already exists on the task, this is a no-op (idempotent).
//
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
func (t *TaskScope) AddTag(ctx context.Context, tag string) error {
// Get task info for project_id
task, err := t.Get(ctx)
if err != nil {
return err
}
// Get current tags
currentTags, err := t.client.GetTaskTags(ctx, t.taskID)
if err != nil {
return err
}
// Check if tag already exists (idempotent)
for _, existingTag := range currentTags {
if existingTag == tag {
return nil
}
}
// Build new tag list
tagNames := make([]string, 0, len(currentTags)+1)
for _, name := range currentTags {
tagNames = append(tagNames, name)
}
tagNames = append(tagNames, tag)
// Set updated tags
return t.client.SetTaskTags(ctx, int(task.ProjectID), t.taskID, tagNames)
}
// RemoveTag removes a tag from this task using a read-modify-write pattern.
// If the tag doesn't exist on the task, this is a no-op (idempotent).
//
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
func (t *TaskScope) RemoveTag(ctx context.Context, tag string) error {
// Get task info for project_id
task, err := t.Get(ctx)
if err != nil {
return err
}
// Get current tags
currentTags, err := t.client.GetTaskTags(ctx, t.taskID)
if err != nil {
return err
}
// Filter out the tag to remove
tagNames := make([]string, 0, len(currentTags))
found := false
for _, name := range currentTags {
if name == tag {
found = true
continue
}
tagNames = append(tagNames, name)
}
// If tag wasn't found, nothing to do (idempotent)
if !found {
return nil
}
// Set updated tags
return t.client.SetTaskTags(ctx, int(task.ProjectID), t.taskID, tagNames)
}
// HasTag checks if this task has a specific tag.
func (t *TaskScope) HasTag(ctx context.Context, tag string) (bool, error) {
tags, err := t.client.GetTaskTags(ctx, t.taskID)
if err != nil {
return false, err
}
for _, name := range tags {
if name == tag {
return true, nil
}
}
return false, nil
}

View file

@ -260,3 +260,393 @@ func TestTaskScope_Chaining(t *testing.T) {
t.Error("expected same taskID for same task scope") t.Error("expected same taskID for same task scope")
} }
} }
func TestTaskScope_GetTags(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 != "getTaskTags" {
t.Errorf("expected method=getTaskTags, 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(`{"1": "urgent", "2": "backend"}`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
tags, err := client.Task(42).GetTags(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tags) != 2 {
t.Errorf("expected 2 tags, got %d", len(tags))
}
if tags[1] != "urgent" {
t.Errorf("expected tags[1]='urgent', got %s", tags[1])
}
}
func TestTaskScope_SetTags(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: getTask to get project_id
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "title": "Test", "project_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
} else {
// Second call: setTaskTags
if req.Method != "setTaskTags" {
t.Errorf("expected method=setTaskTags, 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["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.Task(42).SetTags(context.Background(), "urgent", "review")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_ClearTags(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: getTask
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "title": "Test", "project_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
} else {
// Second call: setTaskTags with empty array
if req.Method != "setTaskTags" {
t.Errorf("expected method=setTaskTags, got %s", req.Method)
}
params := req.Params.(map[string]any)
tags, ok := params["tags"].([]any)
if !ok {
// Tags might be nil if passed as nil slice
tags = []any{}
}
if len(tags) != 0 {
t.Errorf("expected empty tags array, got %v", tags)
}
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).ClearTags(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_AddTag(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", "title": "Test", "project_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// Second call: getTaskTags
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"1": "existing"}`),
}
json.NewEncoder(w).Encode(resp)
case 3:
// Third call: setTaskTags
if req.Method != "setTaskTags" {
t.Errorf("expected method=setTaskTags, got %s", req.Method)
}
params := req.Params.(map[string]any)
tags := params["tags"].([]any)
if len(tags) != 2 {
t.Errorf("expected 2 tags, got %d", len(tags))
}
// Check new tag is present
hasNew := false
for _, tag := range tags {
if tag == "new-tag" {
hasNew = true
}
}
if !hasNew {
t.Error("expected 'new-tag' in tags")
}
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).AddTag(context.Background(), "new-tag")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_AddTag_Idempotent(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", "title": "Test", "project_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// Second call: getTaskTags - tag already exists
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"1": "existing-tag"}`),
}
json.NewEncoder(w).Encode(resp)
default:
// Should not reach setTaskTags
t.Error("setTaskTags should not be called when tag already exists")
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
// Adding tag that already exists - should be no-op
err := client.Task(42).AddTag(context.Background(), "existing-tag")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_RemoveTag(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", "title": "Test", "project_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// Second call: getTaskTags
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"1": "keep", "2": "remove-me"}`),
}
json.NewEncoder(w).Encode(resp)
case 3:
// Third call: setTaskTags
if req.Method != "setTaskTags" {
t.Errorf("expected method=setTaskTags, got %s", req.Method)
}
params := req.Params.(map[string]any)
tags := params["tags"].([]any)
if len(tags) != 1 {
t.Errorf("expected 1 tag after removal, got %d", len(tags))
}
// Check removed tag is not present
for _, tag := range tags {
if tag == "remove-me" {
t.Error("'remove-me' should have been filtered out")
}
}
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).RemoveTag(context.Background(), "remove-me")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_RemoveTag_Idempotent(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", "title": "Test", "project_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
case 2:
// Second call: getTaskTags - tag doesn't exist
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"1": "other-tag"}`),
}
json.NewEncoder(w).Encode(resp)
default:
// Should not reach setTaskTags
t.Error("setTaskTags should not be called when tag doesn't exist")
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
// Removing tag that doesn't exist - should be no-op
err := client.Task(42).RemoveTag(context.Background(), "nonexistent")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTaskScope_HasTag_True(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(`{"1": "urgent", "2": "backend"}`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
hasTag, err := client.Task(42).HasTag(context.Background(), "urgent")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasTag {
t.Error("expected HasTag to return true")
}
}
func TestTaskScope_HasTag_False(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(`{"1": "urgent"}`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
hasTag, err := client.Task(42).HasTag(context.Background(), "nonexistent")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasTag {
t.Error("expected HasTag to return false")
}
}