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