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