Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fba43cf90 | |||
| 8063341150 | |||
| c4caf4b876 | |||
| 4e856cd206 | |||
| 56da2e0fdc | |||
| 12c1660d94 | |||
| a381c0dc51 | |||
| bc478b44e0 | |||
| 5fd38721b5 | |||
| 280fff21a3 | |||
| c15d52633f | |||
| 449cd2626c | |||
| 508c3ac6d2 | |||
| 96601980c3 |
23 changed files with 1348 additions and 14 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"worktree_root": "/home/oli/Dev/kanboard-api",
|
||||
"last_export_commit": "169f81c4c49391498652aa3534bdc4d8fde7e7c2",
|
||||
"last_export_time": "2026-01-27T10:21:24.788588574+01:00",
|
||||
"jsonl_hash": "75e9b1d700551c2a9307c7c3996c3a005f64dde4fe45621a6e7e94b709b7212e"
|
||||
"last_export_commit": "c4caf4b8761660f52e465c37cbcefa80e11aec01",
|
||||
"last_export_time": "2026-02-02T11:25:57.259799495+01:00",
|
||||
"jsonl_hash": "ccd7fae0d9d72dc744a6028c3d1573e1df0b43bb6fb6b28d433a4a8c3c0d5eb6"
|
||||
}
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
{"id":"kanboard-1cb","title":"Investigate MoveTaskPosition generic error message","description":"## Context\n\nThis is an investigation task to determine if a problem in the `hqcli` tool (which uses this library) has its root cause here.\n\nThe `kb ticket move` command in hqcli fails with an unhelpful error:\n\n```\n❯ dist/hqcli kb ticket move 3765\n2026/01/27 11:02:43 Fehler beim Verschieben des Tickets: moveTaskPosition: failed to move task 3765\n```\n\n## Library Behavior\n\nLooking at `tasks.go:131-150`, the `MoveTaskPosition` function:\n\n```go\nfunc (c *Client) MoveTaskPosition(...) error {\n // ...\n var success bool\n if err := c.call(ctx, \"moveTaskPosition\", params, \u0026success); err != nil {\n return fmt.Errorf(\"moveTaskPosition: %w\", err)\n }\n\n if !success {\n return fmt.Errorf(\"moveTaskPosition: failed to move task %d\", taskID) // \u003c-- generic error\n }\n return nil\n}\n```\n\nWhen the API returns `false`, the error message is completely generic with no actionable information.\n\n## Investigation Needed\n\n1. **API Response Analysis**: What does Kanboard actually return when `moveTaskPosition` fails?\n - Does it include an error message in the JSON-RPC response?\n - Is the `false` result the only indication of failure?\n\n2. **Possible Causes for API returning false**:\n - Invalid column/project combination\n - Permission issues\n - Task doesn't exist\n - Position 0 might have different semantics than documented\n\n3. **Documentation Check**: [Kanboard API docs](https://docs.kanboard.org/v1/api/task_procedures/#movetaskposition)\n\n## Potential Improvements\n\nIf the API provides no additional details, consider:\n- Adding debug logging to show the full API response\n- Checking if the task/column/project exist before calling\n- Providing a more descriptive error: \"moveTaskPosition returned false - verify task exists, column belongs to project, and user has permission\"\n\n## Acceptance Criteria\n\n- [ ] Root cause identified (is it library issue or hqcli usage issue?)\n- [ ] If library issue: improve error message or error handling\n- [ ] Document findings","status":"closed","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T11:05:16.089428488+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:11:58.26724838+01:00","closed_at":"2026-01-27T11:11:58.26724838+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-1ov","title":"Add GetColorList for available task colors","description":"## Context\n\nTask colors are already partially supported in the library:\n- `ColorID` field exists in the `Task` type\n- `WithColor()` works for task creation via `TaskParams`\n- `SetColor()` works for task updates via `TaskUpdateParams`\n\nHowever, there's no way to query the available colors from the Kanboard instance.\n\n## Missing Feature\n\nThe Kanboard API provides `getColorList` in the [Application Procedures](https://docs.kanboard.org/v1/api/application_procedures/):\n\n```\nRequest:\n{\"jsonrpc\": \"2.0\", \"method\": \"getColorList\", \"id\": 1}\n\nResponse:\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"yellow\": \"Yellow\",\n \"blue\": \"Blue\",\n \"green\": \"Green\",\n ...\n }\n}\n```\n\n## Implementation\n\nCreate a new file `application.go` with:\n\n```go\n// GetColorList returns the available task colors.\n// Returns a map of color_id to display name.\nfunc (c *Client) GetColorList(ctx context.Context) (map[string]string, error) {\n var result map[string]string\n if err := c.call(ctx, \"getColorList\", nil, \u0026result); err != nil {\n return nil, fmt.Errorf(\"getColorList: %w\", err)\n }\n return result, nil\n}\n```\n\n## Files to Create/Modify\n\n- `application.go` - New file with `GetColorList()`\n- `application_test.go` - Tests for the new function\n\n## Acceptance Criteria\n\n- [ ] `GetColorList()` returns map of color_id to display name\n- [ ] Works with standard Kanboard color set\n- [ ] Tests written and passing","status":"closed","priority":3,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T12:11:00.218309278+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T12:13:06.411067375+01:00","closed_at":"2026-01-27T12:13:06.411067375+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-3pe","title":"Add DateMoved timestamp field to Task struct","description":"The Task struct in `types.go` is missing the `date_moved` timestamp that Kanboard returns when a task is moved between columns or swimlanes.\n\n## Background\n\nKanboard tracks when a task was last moved with the `date_moved` field. This timestamp is returned in API responses but is currently not captured by the Go client's Task struct.\n\n## Implementation\n\nAdd the following field to the Task struct in `types.go`:\n\n```go\nDateMoved Timestamp `json:\"date_moved\"`\n```\n\nThis should be added alongside the other date fields (after `DateDue` for consistency with the grouping of timestamp fields).\n\n## Acceptance Criteria\n\n- [ ] `DateMoved` field added to `Task` struct in `types.go`\n- [ ] Field uses `Timestamp` type with JSON tag `json:\"date_moved\"`\n- [ ] All existing tests pass\n- [ ] Field is properly handled in all task-related operations (read/unmarshal)","status":"closed","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T12:05:50.949698791+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T12:09:01.530912406+01:00","closed_at":"2026-01-28T12:09:01.530912406+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-4n3","title":"Implement category write operations","description":"Implement the missing category write operations from the Kanboard API: `createCategory`, `updateCategory`, and `removeCategory`. Additionally, add a client-side `getCategoryByName` helper.\n\nReference: https://docs.kanboard.org/v1/api/category_procedures/\n\n## Details\n\nCurrently only read operations exist (`GetAllCategories`, `GetCategory` in `categories.go`). The following need to be added:\n\n### createCategory\n- Parameters: `project_id` (int), `name` (string), optional `color_id` (string)\n- Returns: category ID on success\n\n### updateCategory\n- Parameters: `id` (int), `name` (string), optional `color_id` (string)\n- Returns: bool\n\n### removeCategory\n- Parameters: `category_id` (int)\n- Returns: bool\n\n### getCategoryByName (client-side helper)\n- Since `getAllCategories` returns the full array, implement `GetCategoryByName(projectID, name)` as a client-side filter over `GetAllCategories` rather than a separate RPC call (unless a server-side procedure exists).\n- Return `ErrCategoryNotFound` if no match.\n\n## Acceptance Criteria\n- [ ] `CreateCategory(projectID, name, opts...)` implemented with functional options for color_id\n- [ ] `UpdateCategory(id, name, opts...)` implemented with functional options for color_id\n- [ ] `RemoveCategory(categoryID)` implemented\n- [ ] `GetCategoryByName(projectID, name)` implemented (client-side filter)\n- [ ] All new methods have corresponding unit tests in `categories_test.go`\n- [ ] BoardScope wrappers added where appropriate\n- [ ] Follow existing code patterns and conventions from the project","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-29T09:12:59.169475651+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-29T09:20:09.394074185+01:00","closed_at":"2026-01-29T09:20:09.394074185+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-521","title":"Add GetTaskByReference function","description":"Add a `GetTaskByReference` function that wraps the Kanboard JSON-RPC `getTaskByReference` endpoint.\n\n**Signature:** `GetTaskByReference(ctx context.Context, projectID int, reference string) (*Task, error)`\n\n- Calls the `getTaskByReference` JSON-RPC method with `project_id` and `reference` parameters\n- Returns the matching `*Task` on success\n- Returns `ErrTaskNotFound` if no task matches the given reference\n\n**Context:** Needed by hqcli for the `find-ref` command (hqcli-a57).\n\n## Acceptance Criteria\n- [ ] `GetTaskByReference` implemented with correct JSON-RPC call\n- [ ] Returns `ErrTaskNotFound` when no task matches\n- [ ] Tests written and passing","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-02T11:22:16.674849553+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-02T11:25:53.151693032+01:00","closed_at":"2026-02-02T11:25:53.151693032+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-7es","title":"JSON-RPC Request-ID: Zufälligen Wert statt fester 1 verwenden","description":"## Kontext\n\nIn jedem JSON-RPC Request an die Kanboard-API wird im Root-Objekt ein Feld `id` mitgeliefert. Dieses dient dazu, bei asynchroner Kommunikation Request und Response einander zuordnen zu können – die API liefert diese ID in der Antwort zurück.\n\n**Aktuell:** Die ID ist fest auf `1` gesetzt.\n\n## Anforderung\n\n1. **Wenn keine ID von außen gesetzt wird:** Die Library soll intern einen zufälligen Wert generieren\n2. **API-Dokumentation prüfen:** Welche Werte sind erlaubt? Welche Größenordnung? (vermutlich Integer)\n3. **Signatur beibehalten:** Die öffentliche API der Library-Funktionen soll unverändert bleiben\n4. **Interne Generierung:** Die Library bestimmt selbst einen zufälligen Wert\n\n## Implementierungshinweise\n\n- Prüfen: Kanboard JSON-RPC Dokumentation bezüglich erlaubter ID-Werte\n- Vermutlich: `int64` oder `int32` Range\n- Zufallsgenerator: `math/rand` mit Seed oder `crypto/rand` für bessere Verteilung\n- Ggf. bestehende `requestIDCounter` in `jsonrpc.go` (Zeile 40) anpassen oder ersetzen\n\n## Beispiel\n\n**Vorher (immer gleich):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 1, \"params\": {...}}\n```\n\n**Nachher (zufällig):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 847291536, \"params\": {...}}\n```\n\n## Referenz\n\n- Datei: `jsonrpc.go`\n- Zeile 17: `ID int64 \\`json:\"id\"\\``\n- Zeile 40: `requestIDCounter` (existiert bereits)","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:44:51.566737509+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:21:06.415129879+01:00","closed_at":"2026-01-27T10:21:06.415129879+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-9wa","title":"Support custom authentication header name","description":"## Description\n\nKanboard supports using an alternative HTTP header for authentication when the server has specific configuration requirements.\n\nCurrently, authentication uses the standard `Authorization` header via Go's `SetBasicAuth()`. This needs to be configurable so users can specify a custom header name (e.g., `X-API-Auth`).\n\n## Requirements\n\n- Add an optional configuration parameter for custom auth header name\n- Default to standard `Authorization` header if not specified\n- When custom header is set, use that header name instead of `Authorization`\n- The header value format should remain the same (Basic Auth base64-encoded credentials)\n\n## Acceptance Criteria\n\n- [ ] New client configuration method (e.g., `WithAuthHeader(headerName string)`)\n- [ ] Default behavior unchanged when no custom header specified\n- [ ] Custom header name is used when configured\n- [ ] Works with both API token and basic auth\n- [ ] Tests cover default and custom header scenarios\n\n## Reference\n\nKanboard API documentation: \"You can use an alternative HTTP header for authentication if your server has a very specific configuration.\"","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T18:08:31.507616093+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T18:26:50.40804952+01:00","closed_at":"2026-01-23T18:26:50.40804952+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-a7x","title":"Handle URLs already ending in /jsonrpc.php","description":"## Context\n\nThe `NewClient()` function in `client.go` always appends `/jsonrpc.php` to the base URL. This causes issues when users pass a URL that already includes the endpoint path.\n\n## Problem\n\nIf a user calls:\n```go\nclient := kanboard.NewClient(\"https://example.com/jsonrpc.php\")\n```\n\nThe resulting endpoint becomes `https://example.com/jsonrpc.php/jsonrpc.php`, which fails.\n\n## Solution\n\nModify `NewClient()` to detect and handle URLs that already end in `/jsonrpc.php`:\n\n```go\nfunc NewClient(baseURL string) *Client {\n // Ensure no trailing slash\n baseURL = strings.TrimSuffix(baseURL, \"/\")\n\n // Handle URLs that already include /jsonrpc.php\n endpoint := baseURL\n if !strings.HasSuffix(baseURL, \"/jsonrpc.php\") {\n endpoint = baseURL + \"/jsonrpc.php\"\n }\n\n c := \u0026Client{\n baseURL: baseURL,\n endpoint: endpoint,\n }\n // ... rest unchanged\n}\n```\n\n## Files to Modify\n\n- `client.go` - Update `NewClient()` to check for existing `/jsonrpc.php` suffix\n\n## Acceptance Criteria\n\n- [ ] `NewClient(\"https://example.com\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/jsonrpc.php\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/kanboard/jsonrpc.php\")` → endpoint `https://example.com/kanboard/jsonrpc.php`\n- [ ] Tests written and passing","status":"closed","priority":2,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:25:51.077352962+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:27:15.189568683+01:00","closed_at":"2026-01-27T10:27:15.189568683+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-amh","title":"Implement Attachment API methods","description":"Add support for Kanboard's task file/attachment API methods.\n\n## API Methods to Implement\n\nBased on [Kanboard Task File Procedures](https://docs.kanboard.org/v1/api/task_file_procedures/):\n\n### 1. createTaskFile\n- **Purpose:** Create and upload a new task attachment\n- **Parameters:** `project_id` (int), `task_id` (int), `filename` (string), `blob` (base64-encoded content)\n- **Returns:** `file_id` on success, `false` on failure\n- **Note:** Limited by PHP config; unsuitable for large files\n\n### 2. getAllTaskFiles\n- **Purpose:** Get all files attached to a task\n- **Parameters:** `task_id` (int)\n- **Returns:** Array of file objects with properties: id, name, path, is_image, task_id, date, user_id, size, username, user_name\n\n### 3. getTaskFile\n- **Purpose:** Get single file metadata\n- **Parameters:** `file_id` (int)\n- **Returns:** File object (id, name, path, is_image, task_id, date, user_id, size)\n\n### 4. downloadTaskFile\n- **Purpose:** Download file contents\n- **Parameters:** `file_id` (int)\n- **Returns:** Base64-encoded string\n\n### 5. removeTaskFile\n- **Purpose:** Delete a file attachment\n- **Parameters:** `file_id` (int)\n- **Returns:** `true` on success, `false` on failure\n\n### 6. removeAllTaskFiles (bonus)\n- **Purpose:** Remove all files from a task\n- **Parameters:** `task_id` (int)\n- **Returns:** `true` on success, `false` on failure\n\n## Acceptance Criteria\n\n- [ ] Implement `CreateTaskFile` method with base64 blob upload\n- [ ] Implement `GetAllTaskFiles` method returning typed file slice\n- [ ] Implement `GetTaskFile` method returning file metadata struct\n- [ ] Implement `DownloadTaskFile` method returning decoded bytes\n- [ ] Implement `RemoveTaskFile` method\n- [ ] Implement `RemoveAllTaskFiles` method\n- [ ] Define `TaskFile` struct with all documented fields\n- [ ] Add comprehensive tests for all methods\n- [ ] Follow existing code patterns in the repository","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T16:06:53.739050479+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T17:01:10.618378834+01:00","closed_at":"2026-01-27T17:01:10.618378834+01:00","close_reason":"Closed"}
|
||||
{"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":"closed","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-15T18:25:07.250066801+01:00","closed_at":"2026-01-15T18:25:07.250066801+01:00","close_reason":"Closed","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":"closed","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-15T18:20:17.392248254+01:00","closed_at":"2026-01-15T18:20:17.392248254+01:00","close_reason":"Closed","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":"closed","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-15T18:15:27.299644047+01:00","closed_at":"2026-01-15T18:15:27.299644047+01:00","close_reason":"Closed"}
|
||||
|
|
@ -27,4 +34,7 @@
|
|||
{"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":"closed","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-15T19:25:02.384085528+01:00","closed_at":"2026-01-15T19:25:02.384085528+01:00","close_reason":"Created basic, fluent, and search example programs","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":"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":"closed","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-15T18:45:05.499871045+01:00","closed_at":"2026-01-15T18:45:05.499871045+01:00","close_reason":"Closed","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-iuf","title":"Remove Basic prefix from custom auth headers","description":"## Problem\n\nWhen using a custom header name (e.g., `X-API-Auth` for Kanboard), the library incorrectly prepends `\"Basic \"` to the base64-encoded credentials.\n\n**Current behavior** (`auth.go:27` and `auth.go:43`):\n```go\nreq.Header.Set(a.headerName, \"Basic \"+basicAuthValue(user, a.token))\n```\n\nSends: `X-API-Auth: Basic dXNlcjpwYXNzd29yZA==`\n\n**Expected behavior** per [Kanboard API docs](https://docs.kanboard.org/v1/api/authentication/):\n\n- Standard `Authorization` header: `Basic base64(username:password)` ✓\n- Custom `X-API-Auth` header: `base64(username:password)` (no \"Basic \" prefix!)\n\nShould send: `X-API-Auth: dXNlcjpwYXNzd29yZA==`\n\n## Impact\n\nAuthentication fails when using the custom header option. The server returns HTML (login page) instead of JSON because authentication is rejected.\n\n## Suggested Fix\n\nIn both `apiTokenAuth.Apply()` and `basicAuth.Apply()`, remove the \"Basic \" prefix when using a custom header name:\n\n```go\nif a.headerName != \"\" {\n req.Header.Set(a.headerName, basicAuthValue(user, a.token)) // no \"Basic \" prefix\n} else {\n req.SetBasicAuth(user, a.token) // uses standard Authorization header with \"Basic \"\n}\n```\n\n## Affected Code\n\n- `auth.go:27` - `apiTokenAuth.Apply()`\n- `auth.go:43` - `basicAuth.Apply()`\n\n## Acceptance Criteria\n\n- [ ] Custom header (`X-API-Auth`) sends raw base64 value without \"Basic \" prefix\n- [ ] Standard `Authorization` header still works with \"Basic \" prefix\n- [ ] Tests updated to verify both behaviors\n- [ ] All tests passing","status":"closed","priority":1,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:43:21.717917986+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:01:19.467981335+01:00","closed_at":"2026-01-27T11:01:19.467981335+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-n03","title":"Implement Swimlane CRUD methods","description":"The kanboard-api client already supports swimlane_id on tasks (InSwimlane builder, MoveTaskPosition, Task.SwimlaneID field) but lacks dedicated Swimlane management methods.\n\nImplement the following Kanboard JSON-RPC API methods:\n\n**Read operations:**\n- getActiveSwimlanes(project_id)\n- getAllSwimlanes(project_id)\n- getSwimlane(swimlane_id)\n- getSwimlaneById(swimlane_id)\n- getSwimlaneByName(project_id, name)\n\n**Write operations:**\n- addSwimlane(project_id, name, description)\n- updateSwimlane(swimlane_id, name, description)\n- removeSwimlane(project_id, swimlane_id)\n- changeSwimlanePosition(project_id, swimlane_id, position)\n- enableSwimlane(project_id, swimlane_id)\n- disableSwimlane(project_id, swimlane_id)\n\nCreate a new file `swimlanes.go` with a Swimlane struct:\n```go\ntype Swimlane struct {\n ID int\n Name string\n Description string\n Position int\n IsActive bool\n ProjectID int\n}\n```\n\nAnd corresponding test file `swimlanes_test.go`.\n\n## Acceptance Criteria\n- [ ] Swimlane struct defined with ID, Name, Description, Position, IsActive, ProjectID\n- [ ] All 11 JSON-RPC methods implemented\n- [ ] Read methods: getActiveSwimlanes, getAllSwimlanes, getSwimlane, getSwimlaneById, getSwimlaneByName\n- [ ] Write methods: addSwimlane, updateSwimlane, removeSwimlane, changeSwimlanePosition, enableSwimlane, disableSwimlane\n- [ ] Code in swimlanes.go follows existing patterns (e.g., categories.go)\n- [ ] Tests written in swimlanes_test.go and passing","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-01T10:07:54.192295081+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-01T10:07:54.192295081+01:00"}
|
||||
{"id":"kanboard-qle","title":"Add timezone support with automatic timestamp conversion","description":"## Description\n\nImplement timezone support by calling the Kanboard `getTimezone` API endpoint and converting all UTC timestamps to the configured timezone.\n\nThe Kanboard API provides `getTimezone` which returns a timezone string (e.g. `Europe/Berlin`). The client should:\n\n1. Query the timezone on initialization (or lazily on first use)\n2. Automatically convert all timestamp fields to local time, including:\n - `date_creation`, `date_started`, `date_moved`, `date_due`\n - Comment dates (`date_creation`)\n - Any other timestamp fields returned by the API\n\n### Design Option\n\nProvide a `WithTimezone()` client option so callers can control whether conversion happens. When enabled, the client fetches the timezone from the API and converts timestamps transparently. When disabled (default for backward compatibility), timestamps remain as-is.\n\n## Acceptance Criteria\n\n- [ ] Implement `GetTimezone()` API method that calls `getTimezone`\n- [ ] Add `WithTimezone()` client option to enable automatic conversion\n- [ ] When enabled, all timestamp fields in responses are converted to the configured timezone\n- [ ] Timezone is fetched once and cached for the client lifetime\n- [ ] Tests written and passing for timezone conversion logic\n- [ ] Backward compatible: no behavior change without `WithTimezone()` option","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-02T12:25:32.830875466+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-02T12:34:10.955810869+01:00","closed_at":"2026-02-02T12:34:10.955810869+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-r3p","title":"Support optional API user in token authentication","description":"## Description\n\nBei der API-Token-Authentifizierung soll neben dem API-Key optional auch ein API-User konfigurierbar sein. Wenn kein User angegeben wird, soll weiterhin der Standard-User \"jsonrpc\" verwendet werden.\n\n## Current Behavior\n\nIn `auth.go`, the `apiTokenAuth` struct hardcodes the username \"jsonrpc\":\n\n```go\nfunc (a *apiTokenAuth) Apply(req *http.Request) {\n req.SetBasicAuth(\"jsonrpc\", a.token)\n}\n```\n\n## Expected Behavior\n\n- Add an optional `user` field to `apiTokenAuth`\n- If user is empty/not provided, default to \"jsonrpc\"\n- If user is provided, use that value for HTTP Basic Auth\n\n## Acceptance Criteria\n\n- [ ] `apiTokenAuth` struct has an optional user field\n- [ ] Default behavior unchanged when no user specified (uses \"jsonrpc\")\n- [ ] Custom user is used when explicitly provided\n- [ ] Client configuration supports setting the API user\n- [ ] Tests cover both default and custom user scenarios","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:39:37.745294723+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T17:55:23.860864584+01:00","closed_at":"2026-01-23T17:55:23.860864584+01:00","close_reason":"Closed"}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
# kanboard-api
|
||||
|
||||
[](https://github.com/jakoubek/kanboard-api)
|
||||
[](https://pkg.go.dev/code.beautifulmachines.dev/jakoubek/kanboard-api)
|
||||
[](https://goreportcard.com/report/code.beautifulmachines.dev/jakoubek/kanboard-api)
|
||||
[](LICENSE)
|
||||
|
||||
Go library for the [Kanboard](https://kanboard.org/) JSON-RPC API. Provides a fluent, chainable API for integrating Kanboard into Go applications.
|
||||
|
||||
## Installation
|
||||
|
|
|
|||
17
application.go
Normal file
17
application.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package kanboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetColorList returns the available task colors.
|
||||
// Returns a map of color_id to display name (e.g., "yellow" -> "Yellow").
|
||||
func (c *Client) GetColorList(ctx context.Context) (map[string]string, error) {
|
||||
var result map[string]string
|
||||
if err := c.call(ctx, "getColorList", nil, &result); err != nil {
|
||||
return nil, fmt.Errorf("getColorList: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
94
application_test.go
Normal file
94
application_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package kanboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClient_GetColorList(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 != "getColorList" {
|
||||
t.Errorf("expected method=getColorList, got %s", req.Method)
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`{
|
||||
"yellow": "Yellow",
|
||||
"blue": "Blue",
|
||||
"green": "Green",
|
||||
"purple": "Purple",
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"grey": "Grey",
|
||||
"brown": "Brown",
|
||||
"deep_orange": "Deep Orange",
|
||||
"dark_grey": "Dark Grey",
|
||||
"pink": "Pink",
|
||||
"teal": "Teal",
|
||||
"cyan": "Cyan",
|
||||
"lime": "Lime",
|
||||
"light_green": "Light Green",
|
||||
"amber": "Amber"
|
||||
}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
colors, err := client.GetColorList(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check that we got some colors
|
||||
if len(colors) != 16 {
|
||||
t.Errorf("expected 16 colors, got %d", len(colors))
|
||||
}
|
||||
|
||||
// Check specific colors
|
||||
if colors["yellow"] != "Yellow" {
|
||||
t.Errorf("expected yellow='Yellow', got %s", colors["yellow"])
|
||||
}
|
||||
if colors["blue"] != "Blue" {
|
||||
t.Errorf("expected blue='Blue', got %s", colors["blue"])
|
||||
}
|
||||
if colors["deep_orange"] != "Deep Orange" {
|
||||
t.Errorf("expected deep_orange='Deep Orange', got %s", colors["deep_orange"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetColorList_Empty(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(`{}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
colors, err := client.GetColorList(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(colors) != 0 {
|
||||
t.Errorf("expected 0 colors, got %d", len(colors))
|
||||
}
|
||||
}
|
||||
4
auth.go
4
auth.go
|
|
@ -24,7 +24,7 @@ func (a *apiTokenAuth) Apply(req *http.Request) {
|
|||
user = "jsonrpc"
|
||||
}
|
||||
if a.headerName != "" {
|
||||
req.Header.Set(a.headerName, "Basic "+basicAuthValue(user, a.token))
|
||||
req.Header.Set(a.headerName, basicAuthValue(user, a.token))
|
||||
} else {
|
||||
req.SetBasicAuth(user, a.token)
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ type basicAuth struct {
|
|||
// Apply adds HTTP Basic Auth with username and password.
|
||||
func (a *basicAuth) Apply(req *http.Request) {
|
||||
if a.headerName != "" {
|
||||
req.Header.Set(a.headerName, "Basic "+basicAuthValue(a.username, a.password))
|
||||
req.Header.Set(a.headerName, basicAuthValue(a.username, a.password))
|
||||
} else {
|
||||
req.SetBasicAuth(a.username, a.password)
|
||||
}
|
||||
|
|
|
|||
12
auth_test.go
12
auth_test.go
|
|
@ -200,8 +200,8 @@ func TestAPITokenAuth_CustomHeader(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
// Verify the custom header has the correct Basic auth value
|
||||
expected := "Basic " + base64Encode("jsonrpc:my-api-token")
|
||||
// Verify the custom header has raw base64 value (no "Basic " prefix)
|
||||
expected := base64Encode("jsonrpc:my-api-token")
|
||||
if customAuth != expected {
|
||||
t.Errorf("expected X-API-Auth=%s, got %s", expected, customAuth)
|
||||
}
|
||||
|
|
@ -244,8 +244,8 @@ func TestBasicAuth_CustomHeader(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
// Verify the custom header has the correct Basic auth value
|
||||
expected := "Basic " + base64Encode("admin:secret")
|
||||
// Verify the custom header has raw base64 value (no "Basic " prefix)
|
||||
expected := base64Encode("admin:secret")
|
||||
if customAuth != expected {
|
||||
t.Errorf("expected X-Custom-Auth=%s, got %s", expected, customAuth)
|
||||
}
|
||||
|
|
@ -281,8 +281,8 @@ func TestCustomHeader_WithCustomUser(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
// Verify the custom header uses the custom username
|
||||
expected := "Basic " + base64Encode("custom-user:my-token")
|
||||
// Verify the custom header uses raw base64 value with custom username
|
||||
expected := base64Encode("custom-user:my-token")
|
||||
if customAuth != expected {
|
||||
t.Errorf("expected X-API-Auth=%s, got %s", expected, customAuth)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ func (b *BoardScope) CreateTask(ctx context.Context, req CreateTaskRequest) (*Ta
|
|||
return b.client.CreateTask(ctx, req)
|
||||
}
|
||||
|
||||
// CreateCategory creates a new category in the project and returns its ID.
|
||||
func (b *BoardScope) CreateCategory(ctx context.Context, name string, colorID string) (int, error) {
|
||||
return b.client.CreateCategory(ctx, b.projectID, name, colorID)
|
||||
}
|
||||
|
||||
// GetCategoryByName returns a category by name within the project.
|
||||
func (b *BoardScope) GetCategoryByName(ctx context.Context, name string) (*Category, error) {
|
||||
return b.client.GetCategoryByName(ctx, b.projectID, name)
|
||||
}
|
||||
|
||||
// CreateTaskFromParams creates a new task in the project using TaskParams.
|
||||
// This provides a fluent interface for task creation.
|
||||
func (b *BoardScope) CreateTaskFromParams(ctx context.Context, params *TaskParams) (*Task, error) {
|
||||
|
|
|
|||
|
|
@ -33,3 +33,80 @@ func (c *Client) GetCategory(ctx context.Context, categoryID int) (*Category, er
|
|||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateCategory creates a new category and returns its ID.
|
||||
func (c *Client) CreateCategory(ctx context.Context, projectID int, name string, colorID string) (int, error) {
|
||||
params := map[string]interface{}{
|
||||
"project_id": projectID,
|
||||
"name": name,
|
||||
}
|
||||
if colorID != "" {
|
||||
params["color_id"] = colorID
|
||||
}
|
||||
|
||||
var result IntOrFalse
|
||||
if err := c.call(ctx, "createCategory", params, &result); err != nil {
|
||||
return 0, fmt.Errorf("createCategory: %w", err)
|
||||
}
|
||||
|
||||
if int(result) == 0 {
|
||||
return 0, fmt.Errorf("createCategory: failed to create category %q", name)
|
||||
}
|
||||
|
||||
return int(result), nil
|
||||
}
|
||||
|
||||
// UpdateCategory updates a category's name and optionally its color.
|
||||
func (c *Client) UpdateCategory(ctx context.Context, categoryID int, name string, colorID string) error {
|
||||
params := map[string]interface{}{
|
||||
"id": categoryID,
|
||||
"name": name,
|
||||
}
|
||||
if colorID != "" {
|
||||
params["color_id"] = colorID
|
||||
}
|
||||
|
||||
var result bool
|
||||
if err := c.call(ctx, "updateCategory", params, &result); err != nil {
|
||||
return fmt.Errorf("updateCategory: %w", err)
|
||||
}
|
||||
|
||||
if !result {
|
||||
return fmt.Errorf("updateCategory: failed to update category %d", categoryID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCategory deletes a category.
|
||||
func (c *Client) RemoveCategory(ctx context.Context, categoryID int) error {
|
||||
params := map[string]int{"category_id": categoryID}
|
||||
|
||||
var result bool
|
||||
if err := c.call(ctx, "removeCategory", params, &result); err != nil {
|
||||
return fmt.Errorf("removeCategory: %w", err)
|
||||
}
|
||||
|
||||
if !result {
|
||||
return fmt.Errorf("removeCategory: failed to remove category %d", categoryID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCategoryByName returns a category by name within a project.
|
||||
// Returns ErrCategoryNotFound if no category matches.
|
||||
func (c *Client) GetCategoryByName(ctx context.Context, projectID int, name string) (*Category, error) {
|
||||
categories, err := c.GetAllCategories(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range categories {
|
||||
if categories[i].Name == name {
|
||||
return &categories[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: category %q in project %d", ErrCategoryNotFound, name, projectID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,266 @@ func TestClient_GetCategory_NotFound(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateCategory(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 != "createCategory" {
|
||||
t.Errorf("expected method=createCategory, 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["name"].(string) != "Bug" {
|
||||
t.Errorf("expected name=Bug, got %v", params["name"])
|
||||
}
|
||||
if params["color_id"].(string) != "red" {
|
||||
t.Errorf("expected color_id=red, got %v", params["color_id"])
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`42`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
id, err := client.CreateCategory(context.Background(), 1, "Bug", "red")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != 42 {
|
||||
t.Errorf("expected id=42, got %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateCategory_NoColor(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req JSONRPCRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
params := req.Params.(map[string]any)
|
||||
if _, ok := params["color_id"]; ok {
|
||||
t.Error("expected color_id to be absent")
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`10`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
id, err := client.CreateCategory(context.Background(), 1, "Bug", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != 10 {
|
||||
t.Errorf("expected id=10, got %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateCategory_Failure(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(`false`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
_, err := client.CreateCategory(context.Background(), 1, "Bug", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UpdateCategory(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 != "updateCategory" {
|
||||
t.Errorf("expected method=updateCategory, got %s", req.Method)
|
||||
}
|
||||
|
||||
params := req.Params.(map[string]any)
|
||||
if params["id"].(float64) != 5 {
|
||||
t.Errorf("expected id=5, got %v", params["id"])
|
||||
}
|
||||
if params["name"].(string) != "Feature" {
|
||||
t.Errorf("expected name=Feature, got %v", params["name"])
|
||||
}
|
||||
if params["color_id"].(string) != "blue" {
|
||||
t.Errorf("expected color_id=blue, got %v", params["color_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.UpdateCategory(context.Background(), 5, "Feature", "blue")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UpdateCategory_Failure(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(`false`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
err := client.UpdateCategory(context.Background(), 5, "Feature", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveCategory(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 != "removeCategory" {
|
||||
t.Errorf("expected method=removeCategory, got %s", req.Method)
|
||||
}
|
||||
|
||||
params := req.Params.(map[string]any)
|
||||
if params["category_id"].(float64) != 3 {
|
||||
t.Errorf("expected category_id=3, got %v", params["category_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.RemoveCategory(context.Background(), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveCategory_Failure(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(`false`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
err := client.RemoveCategory(context.Background(), 3)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCategoryByName(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(`[
|
||||
{"id": "1", "name": "Bug", "project_id": "1", "color_id": "red"},
|
||||
{"id": "2", "name": "Feature", "project_id": "1", "color_id": "blue"}
|
||||
]`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
cat, err := client.GetCategoryByName(context.Background(), 1, "Feature")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cat.Name != "Feature" {
|
||||
t.Errorf("expected name=Feature, got %s", cat.Name)
|
||||
}
|
||||
if int(cat.ID) != 2 {
|
||||
t.Errorf("expected id=2, got %d", cat.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCategoryByName_NotFound(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(`[]`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
_, err := client.GetCategoryByName(context.Background(), 1, "Nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent category")
|
||||
}
|
||||
if !errors.Is(err, ErrCategoryNotFound) {
|
||||
t.Errorf("expected ErrCategoryNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetAllCategories_ContextCanceled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {}
|
||||
|
|
|
|||
20
client.go
20
client.go
|
|
@ -4,6 +4,7 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -19,6 +20,9 @@ type Client struct {
|
|||
auth Authenticator
|
||||
logger *slog.Logger
|
||||
authHeaderName string // custom auth header, empty = use "Authorization"
|
||||
timezone *time.Location
|
||||
tzOnce sync.Once
|
||||
tzEnabled bool
|
||||
}
|
||||
|
||||
// NewClient creates a new Kanboard API client.
|
||||
|
|
@ -29,9 +33,15 @@ func NewClient(baseURL string) *Client {
|
|||
// Ensure no trailing slash
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
// Handle URLs that already include /jsonrpc.php
|
||||
endpoint := baseURL
|
||||
if !strings.HasSuffix(baseURL, "/jsonrpc.php") {
|
||||
endpoint = baseURL + "/jsonrpc.php"
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
baseURL: baseURL,
|
||||
endpoint: baseURL + "/jsonrpc.php",
|
||||
endpoint: endpoint,
|
||||
}
|
||||
|
||||
c.httpClient = &http.Client{
|
||||
|
|
@ -97,3 +107,11 @@ func (c *Client) WithLogger(logger *slog.Logger) *Client {
|
|||
c.logger = logger
|
||||
return c
|
||||
}
|
||||
|
||||
// WithTimezone enables automatic timestamp conversion. On the first API call,
|
||||
// the client fetches the server's timezone via getTimezone and converts all
|
||||
// Timestamp fields in responses to that timezone.
|
||||
func (c *Client) WithTimezone() *Client {
|
||||
c.tzEnabled = true
|
||||
return c
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,22 @@ func TestNewClient_Subdirectory(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewClient_WithJsonrpcPhpSuffix(t *testing.T) {
|
||||
client := NewClient("https://example.com/jsonrpc.php")
|
||||
|
||||
if client.endpoint != "https://example.com/jsonrpc.php" {
|
||||
t.Errorf("expected endpoint='https://example.com/jsonrpc.php', got %s", client.endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_SubdirectoryWithJsonrpcPhpSuffix(t *testing.T) {
|
||||
client := NewClient("https://example.com/kanboard/jsonrpc.php")
|
||||
|
||||
if client.endpoint != "https://example.com/kanboard/jsonrpc.php" {
|
||||
t.Errorf("expected endpoint='https://example.com/kanboard/jsonrpc.php', got %s", client.endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTimeout(t *testing.T) {
|
||||
if DefaultTimeout != 30*time.Second {
|
||||
t.Errorf("expected DefaultTimeout=30s, got %v", DefaultTimeout)
|
||||
|
|
|
|||
26
errors.go
26
errors.go
|
|
@ -82,6 +82,26 @@ func (e *APIError) Error() string {
|
|||
return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// OperationFailedError represents an API operation that returned false without details.
|
||||
// The Kanboard API often returns only true/false without explaining why an operation failed.
|
||||
type OperationFailedError struct {
|
||||
Operation string
|
||||
Hints []string
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *OperationFailedError) Error() string {
|
||||
msg := fmt.Sprintf("%s: operation failed", e.Operation)
|
||||
if len(e.Hints) > 0 {
|
||||
msg += " (possible causes: " + e.Hints[0]
|
||||
for _, hint := range e.Hints[1:] {
|
||||
msg += ", " + hint
|
||||
}
|
||||
msg += ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// IsNotFound returns true if the error indicates a resource was not found.
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, ErrNotFound) ||
|
||||
|
|
@ -102,3 +122,9 @@ func IsAPIError(err error) bool {
|
|||
var apiErr *APIError
|
||||
return errors.As(err, &apiErr)
|
||||
}
|
||||
|
||||
// IsOperationFailed returns true if the error is an OperationFailedError.
|
||||
func IsOperationFailed(err error) bool {
|
||||
var opErr *OperationFailedError
|
||||
return errors.As(err, &opErr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package kanboard
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -217,3 +218,71 @@ func TestErrorWrapping(t *testing.T) {
|
|||
t.Errorf("unexpected error message: %s", wrappedTwice.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationFailedError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *OperationFailedError
|
||||
expectedSubstr []string
|
||||
}{
|
||||
{
|
||||
name: "with hints",
|
||||
err: &OperationFailedError{
|
||||
Operation: "moveTaskPosition(task=42, column=5, project=1)",
|
||||
Hints: []string{"task may not exist", "column may not belong to project"},
|
||||
},
|
||||
expectedSubstr: []string{
|
||||
"moveTaskPosition",
|
||||
"operation failed",
|
||||
"possible causes",
|
||||
"task may not exist",
|
||||
"column may not belong to project",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "without hints",
|
||||
err: &OperationFailedError{
|
||||
Operation: "someOperation",
|
||||
Hints: nil,
|
||||
},
|
||||
expectedSubstr: []string{"someOperation", "operation failed"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
errMsg := tt.err.Error()
|
||||
for _, substr := range tt.expectedSubstr {
|
||||
if !containsSubstr(errMsg, substr) {
|
||||
t.Errorf("error message %q should contain %q", errMsg, substr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOperationFailed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{"OperationFailedError", &OperationFailedError{Operation: "test"}, true},
|
||||
{"wrapped OperationFailedError", fmt.Errorf("call failed: %w", &OperationFailedError{Operation: "test"}), true},
|
||||
{"ErrUnauthorized", ErrUnauthorized, false},
|
||||
{"generic error", errors.New("some error"), false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsOperationFailed(tt.err); got != tt.expected {
|
||||
t.Errorf("IsOperationFailed(%v) = %v, want %v", tt.err, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsSubstr(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
|
|
|
|||
32
files.go
32
files.go
|
|
@ -75,3 +75,35 @@ func (c *Client) RemoveTaskFile(ctx context.Context, fileID int) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTaskFile returns a single file's metadata by its ID.
|
||||
func (c *Client) GetTaskFile(ctx context.Context, fileID int) (*TaskFile, error) {
|
||||
params := map[string]int{"file_id": fileID}
|
||||
|
||||
var result *TaskFile
|
||||
if err := c.call(ctx, "getTaskFile", params, &result); err != nil {
|
||||
return nil, fmt.Errorf("getTaskFile: %w", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("%w: file %d", ErrNotFound, fileID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RemoveAllTaskFiles removes all files attached to a task.
|
||||
func (c *Client) RemoveAllTaskFiles(ctx context.Context, taskID int) error {
|
||||
params := map[string]int{"task_id": taskID}
|
||||
|
||||
var success bool
|
||||
if err := c.call(ctx, "removeAllTaskFiles", params, &success); err != nil {
|
||||
return fmt.Errorf("removeAllTaskFiles: %w", err)
|
||||
}
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("removeAllTaskFiles: delete failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
240
files_test.go
240
files_test.go
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
|
@ -322,3 +323,242 @@ func TestTaskScope_UploadFile(t *testing.T) {
|
|||
t.Errorf("expected fileID=10, got %d", fileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetTaskFile(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 != "getTaskFile" {
|
||||
t.Errorf("expected method=getTaskFile, got %s", req.Method)
|
||||
}
|
||||
|
||||
params := req.Params.(map[string]any)
|
||||
if params["file_id"].(float64) != 5 {
|
||||
t.Errorf("expected file_id=5, got %v", params["file_id"])
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`{"id": "5", "name": "document.pdf", "path": "files/5", "is_image": "0", "task_id": "42", "user_id": "1", "size": "1024"}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
file, err := client.GetTaskFile(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if int(file.ID) != 5 {
|
||||
t.Errorf("expected id=5, got %d", file.ID)
|
||||
}
|
||||
if file.Name != "document.pdf" {
|
||||
t.Errorf("expected name='document.pdf', got %s", file.Name)
|
||||
}
|
||||
if int(file.TaskID) != 42 {
|
||||
t.Errorf("expected task_id=42, got %d", file.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetTaskFile_NotFound(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(`null`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
_, err := client.GetTaskFile(context.Background(), 999)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for not found file")
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveAllTaskFiles(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 != "removeAllTaskFiles" {
|
||||
t.Errorf("expected method=removeAllTaskFiles, 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(`true`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
err := client.RemoveAllTaskFiles(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveAllTaskFiles_Failure(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(`false`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
err := client.RemoveAllTaskFiles(context.Background(), 42)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskScope_GetFile(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 != "getTaskFile" {
|
||||
t.Errorf("expected method=getTaskFile, got %s", req.Method)
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`{"id": "5", "name": "file.txt", "task_id": "42", "is_image": "0"}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
file, err := client.Task(42).GetFile(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if int(file.ID) != 5 {
|
||||
t.Errorf("expected id=5, got %d", file.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskScope_DownloadFile(t *testing.T) {
|
||||
testContent := []byte("file content")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req JSONRPCRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
if req.Method != "downloadTaskFile" {
|
||||
t.Errorf("expected method=downloadTaskFile, got %s", req.Method)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(testContent)
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`"` + encoded + `"`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
content, err := client.Task(42).DownloadFile(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != string(testContent) {
|
||||
t.Errorf("expected content='%s', got '%s'", testContent, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskScope_RemoveFile(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 != "removeTaskFile" {
|
||||
t.Errorf("expected method=removeTaskFile, got %s", req.Method)
|
||||
}
|
||||
|
||||
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).RemoveFile(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskScope_RemoveAllFiles(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 != "removeAllTaskFiles" {
|
||||
t.Errorf("expected method=removeAllTaskFiles, 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(`true`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
err := client.Task(42).RemoveAllFiles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ func nextRequestID() int64 {
|
|||
// 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 {
|
||||
if method != "getTimezone" {
|
||||
if err := c.ensureTimezone(ctx); err != nil {
|
||||
return fmt.Errorf("failed to load timezone: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := JSONRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
Method: method,
|
||||
|
|
@ -138,6 +144,9 @@ func (c *Client) call(ctx context.Context, method string, params interface{}, re
|
|||
if err := json.Unmarshal(rpcResp.Result, result); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal result: %w", err)
|
||||
}
|
||||
if c.tzEnabled && c.timezone != nil {
|
||||
c.convertTimestamps(result)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -281,3 +281,23 @@ func (t *TaskScope) UploadFile(ctx context.Context, filename string, content []b
|
|||
}
|
||||
return t.client.CreateTaskFile(ctx, int(task.ProjectID), t.taskID, filename, content)
|
||||
}
|
||||
|
||||
// GetFile returns a file's metadata by ID.
|
||||
func (t *TaskScope) GetFile(ctx context.Context, fileID int) (*TaskFile, error) {
|
||||
return t.client.GetTaskFile(ctx, fileID)
|
||||
}
|
||||
|
||||
// RemoveFile removes a file by ID.
|
||||
func (t *TaskScope) RemoveFile(ctx context.Context, fileID int) error {
|
||||
return t.client.RemoveTaskFile(ctx, fileID)
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file's content by ID.
|
||||
func (t *TaskScope) DownloadFile(ctx context.Context, fileID int) ([]byte, error) {
|
||||
return t.client.DownloadTaskFile(ctx, fileID)
|
||||
}
|
||||
|
||||
// RemoveAllFiles removes all files from this task.
|
||||
func (t *TaskScope) RemoveAllFiles(ctx context.Context) error {
|
||||
return t.client.RemoveAllTaskFiles(ctx, t.taskID)
|
||||
}
|
||||
|
|
|
|||
36
tasks.go
36
tasks.go
|
|
@ -24,6 +24,23 @@ func (c *Client) GetTask(ctx context.Context, taskID int) (*Task, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// GetTaskByReference returns a task by its external reference within a project.
|
||||
// Returns ErrTaskNotFound if no task matches the reference.
|
||||
func (c *Client) GetTaskByReference(ctx context.Context, projectID int, reference string) (*Task, error) {
|
||||
params := map[string]any{"project_id": projectID, "reference": reference}
|
||||
|
||||
var result *Task
|
||||
if err := c.call(ctx, "getTaskByReference", params, &result); err != nil {
|
||||
return nil, fmt.Errorf("getTaskByReference: %w", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("%w: reference %q in project %d", ErrTaskNotFound, reference, projectID)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllTasks returns all tasks for a project with the specified status.
|
||||
func (c *Client) GetAllTasks(ctx context.Context, projectID int, status TaskStatus) ([]Task, error) {
|
||||
params := map[string]int{
|
||||
|
|
@ -143,7 +160,15 @@ func (c *Client) MoveTaskPosition(ctx context.Context, projectID, taskID, column
|
|||
}
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("moveTaskPosition: failed to move task %d", taskID)
|
||||
return &OperationFailedError{
|
||||
Operation: fmt.Sprintf("moveTaskPosition(task=%d, column=%d, project=%d)", taskID, columnID, projectID),
|
||||
Hints: []string{
|
||||
"task may not exist",
|
||||
"column may not belong to project",
|
||||
"insufficient permissions",
|
||||
"task may already be in target position",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -162,7 +187,14 @@ func (c *Client) MoveTaskToProject(ctx context.Context, taskID, projectID int) e
|
|||
}
|
||||
|
||||
if !success {
|
||||
return fmt.Errorf("moveTaskToProject: failed to move task %d to project %d", taskID, projectID)
|
||||
return &OperationFailedError{
|
||||
Operation: fmt.Sprintf("moveTaskToProject(task=%d, project=%d)", taskID, projectID),
|
||||
Hints: []string{
|
||||
"task may not exist",
|
||||
"target project may not exist",
|
||||
"insufficient permissions",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -76,6 +77,73 @@ func TestClient_GetTask_NotFound(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClient_GetTaskByReference(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 != "getTaskByReference" {
|
||||
t.Errorf("expected method=getTaskByReference, 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["reference"] != "EXT-123" {
|
||||
t.Errorf("expected reference='EXT-123', got %v", params["reference"])
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`{"id": "42", "title": "Test Task", "project_id": "1", "column_id": "1", "is_active": "1", "reference": "EXT-123"}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
task, err := client.GetTaskByReference(context.Background(), 1, "EXT-123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if int(task.ID) != 42 {
|
||||
t.Errorf("expected ID=42, got %d", task.ID)
|
||||
}
|
||||
if task.Title != "Test Task" {
|
||||
t.Errorf("expected title='Test Task', got %s", task.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetTaskByReference_NotFound(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(`null`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
_, err := client.GetTaskByReference(context.Background(), 1, "NONEXISTENT")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent task")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("expected ErrTaskNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetAllTasks(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req JSONRPCRequest
|
||||
|
|
@ -639,6 +707,20 @@ func TestClient_MoveTaskPosition_Failure(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected error for failed move")
|
||||
}
|
||||
|
||||
// Verify it's an OperationFailedError with helpful hints
|
||||
if !IsOperationFailed(err) {
|
||||
t.Errorf("expected OperationFailedError, got %T", err)
|
||||
}
|
||||
|
||||
// Error message should contain actionable hints
|
||||
errMsg := err.Error()
|
||||
if !strings.Contains(errMsg, "moveTaskPosition") {
|
||||
t.Error("error should mention operation name")
|
||||
}
|
||||
if !strings.Contains(errMsg, "possible causes") {
|
||||
t.Error("error should include possible causes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_MoveTaskToProject(t *testing.T) {
|
||||
|
|
|
|||
81
timezone.go
Normal file
81
timezone.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package kanboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetTimezone returns the server's configured timezone string (e.g., "UTC", "Europe/Berlin").
|
||||
func (c *Client) GetTimezone(ctx context.Context) (string, error) {
|
||||
var tz string
|
||||
if err := c.call(ctx, "getTimezone", nil, &tz); err != nil {
|
||||
return "", fmt.Errorf("getTimezone: %w", err)
|
||||
}
|
||||
return tz, nil
|
||||
}
|
||||
|
||||
// loadTimezone fetches and caches the timezone location from the server.
|
||||
func (c *Client) loadTimezone(ctx context.Context) error {
|
||||
tz, err := c.GetTimezone(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timezone %q: %w", tz, err)
|
||||
}
|
||||
c.timezone = loc
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTimezone loads the timezone if tzEnabled and not yet loaded.
|
||||
func (c *Client) ensureTimezone(ctx context.Context) error {
|
||||
if !c.tzEnabled {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
c.tzOnce.Do(func() {
|
||||
err = c.loadTimezone(ctx)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// convertTimestamps converts all Timestamp fields in v to the client's timezone.
|
||||
// v must be a pointer. Handles structs, pointers to structs, and slices of structs.
|
||||
func (c *Client) convertTimestamps(v any) {
|
||||
if c.timezone == nil {
|
||||
return
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
c.walkAndConvert(rv)
|
||||
}
|
||||
|
||||
var timestampType = reflect.TypeOf(Timestamp{})
|
||||
|
||||
func (c *Client) walkAndConvert(rv reflect.Value) {
|
||||
switch rv.Kind() {
|
||||
case reflect.Ptr:
|
||||
if !rv.IsNil() {
|
||||
c.walkAndConvert(rv.Elem())
|
||||
}
|
||||
case reflect.Struct:
|
||||
if rv.Type() == timestampType {
|
||||
if rv.CanSet() {
|
||||
ts := rv.Addr().Interface().(*Timestamp)
|
||||
if !ts.IsZero() {
|
||||
ts.Time = ts.Time.In(c.timezone)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := 0; i < rv.NumField(); i++ {
|
||||
c.walkAndConvert(rv.Field(i))
|
||||
}
|
||||
case reflect.Slice:
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
c.walkAndConvert(rv.Index(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
233
timezone_test.go
Normal file
233
timezone_test.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package kanboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_GetTimezone(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 != "getTimezone" {
|
||||
t.Errorf("expected method=getTimezone, got %s", req.Method)
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`"Europe/Berlin"`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
tz, err := client.GetTimezone(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tz != "Europe/Berlin" {
|
||||
t.Errorf("expected Europe/Berlin, got %s", tz)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_WithTimezone_ConvertsTaskTimestamps(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)
|
||||
|
||||
var result json.RawMessage
|
||||
switch req.Method {
|
||||
case "getTimezone":
|
||||
callCount++
|
||||
result = json.RawMessage(`"America/New_York"`)
|
||||
case "getTask":
|
||||
result = json.RawMessage(`{
|
||||
"id": "1",
|
||||
"title": "Test",
|
||||
"description": "",
|
||||
"date_creation": 1609459200,
|
||||
"date_modification": 1609459200,
|
||||
"date_completed": 0,
|
||||
"date_started": 0,
|
||||
"date_due": 0,
|
||||
"date_moved": 0,
|
||||
"color_id": "yellow",
|
||||
"project_id": "1",
|
||||
"column_id": "1",
|
||||
"owner_id": "0",
|
||||
"creator_id": "1",
|
||||
"position": "1",
|
||||
"is_active": "1",
|
||||
"score": "0",
|
||||
"category_id": "0",
|
||||
"swimlane_id": "0",
|
||||
"priority": "0",
|
||||
"reference": "",
|
||||
"recurrence_status": "0",
|
||||
"recurrence_trigger": "0",
|
||||
"recurrence_factor": "0",
|
||||
"recurrence_timeframe": "0",
|
||||
"recurrence_basedate": "0",
|
||||
"recurrence_parent": "0",
|
||||
"recurrence_child": "0"
|
||||
}`)
|
||||
default:
|
||||
t.Errorf("unexpected method: %s", req.Method)
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: result,
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token").WithTimezone()
|
||||
|
||||
task, err := client.GetTask(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("America/New_York")
|
||||
expected := time.Unix(1609459200, 0).In(loc)
|
||||
if !task.DateCreation.Time.Equal(expected) {
|
||||
t.Errorf("expected time %v, got %v", expected, task.DateCreation.Time)
|
||||
}
|
||||
if task.DateCreation.Time.Location().String() != "America/New_York" {
|
||||
t.Errorf("expected location America/New_York, got %s", task.DateCreation.Time.Location())
|
||||
}
|
||||
|
||||
// Verify getTimezone was called exactly once
|
||||
if callCount != 1 {
|
||||
t.Errorf("expected getTimezone called once, got %d", callCount)
|
||||
}
|
||||
|
||||
// Make a second call — should NOT call getTimezone again
|
||||
_, err = client.GetTask(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on second call: %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("expected getTimezone still called once, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_WithTimezone_Disabled(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 == "getTimezone" {
|
||||
t.Error("getTimezone should not be called when timezone is disabled")
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`{
|
||||
"id": "1",
|
||||
"title": "Test",
|
||||
"description": "",
|
||||
"date_creation": 1609459200,
|
||||
"date_modification": 0,
|
||||
"date_completed": 0,
|
||||
"date_started": 0,
|
||||
"date_due": 0,
|
||||
"date_moved": 0,
|
||||
"color_id": "yellow",
|
||||
"project_id": "1",
|
||||
"column_id": "1",
|
||||
"owner_id": "0",
|
||||
"creator_id": "1",
|
||||
"position": "1",
|
||||
"is_active": "1",
|
||||
"score": "0",
|
||||
"category_id": "0",
|
||||
"swimlane_id": "0",
|
||||
"priority": "0",
|
||||
"reference": "",
|
||||
"recurrence_status": "0",
|
||||
"recurrence_trigger": "0",
|
||||
"recurrence_factor": "0",
|
||||
"recurrence_timeframe": "0",
|
||||
"recurrence_basedate": "0",
|
||||
"recurrence_parent": "0",
|
||||
"recurrence_child": "0"
|
||||
}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
task, err := client.GetTask(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Without WithTimezone, timestamps stay as unmarshalled (Local from time.Unix)
|
||||
if task.DateCreation.Time.Location() != time.Local {
|
||||
t.Errorf("expected Local, got %s", task.DateCreation.Time.Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertTimestamps(t *testing.T) {
|
||||
loc, _ := time.LoadLocation("Asia/Tokyo")
|
||||
client := &Client{timezone: loc}
|
||||
|
||||
t.Run("struct with Timestamp fields", func(t *testing.T) {
|
||||
task := &Task{
|
||||
DateCreation: Timestamp{Time: time.Unix(1609459200, 0)},
|
||||
DateModification: Timestamp{Time: time.Unix(1609459200, 0)},
|
||||
DateCompleted: Timestamp{}, // zero — should stay zero
|
||||
}
|
||||
client.convertTimestamps(task)
|
||||
|
||||
if task.DateCreation.Time.Location().String() != "Asia/Tokyo" {
|
||||
t.Errorf("expected Asia/Tokyo, got %s", task.DateCreation.Time.Location())
|
||||
}
|
||||
if task.DateModification.Time.Location().String() != "Asia/Tokyo" {
|
||||
t.Errorf("expected Asia/Tokyo, got %s", task.DateModification.Time.Location())
|
||||
}
|
||||
if !task.DateCompleted.IsZero() {
|
||||
t.Error("zero timestamp should remain zero")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("slice of structs", func(t *testing.T) {
|
||||
tasks := &[]Task{
|
||||
{DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}},
|
||||
{DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}},
|
||||
}
|
||||
client.convertTimestamps(tasks)
|
||||
|
||||
for i, task := range *tasks {
|
||||
if task.DateCreation.Time.Location().String() != "Asia/Tokyo" {
|
||||
t.Errorf("task[%d]: expected Asia/Tokyo, got %s", i, task.DateCreation.Time.Location())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil timezone is no-op", func(t *testing.T) {
|
||||
noTzClient := &Client{}
|
||||
task := &Task{DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}}
|
||||
noTzClient.convertTimestamps(task)
|
||||
// Should not panic or change anything
|
||||
if task.DateCreation.Time.Location() != time.Local {
|
||||
t.Errorf("expected Local, got %s", task.DateCreation.Time.Location())
|
||||
}
|
||||
})
|
||||
}
|
||||
3
types.go
3
types.go
|
|
@ -165,6 +165,7 @@ type Task struct {
|
|||
DateCompleted Timestamp `json:"date_completed"`
|
||||
DateStarted Timestamp `json:"date_started"`
|
||||
DateDue Timestamp `json:"date_due"`
|
||||
DateMoved Timestamp `json:"date_moved"`
|
||||
ColorID string `json:"color_id"`
|
||||
ProjectID StringInt `json:"project_id"`
|
||||
ColumnID StringInt `json:"column_id"`
|
||||
|
|
@ -237,6 +238,8 @@ type TaskFile struct {
|
|||
DateCreation Timestamp `json:"date_creation"`
|
||||
UserID StringInt `json:"user_id"`
|
||||
Size StringInt64 `json:"size"`
|
||||
Username string `json:"username"` // Only returned by getAllTaskFiles
|
||||
UserName string `json:"user_name"` // Only returned by getAllTaskFiles
|
||||
}
|
||||
|
||||
// Tag represents a Kanboard tag.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue