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