Implement Timestamp type for Unix timestamp JSON handling
Add custom Timestamp type that wraps time.Time for Kanboard's Unix timestamp format: UnmarshalJSON supports: - Unix timestamps as integers - Empty strings and "0" strings as zero time - Null values as zero time - Numeric strings (e.g., "1609459200") MarshalJSON returns: - 0 for zero time - Unix timestamp for non-zero time Includes comprehensive tests for round-trip marshaling and struct embedding scenarios. Closes: kanboard-api-25y
This commit is contained in:
parent
a56456cc00
commit
dbac08ac1e
3 changed files with 268 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.6133153+01:00","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]}
|
||||
{"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.526810135+01:00","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]}
|
||||
{"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:55.0044989+01:00"}
|
||||
{"id":"kanboard-api-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"}
|
||||
{"id":"kanboard-api-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:10:29.68466887+01:00","closed_at":"2026-01-15T18:10:29.68466887+01:00","close_reason":"Closed"}
|
||||
{"id":"kanboard-api-2ze","title":"Implement BoardScope fluent builder","description":"Implement BoardScope for fluent project-scoped operations.\n\n## Requirements\n- BoardScope struct with client and projectID\n- Client.Board(projectID int) *BoardScope method\n- BoardScope methods:\n - GetColumns(ctx) ([]Column, error)\n - GetCategories(ctx) ([]Category, error)\n - GetTasks(ctx, status TaskStatus) ([]Task, error)\n - SearchTasks(ctx, query string) ([]Task, error)\n - CreateTask(ctx, task *TaskParams) (*Task, error)\n\n## Files to create\n- board_scope.go\n\n## Example usage\n```go\ncolumns, _ := client.Board(1).GetColumns(ctx)\ntask, _ := client.Board(1).CreateTask(ctx, kanboard.NewTask(\"Title\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.044649709+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.044649709+01:00","dependencies":[{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:43:30.81063282+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:43:30.874964284+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-0fz","type":"blocks","created_at":"2026-01-15T17:43:30.939377116+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.005026627+01:00","created_by":"Oliver Jakoubek"}]}
|
||||
{"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]}
|
||||
|
|
|
|||
64
timestamp.go
Normal file
64
timestamp.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package kanboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timestamp wraps time.Time and handles Kanboard's Unix timestamp JSON format.
|
||||
// Kanboard returns timestamps as Unix integers, with 0 or empty strings for null values.
|
||||
type Timestamp struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
// Supports Unix timestamps as integers, empty strings, "0" strings, and zero values.
|
||||
func (t *Timestamp) UnmarshalJSON(data []byte) error {
|
||||
// Try to unmarshal as integer (Unix timestamp)
|
||||
var unix int64
|
||||
if err := json.Unmarshal(data, &unix); err == nil {
|
||||
if unix == 0 {
|
||||
t.Time = time.Time{}
|
||||
} else {
|
||||
t.Time = time.Unix(unix, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to unmarshal as string (empty or "0")
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
if str == "" || str == "0" {
|
||||
t.Time = time.Time{}
|
||||
return nil
|
||||
}
|
||||
// Try to parse as numeric string
|
||||
var unix int64
|
||||
if _, err := fmt.Sscanf(str, "%d", &unix); err == nil {
|
||||
if unix == 0 {
|
||||
t.Time = time.Time{}
|
||||
} else {
|
||||
t.Time = time.Unix(unix, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Handle null
|
||||
if string(data) == "null" {
|
||||
t.Time = time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot unmarshal timestamp: %s", string(data))
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
// Returns 0 for zero time, otherwise returns Unix timestamp.
|
||||
func (t Timestamp) MarshalJSON() ([]byte, error) {
|
||||
if t.IsZero() {
|
||||
return []byte("0"), nil
|
||||
}
|
||||
return json.Marshal(t.Unix())
|
||||
}
|
||||
203
timestamp_test.go
Normal file
203
timestamp_test.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package kanboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimestamp_UnmarshalJSON_Integer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "positive unix timestamp",
|
||||
input: "1609459200",
|
||||
expected: time.Unix(1609459200, 0), // 2021-01-01 00:00:00 UTC
|
||||
},
|
||||
{
|
||||
name: "zero",
|
||||
input: "0",
|
||||
expected: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "recent timestamp",
|
||||
input: "1704067200",
|
||||
expected: time.Unix(1704067200, 0), // 2024-01-01 00:00:00 UTC
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var ts Timestamp
|
||||
if err := json.Unmarshal([]byte(tt.input), &ts); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ts.Time.Equal(tt.expected) {
|
||||
t.Errorf("expected %v, got %v", tt.expected, ts.Time)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestamp_UnmarshalJSON_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: `""`,
|
||||
expected: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "zero string",
|
||||
input: `"0"`,
|
||||
expected: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "numeric string",
|
||||
input: `"1609459200"`,
|
||||
expected: time.Unix(1609459200, 0),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var ts Timestamp
|
||||
if err := json.Unmarshal([]byte(tt.input), &ts); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ts.Time.Equal(tt.expected) {
|
||||
t.Errorf("expected %v, got %v", tt.expected, ts.Time)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestamp_UnmarshalJSON_Null(t *testing.T) {
|
||||
var ts Timestamp
|
||||
if err := json.Unmarshal([]byte("null"), &ts); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ts.IsZero() {
|
||||
t.Errorf("expected zero time for null, got %v", ts.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestamp_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ts Timestamp
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "zero time",
|
||||
ts: Timestamp{},
|
||||
expected: "0",
|
||||
},
|
||||
{
|
||||
name: "positive timestamp",
|
||||
ts: Timestamp{Time: time.Unix(1609459200, 0)},
|
||||
expected: "1609459200",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(tt.ts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(data) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestamp_RoundTrip(t *testing.T) {
|
||||
// Test that marshal/unmarshal produces the same result
|
||||
original := Timestamp{Time: time.Unix(1609459200, 0)}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal error: %v", err)
|
||||
}
|
||||
|
||||
var parsed Timestamp
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
if !original.Time.Equal(parsed.Time) {
|
||||
t.Errorf("round trip failed: %v != %v", original.Time, parsed.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestamp_InStruct(t *testing.T) {
|
||||
// Test Timestamp as part of a struct (simulating API response)
|
||||
type Task struct {
|
||||
ID int `json:"id"`
|
||||
DateCreation Timestamp `json:"date_creation"`
|
||||
DateDue Timestamp `json:"date_due"`
|
||||
}
|
||||
|
||||
jsonData := `{"id":42,"date_creation":1609459200,"date_due":0}`
|
||||
|
||||
var task Task
|
||||
if err := json.Unmarshal([]byte(jsonData), &task); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
if task.ID != 42 {
|
||||
t.Errorf("expected ID=42, got %d", task.ID)
|
||||
}
|
||||
if task.DateCreation.IsZero() {
|
||||
t.Error("DateCreation should not be zero")
|
||||
}
|
||||
if !task.DateDue.IsZero() {
|
||||
t.Error("DateDue should be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestamp_InStructWithStringTimestamp(t *testing.T) {
|
||||
// Test with string timestamps (Kanboard sometimes returns these)
|
||||
type Task struct {
|
||||
ID int `json:"id"`
|
||||
DateCreation Timestamp `json:"date_creation"`
|
||||
DateDue Timestamp `json:"date_due"`
|
||||
}
|
||||
|
||||
jsonData := `{"id":42,"date_creation":"1609459200","date_due":""}`
|
||||
|
||||
var task Task
|
||||
if err := json.Unmarshal([]byte(jsonData), &task); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
if task.ID != 42 {
|
||||
t.Errorf("expected ID=42, got %d", task.ID)
|
||||
}
|
||||
if task.DateCreation.IsZero() {
|
||||
t.Error("DateCreation should not be zero")
|
||||
}
|
||||
if !task.DateDue.IsZero() {
|
||||
t.Error("DateDue should be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestamp_IsZero(t *testing.T) {
|
||||
var zero Timestamp
|
||||
if !zero.IsZero() {
|
||||
t.Error("default Timestamp should be zero")
|
||||
}
|
||||
|
||||
nonZero := Timestamp{Time: time.Unix(1609459200, 0)}
|
||||
if nonZero.IsZero() {
|
||||
t.Error("non-zero Timestamp should not be zero")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue