X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/2e2524881dfe8f6c5cabfd9d342d5d3492953bd4..53c6c8a5a17e2dfc8bd3ff02ab99d29d709dda2f:/whatsapp_test.go?ds=inline diff --git a/whatsapp_test.go b/whatsapp_test.go index 2bdb4cc..c14571b 100644 --- a/whatsapp_test.go +++ b/whatsapp_test.go @@ -1,14 +1,13 @@ package main import ( - "crypto/sha256" - "encoding/hex" + "context" "errors" - "net/http" "os" "path/filepath" "strings" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/require" @@ -38,14 +37,235 @@ func TestWhatsAppStatusFollowsTheConnection(t *testing.T) { } func TestWhatsAppStatusOfAFirstConnectionThatFailed(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + test.fake.connectErr = errors.New("network unavailable") + test.start(t) + synctest.Wait() + + require.Equal(t, "WhatsApp is linked and disconnected since "+time.Now().Format(whatsappTimeLayout)+".", test.whatsapp.Status()) + require.Equal(t, "first connect failed, retrying: network unavailable\n", test.logs.String()) + }) +} + +func TestWhatsAppRetriesTheFirstConnectionWithCappedBackoff(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + for range 10 { + test.fake.connectErrors = append(test.fake.connectErrors, errors.New("network unavailable")) + } + + test.start(t) + synctest.Wait() + require.Len(t, test.fake.Connections(), 1) + require.True(t, test.fake.Emit(&events.Disconnected{})) + require.True(t, test.fake.Emit(&events.Disconnected{})) + + delays := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second, 32 * time.Second, 64 * time.Second, 128 * time.Second, 256 * time.Second, 5 * time.Minute, 5 * time.Minute} + var outage time.Duration + for index, delay := range delays { + time.Sleep(delay - time.Nanosecond) + synctest.Wait() + require.Len(t, test.fake.Connections(), index+1) + time.Sleep(time.Nanosecond) + synctest.Wait() + require.Len(t, test.fake.Connections(), index+2) + outage += delay + } + + times := test.fake.ConnectionTimes() + for index, delay := range delays { + require.Equal(t, delay, times[index+1].Sub(times[index])) + } + + require.True(t, test.fake.Emit(&events.Connected{})) + require.True(t, test.fake.Emit(&events.OfflineSyncCompleted{})) + synctest.Wait() + time.Sleep(10 * time.Minute) + synctest.Wait() + require.Len(t, test.fake.Connections(), 11) + require.Equal(t, "first connect failed, retrying: network unavailable\nconnected again after "+durationText(outage)+"\n", test.logs.String()) + }) +} + +func TestWhatsAppFirstConnectionRetriesStopWhenContextEnds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + test.fake.connectErr = errors.New("network unavailable") + test.start(t) + synctest.Wait() + time.Sleep(2 * time.Second) + synctest.Wait() + require.Len(t, test.fake.Connections(), 2) + + test.cancel() + require.NoError(t, <-test.done) + time.Sleep(10 * time.Minute) + synctest.Wait() + require.Len(t, test.fake.Connections(), 2) + }) +} + +func TestWhatsAppFirstConnectionRetriesStopForLinkChanges(t *testing.T) { + testCases := []struct { + name string + stop func(test *WhatsAppRetryTest) + }{ + { + name: "unlink", + stop: func(test *WhatsAppRetryTest) { + test.whatsapp.Command("unlink", func(text string) {}) + }, + }, + { + name: "logout", + stop: func(test *WhatsAppRetryTest) { + test.fake.Emit(&events.LoggedOut{}) + }, + }, + { + name: "pair command", + stop: func(test *WhatsAppRetryTest) { + test.whatsapp.Command("pair 15550001111", func(text string) {}) + }, + }, + { + name: "permanent disconnect", + stop: func(test *WhatsAppRetryTest) { + test.fake.Emit(&events.StreamReplaced{}) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + test.fake.connectErr = errors.New("network unavailable") + test.start(t) + synctest.Wait() + tc.stop(test) + synctest.Wait() + time.Sleep(10 * time.Minute) + synctest.Wait() + require.Len(t, test.fake.Connections(), 1) + }) + }) + } +} + +func TestWhatsAppPairsAfterFirstConnectionRetriesStop(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + test.fake.connectErr = errors.New("network unavailable") + test.start(t) + synctest.Wait() + test.whatsapp.Command("unlink", func(text string) {}) + synctest.Wait() + require.False(t, test.whatsapp.isLinked()) + + test.fake.mutex.Lock() + test.fake.connectErr = nil + test.fake.mutex.Unlock() + test.whatsapp.Command("pair 15550001111", func(text string) {}) + items := <-test.fake.pairings + items <- whatsmeow.QRChannelItem{Event: whatsmeow.QRChannelEventCode} + synctest.Wait() + require.True(t, test.fake.Emit(&events.PairSuccess{ + ID: types.NewADJID(whatsappTestNumber, 0, 1), + LID: types.NewADJID(whatsappTestLID, 0, 1), + })) + items <- whatsmeow.QRChannelSuccess + synctest.Wait() + require.True(t, test.whatsapp.isLinked()) + time.Sleep(10 * time.Minute) + synctest.Wait() + require.Len(t, test.fake.Connections(), 2) + require.Equal(t, []string{whatsappTestNumber}, test.fake.Phones()) + }) +} + +func TestWhatsAppInboxFailureStopsFirstConnectionRetries(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + test.fake.connectErr = errors.New("network unavailable") + test.start(t) + synctest.Wait() + failure := errors.New("state write failed") + test.whatsapp.inbox.failures <- failure + require.ErrorIs(t, <-test.done, failure) + time.Sleep(10 * time.Minute) + synctest.Wait() + require.Len(t, test.fake.Connections(), 1) + }) +} + +func TestWhatsAppLongFirstConnectionOutageGivesOneNotice(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + test := whatsappRetryTest(t) + test.fake.connectErr = errors.New("network unavailable") + test.start(t) + synctest.Wait() + since := time.Now().Format(whatsappTimeLayout) + time.Sleep(whatsappNoticeDelay) + synctest.Wait() + require.Equal(t, 1, test.events.Count("notice")) + require.Equal(t, "WhatsApp is disconnected since "+since+". OneChat tries again.", test.events.Last("notice")) + + time.Sleep(whatsappNoticeDelay) + synctest.Wait() + require.Equal(t, 1, test.events.Count("notice")) + test.fake.mutex.Lock() + test.fake.connectErr = nil + test.fake.mutex.Unlock() + time.Sleep(whatsappRetryLimit) + synctest.Wait() + require.True(t, test.fake.Emit(&events.Connected{})) + require.True(t, test.fake.Emit(&events.OfflineSyncCompleted{})) + synctest.Wait() + require.Equal(t, 2, test.events.Count("notice")) + require.Equal(t, "WhatsApp is connected again.", test.events.Last("notice")) + }) +} + +func TestWhatsAppStartingRetriesDoNotDuplicateLibraryRetries(t *testing.T) { dir := t.TempDir() whatsappTestSession(t, dir, true) - whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock()) + whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock()) + client, ok := whatsappNewClient(whatsapp.device, whatsapp.handle).(*whatsmeow.Client) + require.True(t, ok) + require.False(t, client.InitialAutoReconnect) + require.True(t, client.EnableAutoReconnect) + + device := whatsapp.container.NewDevice() + client, ok = whatsappNewClient(device, whatsapp.handle).(*whatsmeow.Client) + require.True(t, ok) + require.True(t, client.InitialAutoReconnect) +} + +func TestWhatsAppLogsEachChangeOfTheConnectionOnce(t *testing.T) { + dir := t.TempDir() + whatsappTestSession(t, dir, true) + clock := newTestClock() + whatsapp, fake, logs := whatsappStartTest(t, dir, clock) daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) <-fake.connects + require.True(t, fake.Emit(&events.Connected{})) + clock.now = clock.now.Add(time.Minute) + require.True(t, fake.Emit(&events.Disconnected{})) + clock.now = clock.now.Add(time.Minute) require.True(t, fake.Emit(&events.Disconnected{})) - require.Equal(t, "WhatsApp is linked and disconnected since 2026-01-01 12:00.", whatsapp.Status()) + clock.now = clock.now.Add(25 * time.Second) + require.True(t, fake.Emit(&events.Connected{})) + + require.Equal(t, []string{ + "connected", + "disconnected, retrying", + "connected again after 1m25s", + }, strings.Split(strings.TrimSuffix(logs.String(), "\n"), "\n")) + require.NotContains(t, logs.String(), whatsappTestNumber) + require.NotContains(t, logs.String(), whatsappTestLID) } func TestWhatsAppIsNotLinkedWithoutALinkedDevice(t *testing.T) { @@ -373,7 +593,7 @@ func TestWhatsAppLogsNoOwnNumber(t *testing.T) { require.Eventually(t, func() bool { return logs.String() != "" }, 10*time.Second, time.Millisecond) - require.Equal(t, "connect: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String()) + require.Equal(t, "first connect failed, retrying: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String()) } func TestWhatsAppPhone(t *testing.T) { @@ -465,193 +685,59 @@ func TestSubagentHasNoWhatsAppTools(t *testing.T) { require.Equal(t, `error: unknown tool "whatsapp_send"`, box.run(t.Context(), "whatsapp_send", `{"id":"a","chat":"15550002222@s.whatsapp.net","text":"hi"}`)) } -func TestWhatsAppSendSendsOnceForEachID(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - whatsappTestContacts(t, whatsapp) - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) - - answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "The keys are under the mat.") - require.NoError(t, err) - require.Equal(t, "Sent to Ana Souza at 12:05.", answer) - - answer, err = whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "The keys are under the mat.") - require.NoError(t, err) - require.Equal(t, "Sent to Ana Souza at 12:05. This is the answer of the first call with this id: nothing was sent again.", answer) - - _, err = whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "Another text.") - require.EqualError(t, err, "the id keys belongs to another message: give a new id for each message") - - require.Equal(t, []WhatsAppFakeSend{ - { - to: whatsappTestAna.String(), - text: "The keys are under the mat.", - id: "MSG1", - }, - }, fake.Sent()) - - again, err := openWhatsAppState(filepath.Join(whatsapp.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow) - require.NoError(t, err) - - record, found := again.Send("keys") - require.True(t, found) - require.Equal(t, whatsappSent, record.State) - require.Equal(t, "MSG1", record.MessageID) +// 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 TestWhatsAppSendToAGroup(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - fake.groups = []*types.GroupInfo{ - { - JID: whatsappTestFamily, - GroupName: types.GroupName{ - Name: "Family", - }, - }, - } - - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) - - answer, err := whatsapp.Send(t.Context(), "cake", whatsappTestFamily.String(), "I bring the cake.") - require.NoError(t, err) - require.Equal(t, "Sent to Family at 12:05.", answer) -} +func (r *WhatsAppRetryTest) start(t *testing.T) { + t.Helper() -func TestWhatsAppSendThatFails(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - fake.sendErr = errors.New("server error for " + whatsappTestNumber) - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) + 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) + }() - for range 2 { - answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi") - require.NoError(t, err) - require.True(t, strings.HasPrefix(answer, "The send failed: server error for OWN."), answer) - } - - require.Len(t, fake.Sent(), 1) + t.Cleanup(func() { + cancel() + synctest.Wait() + }) } -func TestWhatsAppSendCutByAStop(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) - - digest := sha256.Sum256([]byte("hi")) - require.NoError(t, whatsapp.state.PutSend(WhatsAppSend{ - ID: "keys", - Chat: whatsappTestAna.String(), - Digest: hex.EncodeToString(digest[:]), - State: whatsappSending, - })) +func whatsappRetryTest(t *testing.T) *WhatsAppRetryTest { + t.Helper() - answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi") + dir := t.TempDir() + whatsappTestSession(t, dir, true) + whatsapp, fake, logs := whatsappStartTest(t, dir, newTestClock()) + whatsapp.clock = time.Now + agent, err := openAgent(t.TempDir()) require.NoError(t, err) - require.Contains(t, answer, "its result is unknown") - require.Empty(t, fake.Sent()) -} - -func TestWhatsAppSendRefuses(t *testing.T) { - testCases := []struct { - name string - id string - chat string - text string - err string - }{ - { - name: "no id", - id: " ", - chat: whatsappTestAna.String(), - text: "hi", - err: "the id must have 1 to 100 bytes", - }, - { - name: "no text", - id: "a", - chat: whatsappTestAna.String(), - text: "", - err: "the text must have 1 to 65536 bytes", - }, - { - name: "a name for a chat", - id: "a", - chat: "Ana Souza", - text: "hi", - err: `"Ana Souza" is not the ID of a WhatsApp chat: find the ID with whatsapp_find`, - }, - { - name: "a status broadcast", - id: "a", - chat: types.StatusBroadcastJID.String(), - text: "hi", - err: `"status@broadcast" is not a private chat or a group`, - }, - { - name: "the chat of the user", - id: "a", - chat: whatsappTestOwn.String(), - text: "hi", - err: "this is the chat of the user with itself: send to another chat", - }, - } - - whatsapp, fake := whatsappStartTestLinked(t) - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - _, err := whatsapp.Send(t.Context(), tc.id, tc.chat, tc.text) - require.EqualError(t, err, tc.err) - }) - } - - require.Empty(t, fake.Sent()) -} - -func TestWhatsAppSendWhileDisconnected(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - daemonStartTestWithWhatsApp(t, newModelFake(t), nil, whatsapp) - whatsappTestConnect(t, fake) - require.True(t, fake.Emit(&events.Disconnected{})) - - _, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi") - require.EqualError(t, err, "WhatsApp is disconnected, so nothing was sent: try again later") - - require.True(t, fake.Emit(&events.Connected{})) - answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi") + memory, err := openMemory(t.Context(), agent.dir, time.Now, newFakeSummarizer(), discardLog()) require.NoError(t, err) - require.Equal(t, "Sent to +15550002222 at 12:05.", answer) -} - -func TestWhatsAppSendWithoutALink(t *testing.T) { - whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock()) + t.Cleanup(func() { + require.NoError(t, memory.Close()) + }) - answer, err := whatsapp.Send(t.Context(), "keys", whatsappTestAna.String(), "hi") + events := &TurnEventRecorder{} + client := newModelClient("https://example.com", ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) + loop, err := newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, newTestSchedules(t, time.Now), client, time.Now, discardLog(), events.Add) require.NoError(t, err) - require.Equal(t, whatsappNotLinked, answer) -} - -func TestAgentSendsAWhatsAppMessage(t *testing.T) { - whatsapp, fake := whatsappStartTestLinked(t) - whatsappTestContacts(t, whatsapp) - server := newModelFake(t) - server.reply = func(w http.ResponseWriter, call ModelFakeCall) { - if call.number == 0 { - modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "whatsapp_send", `{"id":"keys","chat":"15550002222@s.whatsapp.net","text":"The keys are under the mat."}`)}, 100, 0, 5)) - return - } - modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Sent.")}, 100, 0, 5)) + return &WhatsAppRetryTest{ + whatsapp: whatsapp, + fake: fake, + logs: logs, + loop: loop, + events: events, } - - socket, _ := daemonStartTestWithWhatsApp(t, server, nil, whatsapp) - client := terminalStartTestClient(t, socket) - whatsappTestConnect(t, fake) - - client.Type("Tell Ana that the keys are under the mat.") - client.WaitFor(t, "[result] Sent to Ana Souza at 12:05.\nSent.\n[done]") - require.Len(t, fake.Sent(), 1) }