]> git.giacomolli.uk - onechat/blob - whatsapp_test.go
Make two WhatsApp tests independent of the load
[onechat] / whatsapp_test.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14 "go.mau.fi/whatsmeow"
15 "go.mau.fi/whatsmeow/proto/waE2E"
16 "go.mau.fi/whatsmeow/proto/waWeb"
17 "go.mau.fi/whatsmeow/types"
18 "go.mau.fi/whatsmeow/types/events"
19 )
20
21 func TestWhatsAppStatusFollowsTheConnection(t *testing.T) {
22 dir := t.TempDir()
23 whatsappTestSession(t, dir, true)
24 clock := newTestClock()
25 whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
26 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
27
28 <-fake.connects
29 require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
30
31 require.True(t, fake.Emit(&events.Connected{}))
32 require.Equal(t, "WhatsApp is linked and connected since 2026-01-01 12:00.", whatsapp.Status())
33
34 clock.now = clock.now.Add(20 * time.Minute)
35 require.True(t, fake.Emit(&events.Disconnected{}))
36 clock.now = clock.now.Add(time.Minute)
37 require.True(t, fake.Emit(&events.Disconnected{}))
38 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())
39 }
40
41 func TestWhatsAppStatusOfAFirstConnectionThatFailed(t *testing.T) {
42 dir := t.TempDir()
43 whatsappTestSession(t, dir, true)
44 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
45 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
46
47 <-fake.connects
48 require.True(t, fake.Emit(&events.Disconnected{}))
49 require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:00.", whatsapp.Status())
50 }
51
52 func TestWhatsAppIsNotLinkedWithoutALinkedDevice(t *testing.T) {
53 testCases := []struct {
54 name string
55 session func(t *testing.T, dir string)
56 }{
57 {
58 name: "no session file",
59 session: func(t *testing.T, dir string) {
60 },
61 },
62 {
63 name: "a session of a pairing that did not end",
64 session: func(t *testing.T, dir string) {
65 whatsappTestSession(t, dir, false)
66 },
67 },
68 }
69
70 for _, tc := range testCases {
71 t.Run(tc.name, func(t *testing.T) {
72 dir := t.TempDir()
73 tc.session(t, dir)
74 whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
75 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
76 })
77 }
78 }
79
80 func TestWhatsAppRefusesASessionThatOthersCanRead(t *testing.T) {
81 dir := t.TempDir()
82 whatsappTestSession(t, dir, true)
83 require.NoError(t, os.Chmod(filepath.Join(dir, whatsappDirName, whatsappSessionFileName), 0o644))
84
85 _, err := openWhatsApp(t.Context(), dir, time.Now, whatsappDiscardLog(), whatsappNewClient)
86 require.EqualError(t, err, "whatsapp/session.db holds the WhatsApp session, so its mode must be 0600")
87 }
88
89 func TestWhatsAppCommandsAnswerOnlyTheClientThatAsked(t *testing.T) {
90 server := newModelFake(t)
91 telegram := newTelegramFake(t)
92 bot, _ := telegramStartTestBot(t, telegram)
93 socket := daemonStartTestWithBot(t, server, bot)
94 asking := terminalStartTestClient(t, socket)
95 other := terminalStartTestClient(t, socket)
96 asking.WaitFor(t, "[connected to OneChat]")
97 other.WaitFor(t, "[connected to OneChat]")
98
99 asking.Type("/whatsapp status")
100 asking.WaitFor(t, "WhatsApp is not linked.")
101 asking.Type(" /whatsapp unknown ")
102 asking.WaitFor(t, whatsappUsage)
103 asking.Type("/whatsapp pair 0123")
104 asking.WaitFor(t, "PHONE must be the number of your WhatsApp account")
105
106 telegram.Add(TelegramUpdate{
107 UpdateID: 1,
108 Message: telegramOwnerMessage("/whatsapp status"),
109 })
110
111 telegramWaitForSent(t, telegram, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.")
112 require.Len(t, telegram.Sent(), 1)
113 require.Equal(t, "[connected to OneChat]\n", other.Output())
114 require.Zero(t, server.Calls())
115 }
116
117 func TestWhatsAppPairsWithACode(t *testing.T) {
118 dir := t.TempDir()
119 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
120 server := newModelFake(t)
121 socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
122 asking := terminalStartTestClient(t, socket)
123 other := terminalStartTestClient(t, socket)
124 asking.WaitFor(t, "[connected to OneChat]")
125 other.WaitFor(t, "[connected to OneChat]")
126
127 asking.Type("/whatsapp pair +1 555-000-1111")
128 items := <-fake.pairings
129 items <- whatsmeow.QRChannelItem{
130 Event: whatsmeow.QRChannelEventCode,
131 }
132
133 asking.WaitFor(t, whatsappPairSteps+"\n"+whatsappTestCode+"\n")
134 require.True(t, fake.Emit(&events.PairSuccess{
135 ID: types.NewADJID(whatsappTestNumber, 0, 1),
136 LID: types.NewADJID(whatsappTestLID, 0, 1),
137 }))
138 items <- whatsmeow.QRChannelSuccess
139 asking.WaitFor(t, "WhatsApp is linked to +15550001111.")
140
141 require.Equal(t, []string{whatsappTestNumber}, fake.Phones())
142 require.Equal(t, "WhatsApp is linked and not connected yet.", whatsapp.Status())
143 require.Equal(t, "[connected to OneChat]\n", other.Output())
144 require.Zero(t, server.Calls())
145 require.Equal(t, []string{"WhatsApp is linked."}, whatsappTestLogTexts(t, memory))
146
147 info, err := os.Stat(filepath.Join(dir, whatsappDirName))
148 require.NoError(t, err)
149 require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
150
151 info, err = os.Stat(filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
152 require.NoError(t, err)
153 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
154 }
155
156 func TestWhatsAppPairingCodeIsATelegramMessageAlone(t *testing.T) {
157 whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
158 telegram := newTelegramFake(t)
159 bot, _ := telegramStartTestBot(t, telegram)
160 daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
161
162 telegram.Add(TelegramUpdate{
163 UpdateID: 1,
164 Message: telegramOwnerMessage("/whatsapp pair 15550001111"),
165 })
166
167 items := <-fake.pairings
168 items <- whatsmeow.QRChannelItem{
169 Event: whatsmeow.QRChannelEventCode,
170 }
171
172 telegramWaitForSent(t, telegram, whatsappPairSteps, whatsappTestCode)
173 }
174
175 func TestWhatsAppPairingEnds(t *testing.T) {
176 testCases := []struct {
177 name string
178 end func(items chan whatsmeow.QRChannelItem)
179 answer string
180 }{
181 {
182 name: "the code expires",
183 end: func(items chan whatsmeow.QRChannelItem) {
184 items <- whatsmeow.QRChannelTimeout
185 },
186 answer: whatsappPairExpired,
187 },
188 {
189 name: "the library closes the pairing",
190 end: func(items chan whatsmeow.QRChannelItem) {
191 close(items)
192 },
193 answer: whatsappPairExpired,
194 },
195 {
196 name: "the pairing fails",
197 end: func(items chan whatsmeow.QRChannelItem) {
198 items <- whatsmeow.QRChannelItem{
199 Event: whatsmeow.QRChannelEventError,
200 Error: errors.New("bad handshake"),
201 }
202 },
203 answer: "The pairing failed: bad handshake. Run /whatsapp pair PHONE again.",
204 },
205 }
206
207 for _, tc := range testCases {
208 t.Run(tc.name, func(t *testing.T) {
209 whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
210 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
211 client := terminalStartTestClient(t, socket)
212 client.Type("/whatsapp pair 15550001111")
213
214 items := <-fake.pairings
215 items <- whatsmeow.QRChannelItem{
216 Event: whatsmeow.QRChannelEventCode,
217 }
218
219 client.WaitFor(t, whatsappTestCode)
220 tc.end(items)
221 client.WaitFor(t, tc.answer)
222 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
223 })
224 }
225 }
226
227 func TestWhatsAppNewPairingEndsTheOneBefore(t *testing.T) {
228 whatsapp, fake, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
229 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
230 client := terminalStartTestClient(t, socket)
231
232 client.Type("/whatsapp pair 15550001111")
233 <-fake.pairings
234 client.Type("/whatsapp pair 15550002222")
235 items := <-fake.pairings
236 items <- whatsmeow.QRChannelItem{
237 Event: whatsmeow.QRChannelEventCode,
238 }
239
240 client.WaitFor(t, whatsappTestCode)
241 require.Equal(t, []string{"15550002222"}, fake.Phones())
242 require.NotContains(t, client.Output(), "did not start the pairing")
243 }
244
245 func TestWhatsAppPairWhenLinked(t *testing.T) {
246 dir := t.TempDir()
247 whatsappTestSession(t, dir, true)
248 whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
249 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
250 client := terminalStartTestClient(t, socket)
251
252 client.Type("/whatsapp pair 15550001111")
253 client.WaitFor(t, "WhatsApp is linked already. Run /whatsapp unlink first.")
254 }
255
256 func TestWhatsAppUnlink(t *testing.T) {
257 testCases := []struct {
258 name string
259 logoutErr error
260 answer string
261 }{
262 {
263 name: "with a logout",
264 answer: "WhatsApp is unlinked.",
265 },
266 {
267 name: "with no logout",
268 logoutErr: errors.New("not connected"),
269 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.",
270 },
271 }
272
273 for _, tc := range testCases {
274 t.Run(tc.name, func(t *testing.T) {
275 dir := t.TempDir()
276 whatsappTestSession(t, dir, true)
277 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
278 fake.logoutErr = tc.logoutErr
279 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
280 client := terminalStartTestClient(t, socket)
281 <-fake.connects
282
283 client.Type("/whatsapp unlink")
284 client.WaitFor(t, tc.answer)
285 require.Equal(t, 1, fake.Logouts())
286 require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
287 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
288 require.Equal(t, []string{"WhatsApp is unlinked."}, whatsappTestLogTexts(t, memory))
289
290 client.Type("/whatsapp unlink")
291 client.WaitFor(t, "WhatsApp is not linked.\n")
292 })
293 }
294 }
295
296 func TestWhatsAppLogoutFromThePhone(t *testing.T) {
297 dir := t.TempDir()
298 whatsappTestSession(t, dir, true)
299 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
300 telegram := newTelegramFake(t)
301 bot, _ := telegramStartTestBot(t, telegram)
302 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), bot, whatsapp)
303 client := terminalStartTestClient(t, socket)
304 client.WaitFor(t, "[connected to OneChat]")
305 <-fake.connects
306
307 require.True(t, fake.Emit(&events.LoggedOut{}))
308 notice := "WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again."
309 client.WaitFor(t, notice)
310 telegramWaitForSent(t, telegram, notice)
311 require.NoFileExists(t, filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
312 require.Equal(t, "WhatsApp is not linked. Run /whatsapp pair PHONE to link it.", whatsapp.Status())
313 require.Equal(t, []string{"WhatsApp was unlinked from the phone."}, whatsappTestLogTexts(t, memory))
314 }
315
316 func TestWhatsAppLongDisconnectionGivesOneNotice(t *testing.T) {
317 dir := t.TempDir()
318 whatsappTestSession(t, dir, true)
319 clock := newTestClock()
320 whatsapp, fake, _ := whatsappStartTest(t, dir, clock)
321 whatsapp.noticeDelay = 300 * time.Millisecond
322 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
323 client := terminalStartTestClient(t, socket)
324 client.WaitFor(t, "[connected to OneChat]")
325 <-fake.connects
326
327 require.True(t, fake.Emit(&events.Connected{}))
328 require.True(t, fake.Emit(&events.Disconnected{}))
329 require.True(t, fake.Emit(&events.Connected{}))
330 time.Sleep(400 * time.Millisecond)
331 require.Equal(t, "[connected to OneChat]\n", client.Output())
332
333 clock.now = clock.now.Add(time.Hour)
334 require.True(t, fake.Emit(&events.Disconnected{}))
335 require.True(t, fake.Emit(&events.Disconnected{}))
336 client.WaitFor(t, "WhatsApp is disconnected since 2026-01-01 13:00. OneChat tries again.\n")
337 require.True(t, fake.Emit(&events.Disconnected{}))
338 time.Sleep(400 * time.Millisecond)
339
340 require.True(t, fake.Emit(&events.Connected{}))
341 client.WaitFor(t, "WhatsApp is connected again.\n")
342 require.Equal(t, 1, strings.Count(client.Output(), "WhatsApp is disconnected since"))
343 }
344
345 func TestWhatsAppDisconnectionWithNoRetryGivesANoticeAtOnce(t *testing.T) {
346 dir := t.TempDir()
347 whatsappTestSession(t, dir, true)
348 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
349 socket, _ := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
350 client := terminalStartTestClient(t, socket)
351 client.WaitFor(t, "[connected to OneChat]")
352 <-fake.connects
353
354 require.True(t, fake.Emit(&events.StreamReplaced{}))
355 client.WaitFor(t, "WhatsApp is disconnected and does not connect again by itself: stream replaced. Restart the daemon to try again.")
356 }
357
358 func TestWhatsAppLogsNoOwnNumber(t *testing.T) {
359 dir := t.TempDir()
360 whatsappTestSession(t, dir, true)
361 whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
362 fake.connectErr = errors.New("no route for " + whatsappTestNumber + "@s.whatsapp.net and " + whatsappTestLID + "@lid")
363 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
364 <-fake.connects
365
366 require.Eventually(t, func() bool {
367 return logs.String() != ""
368 }, 10*time.Second, time.Millisecond)
369 require.Equal(t, "connect: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String())
370 }
371
372 func TestWhatsAppPhone(t *testing.T) {
373 testCases := []struct {
374 name string
375 phone string
376 valid bool
377 }{
378 {
379 name: "a number with a plus and dashes",
380 phone: "+1-555-000-1111",
381 valid: true,
382 },
383 {
384 name: "a number with no country code",
385 phone: "0115550001111",
386 valid: false,
387 },
388 {
389 name: "a short number",
390 phone: "123456",
391 valid: false,
392 },
393 {
394 name: "a long number",
395 phone: "1234567890123456",
396 valid: false,
397 },
398 {
399 name: "a number with letters",
400 phone: "1555ABC1111",
401 valid: false,
402 },
403 }
404
405 for _, tc := range testCases {
406 t.Run(tc.name, func(t *testing.T) {
407 _, valid := whatsappPhone(tc.phone)
408 require.Equal(t, tc.valid, valid)
409 })
410 }
411 }
412
413 func TestWhatsAppCommandArgs(t *testing.T) {
414 testCases := []struct {
415 name string
416 line string
417 args string
418 found bool
419 }{
420 {
421 name: "a command with its arguments",
422 line: " /whatsapp pair +1 555 ",
423 args: "pair +1 555",
424 found: true,
425 },
426 {
427 name: "the command alone",
428 line: "/whatsapp",
429 args: "",
430 found: true,
431 },
432 {
433 name: "a word that starts with the command",
434 line: "/whatsappstatus",
435 args: "",
436 found: false,
437 },
438 {
439 name: "a message that names the command",
440 line: "what does /whatsapp status do?",
441 args: "",
442 found: false,
443 },
444 }
445
446 for _, tc := range testCases {
447 t.Run(tc.name, func(t *testing.T) {
448 args, found := whatsappCommandArgs(tc.line)
449 require.Equal(t, tc.args, args)
450 require.Equal(t, tc.found, found)
451 })
452 }
453 }
454
455 // whatsappTestLogTexts returns the texts of the messages in the log of memory,
456 // after the log holds at least one message.
457 func whatsappTestLogTexts(t *testing.T, memory *Memory) []string {
458 t.Helper()
459
460 require.Eventually(t, func() bool {
461 return memory.Len() > 0
462 }, 10*time.Second, time.Millisecond)
463
464 var texts []string
465 for id := range memory.Len() {
466 message, err := memory.Message(id)
467 require.NoError(t, err)
468
469 texts = append(texts, message.Text)
470 }
471
472 return texts
473 }
474
475 func TestWhatsAppPrivateBatchStartsATurnWithAnEmptyReply(t *testing.T) {
476 whatsapp, fake := whatsappStartTestLinked(t)
477 require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
478
479 server := newModelFake(t)
480 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
481 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
482 }
483
484 telegram := newTelegramFake(t)
485 bot, _ := telegramStartTestBot(t, telegram)
486 socket, memory := daemonStartTestWithWhatsApp(t, server, bot, whatsapp)
487 client := terminalStartTestClient(t, socket)
488 client.WaitFor(t, "[connected to OneChat]")
489 <-fake.connects
490
491 // The catch-up holds the batch open until its end, so the two messages
492 // form one batch also on a slow machine.
493 require.True(t, fake.Emit(&events.Connected{}))
494 at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
495 require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Are you at home?", at)))
496 require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m2", "Yes", at)))
497 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
498
499 text := "[WhatsApp] Ana Souza, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n12:00 Ana Souza: Are you at home?\n12:00 You: Yes\n" + whatsappBatchEmptyReply
500 client.WaitFor(t, text+"\n[working]\n[done]\n")
501 require.Equal(t, 1, server.Calls())
502 require.Contains(t, string(server.Call(0).raw), "Are you at home?")
503 require.Equal(t, []string{text}, whatsappTestLogTexts(t, memory))
504 require.Empty(t, telegram.Sent())
505 require.Eventually(t, func() bool {
506 return len(whatsapp.batches.Closed()) == 0
507 }, 10*time.Second, time.Millisecond)
508 }
509
510 func TestWhatsAppGroupTurns(t *testing.T) {
511 testCases := []struct {
512 name string
513 message func(event *events.Message)
514 line string
515 turn bool
516 }{
517 {
518 name: "plain",
519 message: func(event *events.Message) {
520 },
521 line: "12:00 Bruno Lima: Who brings the cake?",
522 },
523 {
524 name: "mention",
525 message: func(event *events.Message) {
526 event.Message = &waE2E.Message{
527 ExtendedTextMessage: &waE2E.ExtendedTextMessage{
528 Text: new("@" + whatsappTestLID + " can you bring the cake? Call " + whatsappTestNumber + "."),
529 ContextInfo: &waE2E.ContextInfo{
530 MentionedJID: []string{whatsappTestLID + "@lid"},
531 },
532 },
533 }
534 },
535 line: "12:00 Bruno Lima (mentions you): @you can you bring the cake? Call OWN.",
536 turn: true,
537 },
538 {
539 name: "reply",
540 message: func(event *events.Message) {
541 event.Message = &waE2E.Message{
542 ExtendedTextMessage: &waE2E.ExtendedTextMessage{
543 Text: new("Who brings the cake?"),
544 ContextInfo: &waE2E.ContextInfo{
545 Participant: new(whatsappTestOwn.String()),
546 },
547 },
548 }
549 },
550 line: "12:00 Bruno Lima (replies to you): Who brings the cake?",
551 turn: true,
552 },
553 }
554
555 for _, tc := range testCases {
556 t.Run(tc.name, func(t *testing.T) {
557 whatsapp, fake := whatsappStartTestLinked(t)
558 fake.groups = []*types.GroupInfo{
559 {
560 JID: whatsappTestFamily,
561 GroupName: types.GroupName{
562 Name: "Family",
563 },
564 },
565 }
566
567 server := newModelFake(t)
568 socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
569 client := terminalStartTestClient(t, socket)
570 client.WaitFor(t, "[connected to OneChat]")
571 whatsappTestConnect(t, fake)
572
573 event := whatsappTestMessage(whatsappTestFamily, whatsappTestBruno, "m1", "Who brings the cake?", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
574 event.Info.PushName = "Bruno Lima"
575 tc.message(event)
576 require.True(t, fake.Emit(event))
577
578 text := "[WhatsApp] Family, group 120363000000000001@g.us, Thursday 2026-01-01\n" + tc.line
579 if tc.turn {
580 text += "\n" + whatsappBatchEmptyReply
581 client.WaitFor(t, "[done]")
582 require.Equal(t, 1, server.Calls())
583 } else {
584 client.WaitFor(t, text)
585 time.Sleep(100 * time.Millisecond)
586 require.Zero(t, server.Calls())
587 }
588
589 require.Equal(t, text, whatsappTestLogTexts(t, memory)[0])
590 })
591 }
592 }
593
594 func TestWhatsAppTakesOnlyText(t *testing.T) {
595 at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
596 testCases := []struct {
597 name string
598 change func(event *events.Message)
599 }{
600 {
601 name: "an edit",
602 change: func(event *events.Message) {
603 event.IsEdit = true
604 },
605 },
606 {
607 name: "old history",
608 change: func(event *events.Message) {
609 event.SourceWebMsg = &waWeb.WebMessageInfo{}
610 },
611 },
612 {
613 name: "a view-once message",
614 change: func(event *events.Message) {
615 event.IsViewOnce = true
616 },
617 },
618 {
619 name: "a disappearing message",
620 change: func(event *events.Message) {
621 event.IsEphemeral = true
622 },
623 },
624 {
625 name: "the chat of the user with itself",
626 change: func(event *events.Message) {
627 event.Info.Chat = whatsappTestOwn
628 },
629 },
630 {
631 name: "a status broadcast",
632 change: func(event *events.Message) {
633 event.Info.Chat = types.StatusBroadcastJID
634 },
635 },
636 {
637 name: "a photo with a caption",
638 change: func(event *events.Message) {
639 event.Message = &waE2E.Message{
640 ImageMessage: &waE2E.ImageMessage{
641 Caption: new("look"),
642 },
643 }
644 },
645 },
646 {
647 name: "a text of spaces",
648 change: func(event *events.Message) {
649 event.Message.Conversation = new(" ")
650 },
651 },
652 }
653
654 for _, tc := range testCases {
655 t.Run(tc.name, func(t *testing.T) {
656 whatsapp, _ := whatsappStartTestLinked(t)
657 event := whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "hi", at)
658 tc.change(event)
659 require.True(t, whatsapp.handle(event))
660 require.NoError(t, whatsapp.batches.CloseAll())
661 require.Empty(t, whatsapp.batches.Closed())
662 })
663 }
664 }
665
666 // whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary
667 // directory, with a quiet time of 50 ms for its batches.
668 func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) {
669 t.Helper()
670
671 dir := t.TempDir()
672 whatsappTestSession(t, dir, true)
673 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
674 whatsapp.batches.quiet = 50 * time.Millisecond
675
676 return whatsapp, fake
677 }
678
679 func TestWhatsAppCatchupGivesOneTurn(t *testing.T) {
680 whatsapp, fake := whatsappStartTestLinked(t)
681 server := newModelFake(t)
682 socket, memory := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
683 client := terminalStartTestClient(t, socket)
684 client.WaitFor(t, "[connected to OneChat]")
685 <-fake.connects
686
687 require.True(t, fake.Emit(&events.Connected{}))
688 at := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC)
689 for index, event := range []*events.Message{
690 whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m1", "Good morning", at),
691 whatsappTestMessage(whatsappTestBruno, whatsappTestBruno, "m2", "Call me", at.Add(time.Hour)),
692 whatsappTestMessage(whatsappTestAna, whatsappTestAna, "m3", "Are you there?", at.Add(2*time.Hour)),
693 } {
694 event.Info.PushName = []string{"Ana", "Bruno", "Ana"}[index]
695 require.True(t, fake.Emit(event))
696 }
697
698 time.Sleep(100 * time.Millisecond)
699 require.Zero(t, memory.Len())
700
701 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
702 client.WaitFor(t, "[done]")
703 require.Equal(t, []string{
704 "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n08:00 Ana: Good morning\n" + whatsappBatchEmptyReply,
705 "[WhatsApp] Bruno, private chat 15550003333@s.whatsapp.net, Thursday 2026-01-01\n09:00 Bruno: Call me\n" + whatsappBatchEmptyReply,
706 "[WhatsApp] Ana, private chat 15550002222@s.whatsapp.net, Thursday 2026-01-01\n10:00 Ana: Are you there?\n" + whatsappBatchEmptyReply,
707 }, whatsappTestLogTexts(t, memory)[:3])
708
709 time.Sleep(100 * time.Millisecond)
710 require.Equal(t, 1, server.Calls())
711 for _, text := range []string{"Good morning", "Call me", "Are you there?"} {
712 require.Contains(t, string(server.Call(0).raw), text)
713 }
714 }
715
716 func TestWhatsAppCatchupEndsAfterItsLimit(t *testing.T) {
717 whatsapp, fake := whatsappStartTestLinked(t)
718 whatsapp.catchupLimit = 100 * time.Millisecond
719 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
720 client := terminalStartTestClient(t, socket)
721 client.WaitFor(t, "[connected to OneChat]")
722 <-fake.connects
723
724 require.True(t, fake.Emit(&events.Connected{}))
725 require.True(t, fake.Emit(whatsappTestMessage(whatsappTestAna, whatsappTestOwn, "m1", "On my way.", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))))
726 client.WaitFor(t, "12:00 You: On my way.")
727 require.Len(t, whatsappTestLogTexts(t, memory), 1)
728 }
729
730 func TestWhatsAppBatchesOfTheStateFileWaitForTheCatchup(t *testing.T) {
731 dir := t.TempDir()
732 whatsappTestSession(t, dir, true)
733 before, _, _ := whatsappStartTest(t, dir, newTestClock())
734 _, err := before.batches.Add(whatsappTestChat, WhatsAppMessage{
735 ID: "m1",
736 Sender: "Ana Souza",
737 Time: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC),
738 Text: "Did you see my message?",
739 }, time.Now().Add(-time.Hour))
740 require.NoError(t, err)
741
742 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
743 socket, memory := daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
744 client := terminalStartTestClient(t, socket)
745 client.WaitFor(t, "[connected to OneChat]")
746 <-fake.connects
747
748 time.Sleep(100 * time.Millisecond)
749 require.Zero(t, memory.Len())
750
751 require.True(t, fake.Emit(&events.Connected{}))
752 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
753 client.WaitFor(t, "12:00 Ana Souza: Did you see my message?")
754 client.WaitFor(t, "[done]")
755 }
756
757 // whatsappTestConnect waits for the connection of the daemon and gives it the
758 // events of a connection with an empty catch-up.
759 func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) {
760 t.Helper()
761
762 <-fake.connects
763 require.True(t, fake.Emit(&events.Connected{}))
764 require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
765 }
766
767 func TestWhatsAppFindMatchesNamesWithNoRegardToCaseAndAccents(t *testing.T) {
768 whatsapp, fake := whatsappStartTestLinked(t)
769 fake.groups = []*types.GroupInfo{
770 {
771 JID: whatsappTestFamily,
772 GroupName: types.GroupName{
773 Name: "Família Souza",
774 },
775 },
776 }
777
778 whatsappTestContacts(t, whatsapp)
779 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
780 whatsappTestConnect(t, fake)
781
782 testCases := []struct {
783 name string
784 query string
785 answer string
786 }{
787 {
788 name: "no accent finds an accent",
789 query: "joao",
790 answer: "15550004444@s.whatsapp.net | João Silva | contact",
791 },
792 {
793 name: "a part of a name finds contacts and groups",
794 query: "SOUZA",
795 answer: "15550002222@s.whatsapp.net | Ana Souza | contact\n120363000000000001@g.us | Família Souza | group",
796 },
797 {
798 name: "a business name",
799 query: "bakery",
800 answer: "15550005555@s.whatsapp.net | Corner Bakery | contact",
801 },
802 {
803 name: "no match",
804 query: "Zed",
805 answer: "No contact or group has a name with \"Zed\".",
806 },
807 }
808
809 for _, tc := range testCases {
810 t.Run(tc.name, func(t *testing.T) {
811 answer, err := whatsapp.Find(t.Context(), tc.query)
812 require.NoError(t, err)
813 require.Equal(t, tc.answer, answer)
814 })
815 }
816 }
817
818 func TestWhatsAppFindGivesAtMostTwentyMatches(t *testing.T) {
819 whatsapp, _ := whatsappStartTestLinked(t)
820 for index := range 25 {
821 jid := types.NewJID(fmt.Sprintf("1555001%04d", index), types.DefaultUserServer)
822 require.NoError(t, whatsapp.device.Contacts.PutContactName(t.Context(), jid, "", fmt.Sprintf("Member %02d", index)))
823 }
824
825 answer, err := whatsapp.Find(t.Context(), "member")
826 require.NoError(t, err)
827
828 lines := strings.Split(answer, "\n")
829 require.Len(t, lines, 22)
830 require.Equal(t, "15550010000@s.whatsapp.net | Member 00 | contact", lines[0])
831 require.Equal(t, "5 more: give a longer part of the name.", lines[20])
832 require.Equal(t, "The groups are not in this answer, because WhatsApp is disconnected.", lines[21])
833 }
834
835 func TestWhatsAppFindBeforeTheNamesArrive(t *testing.T) {
836 whatsapp, fake := whatsappStartTestLinked(t)
837 daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp)
838 whatsappTestConnect(t, fake)
839
840 answer, err := whatsapp.Find(t.Context(), "ana")
841 require.NoError(t, err)
842 require.Equal(t, whatsappNamesNotReady, answer)
843 }
844
845 func TestWhatsAppFindWithoutALink(t *testing.T) {
846 whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
847
848 answer, err := whatsapp.Find(t.Context(), "ana")
849 require.NoError(t, err)
850 require.Equal(t, whatsappNotLinked, answer)
851 }
852
853 func TestAgentFindsAWhatsAppContact(t *testing.T) {
854 whatsapp, fake := whatsappStartTestLinked(t)
855 whatsappTestContacts(t, whatsapp)
856 server := newModelFake(t)
857 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
858 if call.number == 0 {
859 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "whatsapp_find", `{"name":"ana"}`)}, 100, 0, 5))
860 return
861 }
862
863 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Found Ana.")}, 100, 0, 5))
864 }
865
866 socket, _ := daemonStartTestWithWhatsApp(t, server, nil, whatsapp)
867 client := terminalStartTestClient(t, socket)
868 whatsappTestConnect(t, fake)
869
870 client.Type("Find Ana on WhatsApp.")
871 client.WaitFor(t, "[result] 15550002222@s.whatsapp.net | Ana Souza | contact\nFound Ana.\n[done]")
872 }
873
874 func TestSubagentHasNoWhatsAppTools(t *testing.T) {
875 box := &Toolbox{}
876 require.Equal(t, `error: unknown tool "whatsapp_find"`, box.run(t.Context(), "whatsapp_find", `{"name":"ana"}`))
877 }
878
879 // whatsappTestContacts puts the contacts of the tests into the session of
880 // whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user.
881 func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) {
882 t.Helper()
883
884 contacts := whatsapp.device.Contacts
885 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
886 require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva"))
887 _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery")
888 require.NoError(t, err)
889 require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza"))
890 }