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
57 changes: 46 additions & 11 deletions internal/api/attachment_date_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,52 @@ import (
"testing"
)

// TestAttachmentDateUnmarshalsStringFromAPI is a regression test: ClickUp's API
// returns an attachment "date" as a JSON string (e.g. "1749381600000"), not a
// number. When Date was typed int64, unmarshalling any task/attachment response
// failed with "cannot unmarshal string into Go struct field Attachment.date".
func TestAttachmentDateUnmarshalsStringFromAPI(t *testing.T) {
payload := []byte(`{"id":"abc123","version":"0","date":"1749381600000","title":"file.md","extension":"md"}`)
var a Attachment
if err := json.Unmarshal(payload, &a); err != nil {
t.Fatalf("failed to unmarshal attachment with string date: %v", err)
// ClickUp sends an attachment's scalar fields as either a JSON string or a JSON
// number, and not consistently per field or per endpoint: `date` arrived as
// "1749381600000" (#6) and `version` as 0 (2026-08-18). Either one typed as a
// plain Go scalar breaks unmarshalling of EVERY task response that carries an
// attachment, which takes out `task get` and `task update` for that task. Both
// fields are FlexString; this pins both shapes.
func TestAttachmentScalarsAcceptStringOrNumber(t *testing.T) {
for _, tc := range []struct {
name string
payload string
wantVersion FlexString
wantDate FlexString
}{
{
name: "strings",
payload: `{"id":"abc123","version":"0","date":"1749381600000","title":"file.md"}`,
wantVersion: "0",
wantDate: "1749381600000",
},
{
name: "numbers",
payload: `{"id":"abc123","version":0,"date":1749381600000,"title":"file.md"}`,
wantVersion: "0",
wantDate: "1749381600000",
},
} {
t.Run(tc.name, func(t *testing.T) {
var a Attachment
if err := json.Unmarshal([]byte(tc.payload), &a); err != nil {
t.Fatalf("failed to unmarshal attachment: %v", err)
}
if a.Version != tc.wantVersion {
t.Fatalf("version: got %q, want %q", a.Version, tc.wantVersion)
}
if a.Date != tc.wantDate {
t.Fatalf("date: got %q, want %q", a.Date, tc.wantDate)
}
})
}
if a.Date != "1749381600000" {
t.Fatalf("unexpected date: %q", a.Date)
}

// A field that is neither shape must fail loudly rather than land as an empty
// string that reads like a missing value downstream.
func TestAttachmentScalarRejectsNonScalar(t *testing.T) {
var a Attachment
if err := json.Unmarshal([]byte(`{"id":"abc","version":{"n":1}}`), &a); err == nil {
t.Fatal("expected an error for an object in a FlexString field")
}
}
39 changes: 31 additions & 8 deletions internal/api/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,38 @@ import (
"path/filepath"
)

// FlexString is a string that also accepts a JSON number. ClickUp is not
// consistent about which scalar it uses for a field: an attachment's `date`
// arrives as "1749381600000" while its `version` arrives as 0, and the same
// field can differ between the attachment endpoint and the copy embedded in a
// task response. Typing such a field as either Go scalar makes every response
// carrying an attachment fail to unmarshal, which took out `task get` and
// `task update` twice (#6, and again on 2026-08-18 with `version`).
type FlexString string

func (f *FlexString) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err == nil {
*f = FlexString(s)
return nil
}
var n json.Number
if err := json.Unmarshal(b, &n); err != nil {
return fmt.Errorf("value is neither a string nor a number: %s", string(b))
}
*f = FlexString(n.String())
return nil
}

type Attachment struct {
ID string `json:"id"`
Version string `json:"version"`
Date string `json:"date"`
Title string `json:"title"`
Extension string `json:"extension"`
ThumbnailSmall string `json:"thumbnail_small"`
ThumbnailLarge string `json:"thumbnail_large"`
URL string `json:"url"`
ID string `json:"id"`
Version FlexString `json:"version"`
Date FlexString `json:"date"`
Title string `json:"title"`
Extension string `json:"extension"`
ThumbnailSmall string `json:"thumbnail_small"`
ThumbnailLarge string `json:"thumbnail_large"`
URL string `json:"url"`
}

func (c *Client) CreateTaskAttachment(ctx context.Context, taskID, filePath string, opts ...*TaskScopedOptions) (*Attachment, error) {
Expand Down
Loading