From c8eea853e5a42731689fa26df5efb435733d10fe Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 15 Jan 2026 18:36:40 +0100 Subject: [PATCH] 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. --- .beads/issues.jsonl | 2 +- task_scope.go | 112 +++++++++++++ task_scope_test.go | 390 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 503 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e76899b..1cf46de 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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-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-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"}]} diff --git a/task_scope.go b/task_scope.go index 41502dc..a3dd540 100644 --- a/task_scope.go +++ b/task_scope.go @@ -51,3 +51,115 @@ func (t *TaskScope) MoveToProject(ctx context.Context, projectID int) error { func (t *TaskScope) Update(ctx context.Context, params *TaskUpdateParams) error { 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 +} diff --git a/task_scope_test.go b/task_scope_test.go index 9fac5d9..2a5679e 100644 --- a/task_scope_test.go +++ b/task_scope_test.go @@ -260,3 +260,393 @@ func TestTaskScope_Chaining(t *testing.T) { 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") + } +}