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