]> git.giacomolli.uk - onechat/blob - whatsapp_fake_helper_test.go
Pass caller contexts through WhatsApp callbacks
[onechat] / whatsapp_fake_helper_test.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "slices"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/require"
15 "go.mau.fi/whatsmeow"
16 waAdv "go.mau.fi/whatsmeow/proto/waAdv"
17 "go.mau.fi/whatsmeow/proto/waE2E"
18 "go.mau.fi/whatsmeow/store"
19 "go.mau.fi/whatsmeow/store/sqlstore"
20 "go.mau.fi/whatsmeow/types"
21 "go.mau.fi/whatsmeow/types/events"
22 waLog "go.mau.fi/whatsmeow/util/log"
23 )
24
25 const (
26 // whatsappTestNumber is the phone number of the own account in the
27 // tests. It is a number for fiction.
28 whatsappTestNumber = "15550001111"
29
30 // whatsappTestLID is the LID of the own account in the tests.
31 whatsappTestLID = "987654321012345"
32
33 // whatsappTestCode is the pairing code of the fake.
34 whatsappTestCode = "ABCD-EFGH"
35 )
36
37 var (
38 // whatsappTestOwn is the own account in the tests.
39 whatsappTestOwn = types.NewJID(whatsappTestNumber, types.DefaultUserServer)
40
41 // whatsappTestAna is a contact in the tests.
42 whatsappTestAna = types.NewJID("15550002222", types.DefaultUserServer)
43
44 // whatsappTestBruno is a member of the test group.
45 whatsappTestBruno = types.NewJID("15550003333", types.DefaultUserServer)
46
47 // whatsappTestFamily is the test group.
48 whatsappTestFamily = types.NewJID("120363000000000001", types.GroupServer)
49
50 // whatsappTestChat is the private chat with whatsappTestAna.
51 whatsappTestChat = WhatsAppChat{
52 ID: whatsappTestAna.String(),
53 Name: "Ana Souza",
54 }
55
56 // whatsappTestSendTime is the time of the sends of the fake.
57 whatsappTestSendTime = time.Date(2026, 1, 1, 12, 5, 0, 0, time.UTC)
58
59 // whatsappTestGroup is the chat of whatsappTestFamily.
60 whatsappTestGroup = WhatsAppChat{
61 ID: whatsappTestFamily.String(),
62 Name: "Family",
63 Group: true,
64 }
65 )
66
67 // WhatsAppFake is a fake WhatsApp client. It keeps the event handler that the
68 // daemon gives it, and Emit gives events to the handler, as the library does.
69 // Each connection keeps its context and sends to connects, and each pairing
70 // sends its channel to pairings, so that the test gives the items of the
71 // pairing. A connection takes its error from connectErrors, then connectErr. A
72 // logout fails with logoutErr, and a send with sendErr. groups are the groups
73 // of the account. A send that does not fail gets the time whatsappTestSendTime.
74 type WhatsAppFake struct {
75 connects chan struct{}
76 pairings chan chan whatsmeow.QRChannelItem
77
78 mutex sync.Mutex
79 handle func(event any) bool
80 connectErr error
81 connectErrors []error
82 connectTimes []time.Time
83 logoutErr error
84 groups []*types.GroupInfo
85 sendErr error
86 downloadData []byte
87 downloadErr error
88 downloadWait time.Duration
89 downloads []whatsmeow.DownloadableMessage
90 phones []string
91 logouts int
92 sent []WhatsAppFakeSend
93 connections []context.Context
94 }
95
96 // ConnectContext keeps ctx and tells the test that the daemon connected.
97 func (f *WhatsAppFake) ConnectContext(ctx context.Context) error {
98 f.mutex.Lock()
99
100 f.connections = append(f.connections, ctx)
101 f.connectTimes = append(f.connectTimes, time.Now())
102 f.mutex.Unlock()
103
104 f.connects <- struct{}{}
105
106 f.mutex.Lock()
107 defer f.mutex.Unlock()
108
109 if len(f.connectErrors) > 0 {
110 err := f.connectErrors[0]
111 f.connectErrors = f.connectErrors[1:]
112
113 return err
114 }
115
116 return f.connectErr
117 }
118
119 // Connections returns the contexts of the connections, in order.
120 func (f *WhatsAppFake) Connections() []context.Context {
121 f.mutex.Lock()
122 defer f.mutex.Unlock()
123
124 return slices.Clone(f.connections)
125 }
126
127 // ConnectionTimes returns the time of each connection call, in order.
128 func (f *WhatsAppFake) ConnectionTimes() []time.Time {
129 f.mutex.Lock()
130 defer f.mutex.Unlock()
131
132 return slices.Clone(f.connectTimes)
133 }
134
135 // Disconnect does nothing.
136 func (f *WhatsAppFake) Disconnect() {
137 }
138
139 // GetQRChannel gives a new channel of pairing items to the test.
140 func (f *WhatsAppFake) GetQRChannel(_ context.Context) (<-chan whatsmeow.QRChannelItem, error) {
141 items := make(chan whatsmeow.QRChannelItem, 8)
142 f.pairings <- items
143
144 return items, nil
145 }
146
147 // PairPhone keeps phone and returns whatsappTestCode.
148 func (f *WhatsAppFake) PairPhone(_ context.Context, phone string, _ bool, _ whatsmeow.PairClientType, _ string) (string, error) {
149 f.mutex.Lock()
150 defer f.mutex.Unlock()
151
152 f.phones = append(f.phones, phone)
153
154 return whatsappTestCode, nil
155 }
156
157 // Logout counts the logouts and returns logoutErr.
158 func (f *WhatsAppFake) Logout(_ context.Context) error {
159 f.mutex.Lock()
160 defer f.mutex.Unlock()
161
162 f.logouts++
163
164 return f.logoutErr
165 }
166
167 // GetJoinedGroups returns groups.
168 func (f *WhatsAppFake) GetJoinedGroups(_ context.Context) ([]*types.GroupInfo, error) {
169 f.mutex.Lock()
170 defer f.mutex.Unlock()
171
172 return f.groups, nil
173 }
174
175 // Download waits for downloadWait, records the request, and returns the made-up
176 // image or downloadErr. A canceled context ends the wait.
177 func (f *WhatsAppFake) Download(ctx context.Context, message whatsmeow.DownloadableMessage) ([]byte, error) {
178 if f.downloadWait > 0 {
179 timer := time.NewTimer(f.downloadWait)
180 defer timer.Stop()
181
182 select {
183 case <-ctx.Done():
184 return nil, ctx.Err()
185 case <-timer.C:
186 }
187 }
188
189 f.mutex.Lock()
190 defer f.mutex.Unlock()
191
192 f.downloads = append(f.downloads, message)
193
194 return slices.Clone(f.downloadData), f.downloadErr
195 }
196
197 // Downloads returns the media requested from the fake.
198 func (f *WhatsAppFake) Downloads() []whatsmeow.DownloadableMessage {
199 f.mutex.Lock()
200 defer f.mutex.Unlock()
201
202 return slices.Clone(f.downloads)
203 }
204
205 // GenerateMessageID returns a new message ID.
206 func (f *WhatsAppFake) GenerateMessageID() types.MessageID {
207 f.mutex.Lock()
208 defer f.mutex.Unlock()
209
210 return fmt.Sprintf("MSG%d", len(f.sent)+1)
211 }
212
213 // SendMessage keeps the send and returns sendErr.
214 func (f *WhatsAppFake) SendMessage(_ context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) {
215 f.mutex.Lock()
216 defer f.mutex.Unlock()
217
218 f.sent = append(f.sent, WhatsAppFakeSend{
219 to: to.String(),
220 text: message.GetConversation(),
221 id: extra[0].ID,
222 })
223
224 if f.sendErr != nil {
225 return whatsmeow.SendResponse{}, f.sendErr
226 }
227
228 return whatsmeow.SendResponse{
229 Timestamp: whatsappTestSendTime,
230 }, nil
231 }
232
233 // Sent returns the sends.
234 func (f *WhatsAppFake) Sent() []WhatsAppFakeSend {
235 f.mutex.Lock()
236 defer f.mutex.Unlock()
237
238 return slices.Clone(f.sent)
239 }
240
241 // Phones returns the phone numbers of the pairings.
242 func (f *WhatsAppFake) Phones() []string {
243 f.mutex.Lock()
244 defer f.mutex.Unlock()
245
246 return append([]string{}, f.phones...)
247 }
248
249 // Logouts returns the number of logouts.
250 func (f *WhatsAppFake) Logouts() int {
251 f.mutex.Lock()
252 defer f.mutex.Unlock()
253
254 return f.logouts
255 }
256
257 // Emit gives event to the handler of the daemon and returns its answer.
258 func (f *WhatsAppFake) Emit(event any) bool {
259 f.mutex.Lock()
260
261 handle := f.handle
262 f.mutex.Unlock()
263
264 return handle(event)
265 }
266
267 // newClient keeps handle and returns the fake as the client of device.
268 func (f *WhatsAppFake) newClient(_ *store.Device, handle func(event any) bool) WhatsAppClient {
269 f.mutex.Lock()
270 defer f.mutex.Unlock()
271
272 f.handle = handle
273
274 return f
275 }
276
277 // WhatsAppFakeSend is one send of the fake: the chat, the text, and the message
278 // ID.
279 type WhatsAppFakeSend struct {
280 to string
281 text string
282 id string
283 }
284
285 // whatsappStartTest opens the WhatsApp of the data directory dir with a fake
286 // client and the test clock, and closes it at the end of the test. The daemon
287 // log goes to the returned buffer.
288 func whatsappStartTest(t *testing.T, dir string, clock *TestClock) (*WhatsApp, *WhatsAppFake, *SyncBuffer) {
289 t.Helper()
290
291 fake := &WhatsAppFake{
292 connects: make(chan struct{}, 16),
293 pairings: make(chan chan whatsmeow.QRChannelItem, 16),
294 }
295
296 logs := &SyncBuffer{}
297 whatsapp, err := openWhatsApp(t.Context(), dir, clock.Now, daemonTestLogger(logs), fake.newClient)
298 require.NoError(t, err)
299
300 t.Cleanup(func() {
301 require.NoError(t, whatsapp.Close())
302 })
303
304 return whatsapp, fake, logs
305 }
306
307 // whatsappTestSession writes a session database with mode 0600 to the data
308 // directory dir. When linked is true, it holds a device linked to
309 // whatsappTestNumber and whatsappTestLID, and else no device.
310 func whatsappTestSession(t *testing.T, dir string, linked bool) {
311 t.Helper()
312
313 path := filepath.Join(dir, whatsappDirName, whatsappSessionFileName)
314 require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
315 require.NoError(t, os.WriteFile(path, nil, 0o600))
316
317 address, err := whatsappSessionAddress(path)
318 require.NoError(t, err)
319
320 container, err := sqlstore.New(t.Context(), "sqlite", address, waLog.Noop)
321 require.NoError(t, err)
322
323 if linked {
324 device := container.NewDevice()
325 jid := types.NewADJID(whatsappTestNumber, 0, 1)
326 device.ID = &jid
327 device.LID = types.NewADJID(whatsappTestLID, 0, 1)
328 device.LID.Server = types.HiddenUserServer
329 device.Account = &waAdv.ADVSignedDeviceIdentity{
330 Details: []byte{},
331 AccountSignature: make([]byte, 64),
332 AccountSignatureKey: make([]byte, 32),
333 DeviceSignature: make([]byte, 64),
334 }
335 require.NoError(t, device.Save(t.Context()))
336 }
337
338 require.NoError(t, container.Close())
339 }
340
341 // whatsappDiscardLog is a logger for a WhatsApp whose log a test does not read.
342 func whatsappDiscardLog() *slog.Logger {
343 return slog.New(slog.DiscardHandler)
344 }
345
346 // whatsappTestMessage returns the event of the text message id of sender in
347 // chat at the time at. A sender with whatsappTestNumber is the user.
348 func whatsappTestMessage(chat, sender types.JID, id, text string, at time.Time) *events.Message {
349 return &events.Message{
350 Info: types.MessageInfo{
351 MessageSource: types.MessageSource{
352 Chat: chat,
353 Sender: sender,
354 IsFromMe: sender.User == whatsappTestNumber,
355 IsGroup: chat.Server == types.GroupServer,
356 },
357 ID: id,
358 Timestamp: at,
359 },
360 Message: &waE2E.Message{
361 Conversation: new(text),
362 },
363 }
364 }
365
366 // whatsappTestLogTexts returns the texts of the messages in the log of memory,
367 // after the log holds at least one message.
368 func whatsappTestLogTexts(t *testing.T, memory *Memory) []string {
369 t.Helper()
370
371 require.Eventually(t, func() bool {
372 return memory.Len() > 0
373 }, 10*time.Second, time.Millisecond)
374
375 var texts []string
376
377 for id := range memory.Len() {
378 message, err := memory.Message(id)
379 require.NoError(t, err)
380
381 texts = append(texts, message.Text)
382 }
383
384 return texts
385 }
386
387 // whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary
388 // directory, with a quiet time of 50 ms for its batches.
389 func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) {
390 t.Helper()
391
392 dir := t.TempDir()
393 whatsappTestSession(t, dir, true)
394
395 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
396 whatsapp.state.quiet = 50 * time.Millisecond
397
398 return whatsapp, fake
399 }
400
401 // whatsappTestConnect waits for the connection of the daemon and gives it the
402 // events of a connection with an empty catch-up.
403 func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) {
404 t.Helper()
405
406 <-fake.connects
407 require.True(t, fake.Emit(&events.Connected{}))
408 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
409 }
410
411 // whatsappTestContacts puts the contacts of the tests into the session of
412 // whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user.
413 func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) {
414 t.Helper()
415
416 contacts := whatsapp.device.Contacts
417 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
418 require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva"))
419
420 _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery")
421 require.NoError(t, err)
422 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza"))
423 }