diff --git a/projects/team_test.go b/projects/team_test.go index a335959..c0d72cd 100644 --- a/projects/team_test.go +++ b/projects/team_test.go @@ -2,6 +2,7 @@ package projects_test import ( "context" + "encoding/json" "fmt" "math/rand" "testing" @@ -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) + } +} diff --git a/types.go b/types.go index 95c4d14..88702de 100644 --- a/types.go +++ b/types.go @@ -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() } diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..65f3a78 --- /dev/null +++ b/types_test.go @@ -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) + } + }) + } +}