]> git.giacomolli.uk - onechat/blobdiff - whatsapp_fake_helper_test.go
Check the width of comment lines
[onechat] / whatsapp_fake_helper_test.go
index f7e6cad948f88fe2c46967f3ffce18852c3311da..8e0f237b5a878b3e6826cc621910cf645ad58adb 100644 (file)
@@ -2,47 +2,310 @@ package main
 
 import (
        "context"
-       "io"
-       "log"
+       "fmt"
+       "log/slog"
        "os"
        "path/filepath"
+       "slices"
        "sync"
        "testing"
+       "testing/synctest"
+       "time"
 
        "github.com/stretchr/testify/require"
+       "go.mau.fi/whatsmeow"
        waAdv "go.mau.fi/whatsmeow/proto/waAdv"
+       "go.mau.fi/whatsmeow/proto/waE2E"
        "go.mau.fi/whatsmeow/store"
        "go.mau.fi/whatsmeow/store/sqlstore"
        "go.mau.fi/whatsmeow/types"
+       "go.mau.fi/whatsmeow/types/events"
        waLog "go.mau.fi/whatsmeow/util/log"
 )
 
-// whatsappTestNumber is the phone number of the own account in the tests. It
-// is a number for fiction.
-const whatsappTestNumber = "15550001111"
+const (
+       // whatsappTestNumber is the phone number of the own account in the
+       // tests. It is a number for fiction.
+       whatsappTestNumber = "15550001111"
+
+       // whatsappTestLID is the LID of the own account in the tests.
+       whatsappTestLID = "987654321012345"
+
+       // whatsappTestCode is the pairing code of the fake.
+       whatsappTestCode = "ABCD-EFGH"
+)
+
+var (
+       // whatsappTestOwn is the own account in the tests.
+       whatsappTestOwn = types.NewJID(whatsappTestNumber, types.DefaultUserServer)
+
+       // whatsappTestAna is a contact in the tests.
+       whatsappTestAna = types.NewJID("15550002222", types.DefaultUserServer)
+
+       // whatsappTestBruno is a member of the test group.
+       whatsappTestBruno = types.NewJID("15550003333", types.DefaultUserServer)
+
+       // whatsappTestFamily is the test group.
+       whatsappTestFamily = types.NewJID("120363000000000001", types.GroupServer)
+
+       // whatsappTestChat is the private chat with whatsappTestAna.
+       whatsappTestChat = WhatsAppChat{
+               ID:   whatsappTestAna.String(),
+               Name: "Ana Souza",
+       }
+
+       // whatsappTestSendTime is the time of the sends of the fake.
+       whatsappTestSendTime = time.Date(2026, 1, 1, 12, 5, 0, 0, time.UTC)
+
+       // whatsappTestGroup is the chat of whatsappTestFamily.
+       whatsappTestGroup = WhatsAppChat{
+               ID:    whatsappTestFamily.String(),
+               Name:  "Family",
+               Group: true,
+       }
+)
 
 // WhatsAppFake is a fake WhatsApp client. It keeps the event handler that the
 // daemon gives it, and Emit gives events to the handler, as the library does.
+// Each connection keeps its context and sends to connects, and each pairing
+// sends its channel to pairings, so that the test gives the items of the
+// pairing. A connection takes its error from connectErrors, then connectErr. A
+// logout fails with logoutErr, and a send with sendErr. groups are the groups
+// of the account. A send that does not fail gets the time whatsappTestSendTime.
 type WhatsAppFake struct {
-       mutex     sync.Mutex
-       handle    func(event any) bool
-       connected chan struct{}
+       connects chan struct{}
+       pairings chan chan whatsmeow.QRChannelItem
+
+       mutex         sync.Mutex
+       handle        func(event any) bool
+       connectErr    error
+       connectErrors []error
+       connectTimes  []time.Time
+       logoutErr     error
+       groups        []*types.GroupInfo
+       sendErr       error
+       uploadErr     error
+       uploads       [][]byte
+       imageSends    []*waE2E.ImageMessage
+       downloadData  []byte
+       downloadErr   error
+       downloadWait  time.Duration
+       downloads     []whatsmeow.DownloadableMessage
+       phones        []string
+       logouts       int
+       sent          []WhatsAppFakeSend
+       connections   []context.Context
 }
 
