diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b6ab7a8..56872ff 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.6133153+01:00","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.526810135+01:00","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:55.0044989+01:00"} -{"id":"kanboard-api-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:53.232007312+01:00"} +{"id":"kanboard-api-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:10:29.68466887+01:00","closed_at":"2026-01-15T18:10:29.68466887+01:00","close_reason":"Closed"} {"id":"kanboard-api-2ze","title":"Implement BoardScope fluent builder","description":"Implement BoardScope for fluent project-scoped operations.\n\n## Requirements\n- BoardScope struct with client and projectID\n- Client.Board(projectID int) *BoardScope method\n- BoardScope methods:\n - GetColumns(ctx) ([]Column, error)\n - GetCategories(ctx) ([]Category, error)\n - GetTasks(ctx, status TaskStatus) ([]Task, error)\n - SearchTasks(ctx, query string) ([]Task, error)\n - CreateTask(ctx, task *TaskParams) (*Task, error)\n\n## Files to create\n- board_scope.go\n\n## Example usage\n```go\ncolumns, _ := client.Board(1).GetColumns(ctx)\ntask, _ := client.Board(1).CreateTask(ctx, kanboard.NewTask(\"Title\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.044649709+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.044649709+01:00","dependencies":[{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:43:30.81063282+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:43:30.874964284+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-0fz","type":"blocks","created_at":"2026-01-15T17:43:30.939377116+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.005026627+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-5fb","title":"Implement TaskUpdateParams builder","description":"Implement TaskUpdateParams for fluent task update configuration.\n\n## Requirements\n- TaskUpdateParams struct with pointer fields\n- NewTaskUpdate() *TaskUpdateParams constructor\n- Fluent setter methods:\n - SetTitle(title string) *TaskUpdateParams\n - SetDescription(desc string) *TaskUpdateParams\n - SetColor(colorID string) *TaskUpdateParams\n - SetOwner(ownerID int) *TaskUpdateParams\n - SetCategory(categoryID int) *TaskUpdateParams\n - SetPriority(priority int) *TaskUpdateParams\n - SetDueDate(date time.Time) *TaskUpdateParams\n- Internal method to convert to UpdateTaskRequest\n\n## Files to create\n- task_update_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTaskUpdate().\n SetTitle(\"New Title\").\n SetPriority(2)\n```\n\n## Acceptance criteria\n- Only set fields are included in update request\n- All setters return *TaskUpdateParams for chaining","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.814955926+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.814955926+01:00","dependencies":[{"issue_id":"kanboard-api-5fb","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.134402453+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..692ccaa --- /dev/null +++ b/auth.go @@ -0,0 +1,29 @@ +package kanboard + +import "net/http" + +// Authenticator applies authentication to HTTP requests. +type Authenticator interface { + Apply(req *http.Request) +} + +// apiTokenAuth implements API token authentication. +type apiTokenAuth struct { + token string +} + +// Apply adds HTTP Basic Auth with username "jsonrpc" and the API token. +func (a *apiTokenAuth) Apply(req *http.Request) { + req.SetBasicAuth("jsonrpc", a.token) +} + +// basicAuth implements username/password authentication. +type basicAuth struct { + username string + password string +} + +// Apply adds HTTP Basic Auth with username and password. +func (a *basicAuth) Apply(req *http.Request) { + req.SetBasicAuth(a.username, a.password) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..29639b8 --- /dev/null +++ b/client.go @@ -0,0 +1,47 @@ +package kanboard + +import ( + "net/http" + "strings" +) + +// Client is the Kanboard API client. +type Client struct { + baseURL string + endpoint string + httpClient *http.Client + auth Authenticator +} + +// NewClient creates a new Kanboard API client. +// The baseURL should be the base URL of the Kanboard instance (e.g., "https://kanboard.example.com"). +// The path /jsonrpc.php is appended automatically. +// Supports subdirectory installations (e.g., "https://example.com/kanboard" → POST https://example.com/kanboard/jsonrpc.php). +func NewClient(baseURL string) *Client { + // Ensure no trailing slash + baseURL = strings.TrimSuffix(baseURL, "/") + + return &Client{ + baseURL: baseURL, + endpoint: baseURL + "/jsonrpc.php", + httpClient: http.DefaultClient, + } +} + +// WithAPIToken configures the client to use API token authentication. +func (c *Client) WithAPIToken(token string) *Client { + c.auth = &apiTokenAuth{token: token} + return c +} + +// WithBasicAuth configures the client to use username/password authentication. +func (c *Client) WithBasicAuth(username, password string) *Client { + c.auth = &basicAuth{username: username, password: password} + return c +} + +// WithHTTPClient sets a custom HTTP client. +func (c *Client) WithHTTPClient(client *http.Client) *Client { + c.httpClient = client + return c +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..e9bd2ef --- /dev/null +++ b/errors.go @@ -0,0 +1,28 @@ +package kanboard + +import ( + "errors" + "fmt" +) + +var ( + // ErrConnectionFailed indicates a connection to the Kanboard server failed. + ErrConnectionFailed = errors.New("connection to Kanboard server failed") + + // ErrUnauthorized indicates authentication failed. + ErrUnauthorized = errors.New("authentication failed: invalid credentials") + + // ErrForbidden indicates insufficient permissions. + ErrForbidden = errors.New("access forbidden: insufficient permissions") +) + +// APIError represents an error returned by the Kanboard API. +type APIError struct { + Code int + Message string +} + +// Error implements the error interface. +func (e *APIError) Error() string { + return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message) +} diff --git a/jsonrpc.go b/jsonrpc.go new file mode 100644 index 0000000..dd10269 --- /dev/null +++ b/jsonrpc.go @@ -0,0 +1,114 @@ +package kanboard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync/atomic" +) + +// JSONRPCRequest represents a JSON-RPC 2.0 request. +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + ID int64 `json:"id"` + Params interface{} `json:"params,omitempty"` +} + +// JSONRPCResponse represents a JSON-RPC 2.0 response. +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +// JSONRPCError represents a JSON-RPC 2.0 error. +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Error implements the error interface. +func (e *JSONRPCError) Error() string { + return fmt.Sprintf("JSON-RPC error (code %d): %s", e.Code, e.Message) +} + +// requestIDCounter provides thread-safe request ID generation. +var requestIDCounter atomic.Int64 + +// nextRequestID returns the next request ID in a thread-safe manner. +func nextRequestID() int64 { + return requestIDCounter.Add(1) +} + +// call sends a JSON-RPC request and parses the response. +// The result parameter should be a pointer to the expected result type. +func (c *Client) call(ctx context.Context, method string, params interface{}, result interface{}) error { + req := JSONRPCRequest{ + JSONRPC: "2.0", + Method: method, + ID: nextRequestID(), + Params: params, + } + + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + + if c.auth != nil { + c.auth.Apply(httpReq) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("%w: %v", ErrConnectionFailed, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return ErrUnauthorized + } + if resp.StatusCode == http.StatusForbidden { + return ErrForbidden + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var rpcResp JSONRPCResponse + if err := json.Unmarshal(respBody, &rpcResp); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + + if rpcResp.Error != nil { + return &APIError{ + Code: rpcResp.Error.Code, + Message: rpcResp.Error.Message, + } + } + + if result != nil && rpcResp.Result != nil { + if err := json.Unmarshal(rpcResp.Result, result); err != nil { + return fmt.Errorf("failed to unmarshal result: %w", err) + } + } + + return nil +} diff --git a/jsonrpc_test.go b/jsonrpc_test.go new file mode 100644 index 0000000..19ded4b --- /dev/null +++ b/jsonrpc_test.go @@ -0,0 +1,392 @@ +package kanboard + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" +) + +func TestJSONRPCRequest_Marshal(t *testing.T) { + req := JSONRPCRequest{ + JSONRPC: "2.0", + Method: "getTask", + ID: 1, + Params: map[string]int{"task_id": 42}, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + + var unmarshaled map[string]interface{} + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal request: %v", err) + } + + if unmarshaled["jsonrpc"] != "2.0" { + t.Errorf("expected jsonrpc=2.0, got %v", unmarshaled["jsonrpc"]) + } + if unmarshaled["method"] != "getTask" { + t.Errorf("expected method=getTask, got %v", unmarshaled["method"]) + } + if unmarshaled["id"].(float64) != 1 { + t.Errorf("expected id=1, got %v", unmarshaled["id"]) + } +} + +func TestJSONRPCRequest_MarshalWithoutParams(t *testing.T) { + req := JSONRPCRequest{ + JSONRPC: "2.0", + Method: "getAllProjects", + ID: 1, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + + var unmarshaled map[string]interface{} + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal request: %v", err) + } + + if _, exists := unmarshaled["params"]; exists { + t.Error("params should be omitted when nil") + } +} + +func TestJSONRPCResponse_Unmarshal(t *testing.T) { + data := `{"jsonrpc":"2.0","id":1,"result":{"id":42,"title":"Test Task"}}` + + var resp JSONRPCResponse + if err := json.Unmarshal([]byte(data), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.JSONRPC != "2.0" { + t.Errorf("expected jsonrpc=2.0, got %v", resp.JSONRPC) + } + if resp.ID != 1 { + t.Errorf("expected id=1, got %v", resp.ID) + } + if resp.Error != nil { + t.Error("expected no error") + } + if resp.Result == nil { + t.Error("expected result to be present") + } +} + +func TestJSONRPCResponse_UnmarshalError(t *testing.T) { + data := `{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}` + + var resp JSONRPCResponse + if err := json.Unmarshal([]byte(data), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.Error == nil { + t.Fatal("expected error to be present") + } + if resp.Error.Code != -32600 { + t.Errorf("expected error code=-32600, got %v", resp.Error.Code) + } + if resp.Error.Message != "Invalid Request" { + t.Errorf("expected error message='Invalid Request', got %v", resp.Error.Message) + } +} + +func TestJSONRPCError_Error(t *testing.T) { + err := &JSONRPCError{ + Code: -32600, + Message: "Invalid Request", + } + + expected := "JSON-RPC error (code -32600): Invalid Request" + if err.Error() != expected { + t.Errorf("expected %q, got %q", expected, err.Error()) + } +} + +func TestNextRequestID_Increments(t *testing.T) { + // Get the current counter value + initial := nextRequestID() + + // Verify increments + for i := int64(1); i <= 5; i++ { + got := nextRequestID() + expected := initial + i + if got != expected { + t.Errorf("expected %d, got %d", expected, got) + } + } +} + +func TestNextRequestID_ThreadSafe(t *testing.T) { + const goroutines = 100 + const iterations = 100 + + var wg sync.WaitGroup + ids := make(chan int64, goroutines*iterations) + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + ids <- nextRequestID() + } + }() + } + + wg.Wait() + close(ids) + + // Collect all IDs and check for uniqueness + seen := make(map[int64]bool) + for id := range ids { + if seen[id] { + t.Errorf("duplicate request ID: %d", id) + } + seen[id] = true + } + + if len(seen) != goroutines*iterations { + t.Errorf("expected %d unique IDs, got %d", goroutines*iterations, len(seen)) + } +} + +func TestClient_Call_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/jsonrpc.php" { + t.Errorf("expected /jsonrpc.php, got %s", r.URL.Path) + } + + var req JSONRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + + if req.JSONRPC != "2.0" { + t.Errorf("expected jsonrpc=2.0, got %s", req.JSONRPC) + } + if req.Method != "getTask" { + t.Errorf("expected method=getTask, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id":42,"title":"Test Task"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + var result struct { + ID int `json:"id"` + Title string `json:"title"` + } + + err := client.call(context.Background(), "getTask", map[string]int{"task_id": 42}, &result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.ID != 42 { + t.Errorf("expected id=42, got %d", result.ID) + } + if result.Title != "Test Task" { + t.Errorf("expected title='Test Task', got %s", result.Title) + } +} + +func TestClient_Call_APIError(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, + Error: &JSONRPCError{ + Code: -32600, + Message: "Invalid Request", + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + var result interface{} + err := client.call(context.Background(), "invalidMethod", nil, &result) + + if err == nil { + t.Fatal("expected error") + } + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.Code != -32600 { + t.Errorf("expected code=-32600, got %d", apiErr.Code) + } +} + +func TestClient_Call_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("invalid-token") + + var result interface{} + err := client.call(context.Background(), "getTask", nil, &result) + + if err != ErrUnauthorized { + t.Errorf("expected ErrUnauthorized, got %v", err) + } +} + +func TestClient_Call_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + var result interface{} + err := client.call(context.Background(), "getTask", nil, &result) + + if err != ErrForbidden { + t.Errorf("expected ErrForbidden, got %v", err) + } +} + +func TestClient_Call_ContextCanceled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate slow response + select {} + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + var result interface{} + err := client.call(ctx, "getTask", nil, &result) + + if err == nil { + t.Fatal("expected error due to canceled context") + } +} + +func TestClient_Call_SubdirectoryInstallation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/kanboard/jsonrpc.php" { + t.Errorf("expected /kanboard/jsonrpc.php, got %s", r.URL.Path) + } + + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Client with subdirectory path + client := NewClient(server.URL + "/kanboard").WithAPIToken("test-token") + + var result bool + err := client.call(context.Background(), "getVersion", nil, &result) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_Call_TrailingSlashHandling(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/jsonrpc.php" { + t.Errorf("expected /jsonrpc.php, got %s", r.URL.Path) + } + + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Client with trailing slash + client := NewClient(server.URL + "/").WithAPIToken("test-token") + + var result bool + err := client.call(context.Background(), "getVersion", nil, &result) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_Call_AuthHeaderSent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok { + t.Error("expected Basic Auth header") + } + if username != "jsonrpc" { + t.Errorf("expected username=jsonrpc, got %s", username) + } + if password != "test-token" { + t.Errorf("expected password=test-token, got %s", password) + } + + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + 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") + + var result bool + err := client.call(context.Background(), "getVersion", nil, &result) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +}