Skip to content

Commit c600660

Browse files
AchoArnoldCopilot
andcommitted
test(integration): verify unarchive thread on receive
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
1 parent 74569d0 commit c600660

2 files changed

Lines changed: 220 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] **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
5657

5758
## Prerequisites
5859

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package tests
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"testing"
11+
"time"
12+
13+
httpsms "github.com/NdoleStudio/httpsms-go"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
type integrationThread struct {
19+
ID string `json:"id"`
20+
Contact string `json:"contact"`
21+
Owner string `json:"owner"`
22+
IsArchived bool `json:"is_archived"`
23+
LastMessageContent *string `json:"last_message_content"`
24+
}
25+
26+
// setUnarchiveThread flips the per-phone unarchive_thread flag via a raw PUT
27+
// (the httpsms-go client has no field for it).
28+
func setUnarchiveThread(ctx context.Context, t *testing.T, phoneNumber string, enabled bool) {
29+
t.Helper()
30+
31+
payload := map[string]interface{}{
32+
"phone_number": phoneNumber,
33+
"sim": "SIM1",
34+
"unarchive_thread": enabled,
35+
}
36+
body, err := json.Marshal(payload)
37+
require.NoError(t, err)
38+
39+
req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/phones", bytes.NewReader(body))
40+
require.NoError(t, err)
41+
req.Header.Set("Content-Type", "application/json")
42+
req.Header.Set("x-api-key", userAPIKey)
43+
44+
resp, err := http.DefaultClient.Do(req)
45+
require.NoError(t, err)
46+
defer resp.Body.Close()
47+
48+
respBody, err := io.ReadAll(resp.Body)
49+
require.NoError(t, err)
50+
require.Equal(t, http.StatusOK, resp.StatusCode, "set unarchive_thread failed: %s", string(respBody))
51+
}
52+
53+
// receiveInbound submits an inbound message as the phone and returns the message ID.
54+
func receiveInbound(ctx context.Context, t *testing.T, phoneAPIKey, from, to, content string, ts time.Time) string {
55+
t.Helper()
56+
57+
payload := map[string]interface{}{
58+
"from": from,
59+
"to": to,
60+
"content": content,
61+
"sim": "SIM1",
62+
"timestamp": ts.UTC().Format(time.RFC3339),
63+
}
64+
body, err := json.Marshal(payload)
65+
require.NoError(t, err)
66+
67+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/v1/messages/receive", bytes.NewReader(body))
68+
require.NoError(t, err)
69+
req.Header.Set("Content-Type", "application/json")
70+
req.Header.Set("x-api-key", phoneAPIKey)
71+
72+
resp, err := http.DefaultClient.Do(req)
73+
require.NoError(t, err)
74+
defer resp.Body.Close()
75+
76+
respBody, err := io.ReadAll(resp.Body)
77+
require.NoError(t, err)
78+
require.Equal(t, http.StatusOK, resp.StatusCode, "receive failed: %s", string(respBody))
79+
80+
var result httpsms.MessageResponse
81+
require.NoError(t, json.Unmarshal(respBody, &result))
82+
id := result.Data.ID.String()
83+
require.NotEmpty(t, id)
84+
return id
85+
}
86+
87+
// fetchThreads returns threads for an owner filtered by archived state.
88+
func fetchThreads(ctx context.Context, t *testing.T, owner string, archived bool) []integrationThread {
89+
t.Helper()
90+
91+
url := fmt.Sprintf("%s/v1/message-threads?owner=%s&is_archived=%t&limit=20", apiBaseURL, owner, archived)
92+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
93+
require.NoError(t, err)
94+
req.Header.Set("x-api-key", userAPIKey)
95+
96+
resp, err := http.DefaultClient.Do(req)
97+
require.NoError(t, err)
98+
defer resp.Body.Close()
99+
100+
respBody, err := io.ReadAll(resp.Body)
101+
require.NoError(t, err)
102+
require.Equal(t, http.StatusOK, resp.StatusCode, "fetch threads failed: %s", string(respBody))
103+
104+
var result struct {
105+
Data []integrationThread `json:"data"`
106+
}
107+
require.NoError(t, json.Unmarshal(respBody, &result))
108+
return result.Data
109+
}
110+
111+
func findThreadByContact(threads []integrationThread, contact string) *integrationThread {
112+
for i := range threads {
113+
if threads[i].Contact == contact {
114+
return &threads[i]
115+
}
116+
}
117+
return nil
118+
}
119+
120+
// waitForThread polls the archived/unarchived thread list until a thread for the
121+
// contact appears (optionally matching a last-message content), then returns it.
122+
func waitForThread(ctx context.Context, t *testing.T, owner, contact string, archived bool, wantContent string, timeout time.Duration) *integrationThread {
123+
t.Helper()
124+
deadline := time.Now().Add(timeout)
125+
for time.Now().Before(deadline) {
126+
thread := findThreadByContact(fetchThreads(ctx, t, owner, archived), contact)
127+
if thread != nil && (wantContent == "" || (thread.LastMessageContent != nil && *thread.LastMessageContent == wantContent)) {
128+
return thread
129+
}
130+
time.Sleep(500 * time.Millisecond)
131+
}
132+
return nil
133+
}
134+
135+
// archiveThread archives a thread by ID.
136+
func archiveThread(ctx context.Context, t *testing.T, threadID string) {
137+
t.Helper()
138+
139+
body, err := json.Marshal(map[string]interface{}{"is_archived": true})
140+
require.NoError(t, err)
141+
142+
req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/message-threads/"+threadID, bytes.NewReader(body))
143+
require.NoError(t, err)
144+
req.Header.Set("Content-Type", "application/json")
145+
req.Header.Set("x-api-key", userAPIKey)
146+
147+
resp, err := http.DefaultClient.Do(req)
148+
require.NoError(t, err)
149+
defer resp.Body.Close()
150+
151+
respBody, err := io.ReadAll(resp.Body)
152+
require.NoError(t, err)
153+
require.Equal(t, http.StatusOK, resp.StatusCode, "archive thread failed: %s", string(respBody))
154+
}
155+
156+
func TestUnarchiveThreadOnReceive_Enabled(t *testing.T) {
157+
ctx := context.Background()
158+
phone := setupPhone(ctx, t, 60)
159+
setUnarchiveThread(ctx, t, phone.PhoneNumber, true)
160+
161+
contact := randomPhoneNumber()
162+
content1 := "first inbound " + randomEncryptionKey()
163+
content2 := "second inbound " + randomEncryptionKey()
164+
165+
// First inbound message creates the thread.
166+
msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute))
167+
pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second)
168+
169+
thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second)
170+
require.NotNil(t, thread, "thread not created for contact %s", contact)
171+
172+
// Archive it.
173+
archiveThread(ctx, t, thread.ID)
174+
archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second)
175+
require.NotNil(t, archived, "thread was not archived")
176+
177+
// Second inbound message should unarchive it.
178+
msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now())
179+
pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second)
180+
181+
unarchived := waitForThread(ctx, t, phone.PhoneNumber, contact, false, content2, 20*time.Second)
182+
require.NotNil(t, unarchived, "thread was not unarchived after inbound message")
183+
assert.False(t, unarchived.IsArchived)
184+
require.NotNil(t, unarchived.LastMessageContent)
185+
assert.Equal(t, content2, *unarchived.LastMessageContent)
186+
}
187+
188+
func TestUnarchiveThreadOnReceive_Disabled(t *testing.T) {
189+
ctx := context.Background()
190+
phone := setupPhone(ctx, t, 60) // unarchive_thread defaults to false; do not enable it
191+
192+
contact := randomPhoneNumber()
193+
content1 := "first inbound " + randomEncryptionKey()
194+
content2 := "second inbound " + randomEncryptionKey()
195+
196+
msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute))
197+
pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second)
198+
199+
thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second)
200+
require.NotNil(t, thread, "thread not created for contact %s", contact)
201+
202+
archiveThread(ctx, t, thread.ID)
203+
archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second)
204+
require.NotNil(t, archived, "thread was not archived")
205+
206+
// Second inbound message must NOT unarchive it. Sync on the archived thread's
207+
// last_message_content updating to content2 (proves the listener processed it),
208+
// then assert it is still archived.
209+
msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now())
210+
pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second)
211+
212+
stillArchived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, content2, 20*time.Second)
213+
require.NotNil(t, stillArchived, "archived thread did not reflect the second inbound message")
214+
assert.True(t, stillArchived.IsArchived, "thread should remain archived when unarchive_thread is disabled")
215+
216+
// And it must not have leaked into the unarchived list.
217+
assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, false), contact),
218+
"thread should not appear in the unarchived list")
219+
}

0 commit comments

Comments
 (0)