From 15e199ccee5f9e5e030ddb40ab5f24e26bb27107 Mon Sep 17 00:00:00 2001 From: uffejaeger <71610258+uffejaeger@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:12:05 +0200 Subject: [PATCH 1/3] Fix live API compatibility --- articles.go | 4 +- contacts.go | 39 +- contacts_test.go | 14 - conversations_test.go | 6 +- fuzz_test.go | 5 +- internal/generated/intercom/client.gen.go | 28 +- internal/generated/intercom/live_compat.go | 311 ++++++++++++++ internal/tools/normalize-spec/main.go | 44 ++ live_api_integration_test.go | 462 +++++++++++++++++++++ live_api_regression_test.go | 285 +++++++++++++ openapi_contract_test.go | 3 +- response.go | 25 ++ response_test.go | 30 ++ 13 files changed, 1201 insertions(+), 55 deletions(-) create mode 100644 internal/generated/intercom/live_compat.go create mode 100644 live_api_integration_test.go create mode 100644 live_api_regression_test.go diff --git a/articles.go b/articles.go index ed28ec5..a53b633 100644 --- a/articles.go +++ b/articles.go @@ -329,11 +329,11 @@ func (s *ArticlesService) GetDraft(ctx context.Context, articleID string) (*Arti if err != nil { return nil, err } - res, err := s.client.generated.RetrieveArticleDraftWithResponse(ctx, id, nil) + res, err := s.client.generated.RetrieveArticleDraft(ctx, id, nil) if err != nil { return nil, err } - article, err := requireOK("get article draft", res.StatusCode(), res.Body, res.JSON200, responseHeaders(res.HTTPResponse)) + article, err := requireHTTPJSON[gen.ArticleSchema]("get article draft", res) return articleFromGenerated(article), err } diff --git a/contacts.go b/contacts.go index e445567..5ad57c5 100644 --- a/contacts.go +++ b/contacts.go @@ -310,7 +310,28 @@ func (s *ContactsService) ListMergeHistory(ctx context.Context, contactID string if err != nil { return nil, err } - return requireOK("list contact merge history", res.StatusCode(), res.Body, res.JSON200, responseHeaders(res.HTTPResponse)) + history, err := requireOK("list contact merge history", res.StatusCode(), res.Body, res.JSON200, responseHeaders(res.HTTPResponse)) + if err == nil || !IsNotFound(err) { + return history, err + } + + includeMergeHistory := true + contactResponse, fallbackErr := s.client.generated.ShowContactWithResponse(ctx, contactID, &gen.ShowContactParams{IncludeMergeHistory: &includeMergeHistory}) + if fallbackErr != nil { + return nil, fallbackErr + } + contact, fallbackErr := requireOK("get contact merge history", contactResponse.StatusCode(), contactResponse.Body, contactResponse.JSON200, responseHeaders(contactResponse.HTTPResponse)) + if fallbackErr != nil { + return nil, fallbackErr + } + + historyType := gen.MergeHistoryListType("list") + hasMore := false + return &ContactMergeHistory{ + Data: contact.MergeHistory, + HasMore: &hasMore, + Type: &historyType, + }, nil } // ContactSearchOperator is an Intercom contact search operator. @@ -521,17 +542,12 @@ func (s *ContactsService) CreateNote(ctx context.Context, contactID string, body return nil, fmt.Errorf("intercom: note body is required") } - id, err := contactIDToInt(contactID) - if err != nil { - return nil, err - } - req := gen.CreateNoteJSONRequestBody{Body: body} if adminID != "" { req.AdminId = &adminID } - res, err := s.client.generated.CreateNoteWithResponse(ctx, id, nil, req) + res, err := s.client.generated.CreateNoteWithResponse(ctx, contactID, nil, req) if err != nil { return nil, err } @@ -733,12 +749,3 @@ func marshalBody(v any) (*bytes.Reader, error) { } return bytes.NewReader(b), nil } - -// contactIDToInt converts a string contact ID to int as required by some generated endpoints. -func contactIDToInt(contactID string) (int, error) { - id, err := strconv.Atoi(contactID) - if err != nil { - return 0, fmt.Errorf("intercom: contact ID %q is not a valid integer: %w", contactID, err) - } - return id, nil -} diff --git a/contacts_test.go b/contacts_test.go index d87be6a..f9d130a 100644 --- a/contacts_test.go +++ b/contacts_test.go @@ -880,20 +880,6 @@ func TestContactsValidation(t *testing.T) { return err }, }, - { - name: "create note: non-numeric contact ID", - call: func(ctx context.Context, client *Client) error { - _, err := client.Contacts.CreateNote(ctx, "not-an-int", "body", "") - return err - }, - }, - { - name: "create note: partial-numeric contact ID", - call: func(ctx context.Context, client *Client) error { - _, err := client.Contacts.CreateNote(ctx, "123abc", "body", "") - return err - }, - }, { name: "list segments: empty contact ID", call: func(ctx context.Context, client *Client) error { diff --git a/conversations_test.go b/conversations_test.go index 7f0a745..b06cde5 100644 --- a/conversations_test.go +++ b/conversations_test.go @@ -7,8 +7,6 @@ import ( "net/http" "testing" - openapi_types "github.com/oapi-codegen/runtime/types" - gen "github.com/uffejaeger/intercom-go/internal/generated/intercom" ) @@ -75,9 +73,9 @@ func TestConversationsServiceRequests(t *testing.T) { msg, err := client.Conversations.Create(ctx, ConversationCreate{ Body: "hello", From: struct { - Id openapi_types.UUID `json:"id"` + Id string `json:"id"` Type gen.CreateConversationRequestFromType `json:"type"` - }{Type: "user"}, + }{Id: "536e564f316c83104c000020", Type: "user"}, }) if err != nil { return err diff --git a/fuzz_test.go b/fuzz_test.go index ea0977c..850211e 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -30,7 +30,7 @@ func FuzzParseErrorResponse(f *testing.F) { }) } -func FuzzIntegerIDConversionHelpers(f *testing.F) { +func FuzzConversationIDConversion(f *testing.F) { f.Add("1") f.Add("0") f.Add("-1") @@ -41,9 +41,6 @@ func FuzzIntegerIDConversionHelpers(f *testing.F) { f.Fuzz(func(t *testing.T, id string) { want, wantErr := strconv.Atoi(id) - contactID, contactErr := contactIDToInt(id) - assertFuzzIDConversion(t, "contactIDToInt", contactID, contactErr, want, wantErr) - conversationID, conversationErr := conversationIDToInt(id) assertFuzzIDConversion(t, "conversationIDToInt", conversationID, conversationErr, want, wantErr) }) diff --git a/internal/generated/intercom/client.gen.go b/internal/generated/intercom/client.gen.go index 16c56ee..75f49e0 100644 --- a/internal/generated/intercom/client.gen.go +++ b/internal/generated/intercom/client.gen.go @@ -8843,7 +8843,7 @@ type ConversationStatisticsSchema struct { LastContactReplyAt *int `json:"last_contact_reply_at,omitempty"` // MedianTimeToReply Median based on all admin replies after a contact reply. Subtracts out of business hours. In seconds. - MedianTimeToReply *int `json:"median_time_to_reply,omitempty"` + MedianTimeToReply *float64 `json:"median_time_to_reply,omitempty"` // TimeToAdminReply Duration until first admin reply. Subtracts out of business hours. In seconds. TimeToAdminReply *int `json:"time_to_admin_reply,omitempty"` @@ -9315,7 +9315,7 @@ type CreateConversationRequestSchema struct { CreatedAt *int `json:"created_at,omitempty"` From struct { // Id The identifier for the contact which is given by Intercom. - Id openapi_types.UUID `json:"id"` + Id string `json:"id"` // Type The role associated to the contact - user or lead. Type CreateConversationRequestFromType `json:"type"` @@ -10836,7 +10836,7 @@ type ErrorSchema struct { // Message Optional. Human readable description of the error. Message *string `json:"message,omitempty"` } `json:"errors"` - RequestId *openapi_types.UUID `json:"request_id,omitempty"` + RequestId *string `json:"request_id,omitempty"` // Type The type is error.list Type string `json:"type"` @@ -20486,9 +20486,9 @@ type ClientInterface interface { ListNotes(ctx context.Context, contactId string, params *ListNotesParams, reqEditors ...RequestEditorFn) (*http.Response, error) // CreateNoteWithBody request with any body - CreateNoteWithBody(ctx context.Context, contactId int, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + CreateNoteWithBody(ctx context.Context, contactId string, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - CreateNote(ctx context.Context, contactId int, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + CreateNote(ctx context.Context, contactId string, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // ListSegmentsForAContact request ListSegmentsForAContact(ctx context.Context, contactId string, params *ListSegmentsForAContactParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -22180,7 +22180,7 @@ func (c *Client) ListNotes(ctx context.Context, contactId string, params *ListNo return c.Client.Do(req) } -func (c *Client) CreateNoteWithBody(ctx context.Context, contactId int, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { +func (c *Client) CreateNoteWithBody(ctx context.Context, contactId string, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCreateNoteRequestWithBody(c.Server, contactId, params, contentType, body) if err != nil { return nil, err @@ -22192,7 +22192,7 @@ func (c *Client) CreateNoteWithBody(ctx context.Context, contactId int, params * return c.Client.Do(req) } -func (c *Client) CreateNote(ctx context.Context, contactId int, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { +func (c *Client) CreateNote(ctx context.Context, contactId string, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCreateNoteRequest(c.Server, contactId, params, body) if err != nil { return nil, err @@ -28956,7 +28956,7 @@ func NewListNotesRequest(server string, contactId string, params *ListNotesParam } // NewCreateNoteRequest calls the generic CreateNote builder with application/json body -func NewCreateNoteRequest(server string, contactId int, params *CreateNoteParams, body CreateNoteJSONRequestBody) (*http.Request, error) { +func NewCreateNoteRequest(server string, contactId string, params *CreateNoteParams, body CreateNoteJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { @@ -28967,12 +28967,12 @@ func NewCreateNoteRequest(server string, contactId int, params *CreateNoteParams } // NewCreateNoteRequestWithBody generates requests for CreateNote with any type of body -func NewCreateNoteRequestWithBody(server string, contactId int, params *CreateNoteParams, contentType string, body io.Reader) (*http.Request, error) { +func NewCreateNoteRequestWithBody(server string, contactId string, params *CreateNoteParams, contentType string, body io.Reader) (*http.Request, error) { var err error var pathParam0 string - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "contact_id", contactId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "integer", Format: ""}) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "contact_id", contactId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -39160,9 +39160,9 @@ type ClientWithResponsesInterface interface { ListNotesWithResponse(ctx context.Context, contactId string, params *ListNotesParams, reqEditors ...RequestEditorFn) (*ListNotesResponse, error) // CreateNoteWithBodyWithResponse request with any body - CreateNoteWithBodyWithResponse(ctx context.Context, contactId int, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) + CreateNoteWithBodyWithResponse(ctx context.Context, contactId string, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) - CreateNoteWithResponse(ctx context.Context, contactId int, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) + CreateNoteWithResponse(ctx context.Context, contactId string, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) // ListSegmentsForAContactWithResponse request ListSegmentsForAContactWithResponse(ctx context.Context, contactId string, params *ListSegmentsForAContactParams, reqEditors ...RequestEditorFn) (*ListSegmentsForAContactResponse, error) @@ -46205,7 +46205,7 @@ func (c *ClientWithResponses) ListNotesWithResponse(ctx context.Context, contact } // CreateNoteWithBodyWithResponse request with arbitrary body returning *CreateNoteResponse -func (c *ClientWithResponses) CreateNoteWithBodyWithResponse(ctx context.Context, contactId int, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) { +func (c *ClientWithResponses) CreateNoteWithBodyWithResponse(ctx context.Context, contactId string, params *CreateNoteParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) { rsp, err := c.CreateNoteWithBody(ctx, contactId, params, contentType, body, reqEditors...) if err != nil { return nil, err @@ -46213,7 +46213,7 @@ func (c *ClientWithResponses) CreateNoteWithBodyWithResponse(ctx context.Context return ParseCreateNoteResponse(rsp) } -func (c *ClientWithResponses) CreateNoteWithResponse(ctx context.Context, contactId int, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) { +func (c *ClientWithResponses) CreateNoteWithResponse(ctx context.Context, contactId string, params *CreateNoteParams, body CreateNoteJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateNoteResponse, error) { rsp, err := c.CreateNote(ctx, contactId, params, body, reqEditors...) if err != nil { return nil, err diff --git a/internal/generated/intercom/live_compat.go b/internal/generated/intercom/live_compat.go new file mode 100644 index 0000000..44c21e1 --- /dev/null +++ b/internal/generated/intercom/live_compat.go @@ -0,0 +1,311 @@ +package intercom + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "time" +) + +// The Intercom API has a handful of response fields whose wire representation +// is broader than the pinned OpenAPI schema. Keep the generated public field +// types stable while accepting the representations observed against the live +// API. + +func (value *AdminSchema) UnmarshalJSON(data []byte) error { + type alias AdminSchema + decoded := struct { + *alias + Avatar json.RawMessage `json:"avatar"` + }{alias: (*alias)(value)} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + if len(decoded.Avatar) == 0 || bytes.Equal(decoded.Avatar, []byte("null")) { + return nil + } + + var avatar string + if err := json.Unmarshal(decoded.Avatar, &avatar); err == nil { + value.Avatar = &avatar + return nil + } + var object struct { + ImageURL *string `json:"image_url"` + } + if err := json.Unmarshal(decoded.Avatar, &object); err != nil { + return fmt.Errorf("decode admin avatar: %w", err) + } + value.Avatar = object.ImageURL + return nil +} + +func (value *CallSchema) UnmarshalJSON(data []byte) error { + type alias CallSchema + decoded := struct { + *alias + AdminID json.RawMessage `json:"admin_id"` + ContactID json.RawMessage `json:"contact_id"` + ConversationID json.RawMessage `json:"conversation_id"` + ID json.RawMessage `json:"id"` + }{alias: (*alias)(value)} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + fields := []struct { + name string + raw json.RawMessage + dest **string + }{ + {name: "admin_id", raw: decoded.AdminID, dest: &value.AdminId}, + {name: "contact_id", raw: decoded.ContactID, dest: &value.ContactId}, + {name: "conversation_id", raw: decoded.ConversationID, dest: &value.ConversationId}, + {name: "id", raw: decoded.ID, dest: &value.Id}, + } + for _, field := range fields { + parsed, err := flexibleString(field.raw) + if err != nil { + return fmt.Errorf("decode call %s: %w", field.name, err) + } + *field.dest = parsed + } + return nil +} + +func (value *ConversationStatisticsSchema) UnmarshalJSON(data []byte) error { + type alias ConversationStatisticsSchema + normalized, err := normalizeIntegralNumberFields(data) + if err != nil { + return err + } + decoded := struct { + *alias + LastClosedByID json.RawMessage `json:"last_closed_by_id"` + }{alias: (*alias)(value)} + if err := json.Unmarshal(normalized, &decoded); err != nil { + return err + } + lastClosedByID, err := flexibleString(decoded.LastClosedByID) + if err != nil { + return fmt.Errorf("decode last_closed_by_id: %w", err) + } + value.LastClosedById = lastClosedByID + return nil +} + +func (value *ConversationResponseTimeSchema) UnmarshalJSON(data []byte) error { + type alias ConversationResponseTimeSchema + normalized, err := normalizeIntegralNumberFields(data) + if err != nil { + return err + } + return json.Unmarshal(normalized, (*alias)(value)) +} + +func (value *CursorPagesSchema) UnmarshalJSON(data []byte) error { + type alias CursorPagesSchema + decoded := struct { + *alias + Next json.RawMessage `json:"next"` + PerPage json.RawMessage `json:"per_page"` + }{alias: (*alias)(value)} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + + perPage, err := flexibleInt(decoded.PerPage) + if err != nil { + return fmt.Errorf("decode cursor per_page: %w", err) + } + value.PerPage = perPage + + if len(decoded.Next) == 0 || bytes.Equal(decoded.Next, []byte("null")) { + value.Next = nil + return nil + } + var cursor string + if err := json.Unmarshal(decoded.Next, &cursor); err == nil { + value.Next = &StartingAfterPagingSchema{StartingAfter: &cursor} + return nil + } + var next StartingAfterPagingSchema + if err := json.Unmarshal(decoded.Next, &next); err != nil { + return fmt.Errorf("decode cursor next: %w", err) + } + value.Next = &next + return nil +} + +func (value *EmailSettingSchema) UnmarshalJSON(data []byte) error { + type alias EmailSettingSchema + decoded := struct { + *alias + CreatedAt json.RawMessage `json:"created_at"` + ForwardedEmailLastReceivedAt json.RawMessage `json:"forwarded_email_last_received_at"` + UpdatedAt json.RawMessage `json:"updated_at"` + }{alias: (*alias)(value)} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + + fields := []struct { + name string + raw json.RawMessage + dest **int + }{ + {name: "created_at", raw: decoded.CreatedAt, dest: &value.CreatedAt}, + {name: "forwarded_email_last_received_at", raw: decoded.ForwardedEmailLastReceivedAt, dest: &value.ForwardedEmailLastReceivedAt}, + {name: "updated_at", raw: decoded.UpdatedAt, dest: &value.UpdatedAt}, + } + for _, field := range fields { + parsed, err := flexibleTimestamp(field.raw) + if err != nil { + return fmt.Errorf("decode email %s: %w", field.name, err) + } + *field.dest = parsed + } + return nil +} + +func (value *PartAttachmentSchema) UnmarshalJSON(data []byte) error { + type alias PartAttachmentSchema + decoded := struct { + *alias + Filesize json.RawMessage `json:"filesize"` + Height json.RawMessage `json:"height"` + Width json.RawMessage `json:"width"` + }{alias: (*alias)(value)} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + fields := []struct { + name string + raw json.RawMessage + dest **int + }{ + {name: "filesize", raw: decoded.Filesize, dest: &value.Filesize}, + {name: "height", raw: decoded.Height, dest: &value.Height}, + {name: "width", raw: decoded.Width, dest: &value.Width}, + } + for _, field := range fields { + parsed, err := flexibleInt(field.raw) + if err != nil { + return fmt.Errorf("decode attachment %s: %w", field.name, err) + } + *field.dest = parsed + } + return nil +} + +func (value *WhatsappMessageStatusListSchema) UnmarshalJSON(data []byte) error { + type alias WhatsappMessageStatusListSchema + var document map[string]json.RawMessage + if err := json.Unmarshal(data, &document); err != nil { + return err + } + if rawPages := document["pages"]; len(rawPages) != 0 { + var pages map[string]json.RawMessage + if err := json.Unmarshal(rawPages, &pages); err != nil { + return err + } + if rawPerPage := pages["per_page"]; len(rawPerPage) != 0 { + perPage, err := flexibleInt(rawPerPage) + if err != nil { + return fmt.Errorf("decode WhatsApp per_page: %w", err) + } + if perPage != nil { + pages["per_page"] = json.RawMessage(strconv.Itoa(*perPage)) + } + } + normalized, err := json.Marshal(pages) + if err != nil { + return err + } + document["pages"] = normalized + } + normalized, err := json.Marshal(document) + if err != nil { + return err + } + return json.Unmarshal(normalized, (*alias)(value)) +} + +func flexibleString(raw json.RawMessage) (*string, error) { + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return &text, nil + } + var number json.Number + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&number); err != nil { + return nil, err + } + text = number.String() + return &text, nil +} + +func flexibleInt(raw json.RawMessage) (*int, error) { + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + integer, err := strconv.Atoi(text) + if err != nil { + return nil, err + } + return &integer, nil + } + var number float64 + if err := json.Unmarshal(raw, &number); err != nil { + return nil, err + } + integer := int(number) + if float64(integer) != number { + return nil, fmt.Errorf("%v is not an integer", number) + } + return &integer, nil +} + +func flexibleTimestamp(raw json.RawMessage) (*int, error) { + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + if integer, err := strconv.Atoi(text); err == nil { + return &integer, nil + } + parsed, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + return nil, err + } + seconds := int(parsed.Unix()) + return &seconds, nil + } + return flexibleInt(raw) +} + +func normalizeIntegralNumberFields(data []byte) ([]byte, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return nil, err + } + for name, raw := range object { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || (!bytes.ContainsAny(trimmed, ".eE")) { + continue + } + number, err := strconv.ParseFloat(string(trimmed), 64) + if err != nil || number != float64(int64(number)) { + continue + } + object[name] = json.RawMessage(strconv.FormatInt(int64(number), 10)) + } + return json.Marshal(object) +} diff --git a/internal/tools/normalize-spec/main.go b/internal/tools/normalize-spec/main.go index 20e0073..fdeaf27 100644 --- a/internal/tools/normalize-spec/main.go +++ b/internal/tools/normalize-spec/main.go @@ -28,6 +28,7 @@ func main() { } patchPathParameters(&spec) + patchLiveAPICompatibility(&spec) patchComponentGoNames(&spec) patchConversationAttributeDiscriminator(&spec) patchPropertyGoNames(&spec) @@ -44,6 +45,49 @@ func main() { } } +// patchLiveAPICompatibility corrects upstream schema constraints that do not +// match identifiers observed from the live API. Keeping these transformations +// in the normalizer leaves the pinned upstream specification untouched while +// making generation reproducible. +func patchLiveAPICompatibility(spec *yaml.Node) { + setScalar(lookup(spec, "components", "schemas", "create_conversation_request", "properties", "from", "properties", "id", "format"), "") + setScalar(lookup(spec, "components", "schemas", "error", "properties", "request_id", "format"), "") + medianTime := lookup(spec, "components", "schemas", "conversation_statistics", "properties", "median_time_to_reply") + setMappingScalar(medianTime, "type", "number") + setMappingScalar(medianTime, "format", "double") + + parameters := lookup(spec, "paths", "/contacts/{contact_id}/notes", "post", "parameters") + if parameters == nil || parameters.Kind != yaml.SequenceNode { + return + } + for _, parameter := range parameters.Content { + if scalarValue(lookup(parameter, "name")) == "contact_id" { + setScalar(lookup(parameter, "schema", "type"), "string") + } + } +} + +func setMappingScalar(mapping *yaml.Node, key, value string) { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return + } + if existing := lookup(mapping, key); existing != nil { + setScalar(existing, value) + return + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, + ) +} + +func setScalar(node *yaml.Node, value string) { + if node == nil || node.Kind != yaml.ScalarNode { + return + } + node.Value = value +} + func patchPropertyGoNames(spec *yaml.Node) { schemas := lookup(spec, "components", "schemas") if schemas == nil || schemas.Kind != yaml.MappingNode { diff --git a/live_api_integration_test.go b/live_api_integration_test.go new file mode 100644 index 0000000..b53553a --- /dev/null +++ b/live_api_integration_test.go @@ -0,0 +1,462 @@ +//go:build live + +package intercom + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "testing" + "time" + + gen "github.com/uffejaeger/intercom-go/internal/generated/intercom" +) + +func TestLiveAPIReadCompatibility(t *testing.T) { + client := newLiveAPIClient(t) + + t.Run("issue 77 admin activity pagination", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + _, err := client.Admins.ListActivityLogs(ctx, strconv.FormatInt(time.Now().Add(-365*24*time.Hour).Unix(), 10)) + liveRequireNoError(t, err) + }) + + t.Run("issue 78 call IDs", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + _, err := client.Calls.List(ctx) + liveRequireNoError(t, err) + }) + + t.Run("issue 79 conversation statistics", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + _, err := client.Conversations.List(ctx) + liveRequireNoError(t, err) + }) + + t.Run("issue 80 email timestamps", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + _, err := client.Emails.List(ctx) + liveRequireNoError(t, err) + }) + + t.Run("issue 84 admin avatar", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + me, err := client.Admins.Me(ctx) + liveRequireNoError(t, err) + if me.Id == nil || *me.Id == "" { + t.Fatal("Admins.Me returned no ID") + } + _, err = client.Admins.Retrieve(ctx, *me.Id) + liveRequireNoError(t, err) + }) + + t.Run("issues 88 98 and 100 typed errors", func(t *testing.T) { + checks := []struct { + name string + call func(context.Context) error + }{ + {name: "brand", call: func(ctx context.Context) error { + _, err := client.Brands.Retrieve(ctx, "intercom-go-missing") + return err + }}, + {name: "macro", call: func(ctx context.Context) error { _, err := client.Macros.Get(ctx, "intercom-go-missing"); return err }}, + {name: "team", call: func(ctx context.Context) error { + _, err := client.Teams.Retrieve(ctx, "intercom-go-missing") + return err + }}, + {name: "job", call: func(ctx context.Context) error { + _, err := client.Workspace.JobStatus(ctx, "intercom-go-missing") + return err + }}, + {name: "visitor", call: func(ctx context.Context) error { + _, err := client.Visitors.GetByUserID(ctx, "intercom-go-missing") + return err + }}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + err := check.call(ctx) + if !IsNotFound(err) { + t.Fatalf("error = %T %v, want typed 404", err, err) + } + }) + } + }) + + t.Run("issue 83 merge history for a listed user", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + contacts, err := client.Contacts.List(ctx) + liveRequireNoError(t, err) + if contacts.Data == nil { + t.Skip("workspace has no contacts") + } + for _, contact := range *contacts.Data { + if contact.Id == nil || contact.Role == nil || *contact.Role != "user" { + continue + } + _, err = client.Contacts.ListMergeHistory(ctx, *contact.Id) + liveRequireNoError(t, err) + return + } + t.Skip("workspace has no listed user contact") + }) + + t.Run("issue 85 audience list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + list, err := client.Audiences.List(ctx, nil) + liveRequireNoError(t, err) + if list.Data == nil || len(*list.Data) == 0 || (*list.Data)[0].Id == nil { + t.Skip("workspace has no audiences") + } + _, err = client.Audiences.Get(ctx, *(*list.Data)[0].Id) + liveRequireNoError(t, err) + }) + + t.Run("issue 86 content snippet list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + list, err := client.Content.ListSnippets(ctx, nil) + liveRequireNoError(t, err) + if list.Data == nil || len(*list.Data) == 0 || (*list.Data)[0].Id == nil { + t.Skip("workspace has no content snippets") + } + _, err = client.Content.GetSnippet(ctx, *(*list.Data)[0].Id) + liveRequireNoError(t, err) + }) + + t.Run("issue 87 news item list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + list, err := client.News.ListItems(ctx) + liveRequireNoError(t, err) + if list.Data == nil || len(*list.Data) == 0 || (*list.Data)[0].Id == nil { + t.Skip("workspace has no news items") + } + _, err = client.News.RetrieveItem(ctx, *(*list.Data)[0].Id) + liveRequireNoError(t, err) + }) + + t.Run("issue 89 empty article draft", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + list, err := client.Articles.List(ctx) + liveRequireNoError(t, err) + if list.Data == nil || len(*list.Data) == 0 || (*list.Data)[0].Id == nil { + t.Skip("workspace has no articles") + } + _, err = client.Articles.GetDraft(ctx, *(*list.Data)[0].Id) + if err != nil && strings.Contains(err.Error(), "unexpected end of JSON input") { + t.Fatalf("generated JSON error leaked: %v", err) + } + }) + + t.Run("issue 90 office hours exception list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + schedules, err := client.OfficeHours.ListSchedules(ctx, nil) + liveRequireNoError(t, err) + if schedules.Data == nil || len(*schedules.Data) == 0 || (*schedules.Data)[0].Id == nil { + t.Skip("workspace has no office-hours schedules") + } + scheduleID := *(*schedules.Data)[0].Id + exceptions, err := client.OfficeHours.ListExceptions(ctx, scheduleID, nil) + liveRequireNoError(t, err) + if exceptions.Data == nil || len(*exceptions.Data) == 0 || (*exceptions.Data)[0].Id == nil { + t.Skip("workspace has no office-hours exceptions") + } + _, err = client.OfficeHours.GetException(ctx, scheduleID, *(*exceptions.Data)[0].Id) + liveRequireNoError(t, err) + }) + + t.Run("issue 91 content import source list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + list, err := client.AIContent.ListContentImportSources(ctx) + liveRequireNoError(t, err) + if list.Data == nil || len(*list.Data) == 0 { + t.Skip("workspace has no content import sources") + } + _, err = client.AIContent.GetContentImportSource(ctx, strconv.Itoa((*list.Data)[0].Id)) + liveRequireNoError(t, err) + }) + + t.Run("issue 92 external page list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + list, err := client.AIContent.ListExternalPages(ctx) + liveRequireNoError(t, err) + if list.Data == nil || len(*list.Data) == 0 { + t.Skip("workspace has no external pages") + } + _, err = client.AIContent.GetExternalPage(ctx, (*list.Data)[0].Id) + liveRequireNoError(t, err) + }) + + t.Run("issue 93 help center redirect list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + helpCenters, err := client.HelpCenters.List(ctx) + liveRequireNoError(t, err) + if helpCenters.Data == nil || len(*helpCenters.Data) == 0 || (*helpCenters.Data)[0].Id == nil { + t.Skip("workspace has no help centers") + } + helpCenterID := *(*helpCenters.Data)[0].Id + redirects, err := client.HelpCenterRedirects.List(ctx, helpCenterID, nil) + liveRequireNoError(t, err) + if redirects.Data == nil || len(*redirects.Data) == 0 || (*redirects.Data)[0].Id == nil { + t.Skip("workspace has no help-center redirects") + } + _, err = client.HelpCenterRedirects.Get(ctx, helpCenterID, *(*redirects.Data)[0].Id) + liveRequireNoError(t, err) + }) + + t.Run("issue 94 data connector execution list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + connectors, err := client.DataConnectors.List(ctx, nil) + liveRequireNoError(t, err) + if connectors.Data == nil || len(*connectors.Data) == 0 || (*connectors.Data)[0].Id == nil { + t.Skip("workspace has no data connectors") + } + connectorID := *(*connectors.Data)[0].Id + results, err := client.DataConnectors.ListExecutionResults(ctx, connectorID, nil) + liveRequireNoError(t, err) + if results.Data == nil || len(*results.Data) == 0 || (*results.Data)[0].Id == nil { + t.Skip("workspace has no data-connector executions") + } + _, err = client.DataConnectors.GetExecutionResult(ctx, connectorID, *(*results.Data)[0].Id) + liveRequireNoError(t, err) + }) + + t.Run("issues 95 and 96 newsfeed list detail consistency", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + feeds, err := client.News.ListFeeds(ctx) + liveRequireNoError(t, err) + if feeds.Data == nil || len(*feeds.Data) == 0 || (*feeds.Data)[0].Id == nil { + t.Skip("workspace has no newsfeeds") + } + feedID := *(*feeds.Data)[0].Id + _, err = client.News.RetrieveFeed(ctx, feedID) + liveRequireNoError(t, err) + _, err = client.News.ListFeedItems(ctx, feedID) + liveRequireNoError(t, err) + }) + + t.Run("issue 97 team metrics", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + teams, err := client.Teams.List(ctx) + liveRequireNoError(t, err) + if teams.Teams == nil || len(*teams.Teams) == 0 || (*teams.Teams)[0].Id == nil { + t.Skip("workspace has no teams") + } + idleThreshold := 1800 + _, err = client.Teams.Metrics(ctx, *(*teams.Teams)[0].Id, &TeamMetricsParams{IdleThreshold: &idleThreshold}) + if err != nil && !IsServerError(err) { + t.Fatalf("live API call failed: %T %v", err, err) + } + }) + + t.Run("issue 101 existing company contact association", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + contacts, err := client.Contacts.List(ctx) + liveRequireNoError(t, err) + if contacts.Data == nil { + t.Skip("workspace has no contacts") + } + for _, contact := range *contacts.Data { + if contact.Id == nil || contact.Companies == nil || contact.Companies.Data == nil { + continue + } + for _, company := range *contact.Companies.Data { + if company.Id == nil { + continue + } + companies, companiesErr := client.Companies.ListForContact(ctx, *contact.Id) + contacts, contactsErr := client.Companies.ListContacts(ctx, *company.Id) + liveRequireNoError(t, companiesErr) + liveRequireNoError(t, contactsErr) + if !liveHasCompany(companies, *company.Id) || !liveHasContact(contacts, *contact.Id) { + t.Fatalf("known association missing from list endpoints: contact=%s company=%s", *contact.Id, *company.Id) + } + return + } + } + t.Skip("workspace has no listed contact/company associations") + }) +} + +func TestLiveAPIFixtureCompatibility(t *testing.T) { + client := newLiveAPIClient(t) + unique := liveUnique("intercom-go-live") + role := "user" + name := "Intercom Go Live Regression" + email := unique + "@example.com" + externalID := unique + + ctx, cancel := liveAPIContext(t) + contact, err := client.Contacts.Create(ctx, ContactCreate{ + Email: &email, + ExternalId: &externalID, + Name: &name, + Role: &role, + }) + cancel() + liveRequireNoError(t, err) + if contact.Id == nil || *contact.Id == "" { + t.Fatal("created contact has no ID") + } + contactID := *contact.Id + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, _ = client.Contacts.Delete(ctx, contactID) + }) + + companyExternalID := unique + "-company" + ctx, cancel = liveAPIContext(t) + company, err := client.Companies.CreateOrUpdate(ctx, CompanyCreate{ + CompanyId: &companyExternalID, + Name: &name, + }) + cancel() + liveRequireNoError(t, err) + if company.Id == nil || *company.Id == "" { + t.Fatal("created company has no ID") + } + companyID := *company.Id + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, _ = client.Companies.DetachContact(ctx, contactID, companyID) + _, _ = client.Companies.Delete(ctx, companyID) + }) + + t.Run("issue 99 create note with Intercom contact ID", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + admin, err := client.Admins.Me(ctx) + liveRequireNoError(t, err) + if admin.Id == nil { + t.Fatal("Admins.Me returned no ID") + } + _, err = client.Contacts.CreateNote(ctx, contactID, "Intercom Go live regression note", *admin.Id) + liveRequireNoError(t, err) + }) + + t.Run("issue 82 create conversation with Intercom contact ID", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + _, err := client.Conversations.Create(ctx, ConversationCreate{ + Body: "Intercom Go live regression conversation", + From: struct { + Id string `json:"id"` + Type gen.CreateConversationRequestFromType `json:"type"` + }{Id: contactID, Type: "user"}, + }) + liveRequireNoError(t, err) + }) + + t.Run("issue 83 merge history for valid contact", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + _, err := client.Contacts.ListMergeHistory(ctx, contactID) + liveRequireNoError(t, err) + }) + + t.Run("issue 101 company contact association", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + _, err := client.Companies.AttachContact(ctx, contactID, companyID) + cancel() + liveRequireNoError(t, err) + + deadline := time.Now().Add(10 * time.Second) + for { + ctx, cancel := liveAPIContext(t) + companies, companiesErr := client.Companies.ListForContact(ctx, contactID) + contacts, contactsErr := client.Companies.ListContacts(ctx, companyID) + cancel() + if companiesErr == nil && contactsErr == nil && liveHasCompany(companies, companyID) && liveHasContact(contacts, contactID) { + return + } + if time.Now().After(deadline) { + if companiesErr != nil || contactsErr != nil { + t.Fatalf("association lists failed: companies=%v contacts=%v", companiesErr, contactsErr) + } + t.Fatalf("attached resources absent from association lists") + } + time.Sleep(500 * time.Millisecond) + } + }) +} + +func newLiveAPIClient(t *testing.T) *Client { + t.Helper() + if os.Getenv("INTERCOM_LIVE_TESTS") != "1" { + t.Skip("set INTERCOM_LIVE_TESTS=1 to run live API tests") + } + client, err := NewClientFromEnv(WithRetry(RetryConfig{ + MaxAttempts: 3, + InitialBackoff: 250 * time.Millisecond, + MaxBackoff: 2 * time.Second, + Jitter: 0.1, + })) + if err != nil { + t.Fatalf("NewClientFromEnv: %v", err) + } + return client +} + +func liveAPIContext(t *testing.T) (context.Context, context.CancelFunc) { + t.Helper() + return context.WithTimeout(context.Background(), 30*time.Second) +} + +func liveRequireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("live API call failed: %T %v", err, err) + } +} + +func liveUnique(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +func liveHasCompany(companies *ContactCompanies, companyID string) bool { + if companies == nil || companies.Companies == nil { + return false + } + for _, company := range *companies.Companies { + if company.Id != nil && *company.Id == companyID { + return true + } + } + return false +} + +func liveHasContact(contacts *CompanyContacts, contactID string) bool { + if contacts == nil || contacts.Data == nil { + return false + } + for _, contact := range *contacts.Data { + if contact.Id != nil && *contact.Id == contactID { + return true + } + } + return false +} diff --git a/live_api_regression_test.go b/live_api_regression_test.go new file mode 100644 index 0000000..2d4a1a2 --- /dev/null +++ b/live_api_regression_test.go @@ -0,0 +1,285 @@ +package intercom + +import ( + "context" + "errors" + "io" + "net/http" + "reflect" + "strings" + "testing" +) + +func TestLiveAPIResponseCompatibility(t *testing.T) { + tests := []struct { + name string + response string + call func(context.Context, *Client) error + }{ + { + name: "WhatsApp pagination accepts string per_page", + response: `{"type":"list","ruleset_id":"ruleset-1","pages":{"type":"pages","per_page":"20","total_pages":1,"next":null},"total_count":0,"events":[]}`, + call: func(ctx context.Context, client *Client) error { + statuses, err := client.WhatsApp.GetMessageStatus(ctx, &WhatsAppMessageStatusParams{RulesetId: "ruleset-1"}) + if err != nil { + return err + } + if statuses.Pages.PerPage != 20 { + t.Fatalf("Pages.PerPage = %d, want 20", statuses.Pages.PerPage) + } + return nil + }, + }, + { + name: "admin activity pagination accepts string next cursor", + response: `{"type":"activity_log.list","activity_logs":[],"pages":{"type":"pages","next":"cursor-1","per_page":50}}`, + call: func(ctx context.Context, client *Client) error { + logs, err := client.Admins.ListActivityLogs(ctx, "1700000000") + if err != nil { + return err + } + if logs.Pages == nil || logs.Pages.Next == nil || logs.Pages.Next.StartingAfter == nil || *logs.Pages.Next.StartingAfter != "cursor-1" { + t.Fatalf("Pages.Next = %#v", logs.Pages) + } + return nil + }, + }, + { + name: "calls accept numeric IDs", + response: `{"type":"list","data":[{"type":"call","id":123,"conversation_id":456,"admin_id":789,"contact_id":101112}],"pages":{"type":"pages"}}`, + call: func(ctx context.Context, client *Client) error { + calls, err := client.Calls.List(ctx) + if err != nil { + return err + } + if calls.Data == nil || len(*calls.Data) != 1 || (*calls.Data)[0].Id == nil || *(*calls.Data)[0].Id != "123" { + t.Fatalf("calls.Data = %#v", calls.Data) + } + call := (*calls.Data)[0] + if call.ConversationId == nil || *call.ConversationId != "456" || call.AdminId == nil || *call.AdminId != "789" || call.ContactId == nil || *call.ContactId != "101112" { + t.Fatalf("call identifiers = %#v", call) + } + return nil + }, + }, + { + name: "conversation statistics accept decimal integer values", + response: `{"type":"conversation.list","conversations":[{"type":"conversation","id":"conversation-1","source":{"attachments":[{"type":"upload","width":"640","height":"480","filesize":"1234"}]},"statistics":{"median_time_to_reply":11.5,"last_closed_by_id":999,"assigned_team_first_response_time":[{"response_time":8.0,"team_id":42}]}}],"total_count":1}`, + call: func(ctx context.Context, client *Client) error { + conversations, err := client.Conversations.List(ctx) + if err != nil { + return err + } + if conversations.Conversations == nil || len(*conversations.Conversations) != 1 { + t.Fatalf("Conversations = %#v", conversations.Conversations) + } + statistics := (*conversations.Conversations)[0].Statistics + if statistics == nil || statistics.MedianTimeToReply == nil || *statistics.MedianTimeToReply != 11.5 { + t.Fatalf("Statistics = %#v", statistics) + } + if statistics.AssignedTeamFirstResponseTime == nil || len(*statistics.AssignedTeamFirstResponseTime) != 1 || (*statistics.AssignedTeamFirstResponseTime)[0].ResponseTime == nil || *(*statistics.AssignedTeamFirstResponseTime)[0].ResponseTime != 8 { + t.Fatalf("AssignedTeamFirstResponseTime = %#v", statistics.AssignedTeamFirstResponseTime) + } + if statistics.LastClosedById == nil || *statistics.LastClosedById != "999" { + t.Fatalf("LastClosedById = %v", statistics.LastClosedById) + } + source := (*conversations.Conversations)[0].Source + if source == nil || source.Attachments == nil || len(*source.Attachments) != 1 { + t.Fatalf("Source = %#v", source) + } + attachment := (*source.Attachments)[0] + if attachment.Width == nil || *attachment.Width != 640 || attachment.Height == nil || *attachment.Height != 480 || attachment.Filesize == nil || *attachment.Filesize != 1234 { + t.Fatalf("attachment dimensions = %#v", attachment) + } + return nil + }, + }, + { + name: "email settings accept RFC3339 timestamps", + response: `{"type":"list","data":[{"type":"email","id":"email-1","created_at":"2025-07-21T14:44:35.000Z"}]}`, + call: func(ctx context.Context, client *Client) error { + emails, err := client.Emails.List(ctx) + if err != nil { + return err + } + if emails.Data == nil || len(*emails.Data) != 1 || (*emails.Data)[0].CreatedAt == nil || *(*emails.Data)[0].CreatedAt != 1753109075 { + t.Fatalf("emails.Data = %#v", emails.Data) + } + return nil + }, + }, + { + name: "admin detail accepts avatar objects", + response: `{"type":"admin","id":"1","avatar":{"type":"avatar","image_url":"https://example.test/avatar.png"}}`, + call: func(ctx context.Context, client *Client) error { + admin, err := client.Admins.Retrieve(ctx, "1") + if err != nil { + return err + } + if admin.Avatar == nil || *admin.Avatar != "https://example.test/avatar.png" { + t.Fatalf("Avatar = %v", admin.Avatar) + } + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := liveRegressionClient(t, http.StatusOK, tt.response, nil) + if err := tt.call(context.Background(), client); err != nil { + t.Fatalf("call returned error: %v", err) + } + }) + } +} + +func TestLiveAPINonUUIDRequestIDPreservesTypedError(t *testing.T) { + client := liveRegressionClient(t, http.StatusNotFound, `{"type":"error.list","request_id":"0055o8c7almlkvraip50","errors":[{"code":"not_found","message":"Resource Not Found"}]}`, nil) + checks := []struct { + name string + call func(context.Context) error + }{ + {name: "brand", call: func(ctx context.Context) error { _, err := client.Brands.Retrieve(ctx, "missing"); return err }}, + {name: "macro", call: func(ctx context.Context) error { _, err := client.Macros.Get(ctx, "missing"); return err }}, + {name: "team", call: func(ctx context.Context) error { _, err := client.Teams.Retrieve(ctx, "missing"); return err }}, + {name: "news item", call: func(ctx context.Context) error { _, err := client.News.RetrieveItem(ctx, "1"); return err }}, + {name: "office-hours exception", call: func(ctx context.Context) error { + _, err := client.OfficeHours.GetException(ctx, "schedule", "exception") + return err + }}, + {name: "data-connector execution", call: func(ctx context.Context) error { + _, err := client.DataConnectors.GetExecutionResult(ctx, "connector", "execution") + return err + }}, + {name: "job", call: func(ctx context.Context) error { _, err := client.Workspace.JobStatus(ctx, "job"); return err }}, + {name: "visitor", call: func(ctx context.Context) error { _, err := client.Visitors.GetByUserID(ctx, "external-id"); return err }}, + {name: "article draft", call: func(ctx context.Context) error { _, err := client.Articles.GetDraft(ctx, "1"); return err }}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + err := check.call(context.Background()) + if !IsNotFound(err) { + t.Fatalf("error = %T %v, want typed 404", err, err) + } + var apiErr *ErrorResponse + if !errors.As(err, &apiErr) { + t.Fatalf("error = %T, want *ErrorResponse", err) + } + if apiErr.RequestID != "0055o8c7almlkvraip50" { + t.Fatalf("RequestID = %q", apiErr.RequestID) + } + }) + } +} + +func TestLiveAPIContactNoteUsesStringContactID(t *testing.T) { + const contactID = "6762f0ad1bb69f9f2193bb62" + var gotPath string + client := liveRegressionClient(t, http.StatusOK, `{"type":"note","id":"31","body":"

