]> git.giacomolli.uk - onechat/blob - whatsapp_fake_helper_test.go
Import WhatsApp history into a child
[onechat] / whatsapp_fake_helper_test.go
1 package main
2
3 import (
4         "context"
5         "fmt"
6         "log/slog"
7         "os"
8         "path/filepath"
9         "slices"
10         "sync"
11         "testing"
12         "time"
13
14         "github.com/stretchr/testify/require"
15         "go.mau.fi/whatsmeow"
16         waAdv "go.mau.fi/whatsmeow/proto/waAdv"
17         "go.mau.fi/whatsmeow/proto/waE2E"
18         "go.mau.fi/whatsmeow/store"
19         "go.mau.fi/whatsmeow/store/sqlstore"
20         "go.mau.fi/whatsmeow/types"
21         "go.mau.fi/whatsmeow/types/events"
22         waLog "go.mau.fi/whatsmeow/util/log"
23 )
24
25 const (
26         // whatsappTestNumber is the phone number of the own account in the
27         // tests. It is a number for fiction.
28         whatsappTestNumber = "15550001111"
29
30         // whatsappTestLID is the LID of the own account in the tests.
31         whatsappTestLID = "987654321012345"
32
33         // whatsappTestCode is the pairing code of the fake.
34         whatsappTestCode = "ABCD-EFGH"
35 )
36
37 var (
38         // whatsappTestOwn is the own account in the tests.
39         whatsappTestOwn = types.NewJID(whatsappTestNumber, types.DefaultUserServer)
40
41         // whatsappTestAna is a contact in the tests.
42         whatsappTestAna = types.NewJID("15550002222", types.DefaultUserServer)
43
44         // whatsappTestBruno is a member of the test group.
45         whatsappTestBruno = types.NewJID("15550003333", types.DefaultUserServer)
46
47         // whatsappTestFamily is the test group.
48         whatsappTestFamily = types.NewJID("120363000000000001", types.GroupServer)
49
50         // whatsappTestChat is the private chat with whatsappTestAna.
51         whatsappTestChat = WhatsAppChat{
52                 ID:   whatsappTestAna.String(),
53                 Name: "Ana Souza",
54         }
55
56         // whatsappTestSendTime is the time of the sends of the fake.
57         whatsappTestSendTime = time.Date(2026, 1, 1, 12, 5, 0, 0, time.UTC)
58
59         // whatsappTestGroup is the chat of whatsappTestFamily.
60         whatsappTestGroup = WhatsAppChat{
61                 ID:    whatsappTestFamily.String(),
62                 Name:  "Family",
63                 Group: true,
64         }
65 )
66
67 // WhatsAppFake is a fake WhatsApp client. It keeps the event handler that the
68 // daemon gives it, and Emit gives events to the handler, as the library does.
69 // Each connection keeps its context and sends to connects, and each pairing
70 // sends its channel to pairings, so that the test gives the items of the
71 // pairing. A connection takes its error from connectErrors, then connectErr. A
72 // logout fails with logoutErr, and a send with sendErr. groups are the groups
73 // of the account. A send that does not fail gets the time whatsappTestSendTime.
74 type WhatsAppFake struct {
75         connects chan struct{}
76         pairings chan chan whatsmeow.QRChannelItem
77
78         mutex         sync.Mutex
79         handle        func(event any) bool
80         connectErr    error
81         connectErrors []error
82         connectTimes  []time.Time
83         logoutErr     error
84         groups        []*types.GroupInfo
85         sendErr       error
86         phones        []string
87         logouts       int
88         sent          []WhatsAppFakeSend
89         connections   []context.Context
90 }
91
92 // ConnectContext keeps ctx and tells the test that the daemon connected.
93 func (f *WhatsAppFake) ConnectContext(ctx context.Context) error {
94         f.mutex.Lock()
95
96         f.connections = append(f.connections, ctx)
97         f.connectTimes = append(f.connectTimes, time.Now())
98         f.mutex.Unlock()
99
100         f.connects <- struct{}{}
101
102         f.mutex.Lock()
103         defer f.mutex.Unlock()
104
105         if len(f.connectErrors) > 0 {
106                 err := f.connectErrors[0]
107                 f.connectErrors = f.connectErrors[1:]
108
109                 return err
110         }
111
112         return f.connectErr
113 }
114
115 // Connections returns the contexts of the connections, in order.
116 func (f *WhatsAppFake) Connections() []context.Context {
117         f.mutex.Lock()
118         defer f.mutex.Unlock()
119
120         return slices.Clone(f.connections)
121 }
122
123 // ConnectionTimes returns the time of each connection call, in order.
124 func (f *WhatsAppFake) ConnectionTimes() []time.Time {
125         f.mutex.Lock()
126         defer f.mutex.Unlock()
127
128         return slices.Clone(f.connectTimes)
129 }
130
131 // Disconnect does nothing.
132 func (f *WhatsAppFake) Disconnect() {
133 }
134
135 // GetQRChannel gives a new channel of pairing items to the test.
136 func (f *WhatsAppFake) GetQRChannel(_ context.Context) (<-chan whatsmeow.QRChannelItem, error) {
137         items := make(chan whatsmeow.QRChannelItem, 8)
138         f.pairings <- items
139
140         return items, nil
141 }
142
143 // PairPhone keeps phone and returns whatsappTestCode.
144 func (f *WhatsAppFake) PairPhone(_ context.Context, phone string, _ bool, _ whatsmeow.PairClientType, _ string) (string, error) {
145         f.mutex.Lock()
146         defer f.mutex.Unlock()
147
148         f.phones = append(f.phones, phone)
149
150         return whatsappTestCode, nil
151 }
152
153 // Logout counts the logouts and returns logoutErr.
154 func (f *WhatsAppFake) Logout(_ context.Context) error {
155         f.mutex.Lock()
156         defer f.mutex.Unlock()
157
158         f.logouts++
159
160         return f.logoutErr
161 }
162
163 // GetJoinedGroups returns groups.
164 func (f *WhatsAppFake) GetJoinedGroups(_ context.Context) ([]*types.GroupInfo, error) {
165         f.mutex.Lock()
166         defer f.mutex.Unlock()
167
168         return f.groups, nil
169 }
170
171 // GenerateMessageID returns a new message ID.
172 func (f *WhatsAppFake) GenerateMessageID() types.MessageID {
173         f.mutex.Lock()
174         defer f.mutex.Unlock()
175
176         return fmt.Sprintf("MSG%d", len(f.sent)+1)
177 }
178
179 // SendMessage keeps the send and returns sendErr.
180 func (f *WhatsAppFake) SendMessage(_ context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) {
181         f.mutex.Lock()
182         defer f.mutex.Unlock()
183
184         f.sent = append(f.sent, WhatsAppFakeSend{
185                 to:   to.String(),
186                 text: message.GetConversation(),
187                 id:   extra[0].ID,
188         })
189
190         if f.sendErr != nil {
191                 return whatsmeow.SendResponse{}, f.sendErr
192         }
193
194         return whatsmeow.SendResponse{
195                 Timestamp: whatsappTestSendTime,
196         }, nil
197 }
198
199 // Sent returns the sends.
200 func (f *WhatsAppFake) Sent() []WhatsAppFakeSend {
201         f.mutex.Lock()
202         defer f.mutex.Unlock()
203
204         return slices.Clone(f.sent)
205 }
206
207 // Phones returns the phone numbers of the pairings.
208 func (f *WhatsAppFake) Phones() []string {
209         f.mutex.Lock()
210         defer f.mutex.Unlock()
211
212         return append([]string{}, f.phones...)
213 }
214
215 // Logouts returns the number of logouts.
216 func (f *WhatsAppFake) Logouts() int {
217         f.mutex.Lock()
218         defer f.mutex.Unlock()
219
220         return f.logouts
221 }
222
223 // Emit gives event to the handler of the daemon and returns its answer.
224 func (f *WhatsAppFake) Emit(event any) bool {
225         f.mutex.Lock()
226
227         handle := f.handle
228         f.mutex.Unlock()
229
230         return handle(event)
231 }
232
233 // newClient keeps handle and returns the fake as the client of device.
234 func (f *WhatsAppFake) newClient(_ *store.Device, handle func(event any) bool) WhatsAppClient {
235         f.mutex.Lock()
236         defer f.mutex.Unlock()
237
238         f.handle = handle
239
240         return f
241 }
242
243 // WhatsAppFakeSend is one send of the fake: the chat, the text, and the message
244 // ID.
245 type WhatsAppFakeSend struct {
246         to   string
247         text string
248         id   string
249 }
250
251 // whatsappStartTest opens the WhatsApp of the data directory dir with a fake
252 // client and the test clock, and closes it at the end of the test. The daemon
253 // log goes to the returned buffer.
254 func whatsappStartTest(t *testing.T, dir string, clock *TestClock) (*WhatsApp, *WhatsAppFake, *SyncBuffer) {
255         t.Helper()
256
257         fake := &WhatsAppFake{
258                 connects: make(chan struct{}, 16),
259                 pairings: make(chan chan whatsmeow.QRChannelItem, 16),
260         }
261
262         logs := &SyncBuffer{}
263         whatsapp, err := openWhatsApp(t.Context(), dir, clock.Now, daemonTestLogger(logs), fake.newClient)
264         require.NoError(t, err)
265
266         t.Cleanup(func() {
267                 require.NoError(t, whatsapp.Close())
268         })
269
270         return whatsapp, fake, logs
271 }
272
273 // whatsappTestSession writes a session database with mode 0600 to the data
274 // directory dir. When linked is true, it holds a device linked to
275 // whatsappTestNumber and whatsappTestLID, and else no device.
276 func whatsappTestSession(t *testing.T, dir string, linked bool) {
277         t.Helper()
278
279         path := filepath.Join(dir, whatsappDirName, whatsappSessionFileName)
280         require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
281         require.NoError(t, os.WriteFile(path, nil, 0o600))
282
283         address, err := whatsappSessionAddress(path)
284         require.NoError(t, err)
285
286         container, err := sqlstore.New(t.Context(), "sqlite", address, waLog.Noop)
287         require.NoError(t, err)
288
289         if linked {
290                 device := container.NewDevice()
291                 jid := types.NewADJID(whatsappTestNumber, 0, 1)
292                 device.ID = &jid
293                 device.LID = types.NewADJID(whatsappTestLID, 0, 1)
294                 device.LID.Server = types.HiddenUserServer
295                 device.Account = &waAdv.ADVSignedDeviceIdentity{
296                         Details:             []byte{},
297                         AccountSignature:    make([]byte, 64),
298                         AccountSignatureKey: make([]byte, 32),
299                         DeviceSignature:     make([]byte, 64),
300                 }
301                 require.NoError(t, device.Save(t.Context()))
302         }
303
304         require.NoError(t, container.Close())
305 }
306
307 // whatsappDiscardLog is a logger for a WhatsApp whose log a test does not read.
308 func whatsappDiscardLog() *slog.Logger {
309         return slog.New(slog.DiscardHandler)
310 }
311
312 // whatsappTestMessage returns the event of the text message id of sender in
313 // chat at the time at. A sender with whatsappTestNumber is the user.
314 func whatsappTestMessage(chat, sender types.JID, id, text string, at time.Time) *events.Message {
315         return &events.Message{
316                 Info: types.MessageInfo{
317                         MessageSource: types.MessageSource{
318                                 Chat:     chat,
319                                 Sender:   sender,
320                                 IsFromMe: sender.User == whatsappTestNumber,
321                                 IsGroup:  chat.Server == types.GroupServer,
322                         },
323                         ID:        id,
324                         Timestamp: at,
325                 },
326                 Message: &waE2E.Message{
327                         Conversation: new(text),
328                 },
329         }
330 }
331
332 // whatsappTestLogTexts returns the texts of the messages in the log of memory,
333 // after the log holds at least one message.
334 func whatsappTestLogTexts(t *testing.T, memory *Memory) []string {
335         t.Helper()
336
337         require.Eventually(t, func() bool {
338                 return memory.Len() > 0
339         }, 10*time.Second, time.Millisecond)
340
341         var texts []string
342
343         for id := range memory.Len() {
344                 message, err := memory.Message(id)
345                 require.NoError(t, err)
346
347                 texts = append(texts, message.Text)
348         }
349
350         return texts
351 }
352
353 // whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary
354 // directory, with a quiet time of 50 ms for its batches.
355 func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) {
356         t.Helper()
357
358         dir := t.TempDir()
359         whatsappTestSession(t, dir, true)
360
361         whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
362         whatsapp.state.quiet = 50 * time.Millisecond
363
364         return whatsapp, fake
365 }
366
367 // whatsappTestConnect waits for the connection of the daemon and gives it the
368 // events of a connection with an empty catch-up.
369 func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) {
370         t.Helper()
371
372         <-fake.connects
373         require.True(t, fake.Emit(&events.Connected{}))
374         require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
375 }
376
377 // whatsappTestContacts puts the contacts of the tests into the session of
378 // whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user.
379 func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) {
380         t.Helper()
381
382         contacts := whatsapp.device.Contacts
383         require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
384         require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva"))
385
386         _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery")
387         require.NoError(t, err)
388         require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza"))
389 }