Skip to content

Commit 24fef0b

Browse files
AchoArnoldCopilot
andcommitted
test: cover message thread read receipts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
1 parent dca7762 commit 24fef0b

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

tests/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect al
5353

5454
- [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → emulator responds with SENT/DELIVERED events → message reaches `delivered` status
5555
- [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint
56+
- [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
5657

5758
## Prerequisites
5859

tests/read_receipts_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package tests
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"testing"
12+
"time"
13+
14+
httpsms "github.com/NdoleStudio/httpsms-go"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
type integrationMessageThread struct {
20+
ID string `json:"id"`
21+
Contact string `json:"contact"`
22+
IsRead bool `json:"is_read"`
23+
LastMessageContent *string `json:"last_message_content"`
24+
}
25+
26+
func requestJSON(
27+
ctx context.Context,
28+
t *testing.T,
29+
method string,
30+
path string,
31+
apiKey string,
32+
payload any,
33+
expectedStatus int,
34+
output any,
35+
) {
36+
t.Helper()
37+
38+
var body io.Reader
39+
if payload != nil {
40+
encoded, err := json.Marshal(payload)
41+
require.NoError(t, err)
42+
body = bytes.NewReader(encoded)
43+
}
44+
45+
request, err := http.NewRequestWithContext(ctx, method, apiBaseURL+path, body)
46+
require.NoError(t, err)
47+
request.Header.Set("x-api-key", apiKey)
48+
request.Header.Set("Content-Type", "application/json")
49+
50+
response, err := http.DefaultClient.Do(request)
51+
require.NoError(t, err)
52+
defer response.Body.Close()
53+
54+
responseBody, err := io.ReadAll(response.Body)
55+
require.NoError(t, err)
56+
require.Equal(t, expectedStatus, response.StatusCode, "response: %s", string(responseBody))
57+
58+
if output != nil {
59+
require.NoError(t, json.Unmarshal(responseBody, output))
60+
}
61+
}
62+
63+
func fetchMessageThreads(ctx context.Context, t *testing.T, owner string) []integrationMessageThread {
64+
t.Helper()
65+
66+
var response struct {
67+
Data []integrationMessageThread `json:"data"`
68+
}
69+
path := fmt.Sprintf(
70+
"/v1/message-threads?owner=%s&skip=0&limit=20&is_archived=false",
71+
url.QueryEscape(owner),
72+
)
73+
requestJSON(ctx, t, http.MethodGet, path, userAPIKey, nil, http.StatusOK, &response)
74+
return response.Data
75+
}
76+
77+
func waitForMessageThread(
78+
ctx context.Context,
79+
t *testing.T,
80+
owner string,
81+
contact string,
82+
timeout time.Duration,
83+
matches func(integrationMessageThread) bool,
84+
) integrationMessageThread {
85+
t.Helper()
86+
87+
deadline := time.Now().Add(timeout)
88+
for time.Now().Before(deadline) {
89+
for _, thread := range fetchMessageThreads(ctx, t, owner) {
90+
if thread.Contact == contact && matches(thread) {
91+
return thread
92+
}
93+
}
94+
time.Sleep(500 * time.Millisecond)
95+
}
96+
97+
t.Fatalf("thread %s -> %s did not reach the expected state within %v", owner, contact, timeout)
98+
return integrationMessageThread{}
99+
}
100+
101+
func markMessageThreadRead(ctx context.Context, t *testing.T, threadID string) integrationMessageThread {
102+
t.Helper()
103+
104+
var response struct {
105+
Data integrationMessageThread `json:"data"`
106+
}
107+
requestJSON(
108+
ctx,
109+
t,
110+
http.MethodPut,
111+
"/v1/message-threads/"+threadID,
112+
userAPIKey,
113+
map[string]any{"is_read": true},
114+
http.StatusOK,
115+
&response,
116+
)
117+
return response.Data
118+
}
119+
120+
func TestMessageThreadReadReceipts(t *testing.T) {
121+
ctx := context.Background()
122+
phone := setupPhone(ctx, t, 60)
123+
contact := randomPhoneNumber()
124+
125+
requestJSON(
126+
ctx,
127+
t,
128+
http.MethodPost,
129+
"/v1/messages/receive",
130+
phone.PhoneAPIKey,
131+
map[string]any{
132+
"from": contact,
133+
"to": phone.PhoneNumber,
134+
"content": "Unread inbound message",
135+
"encrypted": false,
136+
"sim": "SIM1",
137+
"timestamp": time.Now().UTC().Format(time.RFC3339),
138+
},
139+
http.StatusOK,
140+
nil,
141+
)
142+
143+
thread := waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool {
144+
return !thread.IsRead
145+
})
146+
assert.False(t, thread.IsRead)
147+
148+
updated := markMessageThreadRead(ctx, t, thread.ID)
149+
assert.True(t, updated.IsRead)
150+
waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 10*time.Second, func(thread integrationMessageThread) bool {
151+
return thread.IsRead
152+
})
153+
154+
requestJSON(
155+
ctx,
156+
t,
157+
http.MethodPost,
158+
"/v1/messages/calls/missed",
159+
phone.PhoneAPIKey,
160+
map[string]any{
161+
"from": contact,
162+
"to": phone.PhoneNumber,
163+
"sim": "SIM1",
164+
"timestamp": time.Now().UTC().Format(time.RFC3339),
165+
},
166+
http.StatusOK,
167+
nil,
168+
)
169+
170+
thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool {
171+
return !thread.IsRead &&
172+
thread.LastMessageContent != nil &&
173+
*thread.LastMessageContent == "Missed phone call"
174+
})
175+
assert.False(t, thread.IsRead)
176+
177+
outboundContent := "Outbound activity preserves unread"
178+
client := newAPIClient()
179+
_, response, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{
180+
From: phone.PhoneNumber,
181+
To: contact,
182+
Content: outboundContent,
183+
})
184+
require.NoError(t, err)
185+
require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode)
186+
187+
thread = waitForMessageThread(ctx, t, phone.PhoneNumber, contact, 20*time.Second, func(thread integrationMessageThread) bool {
188+
return thread.LastMessageContent != nil &&
189+
*thread.LastMessageContent == outboundContent
190+
})
191+
assert.False(t, thread.IsRead, "outbound activity must not clear unread state")
192+
}

0 commit comments

Comments
 (0)