]> git.giacomolli.uk - onechat/blob - whatsapp_test.go
Retry the first WhatsApp connection
[onechat] / whatsapp_test.go
1 package main
2
3 import (
4         "context"
5         "errors"
6         "os"
7         "path/filepath"
8         "strings"
9         "testing"
10         "testing/synctest"
11         "time"
12
13         "github.com/stretchr/testify/require"
14         "go.mau.fi/whatsmeow"
15         "go.mau.fi/whatsmeow/types"
16         "go.mau.fi/whatsmeow/types/events"
17 )
18
19 func TestWhatsAppStatusFollowsTheConnection(t *testing.T) {
20         dir := t.TempDir()
21         whatsappTestSession(t, dir, true)
22         clock := newTestClock()
23         whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
24         daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
25
26         <-fake.connects
27         require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
28
29         require.True(t, fake.Emit(&events.Connected{}))
30         require.Equal(t, "WhatsApp is linked and connected since 2026-01-01 12:00.", whatsapp.Status())
31
32         clock.now = clock.now.Add(20 * time.Minute)
33         require.True(t, fake.Emit(&events.Disconnected{}))
34         clock.now = clock.now.Add(time.Minute)
35         require.True(t, fake.Emit(&events.Disconnected{}))
36         require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:20. The last connection started at 2026-01-01 12:00.", whatsapp.Status())
37 }
38
39 func TestWhatsAppStatusOfAFirstConnectionThatFailed(t *testing.T) {
40         synctest.Test(t, func(t *testing.T) {
41                 test := whatsappRetryTest(t)
42                 test.fake.connectErr = errors.New("network unavailable")
43                 test.start(t)
44                 synctest.Wait()
45
46                 require.Equal(t, "WhatsApp is linked and disconnected since "+time.Now().Format(whatsappTimeLayout)+".", test.whatsapp.Status())
47                 require.Equal(t, "first connect failed, retrying: network unavailable\n", test.logs.String())
48         })
49 }
50
51 func TestWhatsAppRetriesTheFirstConnectionWithCappedBackoff(t *testing.T) {
52         synctest.Test(t, func(t *testing.T) {
53                 test := whatsappRetryTest(t)
54                 for range 10 {
55                         test.fake.connectErrors = append(test.fake.connectErrors, errors.New("network unavailable"))
56                 }
57
58                 test.start(t)
59                 synctest.Wait()
60                 require.Len(t, test.fake.Connections(), 1)
61                 require.True(t, test.fake.Emit(&events.Disconnected{}))
62                 require.True(t, test.fake.Emit(&events.Disconnected{}))
63
64                 delays := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second, 32 * time.Second, 64 * time.Second, 128 * time.Second, 256 * time.Second, 5 * time.Minute, 5 * time.Minute}
65                 var outage time.Duration
66                 for index, delay := range delays {
67                         time.Sleep(delay - time.Nanosecond)
68                         synctest.Wait()
69                         require.Len(t, test.fake.Connections(), index+1)
70                         time.Sleep(time.Nanosecond)
71                         synctest.Wait()
72                         require.Len(t, test.fake.Connections(), index+2)
73                         outage += delay
74                 }
75
76                 times := test.fake.ConnectionTimes()
77                 for index, delay := range delays {
78                         require.Equal(t, delay, times[index+1].Sub(times[index]))
79                 }
80
81                 require.True(t, test.fake.Emit(&events.Connected{}))
82                 require.True(t, test.fake.Emit(&events.OfflineSyncCompleted{}))
83                 synctest.Wait()
84                 time.Sleep(10 * time.Minute)
85                 synctest.Wait()
86                 require.Len(t, test.fake.Connections(), 11)
87                 require.Equal(t, "first connect failed, retrying: network unavailable\nconnected again after "+durationText(outage)+"\n", test.logs.String())
88         })
89 }
90
91 func TestWhatsAppFirstConnectionRetriesStopWhenContextEnds(t *testing.T) {
92         synctest.Test(t, func(t *testing.T) {
93                 test := whatsappRetryTest(t)
94                 test.fake.connectErr = errors.New("network unavailable")
95                 test.start(t)
96                 synctest.Wait()
97                 time.Sleep(2 * time.Second)
98                 synctest.Wait()
99                 require.Len(t, test.fake.Connections(), 2)
100
101                 test.cancel()
102                 require.NoError(t, <-test.done)
103                 time.Sleep(10 * time.Minute)
104                 synctest.Wait()
105                 require.Len(t, test.fake.Connections(), 2)
106         })
107 }
108
109 func TestWhatsAppFirstConnectionRetriesStopForLinkChanges(t *testing.T) {
110         testCases := []struct {
111                 name string
112                 stop func(test *WhatsAppRetryTest)
113         }{
114                 {
115                         name: "unlink",
116                         stop: func(test *WhatsAppRetryTest) {
117                                 test.whatsapp.Command("unlink", func(text string) {})
118                         },
119                 },
120                 {
121                         name: "logout",
122                         stop: func(test *WhatsAppRetryTest) {
123                                 test.fake.Emit(&events.LoggedOut{})
124                         },
125                 },
126                 {
127                         name: "pair command",
128                         stop: func(test *WhatsAppRetryTest) {
129                                 test.whatsapp.Command("pair 15550001111", func(text string) {})
130                         },
131                 },
132                 {
133                         name: "permanent disconnect",
134                         stop: func(test *WhatsAppRetryTest) {
135                                 test.fake.Emit(&events.StreamReplaced{})
136                         },
137                 },
138         }
139
140         for _, tc := range testCases {
141                 t.Run(tc.name, func(t *testing.T) {
142                         synctest.Test(t, func(t *testing.T) {
143                                 test := whatsappRetryTest(t)
144                                 test.fake.connectErr = errors.New("network unavailable")
145                                 test.start(t)
146                                 synctest.Wait()
147                                 tc.stop(test)
148                                 synctest.Wait()
149                                 time.Sleep(10 * time.Minute)
150                                 synctest.Wait()
151                                 require.Len(t, test.fake.Connections(), 1)
152                         })
153                 })
154         }
155 }
156
157 func TestWhatsAppPairsAfterFirstConnectionRetriesStop(t *testing.T) {
158         synctest.Test(t, func(t *testing.T) {
159                 test := whatsappRetryTest(t)
160                 test.fake.connectErr = errors.New("network unavailable")
161                 test.start(t)
162                 synctest.Wait()
163                 test.whatsapp.Command("unlink", func(text string) {})
164                 synctest.Wait()
165                 require.False(t, test.whatsapp.isLinked())
166
167                 test.fake.mutex.Lock()
168                 test.fake.connectErr = nil
169                 test.fake.mutex.Unlock()
170                 test.whatsapp.Command("pair 15550001111", func(text string) {})
171                 items := <-test.fake.pairings
172                 items <- whatsmeow.QRChannelItem{Event: whatsmeow.QRChannelEventCode}
173                 synctest.Wait()
174                 require.True(t, test.fake.Emit(&events.PairSuccess{
175                         ID:  types.NewADJID(whatsappTestNumber, 0, 1),
176                         LID: types.NewADJID(whatsappTestLID, 0, 1),
177                 }))
178                 items <- whatsmeow.QRChannelSuccess
179                 synctest.Wait()
180                 require.True(t, test.whatsapp.isLinked())
181                 time.Sleep(10 * time.Minute)
182                 synctest.Wait()
183                 require.Len(t, test.fake.Connections(), 2)
184                 require.Equal(t, []string{whatsappTestNumber}, test.fake.Phones())
185         })
186 }
187
188 func TestWhatsAppInboxFailureStopsFirstConnectionRetries(t *testing.T) {
189         synctest.Test(t, func(t *testing.T) {
190                 test := whatsappRetryTest(t)
191                 test.fake.connectErr = errors.New("network unavailable")
192                 test.start(t)
193                 synctest.Wait()
194                 failure := errors.New("state write failed")
195                 test.whatsapp.inbox.failures <- failure
196                 require.ErrorIs(t, <-test.done, failure)
197                 time.Sleep(10 * time.Minute)
198                 synctest.Wait()
199                 require.Len(t, test.fake.Connections(), 1)
200         })
201 }
202
203 func TestWhatsAppLongFirstConnectionOutageGivesOneNotice(t *testing.T) {
204         synctest.Test(t, func(t *testing.T) {
205                 test := whatsappRetryTest(t)
206                 test.fake.connectErr = errors.New("network unavailable")
207                 test.start(t)
208                 synctest.Wait()
209                 since := time.Now().Format(whatsappTimeLayout)
210                 time.Sleep(whatsappNoticeDelay)
211                 synctest.Wait()
212                 require.Equal(t, 1, test.events.Count("notice"))
213                 require.Equal(t, "WhatsApp is disconnected since "+since+". OneChat tries again.", test.events.Last("notice"))
214
215                 time.Sleep(whatsappNoticeDelay)
216                 synctest.Wait()
217                 require.Equal(t, 1, test.events.Count("notice"))
218                 test.fake.mutex.Lock()
219                 test.fake.connectErr = nil
220                 test.fake.mutex.Unlock()
221                 time.Sleep(whatsappRetryLimit)
222                 synctest.Wait()
223                 require.True(t, test.fake.Emit(&events.Connected{}))
224                 require.True(t, test.fake.Emit(&events.OfflineSyncCompleted{}))
225                 synctest.Wait()
226                 require.Equal(t, 2, test.events.Count("notice"))
227                 require.Equal(t, "WhatsApp is connected again.", test.events.Last("notice"))
228         })
229 }
230
231 func TestWhatsAppStartingRetriesDoNotDuplicateLibraryRetries(t *testing.T) {
232         dir := t.TempDir()
233         whatsappTestSession(t, dir, true)
234         whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
235         client, ok := whatsappNewClient(whatsapp.device, whatsapp.handle).(*whatsmeow.Client)
236         require.True(t, ok)
237         require.False(t, client.InitialAutoReconnect)
238         require.True(t, client.EnableAutoReconnect)
239
240         device := whatsapp.container.NewDevice()
241         client, ok = whatsappNewClient(device, whatsapp.handle).(*whatsmeow.Client)
242         require.True(t, ok)
243         require.True(t, client.InitialAutoReconnect)
244 }
245
246 func TestWhatsAppLogsEachChangeOfTheConnectionOnce(t *testing.T) {
247         dir := t.TempDir()
248         whatsappTestSession(t, dir, true)
249         clock := newTestClock()
250         whatsapp, fake, logs := whatsappStartTest(t, dir, clock)
251         daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
252
253         <-fake.connects
254         require.True(t, fake.Emit(&events.Connected{}))
255         clock.now = clock.now.Add(time.Minute)
256         require.True(t, fake.Emit(&events.Disconnected{}))
257         clock.now = clock.now.Add(time.Minute)
258         require.True(t, fake.Emit(&events.Disconnected{}))
259         clock.now = clock.now.Add(25 * time.Second)
260         require.True(t, fake.Emit(&events.Connected{}))
261
262         require.Equal(t, []string{
263                 "connected",
264                 "disconnected, retrying",
265                 "connected again after 1m25s",
266         }, strings.Split(strings.TrimSuffix(logs.String(), "\n"), "\n"))
267         require.NotContains(t, logs.String(), whatsappTestNumber)
268         require.NotContains(t, logs.String(), whatsappTestLID)
269 }
270
271 func TestWhatsAppIsNotLinkedWithoutALinkedDevice(t *testing.T) {
272         testCases := []struct {
273                 name    string
274                 session func(t *testing.T, dir string)
275         }{
276                 {
277                         name: "no session file",
278                         session: func(t *testing.T, dir string) {
279                         },
280                 },
281                 {
282                         name: "a session of a pairing that did not end",
283                         session: func(t *testing.T, dir string) {
284                                 whatsappTestSession(t, dir, false)
285                         },
286                 },
287         }
288
289         for _, tc := range testCases {
290                 t.Run(tc.name, func(t *testing.T) {
291                         dir := t.TempDir()
292                         tc.session(t, dir)
293                         whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
294                         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
295                 })
296         }
297 }
298
299 func TestWhatsAppRefusesASessionThatOthersCanRead(t *testing.T) {
300         dir := t.TempDir()
301         whatsappTestSession(t, dir, true)
302         require.NoError(t, os.Chmod(filepath.Join(dir, whatsappDirName, whatsappSessionFileName), 0o644))
303
304         _, err := openWhatsApp(t.Context(), dir, time.Now, whatsappDiscardLog(), whatsappNewClient)
305         require.EqualError(t, err, "whatsapp/session.db holds the WhatsApp session, so its mode must be 0600")
306 }
307
308 func TestWhatsAppCommandsAnswerOnlyTheClientThatAsked(t *testing.T) {
309         server := newModelFake(t)
310         telegram := newTelegramFake(t)
311         bot, _ := telegramStartTestBot(t, telegram)
312         socket := daemonStartTestWithBot(t, server, bot)
313         asking := terminalStartTestClient(t, socket)
314         other := terminalStartTestClient(t, socket)
315         asking.WaitFor(t, "[connected to OneChat]")
316         other.WaitFor(t, "[connected to OneChat]")
317
318         asking.Type("/whatsapp status")
319         asking.WaitFor(t, "WhatsApp is not linked.")
320         asking.Type(" /whatsapp  unknown ")
321         asking.WaitFor(t, whatsappUsage)
322         asking.Type("/whatsapp pair 0123")
323         asking.WaitFor(t, "PHONE must be the number of your WhatsApp account")
324
325         telegram.Add(TelegramUpdate{
326                 UpdateID: 1,
327                 Message:  telegramOwnerMessage("/whatsapp status"),
328         })
329
330         telegramWaitForSent(t, telegram, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.")
331         require.Len(t, telegram.Sent(), 1)
332         require.Equal(t, "[connected to OneChat]\n", other.Output())
333         require.Zero(t, server.Calls())
334 }
335
336 func TestWhatsAppPairsWithACode(t *testing.T) {
337         dir := t.TempDir()
338         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
339         server := newModelFake(t)
340         socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
341         asking := terminalStartTestClient(t, socket)
342         other := terminalStartTestClient(t, socket)
343         asking.WaitFor(t, "[connected to OneChat]")
344         other.WaitFor(t, "[connected to OneChat]")
345
346         asking.Type("/whatsapp pair +1 555-000-1111")
347         items := <-fake.pairings
348         items <- whatsmeow.QRChannelItem{
349                 Event: whatsmeow.QRChannelEventCode,
350         }
351
352         asking.WaitFor(t, whatsappPairSteps+"\n"+whatsappTestCode+"\n")
353         require.True(t, fake.Emit(&events.PairSuccess{
354                 ID:  types.NewADJID(whatsappTestNumber, 0, 1),
355                 LID: types.NewADJID(whatsappTestLID, 0, 1),
356         }))
357         items <- whatsmeow.QRChannelSuccess
358         asking.WaitFor(t, "WhatsApp is linked to +15550001111.")
359
360         // The library connects again after the link with the context of the
361         // connection, so the end of the pairing must not end it.
362         connections := fake.Connections()
363         require.Len(t, connections, 1)
364         require.Never(t, func() bool {
365                 return connections[0].Err() != nil
366         }, 200*time.Millisecond, time.Millisecond)
367
368         require.Equal(t, []string{whatsappTestNumber}, fake.Phones())
369         require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
370         require.Equal(t, "[connected to OneChat]\n", other.Output())
371         require.Zero(t, server.Calls())
372         require.Equal(t, []string{"WhatsApp is linked."}, whatsappTestLogTexts(t, memory))
373
374         info, err := os.Stat(filepath.Join(dir, whatsappDirName))
375         require.NoError(t, err)
376         require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
377
378         info, err = os.Stat(filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
379         require.NoError(t, err)
380         require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
381 }
382
383 func TestWhatsAppPairingCodeIsATelegramMessageAlone(t *testing.T) {
384         whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
385         telegram := newTelegramFake(t)
386         bot, _ := telegramStartTestBot(t, telegram)
387         daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
388
389         telegram.Add(TelegramUpdate{
390                 UpdateID: 1,
391                 Message:  telegramOwnerMessage("/whatsapp pair 15550001111"),
392         })
393
394         items := <-fake.pairings
395         items <- whatsmeow.QRChannelItem{
396                 Event: whatsmeow.QRChannelEventCode,
397         }
398
399         telegramWaitForSent(t, telegram, whatsappPairSteps, whatsappTestCode)
400 }
401
402 func TestWhatsAppPairingEnds(t *testing.T) {
403         testCases := []struct {
404                 name   string
405                 end    func(items chan whatsmeow.QRChannelItem)
406                 answer string
407         }{
408                 {
409                         name: "the code expires",
410                         end: func(items chan whatsmeow.QRChannelItem) {
411                                 items <- whatsmeow.QRChannelTimeout
412                         },
413                         answer: whatsappPairExpired,
414                 },
415                 {
416                         name: "the library closes the pairing",
417                         end: func(items chan whatsmeow.QRChannelItem) {
418                                 close(items)
419                         },
420                         answer: whatsappPairExpired,
421                 },
422                 {
423                         name: "the pairing fails",
424                         end: func(items chan whatsmeow.QRChannelItem) {
425                                 items <- whatsmeow.QRChannelItem{
426                                         Event: whatsmeow.QRChannelEventError,
427                                         Error: errors.New("bad handshake"),
428                                 }
429                         },
430                         answer: "The pairing failed: bad handshake. Run /whatsapp pair PHONE again.",
431                 },
432         }
433
434         for _, tc := range testCases {
435                 t.Run(tc.name, func(t *testing.T) {
436                         whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
437                         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
438                         client := terminalStartTestClient(t, socket)
439                         client.Type("/whatsapp pair 15550001111")
440
441                         items := <-fake.pairings
442                         items <- whatsmeow.QRChannelItem{
443                                 Event: whatsmeow.QRChannelEventCode,
444                         }
445
446                         client.WaitFor(t, whatsappTestCode)
447                         tc.end(items)
448                         client.WaitFor(t, tc.answer)
449                         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
450                 })
451         }
452 }
453
454 func TestWhatsAppNewPairingEndsTheOneBefore(t *testing.T) {
455         whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
456         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
457         client := terminalStartTestClient(t, socket)
458
459         client.Type("/whatsapp pair 15550001111")
460         <-fake.pairings
461         client.Type("/whatsapp pair 15550002222")
462         items := <-fake.pairings
463         items <- whatsmeow.QRChannelItem{
464                 Event: whatsmeow.QRChannelEventCode,
465         }
466
467         client.WaitFor(t, whatsappTestCode)
468         require.Equal(t, []string{"15550002222"}, fake.Phones())
469         require.NotContains(t, client.Output(), "did not start the pairing")
470 }
471
472 func TestWhatsAppPairWhenLinked(t *testing.T) {
473         dir := t.TempDir()
474         whatsappTestSession(t, dir, true)
475         whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
476         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
477         client := terminalStartTestClient(t, socket)
478
479         client.Type("/whatsapp pair 15550001111")
480         client.WaitFor(t, "WhatsApp is linked already. Run /whatsapp unlink first.")
481 }
482
483 func TestWhatsAppUnlink(t *testing.T) {
484         testCases := []struct {
485                 name      string
486                 logoutErr error
487                 answer    string
488         }{
489                 {
490                         name:   "with a logout",
491                         answer: "WhatsApp is unlinked.",
492                 },
493                 {
494                         name:      "with no logout",
495                         logoutErr: errors.New("not connected"),
496                         answer:    "WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.",
497                 },
498         }
499
500         for _, tc := range testCases {
501                 t.Run(tc.name, func(t *testing.T) {
502                         dir := t.TempDir()
503                         whatsappTestSession(t, dir, true)
504                         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
505                         fake.logoutErr = tc.logoutErr
506                         socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
507                         client := terminalStartTestClient(t, socket)
508                         <-fake.connects
509
510                         client.Type("/whatsapp unlink")
511                         client.WaitFor(t, tc.answer)
512                         require.Equal(t, 1, fake.Logouts())
513                         require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
514                         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
515                         require.Equal(t, []string{"WhatsApp is unlinked."}, whatsappTestLogTexts(t, memory))
516
517                         client.Type("/whatsapp unlink")
518                         client.WaitFor(t, "WhatsApp is not linked.\n")
519                 })
520         }
521 }
522
523 func TestWhatsAppLogoutFromThePhone(t *testing.T) {
524         dir := t.TempDir()
525         whatsappTestSession(t, dir, true)
526         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
527         telegram := newTelegramFake(t)
528         bot, _ := telegramStartTestBot(t, telegram)
529         socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
530         client := terminalStartTestClient(t, socket)
531         client.WaitFor(t, "[connected to OneChat]")
532         <-fake.connects
533
534         require.True(t, fake.Emit(&events.LoggedOut{}))
535         notice := "WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again."
536         client.WaitFor(t, notice)
537         telegramWaitForSent(t, telegram, notice)
538         require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
539         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
540         require.Equal(t, []string{"WhatsApp was unlinked from the phone."}, whatsappTestLogTexts(t, memory))
541 }
542
543 func TestWhatsAppLongDisconnectionGivesOneNotice(t *testing.T) {
544         dir := t.TempDir()
545         whatsappTestSession(t, dir, true)
546         clock := newTestClock()
547         whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
548         whatsapp.noticeDelay = 300 * time.Millisecond
549         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
550         client := terminalStartTestClient(t, socket)
551         client.WaitFor(t, "[connected to OneChat]")
552         <-fake.connects
553
554         require.True(t, fake.Emit(&events.Connected{}))
555         require.True(t, fake.Emit(&events.Disconnected{}))
556         require.True(t, fake.Emit(&events.Connected{}))
557         time.Sleep(400 * time.Millisecond)
558         require.Equal(t, "[connected to OneChat]\n", client.Output())
559
560         clock.now = clock.now.Add(time.Hour)
561         require.True(t, fake.Emit(&events.Disconnected{}))
562         require.True(t, fake.Emit(&events.Disconnected{}))
563         client.WaitFor(t, "WhatsApp is disconnected since 2026-01-01 13:00. OneChat tries again.\n")
564         require.True(t, fake.Emit(&events.Disconnected{}))
565         time.Sleep(400 * time.Millisecond)
566
567         require.True(t, fake.Emit(&events.Connected{}))
568         client.WaitFor(t, "WhatsApp is connected again.\n")
569         require.Equal(t, 1, strings.Count(client.Output(), "WhatsApp is disconnected since"))
570 }
571
572 func TestWhatsAppDisconnectionWithNoRetryGivesANoticeAtOnce(t *testing.T) {
573         dir := t.TempDir()
574         whatsappTestSession(t, dir, true)
575         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
576         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
577         client := terminalStartTestClient(t, socket)
578         client.WaitFor(t, "[connected to OneChat]")
579         <-fake.connects
580
581         require.True(t, fake.Emit(&events.StreamReplaced{}))
582         client.WaitFor(t, "WhatsApp is disconnected and does not connect again by itself: stream replaced. Restart the daemon to try again.")
583 }
584
585 func TestWhatsAppLogsNoOwnNumber(t *testing.T) {
586         dir := t.TempDir()
587         whatsappTestSession(t, dir, true)
588         whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
589         fake.connectErr = errors.New("no route for " + whatsappTestNumber + "@s.whatsapp.net and " + whatsappTestLID + "@lid")
590         daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
591         <-fake.connects
592
593         require.Eventually(t, func() bool {
594                 return logs.String() != ""
595         }, 10*time.Second, time.Millisecond)
596         require.Equal(t, "first connect failed, retrying: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String())
597 }
598
599 func TestWhatsAppPhone(t *testing.T) {
600         testCases := []struct {
601                 name  string
602                 phone string
603                 valid bool
604         }{
605                 {
606                         name:  "a number with a plus and dashes",
607                         phone: "+1-555-000-1111",
608                         valid: true,
609                 },
610                 {
611                         name:  "a number with no country code",
612                         phone: "0115550001111",
613                         valid: false,
614                 },
615                 {
616                         name:  "a short number",
617                         phone: "123456",
618                         valid: false,
619                 },
620                 {
621                         name:  "a long number",
622                         phone: "1234567890123456",
623                         valid: false,
624                 },
625                 {
626                         name:  "a number with letters",
627                         phone: "1555ABC1111",
628                         valid: false,
629                 },
630         }
631
632         for _, tc := range testCases {
633                 t.Run(tc.name, func(t *testing.T) {
634                         _, valid := whatsappPhone(tc.phone)
635                         require.Equal(t, tc.valid, valid)
636                 })
637         }
638 }
639
640 func TestWhatsAppCommandArgs(t *testing.T) {
641         testCases := []struct {
642                 name  string
643                 line  string
644                 args  string
645                 found bool
646         }{
647                 {
648                         name:  "a command with its arguments",
649                         line:  " /whatsapp  pair +1 555 ",
650                         args:  "pair +1 555",
651                         found: true,
652                 },
653                 {
654                         name:  "the command alone",
655                         line:  "/whatsapp",
656                         args:  "",
657                         found: true,
658                 },
659                 {
660                         name:  "a word that starts with the command",
661                         line:  "/whatsappstatus",
662                         args:  "",
663                         found: false,
664                 },
665                 {
666                         name:  "a message that names the command",
667                         line:  "what does /whatsapp status do?",
668                         args:  "",
669                         found: false,
670                 },
671         }
672
673         for _, tc := range testCases {
674                 t.Run(tc.name, func(t *testing.T) {
675                         args, found := whatsappCommandArgs(tc.line)
676                         require.Equal(t, tc.args, args)
677                         require.Equal(t, tc.found, found)
678                 })
679         }
680 }
681
682 func TestSubagentHasNoWhatsAppTools(t *testing.T) {
683         box := &Toolbox{}
684         require.Equal(t, `error: unknown tool "whatsapp_find"`, box.run(t.Context(), "whatsapp_find", `{"name":"ana"}`))
685         require.Equal(t, `error: unknown tool "whatsapp_send"`, box.run(t.Context(), "whatsapp_send", `{"id":"a","chat":"15550002222@s.whatsapp.net","text":"hi"}`))
686 }
687
688 // WhatsAppRetryTest runs WhatsApp without a model or network client call. All
689 // timers and channels belong to its synctest bubble, so backoff uses fake time.
690 type WhatsAppRetryTest struct {
691         whatsapp *WhatsApp
692         fake     *WhatsAppFake
693         logs     *SyncBuffer
694         loop     *TurnLoop
695         events   *TurnEventRecorder
696         cancel   context.CancelFunc
697         done     chan error
698 }
699
700 func (r *WhatsAppRetryTest) start(t *testing.T) {
701         t.Helper()
702
703         ctx, cancel := context.WithCancel(t.Context())
704         r.cancel = cancel
705         r.done = make(chan error, 1)
706         go func() {
707                 r.done <- r.whatsapp.Run(ctx, r.loop, r.events.Add)
708         }()
709
710         t.Cleanup(func() {
711                 cancel()
712                 synctest.Wait()
713         })
714 }
715
716 func whatsappRetryTest(t *testing.T) *WhatsAppRetryTest {
717         t.Helper()
718
719         dir := t.TempDir()
720         whatsappTestSession(t, dir, true)
721         whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
722         whatsapp.clock = time.Now
723         agent, err := openAgent(t.TempDir())
724         require.NoError(t, err)
725         memory, err := openMemory(t.Context(), agent.dir, time.Now, newFakeSummarizer(), discardLog())
726         require.NoError(t, err)
727         t.Cleanup(func() {
728                 require.NoError(t, memory.Close())
729         })
730
731         events := &TurnEventRecorder{}
732         client := newModelClient("https://example.com", ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
733         loop, err := newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, newTestSchedules(t, time.Now), client, time.Now, discardLog(), events.Add)
734         require.NoError(t, err)
735
736         return &WhatsAppRetryTest{
737                 whatsapp: whatsapp,
738                 fake:     fake,
739                 logs:     logs,
740                 loop:     loop,
741                 events:   events,
742         }
743 }