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

@ -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")
}
}