Skip to content

Commit 04eaaa8

Browse files
AchoArnoldCopilot
andcommitted
test: add contacts integration coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f3addc9-71dd-4cb7-bfae-e6fdcc8511af
1 parent 2866f10 commit 04eaaa8

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

tests/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect al
5555
- [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint
5656
- [x] **Message thread read receipts E2E** — Incoming SMS and missed calls mark a thread unread, the existing thread update endpoint marks it read, and outbound activity preserves unread state
5757
- [x] **Unarchive Thread on Receive E2E** — Archived thread returns to the inbox on inbound message when the phone's `unarchive_thread` setting is enabled, and stays archived when disabled
58+
- [x] **Contacts E2E** — JSON CRUD, search and pagination totals, CSV import normalization, and contact details attached to message threads
5859

5960
## Prerequisites
6061

tests/contacts_integration_test.go

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
package tests
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"mime/multipart"
10+
"net/http"
11+
"net/url"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/google/uuid"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
type integrationContact struct {
22+
ID string `json:"id"`
23+
Name string `json:"name"`
24+
Emails []string `json:"emails"`
25+
PhoneNumbers []string `json:"phone_numbers"`
26+
Properties map[string]string `json:"properties"`
27+
}
28+
29+
type integrationContactsResponse struct {
30+
Data []integrationContact `json:"data"`
31+
Total int64 `json:"total"`
32+
}
33+
34+
type integrationContactResponse struct {
35+
Data integrationContact `json:"data"`
36+
}
37+
38+
type integrationContactThread struct {
39+
ID string `json:"id"`
40+
Contact string `json:"contact"`
41+
ContactDetails *integrationContact `json:"contact_details"`
42+
}
43+
44+
func createIntegrationContacts(
45+
ctx context.Context,
46+
t *testing.T,
47+
contacts []map[string]any,
48+
) []integrationContact {
49+
t.Helper()
50+
51+
var response integrationContactsResponse
52+
requestJSON(
53+
ctx,
54+
t,
55+
http.MethodPost,
56+
"/v1/contacts",
57+
userAPIKey,
58+
contacts,
59+
http.StatusCreated,
60+
&response,
61+
)
62+
require.Len(t, response.Data, len(contacts))
63+
return response.Data
64+
}
65+
66+
func deleteIntegrationContact(ctx context.Context, t *testing.T, contactID string) {
67+
t.Helper()
68+
69+
requestJSON(
70+
ctx,
71+
t,
72+
http.MethodDelete,
73+
"/v1/contacts/"+contactID,
74+
userAPIKey,
75+
nil,
76+
http.StatusNoContent,
77+
nil,
78+
)
79+
}
80+
81+
func cleanupIntegrationContact(t *testing.T, contactID string) {
82+
t.Helper()
83+
84+
request, err := http.NewRequest(
85+
http.MethodDelete,
86+
apiBaseURL+"/v1/contacts/"+contactID,
87+
nil,
88+
)
89+
require.NoError(t, err)
90+
request.Header.Set("x-api-key", userAPIKey)
91+
92+
response, err := http.DefaultClient.Do(request)
93+
require.NoError(t, err)
94+
defer response.Body.Close()
95+
96+
responseBody, err := io.ReadAll(response.Body)
97+
require.NoError(t, err)
98+
require.Contains(
99+
t,
100+
[]int{http.StatusNoContent, http.StatusNotFound},
101+
response.StatusCode,
102+
"response: %s",
103+
string(responseBody),
104+
)
105+
}
106+
107+
func listIntegrationContacts(
108+
ctx context.Context,
109+
t *testing.T,
110+
query string,
111+
skip int,
112+
limit int,
113+
) integrationContactsResponse {
114+
t.Helper()
115+
116+
path := fmt.Sprintf(
117+
"/v1/contacts?query=%s&skip=%d&limit=%d",
118+
url.QueryEscape(query),
119+
skip,
120+
limit,
121+
)
122+
var response integrationContactsResponse
123+
requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response)
124+
return response
125+
}
126+
127+
func uploadIntegrationContactsCSV(
128+
ctx context.Context,
129+
t *testing.T,
130+
contents string,
131+
) integrationContactsResponse {
132+
t.Helper()
133+
134+
var body bytes.Buffer
135+
writer := multipart.NewWriter(&body)
136+
part, err := writer.CreateFormFile("document", "contacts.csv")
137+
require.NoError(t, err)
138+
_, err = part.Write([]byte(contents))
139+
require.NoError(t, err)
140+
require.NoError(t, writer.Close())
141+
142+
request, err := http.NewRequestWithContext(
143+
ctx,
144+
http.MethodPost,
145+
apiBaseURL+"/v1/contacts/upload",
146+
&body,
147+
)
148+
require.NoError(t, err)
149+
request.Header.Set("x-api-key", userAPIKey)
150+
request.Header.Set("Content-Type", writer.FormDataContentType())
151+
152+
response, err := http.DefaultClient.Do(request)
153+
require.NoError(t, err)
154+
defer response.Body.Close()
155+
156+
responseBody, err := io.ReadAll(response.Body)
157+
require.NoError(t, err)
158+
require.Equal(t, http.StatusCreated, response.StatusCode, "response: %s", string(responseBody))
159+
160+
var result integrationContactsResponse
161+
require.NoError(t, json.Unmarshal(responseBody, &result))
162+
return result
163+
}
164+
165+
func waitForThreadWithContact(
166+
ctx context.Context,
167+
t *testing.T,
168+
owner string,
169+
phoneNumber string,
170+
timeout time.Duration,
171+
) integrationContactThread {
172+
t.Helper()
173+
174+
deadline := time.Now().Add(timeout)
175+
for time.Now().Before(deadline) {
176+
path := fmt.Sprintf(
177+
"/v1/message-threads?owner=%s&contacts=true&skip=0&limit=20",
178+
url.QueryEscape(owner),
179+
)
180+
var response struct {
181+
Data []integrationContactThread `json:"data"`
182+
}
183+
requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response)
184+
for _, thread := range response.Data {
185+
if thread.Contact == phoneNumber && thread.ContactDetails != nil {
186+
return thread
187+
}
188+
}
189+
time.Sleep(500 * time.Millisecond)
190+
}
191+
192+
t.Fatalf("thread for %s did not include contact details within %v", phoneNumber, timeout)
193+
return integrationContactThread{}
194+
}
195+
196+
func TestContacts_CRUDSearchAndPagination(t *testing.T) {
197+
ctx := context.Background()
198+
marker := strings.ToLower(uuid.New().String())
199+
firstPhone := randomPhoneNumber()
200+
secondPhone := randomPhoneNumber()
201+
202+
created := createIntegrationContacts(ctx, t, []map[string]any{
203+
{
204+
"name": " Alice " + marker + " ",
205+
"emails": []string{" ALICE-" + marker + "@EXAMPLE.COM "},
206+
"phone_numbers": []string{strings.TrimPrefix(firstPhone, "+")},
207+
"properties": map[string]string{"company": "Acme"},
208+
},
209+
{
210+
"name": "Bob " + marker,
211+
"emails": []string{"bob-" + marker + "@example.com"},
212+
"phone_numbers": []string{secondPhone},
213+
},
214+
})
215+
for _, contact := range created {
216+
contactID := contact.ID
217+
t.Cleanup(func() {
218+
cleanupIntegrationContact(t, contactID)
219+
})
220+
}
221+
222+
require.Len(t, created, 2)
223+
assert.Equal(t, "Alice "+marker, created[0].Name)
224+
assert.Equal(t, []string{"alice-" + marker + "@example.com"}, created[0].Emails)
225+
assert.Equal(t, []string{firstPhone}, created[0].PhoneNumbers)
226+
assert.Equal(t, map[string]string{"company": "Acme"}, created[0].Properties)
227+
228+
page := listIntegrationContacts(ctx, t, marker, 0, 1)
229+
assert.EqualValues(t, 2, page.Total)
230+
require.Len(t, page.Data, 1)
231+
232+
emailMatch := listIntegrationContacts(ctx, t, "ALICE-"+marker+"@EXAMPLE.COM", 0, 20)
233+
assert.EqualValues(t, 1, emailMatch.Total)
234+
require.Len(t, emailMatch.Data, 1)
235+
assert.Equal(t, created[0].ID, emailMatch.Data[0].ID)
236+
237+
var updated integrationContactResponse
238+
requestJSON(
239+
ctx,
240+
t,
241+
http.MethodPut,
242+
"/v1/contacts/"+created[0].ID,
243+
userAPIKey,
244+
map[string]any{
245+
"name": "Updated " + marker,
246+
"emails": []string{"updated-" + marker + "@example.com"},
247+
"phone_numbers": []string{firstPhone},
248+
"properties": map[string]string{"company": "NdoleStudio"},
249+
},
250+
http.StatusOK,
251+
&updated,
252+
)
253+
assert.Equal(t, "Updated "+marker, updated.Data.Name)
254+
assert.Equal(t, []string{"updated-" + marker + "@example.com"}, updated.Data.Emails)
255+
assert.Equal(t, map[string]string{"company": "NdoleStudio"}, updated.Data.Properties)
256+
257+
deleteIntegrationContact(ctx, t, created[0].ID)
258+
259+
deletedMatch := listIntegrationContacts(ctx, t, "updated-"+marker+"@example.com", 0, 20)
260+
assert.Zero(t, deletedMatch.Total)
261+
assert.Empty(t, deletedMatch.Data)
262+
}
263+
264+
func TestContacts_CSVUpload(t *testing.T) {
265+
ctx := context.Background()
266+
marker := strings.ToLower(uuid.New().String())
267+
phoneNumber := randomPhoneNumber()
268+
csv := fmt.Sprintf(
269+
"Name,Emails,PhoneNumbers\nCSV %s,\"FIRST-%s@EXAMPLE.COM;second-%s@example.com\",%s\n",
270+
marker,
271+
marker,
272+
marker,
273+
strings.TrimPrefix(phoneNumber, "+"),
274+
)
275+
276+
response := uploadIntegrationContactsCSV(ctx, t, csv)
277+
require.Len(t, response.Data, 1)
278+
contact := response.Data[0]
279+
t.Cleanup(func() {
280+
cleanupIntegrationContact(t, contact.ID)
281+
})
282+
283+
assert.Equal(t, "CSV "+marker, contact.Name)
284+
assert.Equal(t, []string{
285+
"first-" + marker + "@example.com",
286+
"second-" + marker + "@example.com",
287+
}, contact.Emails)
288+
assert.Equal(t, []string{phoneNumber}, contact.PhoneNumbers)
289+
290+
listed := listIntegrationContacts(ctx, t, marker, 0, 20)
291+
assert.EqualValues(t, 1, listed.Total)
292+
require.Len(t, listed.Data, 1)
293+
assert.Equal(t, contact.ID, listed.Data[0].ID)
294+
}
295+
296+
func TestContacts_MessageThreadsIncludeContactDetails(t *testing.T) {
297+
ctx := context.Background()
298+
phone := setupPhone(ctx, t, 60)
299+
contactPhone := randomPhoneNumber()
300+
marker := strings.ToLower(uuid.New().String())
301+
302+
created := createIntegrationContacts(ctx, t, []map[string]any{
303+
{
304+
"name": "Thread Contact " + marker,
305+
"emails": []string{"thread-" + marker + "@example.com"},
306+
"phone_numbers": []string{contactPhone},
307+
"properties": map[string]string{"source": "integration-test"},
308+
},
309+
})
310+
contact := created[0]
311+
t.Cleanup(func() {
312+
cleanupIntegrationContact(t, contact.ID)
313+
})
314+
315+
messageID := receiveInbound(
316+
ctx,
317+
t,
318+
phone.PhoneAPIKey,
319+
contactPhone,
320+
phone.PhoneNumber,
321+
"Contact enrichment "+marker,
322+
time.Now(),
323+
)
324+
pollMessageStatus(ctx, t, messageID, "received", 15*time.Second)
325+
326+
thread := waitForThreadWithContact(ctx, t, phone.PhoneNumber, contactPhone, 20*time.Second)
327+
require.NotNil(t, thread.ContactDetails)
328+
assert.Equal(t, contact.ID, thread.ContactDetails.ID)
329+
assert.Equal(t, contact.Name, thread.ContactDetails.Name)
330+
assert.Equal(t, contact.PhoneNumbers, thread.ContactDetails.PhoneNumbers)
331+
assert.Equal(t, contact.Properties, thread.ContactDetails.Properties)
332+
}

0 commit comments

Comments
 (0)