]> git.giacomolli.uk - onechat/blob - whatsapp_fake_helper_test.go
Keep scenario tests with their implementation
[onechat] / whatsapp_fake_helper_test.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "slices"
9 "sync"
10 "testing"
11 "testing/synctest"
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 uploadErr error
87 uploads [][]byte
88 imageSends []*waE2E.ImageMessage
89 downloadData []byte
90 downloadErr error
91 downloadWait time.Duration
92 downloads []whatsmeow.DownloadableMessage
93 phones []string
94 logouts int
95 sent []WhatsAppFakeSend
96 connections []context.Context
97 }
98
99 // ConnectContext keeps ctx and tells the test that the daemon connected.
100 func (f *WhatsAppFake) ConnectContext(ctx context.Context) error {
101 f.mutex.Lock()
102
103 f.connections = append(f.connections, ctx)
104 f.connectTimes = append(f.connectTimes, time.Now())
105 f.mutex.Unlock()
106
107 f.connects <- struct{}{}
108
109 f.mutex.Lock()
110 defer f.mutex.Unlock()
111
112 if len(f.connectErrors) > 0 {
113 err := f.connectErrors[0]
114 f.connectErrors = f.connectErrors[1:]
115
116 return err
117 }
118
119 return f.connectErr
120 }
121
122 // Connections returns the contexts of the connections, in order.
123 func (f *WhatsAppFake) Connections() []context.Context {
124 f.mutex.Lock()
125 defer f.mutex.Unlock()
126
127 return slices.Clone(f.connections)
128 }
129
130 // ConnectionTimes returns the time of each connection call, in order.
131 func (f *WhatsAppFake) ConnectionTimes() []time.Time {
132 f.mutex.Lock()
133 defer f.mutex.Unlock()
134
135 return slices.Clone(f.connectTimes)
136 }
137
138 // Disconnect does nothing.
139 func (f *WhatsAppFake) Disconnect() {
140 }
141
142 // GetQRChannel gives a new channel of pairing items to the test.
143 func (f *WhatsAppFake) GetQRChannel(_ context.Context) (<-chan whatsmeow.QRChannelItem, error) {
144 items := make(chan whatsmeow.QRChannelItem, 8)
145 f.pairings <- items
146
147 return items, nil
148 }
149
150 // PairPhone keeps phone and returns whatsappTestCode.
151 func (f *WhatsAppFake) PairPhone(_ context.Context, phone string, _ bool, _ whatsmeow.PairClientType, _ string) (string, error) {
152 f.mutex.Lock()
153 defer f.mutex.Unlock()
154
155 f.phones = append(f.phones, phone)
156
157 return whatsappTestCode, nil
158 }
159
160 // Logout counts the logouts and returns logoutErr.
161 func (f *WhatsAppFake) Logout(_ context.Context) error {
162 f.mutex.Lock()
163 defer f.mutex.Unlock()
164
165 f.logouts++
166
167 return f.logoutErr
168 }
169
170 // GetJoinedGroups returns groups.
171 func (f *WhatsAppFake) GetJoinedGroups(_ context.Context) ([]*types.GroupInfo, error) {
172 f.mutex.Lock()
173 defer f.mutex.Unlock()
174
175 return f.groups, nil
176 }
177
178 // Download waits for downloadWait, records the request, and returns the made-up
179 // image or downloadErr. A canceled context ends the wait.
180 func (f *WhatsAppFake) Download(ctx context.Context, message whatsmeow.DownloadableMessage) ([]byte, error) {
181 if f.downloadWait > 0 {
182 timer := time.NewTimer(f.downloadWait)
183 defer timer.Stop()
184
185 select {
186 case <-ctx.Done():
187 return nil, ctx.Err()
188 case <-timer.C:
189 }
190 }
191
192 f.mutex.Lock()
193 defer f.mutex.Unlock()
194
195 f.downloads = append(f.downloads, message)
196
197 return slices.Clone(f.downloadData), f.downloadErr
198 }
199
200 // Downloads returns the media requested from the fake.
201 func (f *WhatsAppFake) Downloads() []whatsmeow.DownloadableMessage {
202 f.mutex.Lock()
203 defer f.mutex.Unlock()
204
205 return slices.Clone(f.downloads)
206 }
207
208 // Upload records the pixels and returns made-up media metadata.
209 func (f *WhatsAppFake) Upload(_ context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error) {
210 f.mutex.Lock()
211 defer f.mutex.Unlock()
212
213 if mediaType != whatsmeow.MediaImage {
214 return whatsmeow.UploadResponse{}, fmt.Errorf("unexpected media type %q", mediaType)
215 }
216
217 f.uploads = append(f.uploads, slices.Clone(data))
218 if f.uploadErr != nil {
219 return whatsmeow.UploadResponse{}, f.uploadErr
220 }
221
222 return whatsmeow.UploadResponse{
223 URL: "https://example.com/image",
224 DirectPath: "/image",
225 MediaKey: []byte("made-up key"),
226 FileSHA256: []byte("made-up hash"),
227 FileEncSHA256: []byte("made-up encrypted hash"),
228 FileLength: uint64(len(data)),
229 }, nil
230 }
231
232 // Uploads returns the pixels passed to Upload.
233 func (f *WhatsAppFake) Uploads() [][]byte {
234 f.mutex.Lock()
235 defer f.mutex.Unlock()
236
237 return slices.Clone(f.uploads)
238 }
239
240 // ImageSends returns the outgoing image messages.
241 func (f *WhatsAppFake) ImageSends() []*waE2E.ImageMessage {
242 f.mutex.Lock()
243 defer f.mutex.Unlock()
244
245 return slices.Clone(f.imageSends)
246 }
247
248 // GenerateMessageID returns a new message ID.
249 func (f *WhatsAppFake) GenerateMessageID() types.MessageID {
250 f.mutex.Lock()
251 defer f.mutex.Unlock()
252
253 return fmt.Sprintf("MSG%d", len(f.sent)+1)
254 }
255
256 // SendMessage keeps the send and returns sendErr.
257 func (f *WhatsAppFake) SendMessage(_ context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) {
258 f.mutex.Lock()
259 defer f.mutex.Unlock()
260
261 if message.GetImageMessage() != nil {
262 f.imageSends = append(f.imageSends, message.GetImageMessage())
263 }
264
265 f.sent = append(f.sent, WhatsAppFakeSend{
266 to: to.String(),
267 text: message.GetConversation(),
268 id: extra[0].ID,
269 })
270
271 if f.sendErr != nil {
272 return whatsmeow.SendResponse{}, f.sendErr
273 }
274
275 return whatsmeow.SendResponse{
276 Timestamp: whatsappTestSendTime,
277 }, nil
278 }
279
280 // Sent returns the sends.
281 func (f *WhatsAppFake) Sent() []WhatsAppFakeSend {
282 f.mutex.Lock()
283 defer f.mutex.Unlock()
284
285 return slices.Clone(f.sent)
286 }
287
288 // Phones returns the phone numbers of the pairings.
289 func (f *WhatsAppFake) Phones() []string {
290 f.mutex.Lock()
291 defer f.mutex.Unlock()
292
293 return append([]string{}, f.phones...)
294 }
295
296 // Logouts returns the number of logouts.
297 func (f *WhatsAppFake) Logouts() int {
298 f.mutex.Lock()
299 defer f.mutex.Unlock()
300
301 return f.logouts
302 }
303
304 // Emit gives event to the handler of the daemon and returns its answer.
305 func (f *WhatsAppFake) Emit(event any) bool {
306 f.mutex.Lock()
307
308 handle := f.handle
309 f.mutex.Unlock()
310
311 return handle(event)
312 }
313
314 // newClient keeps handle and returns the fake as the client of device.
315 func (f *WhatsAppFake) newClient(_ *store.Device, handle func(event any) bool) WhatsAppClient {
316 f.mutex.Lock()
317 defer f.mutex.Unlock()
318
319 f.handle = handle
320
321 return f
322 }
323
324 // WhatsAppFakeSend is one send of the fake: the chat, the text, and the message
325 // ID.
326 type WhatsAppFakeSend struct {
327 to string
328 text string
329 id string
330 }
331
332 // WhatsAppRetryTest runs WhatsApp without a model or network client call. All
333 // timers and channels belong to its synctest bubble, so backoff uses fake time.
334 type WhatsAppRetryTest struct {
335 whatsapp *WhatsApp
336 fake *WhatsAppFake
337 logs *SyncBuffer
338 loop *TurnLoop
339 events *TurnEventRecorder
340 cancel context.CancelFunc
341 done chan error
342 }
343
344 func (r *WhatsAppRetryTest) start(t *testing.T) {
345 t.Helper()
346
347 ctx, cancel := context.WithCancel(t.Context())
348 r.cancel = cancel
349 r.done = make(chan error, 1)
350 go func() {
351 r.done <- r.whatsapp.Run(ctx, r.loop, r.events.Add)
352 }()
353
354 t.Cleanup(func() {
355 cancel()
356 synctest.Wait()
357 })
358 }
359
360 // whatsappStartTest opens the WhatsApp of the data directory dir with a fake
361 // client and the test clock, and closes it at the end of the test. The daemon
362 // log goes to the returned buffer.
363 func whatsappStartTest(t *testing.T, dir string, clock *TestClock) (*WhatsApp, *WhatsAppFake, *SyncBuffer) {
364 t.Helper()
365
366 fake := &WhatsAppFake{
367 connects: make(chan struct{}, 16),
368 pairings: make(chan chan whatsmeow.QRChannelItem, 16),
369 }
370
371 logs := &SyncBuffer{}
372 whatsapp, err := openWhatsApp(t.Context(), dir, clock.Now, daemonTestLogger(logs), fake.newClient)
373 require.NoError(t, err)
374
375 t.Cleanup(func() {
376 require.NoError(t, whatsapp.Close())
377 })
378
379 return whatsapp, fake, logs
380 }
381
382 // whatsappTestSession writes a session database with mode 0600 to the data
383 // directory dir. When linked is true, it holds a device linked to
384 // whatsappTestNumber and whatsappTestLID, and else no device.
385 func whatsappTestSession(t *testing.T, dir string, linked bool) {
386 t.Helper()
387
388 path := filepath.Join(dir, whatsappDirName, whatsappSessionFileName)
389 require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
390 require.NoError(t, os.WriteFile(path, nil, 0o600))
391
392 address, err := whatsappSessionAddress(path)
393 require.NoError(t, err)
394
395 container, err := sqlstore.New(t.Context(), "sqlite", address, waLog.Noop)
396 require.NoError(t, err)
397
398 if linked {
399 device := container.NewDevice()
400 jid := types.NewADJID(whatsappTestNumber, 0, 1)
401 device.ID = &jid
402 device.LID = types.NewADJID(whatsappTestLID, 0, 1)
403 device.LID.Server = types.HiddenUserServer
404 device.Account = &waAdv.ADVSignedDeviceIdentity{
405 Details: []byte{},
406 AccountSignature: make([]byte, 64),
407 AccountSignatureKey: make([]byte, 32),
408 DeviceSignature: make([]byte, 64),
409 }
410 require.NoError(t, device.Save(t.Context()))
411 }
412
413 require.NoError(t, container.Close())
414 }
415
416 // whatsappTestMessage returns the event of the text message id of sender in
417 // chat at the time at. A sender with whatsappTestNumber is the user.
418 func whatsappTestMessage(chat, sender types.JID, id, text string, at time.Time) *events.Message {
419 return &events.Message{
420 Info: types.MessageInfo{
421 MessageSource: types.MessageSource{
422 Chat: chat,
423 Sender: sender,
424 IsFromMe: sender.User == whatsappTestNumber,
425 IsGroup: chat.Server == types.GroupServer,
426 },
427 ID: id,
428 Timestamp: at,
429 },
430 Message: &waE2E.Message{
431 Conversation: new(text),
432 },
433 }
434 }
435
436 // whatsappTestLogTexts returns the texts of the messages in the log of memory,
437 // after the log holds at least one message.
438 func whatsappTestLogTexts(t *testing.T, memory *Memory) []string {
439 t.Helper()
440
441 require.Eventually(t, func() bool {
442 return memory.Len() > 0
443 }, 10*time.Second, time.Millisecond)
444
445 var texts []string
446
447 for id := range memory.Len() {
448 message, err := memory.Message(id)
449 require.NoError(t, err)
450
451 texts = append(texts, message.Text)
452 }
453
454 return texts
455 }
456
457 // whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary
458 // directory.
459 func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) {
460 t.Helper()
461
462 dir := t.TempDir()
463 whatsappTestSession(t, dir, true)
464
465 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
466
467 return whatsapp, fake
468 }
469
470 // whatsappTestConnect waits for the connection of the daemon and gives it the
471 // events of a connection with an empty catch-up.
472 func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) {
473 t.Helper()
474
475 <-fake.connects
476 require.True(t, fake.Emit(&events.Connected{}))
477 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
478 }
479
480 // whatsappTestContacts puts the contacts of the tests into the session of
481 // whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user.
482 func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) {
483 t.Helper()
484
485 contacts := whatsapp.device.Contacts
486 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
487 require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva"))
488
489 _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery")
490 require.NoError(t, err)
491 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza"))
492 }
493
494 func whatsappRetryTest(t *testing.T) *WhatsAppRetryTest {
495 t.Helper()
496
497 dir := t.TempDir()
498 whatsappTestSession(t, dir, true)
499
500 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
501 whatsapp.clock = time.Now
502 loop, events := newTestIdleTurnLoop(t, whatsapp)
503
504 return &WhatsAppRetryTest{
505 whatsapp: whatsapp,
506 fake: fake,
507 logs: logs,
508 loop: loop,
509 events: events,
510 }
511 }