]> git.giacomolli.uk - onechat/blob - whatsapp_test.go
Log failed turns and the ends of subagents
[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 }