]> git.giacomolli.uk - onechat/blob - whatsapp_test.go
Log the start and the length of each subagent run
[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, "first connect failed, retrying\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 "connected",
67 "disconnected, retrying",
68 "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, "connect failed, restart to retry: 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 }