-// ConnectContext tells the test that the daemon connected.
+// ConnectContext keeps ctx and tells the test that the daemon connected.
 func (f *WhatsAppFake) ConnectContext(ctx context.Context) error {
-       close(f.connected)
+       f.mutex.Lock()
+
+       f.connections = append(f.connections, ctx)
+       f.connectTimes = append(f.connectTimes, time.Now())
+       f.mutex.Unlock()
+
+       f.connects <- struct{}{}
+
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       if len(f.connectErrors) > 0 {
+               err := f.connectErrors[0]
+               f.connectErrors = f.connectErrors[1:]
 
-       return nil
+               return err
+       }
+
+       return f.connectErr
+}
+
+// Connections returns the contexts of the connections, in order.
+func (f *WhatsAppFake) Connections() []context.Context {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return slices.Clone(f.connections)
+}
+
+// ConnectionTimes returns the time of each connection call, in order.
+func (f *WhatsAppFake) ConnectionTimes() []time.Time {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return slices.Clone(f.connectTimes)
 }
 
 // Disconnect does nothing.
 func (f *WhatsAppFake) Disconnect() {
 }
 
+// GetQRChannel gives a new channel of pairing items to the test.
+func (f *WhatsAppFake) GetQRChannel(_ context.Context) (<-chan whatsmeow.QRChannelItem, error) {
+       items := make(chan whatsmeow.QRChannelItem, 8)
+       f.pairings <- items
+
+       return items, nil
+}
+
+// PairPhone keeps phone and returns whatsappTestCode.
+func (f *WhatsAppFake) PairPhone(_ context.Context, phone string, _ bool, _ whatsmeow.PairClientType, _ string) (string, error) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       f.phones = append(f.phones, phone)
+
+       return whatsappTestCode, nil
+}
+
+// Logout counts the logouts and returns logoutErr.
+func (f *WhatsAppFake) Logout(_ context.Context) error {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       f.logouts++
+
+       return f.logoutErr
+}
+
+// GetJoinedGroups returns groups.
+func (f *WhatsAppFake) GetJoinedGroups(_ context.Context) ([]*types.GroupInfo, error) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return f.groups, nil
+}
+
+// Download waits for downloadWait, records the request, and returns the made-up
+// image or downloadErr. A canceled context ends the wait.
+func (f *WhatsAppFake) Download(ctx context.Context, message whatsmeow.DownloadableMessage) ([]byte, error) {
+       if f.downloadWait > 0 {
+               timer := time.NewTimer(f.downloadWait)
+               defer timer.Stop()
+
+               select {
+               case <-ctx.Done():
+                       return nil, ctx.Err()
+               case <-timer.C:
+               }
+       }
+
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       f.downloads = append(f.downloads, message)
+
+       return slices.Clone(f.downloadData), f.downloadErr
+}
+
+// Downloads returns the media requested from the fake.
+func (f *WhatsAppFake) Downloads() []whatsmeow.DownloadableMessage {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return slices.Clone(f.downloads)
+}
+
+// Upload records the pixels and returns made-up media metadata.
+func (f *WhatsAppFake) Upload(_ context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       if mediaType != whatsmeow.MediaImage {
+               return whatsmeow.UploadResponse{}, fmt.Errorf("unexpected media type %q", mediaType)
+       }
+
+       f.uploads = append(f.uploads, slices.Clone(data))
+       if f.uploadErr != nil {
+               return whatsmeow.UploadResponse{}, f.uploadErr
+       }
+
+       return whatsmeow.UploadResponse{
+               URL:           "https://example.com/image",
+               DirectPath:    "/image",
+               MediaKey:      []byte("made-up key"),
+               FileSHA256:    []byte("made-up hash"),
+               FileEncSHA256: []byte("made-up encrypted hash"),
+               FileLength:    uint64(len(data)),
+       }, nil
+}
+
+// Uploads returns the pixels passed to Upload.
+func (f *WhatsAppFake) Uploads() [][]byte {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return slices.Clone(f.uploads)
+}
+
+// ImageSends returns the outgoing image messages.
+func (f *WhatsAppFake) ImageSends() []*waE2E.ImageMessage {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return slices.Clone(f.imageSends)
+}
+
+// GenerateMessageID returns a new message ID.
+func (f *WhatsAppFake) GenerateMessageID() types.MessageID {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return fmt.Sprintf("MSG%d", len(f.sent)+1)
+}
+
+// SendMessage keeps the send and returns sendErr.
+func (f *WhatsAppFake) SendMessage(_ context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       if message.GetImageMessage() != nil {
+               f.imageSends = append(f.imageSends, message.GetImageMessage())
+       }
+
+       f.sent = append(f.sent, WhatsAppFakeSend{
+               to:   to.String(),
+               text: message.GetConversation(),
+               id:   extra[0].ID,
+       })
+
+       if f.sendErr != nil {
+               return whatsmeow.SendResponse{}, f.sendErr
+       }
+
+       return whatsmeow.SendResponse{
+               Timestamp: whatsappTestSendTime,
+       }, nil
+}
+
+// Sent returns the sends.
+func (f *WhatsAppFake) Sent() []WhatsAppFakeSend {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return slices.Clone(f.sent)
+}
+
+// Phones returns the phone numbers of the pairings.
+func (f *WhatsAppFake) Phones() []string {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return append([]string{}, f.phones...)
+}
+
+// Logouts returns the number of logouts.
+func (f *WhatsAppFake) Logouts() int {
+       f.mutex.Lock()
+       defer f.mutex.Unlock()
+
+       return f.logouts
+}
+
 // Emit gives event to the handler of the daemon and returns its answer.
 func (f *WhatsAppFake) Emit(event any) bool {
        f.mutex.Lock()
+
        handle := f.handle
        f.mutex.Unlock()
 
@@ -50,7 +313,7 @@ func (f *WhatsAppFake) Emit(event any) bool {
 }
 
 // newClient keeps handle and returns the fake as the client of device.
-func (f *WhatsAppFake) newClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
+func (f *WhatsAppFake) newClient(_ *store.Device, handle func(event any) bool) WhatsAppClient {
        f.mutex.Lock()
        defer f.mutex.Unlock()
 
@@ -59,28 +322,67 @@ func (f *WhatsAppFake) newClient(device *store.Device, handle func(event any) bo
        return f
 }
 
+// WhatsAppFakeSend is one send of the fake: the chat, the text, and the message
+// ID.
+type WhatsAppFakeSend struct {
+       to   string
+       text string
+       id   string
+}
+
+// WhatsAppRetryTest runs WhatsApp without a model or network client call. All
+// timers and channels belong to its synctest bubble, so backoff uses fake time.
+type WhatsAppRetryTest struct {
+       whatsapp *WhatsApp
+       fake     *WhatsAppFake
+       logs     *SyncBuffer
+       loop     *TurnLoop
+       events   *TurnEventRecorder
+       cancel   context.CancelFunc
+       done     chan error
+}
+
+func (r *WhatsAppRetryTest) start(t *testing.T) {
+       t.Helper()
+
+       ctx, cancel := context.WithCancel(t.Context())
+       r.cancel = cancel
+       r.done = make(chan error, 1)
+       go func() {
+               r.done <- r.whatsapp.Run(ctx, r.loop, r.events.Add)
+       }()
+
+       t.Cleanup(func() {
+               cancel()
+               synctest.Wait()
+       })
+}
+
 // whatsappStartTest opens the WhatsApp of the data directory dir with a fake
-// client and the test clock, and closes it at the end of the test.
-func whatsappStartTest(t *testing.T, dir string) (*WhatsApp, *WhatsAppFake) {
+// client and the test clock, and closes it at the end of the test. The daemon
+// log goes to the returned buffer.
+func whatsappStartTest(t *testing.T, dir string, clock *TestClock) (*WhatsApp, *WhatsAppFake, *SyncBuffer) {
        t.Helper()
 
        fake := &WhatsAppFake{
-               connected: make(chan struct{}),
+               connects: make(chan struct{}, 16),
+               pairings: make(chan chan whatsmeow.QRChannelItem, 16),
        }
 
-       whatsapp, err := openWhatsApp(t.Context(), dir, newTestClock().Now, log.New(io.Discard, "", 0), fake.newClient)
+       logs := &SyncBuffer{}
+       whatsapp, err := openWhatsApp(t.Context(), dir, clock.Now, daemonTestLogger(logs), fake.newClient)
        require.NoError(t, err)
 
        t.Cleanup(func() {
                require.NoError(t, whatsapp.Close())
        })
 
-       return whatsapp, fake
+       return whatsapp, fake, logs
 }
 
 // whatsappTestSession writes a session database with mode 0600 to the data
 // directory dir. When linked is true, it holds a device linked to
-// whatsappTestNumber, and else a device with no link.
+// whatsappTestNumber and whatsappTestLID, and else no device.
 func whatsappTestSession(t *testing.T, dir string, linked bool) {
        t.Helper()
 
@@ -98,6 +400,8 @@ func whatsappTestSession(t *testing.T, dir string, linked bool) {
                device := container.NewDevice()
                jid := types.NewADJID(whatsappTestNumber, 0, 1)
                device.ID = &jid
+               device.LID = types.NewADJID(whatsappTestLID, 0, 1)
+               device.LID.Server = types.HiddenUserServer
                device.Account = &waAdv.ADVSignedDeviceIdentity{
                        Details:             []byte{},
                        AccountSignature:    make([]byte, 64),
@@ -109,3 +413,105 @@ func whatsappTestSession(t *testing.T, dir string, linked bool) {
 
        require.NoError(t, container.Close())
 }
+
+// whatsappDiscardLog is a logger for a WhatsApp whose log a test does not read.
+func whatsappDiscardLog() *slog.Logger {
+       return slog.New(slog.DiscardHandler)
+}
+
+// whatsappTestMessage returns the event of the text message id of sender in
+// chat at the time at. A sender with whatsappTestNumber is the user.
+func whatsappTestMessage(chat, sender types.JID, id, text string, at time.Time) *events.Message {
+       return &events.Message{
+               Info: types.MessageInfo{
+                       MessageSource: types.MessageSource{
+                               Chat:     chat,
+                               Sender:   sender,
+                               IsFromMe: sender.User == whatsappTestNumber,
+                               IsGroup:  chat.Server == types.GroupServer,
+                       },
+                       ID:        id,
+                       Timestamp: at,
+               },
+               Message: &waE2E.Message{
+                       Conversation: new(text),
+               },
+       }
+}
+
+// whatsappTestLogTexts returns the texts of the messages in the log of memory,
+// after the log holds at least one message.
+func whatsappTestLogTexts(t *testing.T, memory *Memory) []string {
+       t.Helper()
+
+       require.Eventually(t, func() bool {
+               return memory.Len() > 0
+       }, 10*time.Second, time.Millisecond)
+
+       var texts []string
+
+       for id := range memory.Len() {
+               message, err := memory.Message(id)
+               require.NoError(t, err)
+
+               texts = append(texts, message.Text)
+       }
+
+       return texts
+}
+
+// whatsappStartTestLinked opens a WhatsApp with a linked device in a temporary
+// directory.
+func whatsappStartTestLinked(t *testing.T) (*WhatsApp, *WhatsAppFake) {
+       t.Helper()
+
+       dir := t.TempDir()
+       whatsappTestSession(t, dir, true)
+
+       whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
+
+       return whatsapp, fake
+}
+
+// whatsappTestConnect waits for the connection of the daemon and gives it the
+// events of a connection with an empty catch-up.
+func whatsappTestConnect(t *testing.T, fake *WhatsAppFake) {
+       t.Helper()
+
+       <-fake.connects
+       require.True(t, fake.Emit(&events.Connected{}))
+       require.True(t, fake.Emit(&events.OfflineSyncCompleted{}))
+}
+
+// whatsappTestContacts puts the contacts of the tests into the session of
+// whatsapp: Ana Souza, Joao Silva with an accent, a business, and the user.
+func whatsappTestContacts(t *testing.T, whatsapp *WhatsApp) {
+       t.Helper()
+
+       contacts := whatsapp.device.Contacts
+       require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestAna, "Ana", "Ana Souza"))
+       require.NoError(t, contacts.PutContactName(t.Context(), types.NewJID("15550004444", types.DefaultUserServer), "João", "João Silva"))
+
+       _, _, err := contacts.PutBusinessName(t.Context(), types.NewJID("15550005555", types.DefaultUserServer), "Corner Bakery")
+       require.NoError(t, err)
+       require.NoError(t, contacts.PutContactName(t.Context(), whatsappTestOwn, "Me", "Me Souza"))
+}
+
+func whatsappRetryTest(t *testing.T) *WhatsAppRetryTest {
+       t.Helper()
+
+       dir := t.TempDir()
+       whatsappTestSession(t, dir, true)
+
+       whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock())
+       whatsapp.clock = time.Now
+       loop, events := newTestIdleTurnLoop(t, whatsapp)
+
+       return &WhatsAppRetryTest{
+               whatsapp: whatsapp,
+               fake:     fake,
+               logs:     logs,
+               loop:     loop,
+               events:   events,
+       }
+}