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