]> git.giacomolli.uk - onechat/blob - whatsapp_test.go
Log each change of the WhatsApp connection
[onechat] / whatsapp_test.go
1 package main
2
3 import (
4         "errors"
5         "os"
6         "path/filepath"
7         "strings"
8         "testing"
9         "time"
10
11         "github.com/stretchr/testify/require"
12         "go.mau.fi/whatsmeow"
13         "go.mau.fi/whatsmeow/types"
14         "go.mau.fi/whatsmeow/types/events"
15 )
16
17 func TestWhatsAppStatusFollowsTheConnection(t *testing.T) {
18         dir := t.TempDir()
19         whatsappTestSession(t, dir, true)
20         clock := newTestClock()
21         whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
22         daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
23
24         <-fake.connects
25         require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
26
27         require.True(t, fake.Emit(&events.Connected{}))
28         require.Equal(t, "WhatsApp is linked and connected since 2026-01-01 12:00.", whatsapp.Status())
29
30         clock.now = clock.now.Add(20 * time.Minute)
31         require.True(t, fake.Emit(&events.Disconnected{}))
32         clock.now = clock.now.Add(time.Minute)
33         require.True(t, fake.Emit(&events.Disconnected{}))
34         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())
35 }
36
37 func TestWhatsAppStatusOfAFirstConnectionThatFailed(t *testing.T) {
38         dir := t.TempDir()
39         whatsappTestSession(t, dir, true)
40         whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
41         daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
42
43         <-fake.connects
44         require.True(t, fake.Emit(&events.Disconnected{}))
45         require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:00.", whatsapp.Status())
46         require.Equal(t, "new WhatsApp messages wait, because the first connection failed (the next try is automatic)\n", logs.String())
47 }
48
49 func TestWhatsAppLogsEachChangeOfTheConnectionOnce(t *testing.T) {
50         dir := t.TempDir()
51         whatsappTestSession(t, dir, true)
52         clock := newTestClock()
53         whatsapp, fake, logs := whatsappStartTest(t, dir, clock)
54         daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
55
56         <-fake.connects
57         require.True(t, fake.Emit(&events.Connected{}))
58         clock.now = clock.now.Add(time.Minute)
59         require.True(t, fake.Emit(&events.Disconnected{}))
60         clock.now = clock.now.Add(time.Minute)
61         require.True(t, fake.Emit(&events.Disconnected{}))
62         clock.now = clock.now.Add(25 * time.Second)
63         require.True(t, fake.Emit(&events.Connected{}))
64
65         require.Equal(t, []string{
66                 "WhatsApp is connected, and new messages come in",
67                 "new WhatsApp messages wait, because the connection is lost (the next try is automatic)",
68                 "WhatsApp is connected again, after 1m25s",
69         }, strings.Split(strings.TrimSuffix(logs.String(), "\n"), "\n"))
70         require.NotContains(t, logs.String(), whatsappTestNumber)
71         require.NotContains(t, logs.String(), whatsappTestLID)
72 }
73
74 func TestWhatsAppIsNotLinkedWithoutALinkedDevice(t *testing.T) {
75         testCases := []struct {
76                 name    string
77                 session func(t *testing.T, dir string)
78         }{
79                 {
80                         name: "no session file",
81                         session: func(t *testing.T, dir string) {
82                         },
83                 },
84                 {
85                         name: "a session of a pairing that did not end",
86                         session: func(t *testing.T, dir string) {
87                                 whatsappTestSession(t, dir, false)
88                         },
89                 },
90         }
91
92         for _, tc := range testCases {
93                 t.Run(tc.name, func(t *testing.T) {
94                         dir := t.TempDir()
95                         tc.session(t, dir)
96                         whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
97                         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
98                 })
99         }
100 }
101
102 func TestWhatsAppRefusesASessionThatOthersCanRead(t *testing.T) {
103         dir := t.TempDir()
104         whatsappTestSession(t, dir, true)
105         require.NoError(t, os.Chmod(filepath.Join(dir, whatsappDirName, whatsappSessionFileName), 0o644))
106
107         _, err := openWhatsApp(t.Context(), dir, time.Now, whatsappDiscardLog(), whatsappNewClient)
108         require.EqualError(t, err, "whatsapp/session.db holds the WhatsApp session, so its mode must be 0600")
109 }
110
111 func TestWhatsAppCommandsAnswerOnlyTheClientThatAsked(t *testing.T) {
112         server := newModelFake(t)
113         telegram := newTelegramFake(t)
114         bot, _ := telegramStartTestBot(t, telegram)
115         socket := daemonStartTestWithBot(t, server, bot)
116         asking := terminalStartTestClient(t, socket)
117         other := terminalStartTestClient(t, socket)
118         asking.WaitFor(t, "[connected to OneChat]")
119         other.WaitFor(t, "[connected to OneChat]")
120
121         asking.Type("/whatsapp status")
122         asking.WaitFor(t, "WhatsApp is not linked.")
123         asking.Type(" /whatsapp  unknown ")
124         asking.WaitFor(t, whatsappUsage)
125         asking.Type("/whatsapp pair 0123")
126         asking.WaitFor(t, "PHONE must be the number of your WhatsApp account")
127
128         telegram.Add(TelegramUpdate{
129                 UpdateID: 1,
130                 Message:  telegramOwnerMessage("/whatsapp status"),
131         })
132
133         telegramWaitForSent(t, telegram, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.")
134         require.Len(t, telegram.Sent(), 1)
135         require.Equal(t, "[connected to OneChat]\n", other.Output())
136         require.Zero(t, server.Calls())
137 }
138
139 func TestWhatsAppPairsWithACode(t *testing.T) {
140         dir := t.TempDir()
141         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
142         server := newModelFake(t)
143         socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
144         asking := terminalStartTestClient(t, socket)
145         other := terminalStartTestClient(t, socket)
146         asking.WaitFor(t, "[connected to OneChat]")
147         other.WaitFor(t, "[connected to OneChat]")
148
149         asking.Type("/whatsapp pair +1 555-000-1111")
150         items := <-fake.pairings
151         items <- whatsmeow.QRChannelItem{
152                 Event: whatsmeow.QRChannelEventCode,
153         }
154
155         asking.WaitFor(t, whatsappPairSteps+"\n"+whatsappTestCode+"\n")
156         require.True(t, fake.Emit(&events.PairSuccess{
157                 ID:  types.NewADJID(whatsappTestNumber, 0, 1),
158                 LID: types.NewADJID(whatsappTestLID, 0, 1),
159         }))
160         items <- whatsmeow.QRChannelSuccess
161         asking.WaitFor(t, "WhatsApp is linked to +15550001111.")
162
163         // The library connects again after the link with the context of the
164         // connection, so the end of the pairing must not end it.
165         connections := fake.Connections()
166         require.Len(t, connections, 1)
167         require.Never(t, func() bool {
168                 return connections[0].Err() != nil
169         }, 200*time.Millisecond, time.Millisecond)
170
171         require.Equal(t, []string{whatsappTestNumber}, fake.Phones())
172         require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
173         require.Equal(t, "[connected to OneChat]\n", other.Output())
174         require.Zero(t, server.Calls())
175         require.Equal(t, []string{"WhatsApp is linked."}, whatsappTestLogTexts(t, memory))
176
177         info, err := os.Stat(filepath.Join(dir, whatsappDirName))
178         require.NoError(t, err)
179         require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
180
181         info, err = os.Stat(filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
182         require.NoError(t, err)
183         require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
184 }
185
186 func TestWhatsAppPairingCodeIsATelegramMessageAlone(t *testing.T) {
187         whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
188         telegram := newTelegramFake(t)
189         bot, _ := telegramStartTestBot(t, telegram)
190         daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
191
192         telegram.Add(TelegramUpdate{
193                 UpdateID: 1,
194                 Message:  telegramOwnerMessage("/whatsapp pair 15550001111"),
195         })
196
197         items := <-fake.pairings
198         items <- whatsmeow.QRChannelItem{
199                 Event: whatsmeow.QRChannelEventCode,
200         }
201
202         telegramWaitForSent(t, telegram, whatsappPairSteps, whatsappTestCode)
203 }
204
205 func TestWhatsAppPairingEnds(t *testing.T) {
206         testCases := []struct {
207                 name   string
208                 end    func(items chan whatsmeow.QRChannelItem)
209                 answer string
210         }{
211                 {
212                         name: "the code expires",
213                         end: func(items chan whatsmeow.QRChannelItem) {
214                                 items <- whatsmeow.QRChannelTimeout
215                         },
216                         answer: whatsappPairExpired,
217                 },
218                 {
219                         name: "the library closes the pairing",
220                         end: func(items chan whatsmeow.QRChannelItem) {
221                                 close(items)
222                         },
223                         answer: whatsappPairExpired,
224                 },
225                 {
226                         name: "the pairing fails",
227                         end: func(items chan whatsmeow.QRChannelItem) {
228                                 items <- whatsmeow.QRChannelItem{
229                                         Event: whatsmeow.QRChannelEventError,
230                                         Error: errors.New("bad handshake"),
231                                 }
232                         },
233                         answer: "The pairing failed: bad handshake. Run /whatsapp pair PHONE again.",
234                 },
235         }
236
237         for _, tc := range testCases {
238                 t.Run(tc.name, func(t *testing.T) {
239                         whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
240                         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
241                         client := terminalStartTestClient(t, socket)
242                         client.Type("/whatsapp pair 15550001111")
243
244                         items := <-fake.pairings
245                         items <- whatsmeow.QRChannelItem{
246                                 Event: whatsmeow.QRChannelEventCode,
247                         }
248
249                         client.WaitFor(t, whatsappTestCode)
250                         tc.end(items)
251                         client.WaitFor(t, tc.answer)
252                         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
253                 })
254         }
255 }
256
257 func TestWhatsAppNewPairingEndsTheOneBefore(t *testing.T) {
258         whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
259         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
260         client := terminalStartTestClient(t, socket)
261
262         client.Type("/whatsapp pair 15550001111")
263         <-fake.pairings
264         client.Type("/whatsapp pair 15550002222")
265         items := <-fake.pairings
266         items <- whatsmeow.QRChannelItem{
267                 Event: whatsmeow.QRChannelEventCode,
268         }
269
270         client.WaitFor(t, whatsappTestCode)
271         require.Equal(t, []string{"15550002222"}, fake.Phones())
272         require.NotContains(t, client.Output(), "did not start the pairing")
273 }
274
275 func TestWhatsAppPairWhenLinked(t *testing.T) {
276         dir := t.TempDir()
277         whatsappTestSession(t, dir, true)
278         whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
279         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
280         client := terminalStartTestClient(t, socket)
281
282         client.Type("/whatsapp pair 15550001111")
283         client.WaitFor(t, "WhatsApp is linked already. Run /whatsapp unlink first.")
284 }
285
286 func TestWhatsAppUnlink(t *testing.T) {
287         testCases := []struct {
288                 name      string
289                 logoutErr error
290                 answer    string
291         }{
292                 {
293                         name:   "with a logout",
294                         answer: "WhatsApp is unlinked.",
295                 },
296                 {
297                         name:      "with no logout",
298                         logoutErr: errors.New("not connected"),
299                         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.",
300                 },
301         }
302
303         for _, tc := range testCases {
304                 t.Run(tc.name, func(t *testing.T) {
305                         dir := t.TempDir()
306                         whatsappTestSession(t, dir, true)
307                         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
308                         fake.logoutErr = tc.logoutErr
309                         socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
310                         client := terminalStartTestClient(t, socket)
311                         <-fake.connects
312
313                         client.Type("/whatsapp unlink")
314                         client.WaitFor(t, tc.answer)
315                         require.Equal(t, 1, fake.Logouts())
316                         require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
317                         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
318                         require.Equal(t, []string{"WhatsApp is unlinked."}, whatsappTestLogTexts(t, memory))
319
320                         client.Type("/whatsapp unlink")
321                         client.WaitFor(t, "WhatsApp is not linked.\n")
322                 })
323         }
324 }
325
326 func TestWhatsAppLogoutFromThePhone(t *testing.T) {
327         dir := t.TempDir()
328         whatsappTestSession(t, dir, true)
329         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
330         telegram := newTelegramFake(t)
331         bot, _ := telegramStartTestBot(t, telegram)
332         socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
333         client := terminalStartTestClient(t, socket)
334         client.WaitFor(t, "[connected to OneChat]")
335         <-fake.connects
336
337         require.True(t, fake.Emit(&events.LoggedOut{}))
338         notice := "WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again."
339         client.WaitFor(t, notice)
340         telegramWaitForSent(t, telegram, notice)
341         require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
342         require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
343         require.Equal(t, []string{"WhatsApp was unlinked from the phone."}, whatsappTestLogTexts(t, memory))
344 }
345
346 func TestWhatsAppLongDisconnectionGivesOneNotice(t *testing.T) {
347         dir := t.TempDir()
348         whatsappTestSession(t, dir, true)
349         clock := newTestClock()
350         whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
351         whatsapp.noticeDelay = 300 * time.Millisecond
352         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
353         client := terminalStartTestClient(t, socket)
354         client.WaitFor(t, "[connected to OneChat]")
355         <-fake.connects
356
357         require.True(t, fake.Emit(&events.Connected{}))
358         require.True(t, fake.Emit(&events.Disconnected{}))
359         require.True(t, fake.Emit(&events.Connected{}))
360         time.Sleep(400 * time.Millisecond)
361         require.Equal(t, "[connected to OneChat]\n", client.Output())
362
363         clock.now = clock.now.Add(time.Hour)
364         require.True(t, fake.Emit(&events.Disconnected{}))
365         require.True(t, fake.Emit(&events.Disconnected{}))
366         client.WaitFor(t, "WhatsApp is disconnected since 2026-01-01 13:00. OneChat tries again.\n")
367         require.True(t, fake.Emit(&events.Disconnected{}))
368         time.Sleep(400 * time.Millisecond)
369
370         require.True(t, fake.Emit(&events.Connected{}))
371         client.WaitFor(t, "WhatsApp is connected again.\n")
372         require.Equal(t, 1, strings.Count(client.Output(), "WhatsApp is disconnected since"))
373 }
374
375 func TestWhatsAppDisconnectionWithNoRetryGivesANoticeAtOnce(t *testing.T) {
376         dir := t.TempDir()
377         whatsappTestSession(t, dir, true)
378         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
379         socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
380         client := terminalStartTestClient(t, socket)
381         client.WaitFor(t, "[connected to OneChat]")
382         <-fake.connects
383
384         require.True(t, fake.Emit(&events.StreamReplaced{}))
385         client.WaitFor(t, "WhatsApp is disconnected and does not connect again by itself: stream replaced. Restart the daemon to try again.")
386 }
387
388 func TestWhatsAppLogsNoOwnNumber(t *testing.T) {
389         dir := t.TempDir()
390         whatsappTestSession(t, dir, true)
391         whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
392         fake.connectErr = errors.New("no route for " + whatsappTestNumber + "@s.whatsapp.net and " + whatsappTestLID + "@lid")
393         daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
394         <-fake.connects
395
396         require.Eventually(t, func() bool {
397                 return logs.String() != ""
398         }, 10*time.Second, time.Millisecond)
399         require.Equal(t, "WhatsApp messages do not come, because the connection failed, and it is tried again only at a restart of the daemon: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String())
400 }
401
402 func TestWhatsAppPhone(t *testing.T) {
403         testCases := []struct {
404                 name  string
405                 phone string
406                 valid bool
407         }{
408                 {
409                         name:  "a number with a plus and dashes",
410                         phone: "+1-555-000-1111",
411                         valid: true,
412                 },
413                 {
414                         name:  "a number with no country code",
415                         phone: "0115550001111",
416                         valid: false,
417                 },
418                 {
419                         name:  "a short number",
420                         phone: "123456",
421                         valid: false,
422                 },
423                 {
424                         name:  "a long number",
425                         phone: "1234567890123456",
426                         valid: false,
427                 },
428                 {
429                         name:  "a number with letters",
430                         phone: "1555ABC1111",
431                         valid: false,
432                 },
433         }
434
435         for _, tc := range testCases {
436                 t.Run(tc.name, func(t *testing.T) {
437                         _, valid := whatsappPhone(tc.phone)
438                         require.Equal(t, tc.valid, valid)
439                 })
440         }
441 }
442
443 func TestWhatsAppCommandArgs(t *testing.T) {
444         testCases := []struct {
445                 name  string
446                 line  string
447                 args  string
448                 found bool
449         }{
450                 {
451                         name:  "a command with its arguments",
452                         line:  " /whatsapp  pair +1 555 ",
453                         args:  "pair +1 555",
454                         found: true,
455                 },
456                 {
457                         name:  "the command alone",
458                         line:  "/whatsapp",
459                         args:  "",
460                         found: true,
461                 },
462                 {
463                         name:  "a word that starts with the command",
464                         line:  "/whatsappstatus",
465                         args:  "",
466                         found: false,
467                 },
468                 {
469                         name:  "a message that names the command",
470                         line:  "what does /whatsapp status do?",
471                         args:  "",
472                         found: false,
473                 },
474         }
475
476         for _, tc := range testCases {
477                 t.Run(tc.name, func(t *testing.T) {
478                         args, found := whatsappCommandArgs(tc.line)
479                         require.Equal(t, tc.args, args)
480                         require.Equal(t, tc.found, found)
481                 })
482         }
483 }
484
485 func TestSubagentHasNoWhatsAppTools(t *testing.T) {
486         box := &Toolbox{}
487         require.Equal(t, `error: unknown tool "whatsapp_find"`, box.run(t.Context(), "whatsapp_find", `{"name":"ana"}`))
488         require.Equal(t, `error: unknown tool "whatsapp_send"`, box.run(t.Context(), "whatsapp_send", `{"id":"a","chat":"15550002222@s.whatsapp.net","text":"hi"}`))
489 }