Hello

"}`, func(req *http.Request) { + gotPath = req.URL.EscapedPath() + }) + + if _, err := client.Contacts.CreateNote(context.Background(), contactID, "Hello", "991267583"); err != nil { + t.Fatalf("CreateNote returned error: %v", err) + } + if gotPath != "/contacts/"+contactID+"/notes" { + t.Fatalf("path = %q", gotPath) + } +} + +func TestLiveAPIMergeHistoryFallsBackToContactField(t *testing.T) { + const contactID = "6762f0ad1bb69f9f2193bb62" + requestCount := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + requestCount++ + status := http.StatusNotFound + body := `{"type":"error.list","request_id":"request-1","errors":[{"code":"not_found","message":"missing"}]}` + switch requestCount { + case 1: + if req.URL.EscapedPath() != "/contacts/"+contactID+"/merge_history" { + t.Fatalf("first path = %q", req.URL.EscapedPath()) + } + case 2: + if req.URL.EscapedPath() != "/contacts/"+contactID || req.URL.Query().Get("include_merge_history") != "true" { + t.Fatalf("fallback URL = %s", req.URL.String()) + } + status = http.StatusOK + body = `{"type":"contact","id":"6762f0ad1bb69f9f2193bb62","role":"user","merge_history":[{"type":"merge_history","source_contact_id":"6762f0ad1bb69f9f2193bb61","source_contact_role":"lead","merged_at":1700000000}]}` + default: + t.Fatalf("unexpected request %d", requestCount) + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + client, err := NewClient("token", WithBaseURL("https://example.test"), WithHTTPClient(&http.Client{Transport: transport})) + if err != nil { + t.Fatalf("NewClient returned error: %v", err) + } + + history, err := client.Contacts.ListMergeHistory(context.Background(), contactID) + if err != nil { + t.Fatalf("ListMergeHistory returned error: %v", err) + } + if requestCount != 2 || history.Data == nil || len(*history.Data) != 1 || (*history.Data)[0].SourceContactId == nil || *(*history.Data)[0].SourceContactId != "6762f0ad1bb69f9f2193bb61" { + t.Fatalf("history = %#v; requests = %d", history, requestCount) + } +} + +func TestLiveAPIConversationCreateUsesStringContactID(t *testing.T) { + typeOfRequest := reflect.TypeFor[ConversationCreate]() + from, ok := typeOfRequest.FieldByName("From") + if !ok { + t.Fatal("ConversationCreate.From is missing") + } + id, ok := from.Type.FieldByName("Id") + if !ok { + t.Fatal("ConversationCreate.From.Id is missing") + } + if id.Type.Kind() != reflect.String { + t.Fatalf("ConversationCreate.From.Id kind = %s, want string", id.Type.Kind()) + } +} + +func TestLiveAPIEmptyArticleDraftResponseIsSemanticError(t *testing.T) { + client := liveRegressionClient(t, http.StatusOK, "", nil) + + _, err := client.Articles.GetDraft(context.Background(), "123") + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), "unexpected end of JSON input") { + t.Fatalf("error leaked generated JSON failure: %v", err) + } + if !strings.Contains(err.Error(), "without a response body") { + t.Fatalf("error = %v, want missing response body error", err) + } +} + +func liveRegressionClient(t *testing.T, status int, response string, inspect func(*http.Request)) *Client { + t.Helper() + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if inspect != nil { + inspect(req) + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(response)), + Request: req, + }, nil + }) + client, err := NewClient( + "token", + WithBaseURL("https://example.test"), + WithHTTPClient(&http.Client{Transport: transport}), + ) + if err != nil { + t.Fatalf("NewClient returned error: %v", err) + } + return client +} diff --git a/openapi_contract_test.go b/openapi_contract_test.go index f643b76..894269a 100644 --- a/openapi_contract_test.go +++ b/openapi_contract_test.go @@ -15,7 +15,8 @@ func TestGeneratedOperationsAreAccountedFor(t *testing.T) { generatedOperations := generatedResponseOperations(t) wrappedOperations := wrappedGeneratedOperations(t) exceptions := map[string]string{ - "LisDataEvents": "DataEvents.List uses Client.NewRequest and Client.Do for explicit identifier validation and query encoding.", + "LisDataEvents": "DataEvents.List uses Client.NewRequest and Client.Do for explicit identifier validation and query encoding.", + "RetrieveArticleDraft": "Articles.GetDraft uses the raw generated operation so an empty successful response can be reported as a semantic API error.", } var missing []string diff --git a/response.go b/response.go index 1c52331..d5459b5 100644 --- a/response.go +++ b/response.go @@ -1,8 +1,10 @@ package intercom import ( + "bytes" "encoding/json" "fmt" + "io" "net/http" ) @@ -50,6 +52,29 @@ func requireJSON[T any](operation string, statusCode int, body []byte, headers . return &value, nil } +func requireHTTPJSON[T any](operation string, response *http.Response) (*T, error) { + if response == nil { + return nil, fmt.Errorf("intercom: %s returned no HTTP response", operation) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("intercom: read %s response: %w", operation, err) + } + if response.StatusCode != http.StatusOK { + return nil, parseErrorResponse(response.StatusCode, body, response.Header) + } + if len(bytes.TrimSpace(body)) == 0 { + return nil, fmt.Errorf("intercom: %s returned status %d without a response body", operation, response.StatusCode) + } + + var value T + if err := json.Unmarshal(body, &value); err != nil { + return nil, fmt.Errorf("intercom: decode %s response: %w", operation, err) + } + return &value, nil +} + func responseHeaders(response *http.Response) http.Header { if response == nil { return nil diff --git a/response_test.go b/response_test.go index 0dd8061..4806bf0 100644 --- a/response_test.go +++ b/response_test.go @@ -1,7 +1,10 @@ package intercom import ( + "errors" + "io" "net/http" + "strings" "testing" ) @@ -23,6 +26,28 @@ func TestResponseHeadersNil(t *testing.T) { } } +func TestRequireHTTPJSONTransportFailures(t *testing.T) { + if _, err := requireHTTPJSON[string]("test operation", nil); err == nil || !strings.Contains(err.Error(), "no HTTP response") { + t.Fatalf("nil response error = %v", err) + } + + response := &http.Response{ + StatusCode: http.StatusOK, + Body: requireHTTPJSONErrorReader{err: errors.New("read failed")}, + } + if _, err := requireHTTPJSON[string]("test operation", response); err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("read error = %v", err) + } + + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("{")), + } + if _, err := requireHTTPJSON[string]("test operation", response); err == nil || !strings.Contains(err.Error(), "decode test operation response") { + t.Fatalf("decode error = %v", err) + } +} + func TestRequireOKErrors(t *testing.T) { tests := []struct { name string @@ -68,3 +93,8 @@ func TestRequireOKErrors(t *testing.T) { func ptr[T any](value T) *T { return &value } + +type requireHTTPJSONErrorReader struct{ err error } + +func (reader requireHTTPJSONErrorReader) Read([]byte) (int, error) { return 0, reader.err } +func (requireHTTPJSONErrorReader) Close() error { return nil } From 74a9d51b2a6f9813c92edf13e95157147e7d183f Mon Sep 17 00:00:00 2001 From: uffejaeger <71610258+uffejaeger@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:13:49 +0200 Subject: [PATCH 2/3] Cover merge history fallback errors --- live_api_regression_test.go | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/live_api_regression_test.go b/live_api_regression_test.go index 2d4a1a2..19ba49b 100644 --- a/live_api_regression_test.go +++ b/live_api_regression_test.go @@ -230,6 +230,50 @@ func TestLiveAPIMergeHistoryFallsBackToContactField(t *testing.T) { } } +func TestLiveAPIMergeHistoryFallbackErrors(t *testing.T) { + tests := []struct { + name string + secondStatus int + secondBody string + secondErr error + }{ + {name: "transport error", secondErr: errors.New("fallback failed")}, + {name: "API error", secondStatus: http.StatusInternalServerError, secondBody: `{"type":"error.list","request_id":"request-2","errors":[{"code":"server_error","message":"failed"}]}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requestCount := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + requestCount++ + if requestCount == 2 && tt.secondErr != nil { + return nil, tt.secondErr + } + status := http.StatusNotFound + body := `{"type":"error.list","request_id":"request-1","errors":[{"code":"not_found","message":"missing"}]}` + if requestCount == 2 { + status = tt.secondStatus + body = tt.secondBody + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + client, err := NewClient("token", WithBaseURL("https://example.test"), WithHTTPClient(&http.Client{Transport: transport})) + if err != nil { + t.Fatalf("NewClient returned error: %v", err) + } + + if _, err := client.Contacts.ListMergeHistory(context.Background(), "6762f0ad1bb69f9f2193bb62"); err == nil { + t.Fatal("expected fallback error") + } + }) + } +} + func TestLiveAPIConversationCreateUsesStringContactID(t *testing.T) { typeOfRequest := reflect.TypeFor[ConversationCreate]() from, ok := typeOfRequest.FieldByName("From") From 0b0c5dd9aa0b0f78f3b31e7fa797b95fb62d38b2 Mon Sep 17 00:00:00 2001 From: uffejaeger <71610258+uffejaeger@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:30:24 +0200 Subject: [PATCH 3/3] Decode live contact company associations --- internal/generated/intercom/live_compat.go | 15 +++ live_api_integration_test.go | 124 ++++++++++++++++++++- live_api_regression_test.go | 13 +++ 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/internal/generated/intercom/live_compat.go b/internal/generated/intercom/live_compat.go index 44c21e1..31b7532 100644 --- a/internal/generated/intercom/live_compat.go +++ b/internal/generated/intercom/live_compat.go @@ -73,6 +73,21 @@ func (value *CallSchema) UnmarshalJSON(data []byte) error { return nil } +func (value *ContactAttachedCompaniesSchema) UnmarshalJSON(data []byte) error { + type alias ContactAttachedCompaniesSchema + decoded := struct { + *alias + Data *[]CompanySchema `json:"data"` + }{alias: (*alias)(value)} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + if decoded.Data != nil { + value.Companies = decoded.Data + } + return nil +} + func (value *ConversationStatisticsSchema) UnmarshalJSON(data []byte) error { type alias ConversationStatisticsSchema normalized, err := normalizeIntegralNumberFields(data) diff --git a/live_api_integration_test.go b/live_api_integration_test.go index b53553a..99b1c0f 100644 --- a/live_api_integration_test.go +++ b/live_api_integration_test.go @@ -4,6 +4,7 @@ package intercom import ( "context" + "errors" "fmt" "os" "strconv" @@ -338,6 +339,7 @@ func TestLiveAPIFixtureCompatibility(t *testing.T) { t.Fatal("created company has no ID") } companyID := *company.Id + liveWaitForCompany(t, client, companyID) t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -380,16 +382,24 @@ func TestLiveAPIFixtureCompatibility(t *testing.T) { t.Run("issue 101 company contact association", func(t *testing.T) { ctx, cancel := liveAPIContext(t) - _, err := client.Companies.AttachContact(ctx, contactID, companyID) + attached, err := client.Companies.AttachContact(ctx, contactID, companyID) cancel() liveRequireNoError(t, err) + if attached == nil || attached.Id == nil { + t.Fatal("attach response has no company ID") + } + t.Logf("attach response company ID=%s; created company ID=%s", *attached.Id, companyID) - deadline := time.Now().Add(10 * time.Second) + deadline := time.Now().Add(5 * time.Minute) + var lastCompanies *ContactCompanies + var lastContacts *CompanyContacts for { ctx, cancel := liveAPIContext(t) companies, companiesErr := client.Companies.ListForContact(ctx, contactID) contacts, contactsErr := client.Companies.ListContacts(ctx, companyID) cancel() + lastCompanies = companies + lastContacts = contacts if companiesErr == nil && contactsErr == nil && liveHasCompany(companies, companyID) && liveHasContact(contacts, contactID) { return } @@ -397,11 +407,61 @@ func TestLiveAPIFixtureCompatibility(t *testing.T) { if companiesErr != nil || contactsErr != nil { t.Fatalf("association lists failed: companies=%v contacts=%v", companiesErr, contactsErr) } - t.Fatalf("attached resources absent from association lists") + t.Fatalf("attached resources absent from association lists: company IDs=%v contact IDs=%v raw contact-company response=%s", liveCompanyIDs(lastCompanies), liveContactIDs(lastContacts), liveRawCompaniesForContact(t, client, contactID)) } - time.Sleep(500 * time.Millisecond) + time.Sleep(5 * time.Second) + } + }) +} + +func TestLiveAPIContentExportJobCompatibility(t *testing.T) { + client := newLiveAPIClient(t) + now := time.Now() + + ctx, cancel := liveAPIContext(t) + export, err := client.Workspace.CreateDataExport(ctx, DataExportCreate{ + CreatedAtAfter: int(now.Add(-2 * time.Hour).Unix()), + CreatedAtBefore: int(now.Add(-time.Hour).Unix()), + }) + cancel() + liveRequireNoError(t, err) + if export.JobIdentifier == nil || *export.JobIdentifier == "" { + t.Fatal("created data export has no job identifier") + } + jobID := *export.JobIdentifier + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, _ = client.Workspace.CancelDataExport(ctx, jobID) + }) + + t.Run("issue 98 real export identifier has typed job status", func(t *testing.T) { + ctx, cancel := liveAPIContext(t) + defer cancel() + _, err := client.Workspace.JobStatus(ctx, jobID) + if err == nil { + return + } + var apiErr *ErrorResponse + if !errors.As(err, &apiErr) { + t.Fatalf("JobStatus error = %T %v, want job or typed API response", err, err) } }) + + ctx, cancel = liveAPIContext(t) + got, err := client.Workspace.GetDataExport(ctx, jobID) + cancel() + liveRequireNoError(t, err) + if got.JobIdentifier == nil || *got.JobIdentifier != jobID { + t.Fatalf("GetDataExport job identifier = %v, want %q", got.JobIdentifier, jobID) + } + + ctx, cancel = liveAPIContext(t) + _, cancelErr := client.Workspace.CancelDataExport(ctx, jobID) + cancel() + if cancelErr != nil { + t.Logf("content export could not be cancelled after verification: %v", cancelErr) + } } func newLiveAPIClient(t *testing.T) *Client { @@ -460,3 +520,59 @@ func liveHasContact(contacts *CompanyContacts, contactID string) bool { } return false } + +func liveCompanyIDs(companies *ContactCompanies) []string { + if companies == nil || companies.Companies == nil { + return nil + } + ids := make([]string, 0, len(*companies.Companies)) + for _, company := range *companies.Companies { + if company.Id != nil { + ids = append(ids, *company.Id) + } + } + return ids +} + +func liveContactIDs(contacts *CompanyContacts) []string { + if contacts == nil || contacts.Data == nil { + return nil + } + ids := make([]string, 0, len(*contacts.Data)) + for _, contact := range *contacts.Data { + if contact.Id != nil { + ids = append(ids, *contact.Id) + } + } + return ids +} + +func liveWaitForCompany(t *testing.T, client *Client, companyID string) { + t.Helper() + started := time.Now() + deadline := started.Add(60 * time.Second) + for { + ctx, cancel := liveAPIContext(t) + _, err := client.Companies.Retrieve(ctx, companyID) + cancel() + if err == nil { + t.Logf("company became readable after %s", time.Since(started).Round(time.Millisecond)) + return + } + if !IsNotFound(err) || time.Now().After(deadline) { + t.Fatalf("created company did not become readable: %T %v", err, err) + } + time.Sleep(time.Second) + } +} + +func liveRawCompaniesForContact(t *testing.T, client *Client, contactID string) string { + t.Helper() + ctx, cancel := liveAPIContext(t) + defer cancel() + response, err := client.generated.ListCompaniesForAContactWithResponse(ctx, contactID, nil) + if err != nil { + t.Fatalf("raw list companies for contact: %v", err) + } + return string(response.Body) +} diff --git a/live_api_regression_test.go b/live_api_regression_test.go index 19ba49b..afcc065 100644 --- a/live_api_regression_test.go +++ b/live_api_regression_test.go @@ -188,6 +188,19 @@ func TestLiveAPIContactNoteUsesStringContactID(t *testing.T) { } } +func TestLiveAPIContactCompanyListAcceptsDataEnvelope(t *testing.T) { + const companyID = "company-1" + client := liveRegressionClient(t, http.StatusOK, `{"type":"list","data":[{"type":"company","id":"company-1","name":"Example"}],"pages":{"type":"pages","page":1,"per_page":50,"total_pages":1},"total_count":1}`, nil) + + companies, err := client.Companies.ListForContact(context.Background(), "contact-1") + if err != nil { + t.Fatalf("ListForContact returned error: %v", err) + } + if companies.Companies == nil || len(*companies.Companies) != 1 || (*companies.Companies)[0].Id == nil || *(*companies.Companies)[0].Id != companyID { + t.Fatalf("Companies = %#v", companies.Companies) + } +} + func TestLiveAPIMergeHistoryFallsBackToContactField(t *testing.T) { const contactID = "6762f0ad1bb69f9f2193bb62" requestCount := 0