Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions projects/team_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package projects_test

import (
"context"
"encoding/json"
"fmt"
"math/rand"
"testing"
Expand Down Expand Up @@ -212,3 +213,98 @@ func TestTeamList(t *testing.T) {
})
}
}

// TestTeamDeletedAtEncoding guards the JSON shape of Team.DeletedAt, the SDK's
// only twapi.OptionalDateTime field. Consumers that derive a JSON Schema from
// these models by reflection — the MCP server does — declare the field once and
// then validate every response against it, so a value that re-encodes as a
// timestamp where the model says "unset" makes the whole response unusable.
//
// The empty string is the case to watch: the API sends it for every live team,
// and encoding/json allocates the pointer before OptionalDateTime.UnmarshalJSON
// runs, so the field survives the round trip as a non-nil pointer to the zero
// time rather than as nil.
func TestTeamDeletedAtEncoding(t *testing.T) {
tests := []struct {
name string
payload string
wantNil bool
wantJSON string
}{{
name: "empty string for a live team",
payload: `{"team":{"id":"1","name":"Live","deleted":false,"deletedDate":""}}`,
wantNil: false,
wantJSON: "null",
}, {
name: "null",
payload: `{"team":{"id":"2","name":"Live","deleted":false,"deletedDate":null}}`,
wantNil: true,
wantJSON: "null",
}, {
name: "absent",
payload: `{"team":{"id":"3","name":"Live","deleted":false}}`,
wantNil: true,
wantJSON: "null",
}, {
name: "timestamp for a deleted team",
payload: `{"team":{"id":"4","name":"Gone","deleted":true,"deletedDate":"2026-01-02T03:04:05Z"}}`,
wantNil: false,
wantJSON: `"2026-01-02T03:04:05Z"`,
}}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var response projects.TeamGetResponse
if err := json.Unmarshal([]byte(tt.payload), &response); err != nil {
t.Fatalf("unexpected error decoding %s: %s", tt.payload, err)
}

if isNil := response.Team.DeletedAt == nil; isNil != tt.wantNil {
t.Errorf("expected DeletedAt nil to be %t but got %t", tt.wantNil, isNil)
}

encoded, err := json.Marshal(response.Team.DeletedAt)
if err != nil {
t.Fatalf("unexpected error encoding: %s", err)
}
if string(encoded) != tt.wantJSON {
t.Errorf("expected deletedDate to encode as %s but got %s", tt.wantJSON, encoded)
}
})
}
}

// TestTeamListDeletedAtEncoding covers the same ground as
// TestTeamDeletedAtEncoding for the list envelope, which is where the failure
// was first reported: one live team in a page was enough to reject the response.
func TestTeamListDeletedAtEncoding(t *testing.T) {
payload := `{"teams":[
{"id":"1","name":"Live","deleted":false,"deletedDate":""},
{"id":"2","name":"Gone","deleted":true,"deletedDate":"2026-01-02T03:04:05Z"}
]}`

var response projects.TeamListResponse
if err := json.Unmarshal([]byte(payload), &response); err != nil {
t.Fatalf("unexpected error decoding: %s", err)
}
if len(response.Teams) != 2 {
t.Fatalf("expected 2 teams but got %d", len(response.Teams))
}

encoded, err := json.Marshal(response.Teams)
if err != nil {
t.Fatalf("unexpected error encoding: %s", err)
}

var decoded []map[string]any
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("unexpected error re-decoding: %s", err)
}

if got := decoded[0]["deletedDate"]; got != nil {
t.Errorf("expected the live team to encode deletedDate as null but got %v", got)
}
if got := decoded[1]["deletedDate"]; got != "2026-01-02T03:04:05Z" {
t.Errorf("expected the deleted team to keep its timestamp but got %v", got)
}
}
13 changes: 12 additions & 1 deletion types.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,19 @@ type Relationship struct {
type OptionalDateTime time.Time

// MarshalJSON encodes the OptionalDateTime as a string in the format
// "2006-01-02T15:04:05Z07:00".
// "2006-01-02T15:04:05Z07:00", or as null when unset.
//
// The zero value must round-trip back to null rather than to the year-1
// timestamp time.Time would produce. The API spells "unset" as an empty string
// on some fields, and encoding/json allocates the pointer before calling
// UnmarshalJSON, so an unset value reaches this method as a non-nil pointer to
// the zero time. Emitting the year-1 timestamp there would report a date the
// API never sent — and would break consumers that derive a JSON Schema from
// these models, since the value no longer matches the field's declared shape.
func (d OptionalDateTime) MarshalJSON() ([]byte, error) {
if time.Time(d).IsZero() {
return []byte("null"), nil
}
return time.Time(d).MarshalJSON()
}

Expand Down
86 changes: 86 additions & 0 deletions types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package twapi_test

import (
"encoding/json"
"testing"
"time"

twapi "github.com/teamwork/twapi-go-sdk"
)

// TestOptionalDateTimeRoundTrip covers the shapes the API uses to spell "unset"
// on an optional date-time. The empty string is the interesting one: encoding/json
// allocates the pointer before calling UnmarshalJSON, so the value comes back out
// through MarshalJSON as a non-nil pointer to the zero time and must still encode
// as null.
func TestOptionalDateTimeRoundTrip(t *testing.T) {
type payload struct {
DeletedAt *twapi.OptionalDateTime `json:"deletedDate"`
}

tests := []struct {
name string
in string
want string
}{{
name: "empty string",
in: `{"deletedDate":""}`,
want: `{"deletedDate":null}`,
}, {
name: "null",
in: `{"deletedDate":null}`,
want: `{"deletedDate":null}`,
}, {
name: "absent",
in: `{}`,
want: `{"deletedDate":null}`,
}, {
name: "timestamp",
in: `{"deletedDate":"2026-01-02T03:04:05Z"}`,
want: `{"deletedDate":"2026-01-02T03:04:05Z"}`,
}}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var decoded payload
if err := json.Unmarshal([]byte(tt.in), &decoded); err != nil {
t.Fatalf("unexpected error decoding %s: %s", tt.in, err)
}
encoded, err := json.Marshal(decoded)
if err != nil {
t.Fatalf("unexpected error encoding: %s", err)
}
if string(encoded) != tt.want {
t.Errorf("expected %s but got %s", tt.want, encoded)
}
})
}
}

func TestOptionalDateTimeMarshalJSON(t *testing.T) {
tests := []struct {
name string
input twapi.OptionalDateTime
want string
}{{
name: "zero value",
input: twapi.OptionalDateTime{},
want: "null",
}, {
name: "value",
input: twapi.OptionalDateTime(time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)),
want: `"2026-01-02T03:04:05Z"`,
}}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encoded, err := json.Marshal(tt.input)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if string(encoded) != tt.want {
t.Errorf("expected %s but got %s", tt.want, encoded)
}
})
}
}