From a38e62e77aff2f00c2bd874e8aa74cd36f76e3a1 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 15 Jan 2026 18:18:47 +0100 Subject: [PATCH] Implement entity structs for Kanboard API responses Add all entity structs matching Kanboard's JSON-RPC API format: Entity structs: - Project, Task, Column, Category, Comment - TaskLink, TaskFile, Tag - TaskStatus enum (StatusActive, StatusInactive) Request structs: - CreateTaskRequest with omitempty for optional fields - UpdateTaskRequest with pointer fields for zero-value distinction Custom JSON types for Kanboard's string-encoded values: - StringBool - handles "0"/"1" string booleans - StringInt - handles string-encoded integers - StringInt64 - handles string-encoded int64 values All structs properly handle Kanboard's quirky JSON format where numeric fields are often returned as quoted strings. Closes: kanboard-api-cyc --- .beads/issues.jsonl | 2 +- types.go | 257 ++++++++++++++++++++++ types_test.go | 521 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 types.go create mode 100644 types_test.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c93605d..6fb086f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -10,7 +10,7 @@ {"id":"kanboard-api-9k8","title":"Implement TaskParams builder (Options Pattern)","description":"Implement TaskParams for fluent task creation configuration.\n\n## Requirements\n- TaskParams struct with private fields\n- NewTask(title string) *TaskParams constructor\n- Fluent setter methods:\n - WithDescription(desc string) *TaskParams\n - InColumn(columnID int) *TaskParams\n - WithCategory(categoryID int) *TaskParams\n - WithOwner(ownerID int) *TaskParams\n - WithColor(colorID string) *TaskParams\n - WithPriority(priority int) *TaskParams\n - WithDueDate(date time.Time) *TaskParams\n - WithTags(tags ...string) *TaskParams\n - WithReference(ref string) *TaskParams\n- Internal method to convert to CreateTaskRequest\n\n## Files to create\n- task_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTask(\"My Task\").\n WithDescription(\"Details\").\n InColumn(2).\n WithTags(\"urgent\", \"backend\")\n```\n\n## Acceptance criteria\n- Pure configuration object (no I/O)\n- All setters return *TaskParams for chaining\n- Unset optional fields remain nil","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.439513879+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.439513879+01:00","dependencies":[{"issue_id":"kanboard-api-9k8","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.070100884+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-apl","title":"Implement Project API methods","description":"Implement direct API methods for project/board operations.\n\n## Methods to implement\n- GetAllProjects(ctx) ([]Project, error) - getAllProjects\n- GetProjectByID(ctx, projectID int) (*Project, error) - getProjectById\n- GetProjectByName(ctx, name string) (*Project, error) - getProjectByName (Nice-to-have)\n\n## Files to create\n- projects.go\n\n## Acceptance criteria\n- All methods use context for cancellation\n- Proper error handling and wrapping\n- Returns ErrProjectNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:15.864764497+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:15.864764497+01:00","dependencies":[{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:52.850751716+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:52.946556814+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-c3i","title":"Create README.md with documentation","description":"Create comprehensive README.md documentation in English.\n\n## Sections to include\n- Project overview and features\n- Installation instructions\n- Quick start example\n- API documentation overview\n- Authentication methods (API token, user/password)\n- Fluent API examples\n- Direct API examples\n- Error handling\n- Thread-safety notes\n- Tag operations warning (non-atomic)\n- License (MIT)\n\n## Example code to include\n```go\n// Client creation\nclient := kanboard.NewClient(\"https://kanboard.example.com\").\n WithAPIToken(\"my-token\").\n WithTimeout(30 * time.Second)\n\n// Fluent API\ntask, _ := client.Board(1).CreateTask(ctx,\n kanboard.NewTask(\"My Task\").WithDescription(\"Details\"))\n\n// Direct API\ntasks, _ := client.GetAllTasks(ctx, 1, kanboard.StatusActive)\n```\n\n## Files to create\n- README.md\n\n## Acceptance criteria\n- Clear installation instructions\n- Working code examples\n- Documents all major features","status":"open","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.228407343+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.228407343+01:00","dependencies":[{"issue_id":"kanboard-api-c3i","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:46:55.445847253+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"kanboard-api-cyc","title":"Implement entity structs (types.go)","description":"Implement all entity structs for Kanboard API responses.\n\n## Structs to implement\n- Project (ID, Name, Description, IsActive, Token, LastModified, etc.)\n- Task (ID, Title, Description, dates, ColorID, ProjectID, ColumnID, etc.)\n- Column (ID, Title, Position, ProjectID, TaskLimit, Description)\n- Category (ID, Name, ProjectID, ColorID)\n- Comment (ID, TaskID, UserID, DateCreation, Content, Username, etc.)\n- TaskLink (ID, LinkID, TaskID, OppositeTaskID, Label, Title)\n- TaskFile (ID, Name, Path, IsImage, TaskID, DateCreation, UserID, Size)\n- Tag (ID, Name, ProjectID, ColorID)\n- TaskStatus enum (StatusActive, StatusInactive)\n\n## Request structs\n- CreateTaskRequest\n- UpdateTaskRequest (with pointer fields for optional values)\n\n## Files to create\n- types.go\n\n## Acceptance criteria\n- All JSON tags match Kanboard API\n- Optional fields use pointers with omitempty\n- Timestamp fields use custom Timestamp type","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.484472208+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:55.484472208+01:00","dependencies":[{"issue_id":"kanboard-api-cyc","depends_on_id":"kanboard-api-25y","type":"blocks","created_at":"2026-01-15T17:42:48.385166815+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"kanboard-api-cyc","title":"Implement entity structs (types.go)","description":"Implement all entity structs for Kanboard API responses.\n\n## Structs to implement\n- Project (ID, Name, Description, IsActive, Token, LastModified, etc.)\n- Task (ID, Title, Description, dates, ColorID, ProjectID, ColumnID, etc.)\n- Column (ID, Title, Position, ProjectID, TaskLimit, Description)\n- Category (ID, Name, ProjectID, ColorID)\n- Comment (ID, TaskID, UserID, DateCreation, Content, Username, etc.)\n- TaskLink (ID, LinkID, TaskID, OppositeTaskID, Label, Title)\n- TaskFile (ID, Name, Path, IsImage, TaskID, DateCreation, UserID, Size)\n- Tag (ID, Name, ProjectID, ColorID)\n- TaskStatus enum (StatusActive, StatusInactive)\n\n## Request structs\n- CreateTaskRequest\n- UpdateTaskRequest (with pointer fields for optional values)\n\n## Files to create\n- types.go\n\n## Acceptance criteria\n- All JSON tags match Kanboard API\n- Optional fields use pointers with omitempty\n- Timestamp fields use custom Timestamp type","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.484472208+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:18:40.163015715+01:00","closed_at":"2026-01-15T18:18:40.163015715+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-cyc","depends_on_id":"kanboard-api-25y","type":"blocks","created_at":"2026-01-15T17:42:48.385166815+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-fue","title":"Implement Task search and move methods","description":"Implement task search and movement API methods.\n\n## Methods to implement\n- SearchTasks(ctx, projectID int, query string) ([]Task, error) - searchTasks\n- MoveTaskPosition(ctx, projectID, taskID, columnID, position, swimlaneID int) error - moveTaskPosition\n- MoveTaskToProject(ctx, taskID, projectID int) error - moveTaskToProject\n\n## Files to create\n- tasks.go (extend)\n\n## Acceptance criteria\n- Search supports Kanboard query syntax\n- MoveTaskPosition handles column and position\n- Proper error handling for invalid moves","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:17.380817511+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:17.380817511+01:00","dependencies":[{"issue_id":"kanboard-api-fue","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:42:53.59290181+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-h4u","title":"Implement Comment API methods","description":"Implement direct API methods for comment operations.\n\n## Methods to implement\n- GetAllComments(ctx, taskID int) ([]Comment, error) - getAllComments\n- CreateComment(ctx, taskID, userID int, content string) (*Comment, error) - createComment\n- UpdateComment(ctx, commentID int, content string) error - updateComment\n- RemoveComment(ctx, commentID int) error - removeComment\n\n## TaskScope methods to add\n- AddComment(ctx, content string) (*Comment, error)\n- GetComments(ctx) ([]Comment, error)\n\n## Files to create\n- comments.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateComment returns the created comment\n- Returns ErrCommentNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.156950163+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.156950163+01:00","dependencies":[{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:48.898295911+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.058259216+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-k33","title":"Implement authentication system","description":"Implement HTTP Basic Auth support for both API token and user/password authentication.\n\n## Requirements\n- Authenticator interface for auth strategies\n- API Token auth: HTTP Basic with username `jsonrpc` and API token as password\n- User/Password auth: HTTP Basic with username and password\n- WithAPIToken(token string) fluent method\n- WithBasicAuth(username, password string) fluent method\n- Secure handling - no credential storage/logging\n\n## Files to create\n- auth.go\n\n## Acceptance criteria\n- Both auth methods work correctly\n- Credentials properly encoded in Authorization header\n- No sensitive data logged","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.631074781+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:11:42.58778642+01:00","closed_at":"2026-01-15T18:11:42.58778642+01:00","close_reason":"Closed"} diff --git a/types.go b/types.go new file mode 100644 index 0000000..f6d5a00 --- /dev/null +++ b/types.go @@ -0,0 +1,257 @@ +package kanboard + +import ( + "encoding/json" + "strconv" +) + +// TaskStatus represents the status of a task. +type TaskStatus int + +const ( + // StatusActive represents an open/active task. + StatusActive TaskStatus = 1 + // StatusInactive represents a closed/inactive task. + StatusInactive TaskStatus = 0 +) + +// StringBool is a bool that can be unmarshaled from a string "0" or "1". +type StringBool bool + +// UnmarshalJSON implements json.Unmarshaler. +func (b *StringBool) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + // Try as raw bool + var boolVal bool + if err := json.Unmarshal(data, &boolVal); err != nil { + return err + } + *b = StringBool(boolVal) + return nil + } + *b = s == "1" || s == "true" + return nil +} + +// MarshalJSON implements json.Marshaler. +func (b StringBool) MarshalJSON() ([]byte, error) { + if b { + return []byte(`"1"`), nil + } + return []byte(`"0"`), nil +} + +// StringInt is an int that can be unmarshaled from a JSON string. +type StringInt int + +// UnmarshalJSON implements json.Unmarshaler. +func (i *StringInt) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + // Try as raw int + var intVal int + if err := json.Unmarshal(data, &intVal); err != nil { + return err + } + *i = StringInt(intVal) + return nil + } + if s == "" { + *i = 0 + return nil + } + val, err := strconv.Atoi(s) + if err != nil { + return err + } + *i = StringInt(val) + return nil +} + +// StringInt64 is an int64 that can be unmarshaled from a JSON string. +type StringInt64 int64 + +// UnmarshalJSON implements json.Unmarshaler. +func (i *StringInt64) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + // Try as raw int64 + var intVal int64 + if err := json.Unmarshal(data, &intVal); err != nil { + return err + } + *i = StringInt64(intVal) + return nil + } + if s == "" { + *i = 0 + return nil + } + val, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return err + } + *i = StringInt64(val) + return nil +} + +// Project represents a Kanboard project (board). +type Project struct { + ID StringInt `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsActive StringBool `json:"is_active"` + Token string `json:"token"` + LastModified Timestamp `json:"last_modified"` + IsPublic StringBool `json:"is_public"` + IsPrivate StringBool `json:"is_private"` + DefaultSwimlane string `json:"default_swimlane"` + ShowDefaultSwimlane StringBool `json:"show_default_swimlane"` + Identifier string `json:"identifier"` + StartDate Timestamp `json:"start_date"` + EndDate Timestamp `json:"end_date"` + OwnerID StringInt `json:"owner_id"` + PriorityDefault StringInt `json:"priority_default"` + PriorityStart StringInt `json:"priority_start"` + PriorityEnd StringInt `json:"priority_end"` + Email string `json:"email"` +} + +// Task represents a Kanboard task. +type Task struct { + ID StringInt `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + DateCreation Timestamp `json:"date_creation"` + DateModification Timestamp `json:"date_modification"` + DateCompleted Timestamp `json:"date_completed"` + DateStarted Timestamp `json:"date_started"` + DateDue Timestamp `json:"date_due"` + ColorID string `json:"color_id"` + ProjectID StringInt `json:"project_id"` + ColumnID StringInt `json:"column_id"` + OwnerID StringInt `json:"owner_id"` + CreatorID StringInt `json:"creator_id"` + Position StringInt `json:"position"` + IsActive StringBool `json:"is_active"` + Score StringInt `json:"score"` + CategoryID StringInt `json:"category_id"` + SwimlaneID StringInt `json:"swimlane_id"` + Priority StringInt `json:"priority"` + Reference string `json:"reference"` + RecurrenceStatus StringInt `json:"recurrence_status"` + RecurrenceTrigger StringInt `json:"recurrence_trigger"` + RecurrenceFactor StringInt `json:"recurrence_factor"` + RecurrenceTimeframe StringInt `json:"recurrence_timeframe"` + RecurrenceBasedate StringInt `json:"recurrence_basedate"` + RecurrenceParent StringInt `json:"recurrence_parent"` + RecurrenceChild StringInt `json:"recurrence_child"` +} + +// Column represents a Kanboard column. +type Column struct { + ID StringInt `json:"id"` + Title string `json:"title"` + Position StringInt `json:"position"` + ProjectID StringInt `json:"project_id"` + TaskLimit StringInt `json:"task_limit"` + Description string `json:"description"` +} + +// Category represents a Kanboard category. +type Category struct { + ID StringInt `json:"id"` + Name string `json:"name"` + ProjectID StringInt `json:"project_id"` + ColorID string `json:"color_id"` +} + +// Comment represents a Kanboard comment. +type Comment struct { + ID StringInt `json:"id"` + TaskID StringInt `json:"task_id"` + UserID StringInt `json:"user_id"` + DateCreation Timestamp `json:"date_creation"` + Content string `json:"comment"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + AvatarPath string `json:"avatar_path"` +} + +// TaskLink represents a link between two tasks. +type TaskLink struct { + ID StringInt `json:"id"` + LinkID StringInt `json:"link_id"` + TaskID StringInt `json:"task_id"` + OppositeTaskID StringInt `json:"opposite_task_id"` + Label string `json:"label"` + Title string `json:"title"` +} + +// TaskFile represents a file attached to a task. +type TaskFile struct { + ID StringInt `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + IsImage StringBool `json:"is_image"` + TaskID StringInt `json:"task_id"` + DateCreation Timestamp `json:"date_creation"` + UserID StringInt `json:"user_id"` + Size StringInt64 `json:"size"` +} + +// Tag represents a Kanboard tag. +type Tag struct { + ID StringInt `json:"id"` + Name string `json:"name"` + ProjectID StringInt `json:"project_id"` + ColorID string `json:"color_id"` +} + +// CreateTaskRequest is the request payload for creating a task. +type CreateTaskRequest struct { + Title string `json:"title"` + ProjectID int `json:"project_id"` + Description string `json:"description,omitempty"` + ColumnID int `json:"column_id,omitempty"` + OwnerID int `json:"owner_id,omitempty"` + CreatorID int `json:"creator_id,omitempty"` + ColorID string `json:"color_id,omitempty"` + CategoryID int `json:"category_id,omitempty"` + DateDue int64 `json:"date_due,omitempty"` + Score int `json:"score,omitempty"` + SwimlaneID int `json:"swimlane_id,omitempty"` + Priority int `json:"priority,omitempty"` + Reference string `json:"reference,omitempty"` + Tags []string `json:"tags,omitempty"` + DateStarted int64 `json:"date_started,omitempty"` + RecurrenceStatus int `json:"recurrence_status,omitempty"` + RecurrenceTrigger int `json:"recurrence_trigger,omitempty"` + RecurrenceFactor int `json:"recurrence_factor,omitempty"` + RecurrenceTimeframe int `json:"recurrence_timeframe,omitempty"` + RecurrenceBasedate int `json:"recurrence_basedate,omitempty"` +} + +// UpdateTaskRequest is the request payload for updating a task. +// Pointer fields allow distinguishing between "not set" and "set to zero value". +type UpdateTaskRequest struct { + ID int `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + ColorID *string `json:"color_id,omitempty"` + OwnerID *int `json:"owner_id,omitempty"` + CategoryID *int `json:"category_id,omitempty"` + DateDue *int64 `json:"date_due,omitempty"` + Score *int `json:"score,omitempty"` + Priority *int `json:"priority,omitempty"` + Reference *string `json:"reference,omitempty"` + DateStarted *int64 `json:"date_started,omitempty"` + RecurrenceStatus *int `json:"recurrence_status,omitempty"` + RecurrenceTrigger *int `json:"recurrence_trigger,omitempty"` + RecurrenceFactor *int `json:"recurrence_factor,omitempty"` + RecurrenceTimeframe *int `json:"recurrence_timeframe,omitempty"` + RecurrenceBasedate *int `json:"recurrence_basedate,omitempty"` + Tags []string `json:"tags,omitempty"` +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..2207b87 --- /dev/null +++ b/types_test.go @@ -0,0 +1,521 @@ +package kanboard + +import ( + "encoding/json" + "testing" + "time" +) + +func TestTaskStatus_Constants(t *testing.T) { + if StatusActive != 1 { + t.Errorf("expected StatusActive=1, got %d", StatusActive) + } + if StatusInactive != 0 { + t.Errorf("expected StatusInactive=0, got %d", StatusInactive) + } +} + +func TestStringBool_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"string 1", `"1"`, true}, + {"string 0", `"0"`, false}, + {"string true", `"true"`, true}, + {"string false", `"false"`, false}, + {"bool true", `true`, true}, + {"bool false", `false`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b StringBool + if err := json.Unmarshal([]byte(tt.input), &b); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if bool(b) != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, b) + } + }) + } +} + +func TestStringInt_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + {"string number", `"42"`, 42}, + {"string zero", `"0"`, 0}, + {"int number", `42`, 42}, + {"int zero", `0`, 0}, + {"empty string", `""`, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var i StringInt + if err := json.Unmarshal([]byte(tt.input), &i); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if int(i) != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, i) + } + }) + } +} + +func TestStringInt64_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + expected int64 + }{ + {"string number", `"1048576"`, 1048576}, + {"string zero", `"0"`, 0}, + {"int64 number", `1048576`, 1048576}, + {"int64 zero", `0`, 0}, + {"empty string", `""`, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var i StringInt64 + if err := json.Unmarshal([]byte(tt.input), &i); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if int64(i) != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, i) + } + }) + } +} + +func TestProject_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "1", + "name": "Test Project", + "description": "A test project", + "is_active": "1", + "token": "abc123", + "last_modified": 1609459200, + "is_public": "0", + "is_private": "1", + "owner_id": "42", + "priority_default": "2" + }` + + var project Project + if err := json.Unmarshal([]byte(jsonData), &project); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if int(project.ID) != 1 { + t.Errorf("expected ID=1, got %d", project.ID) + } + if project.Name != "Test Project" { + t.Errorf("expected Name='Test Project', got %s", project.Name) + } + if !bool(project.IsActive) { + t.Error("expected IsActive=true") + } + if bool(project.IsPublic) { + t.Error("expected IsPublic=false") + } + if !bool(project.IsPrivate) { + t.Error("expected IsPrivate=true") + } + if int(project.OwnerID) != 42 { + t.Errorf("expected OwnerID=42, got %d", project.OwnerID) + } +} + +func TestTask_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "42", + "title": "Test Task", + "description": "Task description", + "date_creation": 1609459200, + "date_due": 0, + "color_id": "yellow", + "project_id": "1", + "column_id": "2", + "owner_id": "5", + "is_active": "1", + "priority": "2", + "category_id": "3" + }` + + var task Task + if err := json.Unmarshal([]byte(jsonData), &task); err != nil { + t.Fatalf("unmarshal 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) + } + if int(task.ProjectID) != 1 { + t.Errorf("expected ProjectID=1, got %d", task.ProjectID) + } + if int(task.ColumnID) != 2 { + t.Errorf("expected ColumnID=2, got %d", task.ColumnID) + } + if !bool(task.IsActive) { + t.Error("expected IsActive=true") + } + if task.DateCreation.IsZero() { + t.Error("expected DateCreation to be set") + } + if !task.DateDue.IsZero() { + t.Error("expected DateDue to be zero") + } +} + +func TestColumn_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "1", + "title": "Backlog", + "position": "1", + "project_id": "5", + "task_limit": "10", + "description": "Tasks to be done" + }` + + var column Column + if err := json.Unmarshal([]byte(jsonData), &column); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if int(column.ID) != 1 { + t.Errorf("expected ID=1, got %d", column.ID) + } + if column.Title != "Backlog" { + t.Errorf("expected Title='Backlog', got %s", column.Title) + } + if int(column.Position) != 1 { + t.Errorf("expected Position=1, got %d", column.Position) + } + if int(column.TaskLimit) != 10 { + t.Errorf("expected TaskLimit=10, got %d", column.TaskLimit) + } +} + +func TestCategory_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "3", + "name": "Bug", + "project_id": "1", + "color_id": "red" + }` + + var category Category + if err := json.Unmarshal([]byte(jsonData), &category); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if int(category.ID) != 3 { + t.Errorf("expected ID=3, got %d", category.ID) + } + if category.Name != "Bug" { + t.Errorf("expected Name='Bug', got %s", category.Name) + } + if category.ColorID != "red" { + t.Errorf("expected ColorID='red', got %s", category.ColorID) + } +} + +func TestComment_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "10", + "task_id": "42", + "user_id": "5", + "date_creation": 1609459200, + "comment": "This is a comment", + "username": "admin", + "name": "Admin User", + "email": "admin@example.com" + }` + + var comment Comment + if err := json.Unmarshal([]byte(jsonData), &comment); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if int(comment.ID) != 10 { + t.Errorf("expected ID=10, got %d", comment.ID) + } + if int(comment.TaskID) != 42 { + t.Errorf("expected TaskID=42, got %d", comment.TaskID) + } + if comment.Content != "This is a comment" { + t.Errorf("expected Content='This is a comment', got %s", comment.Content) + } + if comment.Username != "admin" { + t.Errorf("expected Username='admin', got %s", comment.Username) + } +} + +func TestTaskLink_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "1", + "link_id": "2", + "task_id": "42", + "opposite_task_id": "43", + "label": "blocks", + "title": "Related Task" + }` + + var link TaskLink + if err := json.Unmarshal([]byte(jsonData), &link); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if int(link.ID) != 1 { + t.Errorf("expected ID=1, got %d", link.ID) + } + if int(link.TaskID) != 42 { + t.Errorf("expected TaskID=42, got %d", link.TaskID) + } + if int(link.OppositeTaskID) != 43 { + t.Errorf("expected OppositeTaskID=43, got %d", link.OppositeTaskID) + } + if link.Label != "blocks" { + t.Errorf("expected Label='blocks', got %s", link.Label) + } +} + +func TestTaskFile_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "1", + "name": "document.pdf", + "path": "/uploads/document.pdf", + "is_image": "0", + "task_id": "42", + "date_creation": 1609459200, + "user_id": "5", + "size": "1048576" + }` + + var file TaskFile + if err := json.Unmarshal([]byte(jsonData), &file); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if int(file.ID) != 1 { + t.Errorf("expected ID=1, got %d", file.ID) + } + if file.Name != "document.pdf" { + t.Errorf("expected Name='document.pdf', got %s", file.Name) + } + if bool(file.IsImage) { + t.Error("expected IsImage=false") + } + if int64(file.Size) != 1048576 { + t.Errorf("expected Size=1048576, got %d", file.Size) + } +} + +func TestTag_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "id": "5", + "name": "urgent", + "project_id": "1", + "color_id": "red" + }` + + var tag Tag + if err := json.Unmarshal([]byte(jsonData), &tag); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if int(tag.ID) != 5 { + t.Errorf("expected ID=5, got %d", tag.ID) + } + if tag.Name != "urgent" { + t.Errorf("expected Name='urgent', got %s", tag.Name) + } + if tag.ColorID != "red" { + t.Errorf("expected ColorID='red', got %s", tag.ColorID) + } +} + +func TestCreateTaskRequest_MarshalJSON(t *testing.T) { + req := CreateTaskRequest{ + Title: "New Task", + ProjectID: 1, + Description: "Task description", + ColumnID: 2, + Priority: 3, + Tags: []string{"urgent", "backend"}, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var unmarshaled map[string]any + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if unmarshaled["title"] != "New Task" { + t.Errorf("expected title='New Task', got %v", unmarshaled["title"]) + } + if unmarshaled["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", unmarshaled["project_id"]) + } + tags := unmarshaled["tags"].([]any) + if len(tags) != 2 { + t.Errorf("expected 2 tags, got %d", len(tags)) + } +} + +func TestCreateTaskRequest_OmitEmpty(t *testing.T) { + req := CreateTaskRequest{ + Title: "Minimal Task", + ProjectID: 1, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var unmarshaled map[string]any + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + // Should not have omitempty fields + if _, exists := unmarshaled["description"]; exists { + t.Error("description should be omitted when empty") + } + if _, exists := unmarshaled["color_id"]; exists { + t.Error("color_id should be omitted when empty") + } +} + +func TestUpdateTaskRequest_MarshalJSON(t *testing.T) { + title := "Updated Title" + priority := 5 + + req := UpdateTaskRequest{ + ID: 42, + Title: &title, + Priority: &priority, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var unmarshaled map[string]any + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if unmarshaled["id"].(float64) != 42 { + t.Errorf("expected id=42, got %v", unmarshaled["id"]) + } + if unmarshaled["title"] != "Updated Title" { + t.Errorf("expected title='Updated Title', got %v", unmarshaled["title"]) + } + if unmarshaled["priority"].(float64) != 5 { + t.Errorf("expected priority=5, got %v", unmarshaled["priority"]) + } + + // Should not have fields that weren't set + if _, exists := unmarshaled["description"]; exists { + t.Error("description should be omitted when nil") + } +} + +func TestUpdateTaskRequest_ZeroValueVsNil(t *testing.T) { + // Test that we can distinguish between "not set" and "set to zero" + zero := 0 + emptyString := "" + + req := UpdateTaskRequest{ + ID: 42, + Priority: &zero, // Explicitly set to 0 + ColorID: &emptyString, // Explicitly set to empty string + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var unmarshaled map[string]any + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + // These should be present even though they're zero values + if _, exists := unmarshaled["priority"]; !exists { + t.Error("priority should be present when explicitly set to 0") + } + if _, exists := unmarshaled["color_id"]; !exists { + t.Error("color_id should be present when explicitly set to empty string") + } +} + +func TestTask_TimestampFields(t *testing.T) { + jsonData := `{ + "id": "1", + "title": "Test", + "description": "", + "date_creation": 1609459200, + "date_modification": 1609545600, + "date_completed": 0, + "date_due": 1610064000, + "color_id": "blue", + "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" + }` + + var task Task + if err := json.Unmarshal([]byte(jsonData), &task); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + // Check that creation date is parsed correctly + expectedCreation := time.Unix(1609459200, 0) + if !task.DateCreation.Time.Equal(expectedCreation) { + t.Errorf("expected DateCreation=%v, got %v", expectedCreation, task.DateCreation.Time) + } + + // Check that completed date is zero + if !task.DateCompleted.IsZero() { + t.Error("expected DateCompleted to be zero") + } + + // Check that due date is parsed + if task.DateDue.IsZero() { + t.Error("expected DateDue to be set") + } +}