]> git.giacomolli.uk - onechat/blob - botapi_test.go
Limit the time of each Telegram send call
[onechat] / botapi_test.go
1 package main
2
3 import (
4         "context"
5         "encoding/json"
6         "fmt"
7         "io"
8         "net/http"
9         "net/http/httptest"
10         "strings"
11         "sync"
12         "testing"
13         "time"
14
15         "github.com/stretchr/testify/require"
16 )
17
18 const (
19         // testBotToken is the bot token of the tests. It has the form of a real
20         // token.
21         testBotToken = "123456:TEST-secret_token"
22
23         // testOwner is the user ID of the owner of the test bot.
24         testOwner = 4242
25 )
26
27 func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) {
28         server := newFakeBot(t)
29         server.Add(botUpdate{
30                 UpdateID: 7,
31                 Message:  ownerMessage("hello"),
32         })
33
34         updates, err := newBotAPI(server.URL, testBotToken).GetUpdates(t.Context(), 7)
35         require.NoError(t, err)
36         require.Len(t, updates, 1)
37         require.Equal(t, int64(7), updates[0].UpdateID)
38         require.Equal(t, "hello", updates[0].Message.Text)
39
40         call := server.Calls()[0]
41         require.Equal(t, "getUpdates", call.method)
42         require.Equal(t, map[string]any{
43                 "offset":          float64(7),
44                 "timeout":         float64(pollSeconds),
45                 "allowed_updates": []any{"message"},
46         }, call.params)
47 }
48
49 func TestSendMessageSendsPlainText(t *testing.T) {
50         server := newFakeBot(t)
51         err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "*not bold*")
52         require.NoError(t, err)
53
54         require.Equal(t, map[string]any{
55                 "chat_id": float64(testOwner),
56                 "text":    "*not bold*",
57         }, server.Calls()[0].params)
58 }
59
60 func TestSendMessageTimesOutOnAStalledConnection(t *testing.T) {
61         server := newFakeBot(t)
62         server.stall = func(call fakeBotCall) bool {
63                 return call.method == "sendMessage"
64         }
65
66         api := newBotAPI(server.URL, testBotToken)
67         api.sendTimeout = 50 * time.Millisecond
68         start := time.Now()
69         err := api.SendMessage(t.Context(), testOwner, "hi")
70         require.ErrorIs(t, err, errBotNetwork)
71         require.Less(t, time.Since(start), 5*time.Second)
72         require.NotContains(t, err.Error(), testBotToken)
73         require.NotContains(t, err.Error(), server.URL)
74
75         retry, _ := retryable(err)
76         require.True(t, retry)
77
78         api.sendTimeout = time.Hour
79         ctx, cancel := context.WithCancel(t.Context())
80         time.AfterFunc(50*time.Millisecond, cancel)
81         start = time.Now()
82         err = api.SendMessage(ctx, testOwner, "hi")
83         require.Error(t, err)
84         require.Less(t, time.Since(start), 5*time.Second)
85 }
86
87 func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
88         testCases := []struct {
89                 name      string
90                 reply     func(w http.ResponseWriter)
91                 retry     bool
92                 wait      time.Duration
93                 errorText string
94         }{
95                 {
96                         name: "refused connection is retried",
97                         reply: func(w http.ResponseWriter) {
98                                 conn, _, err := http.NewResponseController(w).Hijack()
99                                 if err == nil {
100                                         _ = conn.Close()
101                                 }
102                         },
103                         retry:     true,
104                         errorText: "no valid answer",
105                 },
106                 {
107                         name: "rate limit waits as asked",
108                         reply: func(w http.ResponseWriter) {
109                                 w.WriteHeader(http.StatusTooManyRequests)
110                                 _, _ = io.WriteString(w, `{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 3","parameters":{"retry_after":3}}`)
111                         },
112                         retry:     true,
113                         wait:      3 * time.Second,
114                         errorText: "answered 429",
115                 },
116                 {
117                         name: "server error page is retried",
118                         reply: func(w http.ResponseWriter) {
119                                 w.WriteHeader(http.StatusBadGateway)
120                                 _, _ = io.WriteString(w, "<html>bad gateway</html>")
121                         },
122                         retry:     true,
123                         errorText: "answered 502: Bad Gateway",
124                 },
125                 {
126                         name: "token echoed in a description is hidden",
127                         reply: func(w http.ResponseWriter) {
128                                 w.WriteHeader(http.StatusUnauthorized)
129                                 _, _ = io.WriteString(w, `{"ok":false,"error_code":401,"description":"Unauthorized: bot`+testBotToken+`"}`)
130                         },
131                         retry:     false,
132                         errorText: "Unauthorized: botTOKEN",
133                 },
134                 {
135                         name: "broken answer is retried",
136                         reply: func(w http.ResponseWriter) {
137                                 _, _ = io.WriteString(w, `{"ok":`)
138                         },
139                         retry:     true,
140                         errorText: "decode answer",
141                 },
142         }
143
144         for _, tc := range testCases {
145                 t.Run(tc.name, func(t *testing.T) {
146                         server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147                                 tc.reply(w)
148                         }))
149                         t.Cleanup(server.Close)
150
151                         err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi")
152                         require.ErrorContains(t, err, tc.errorText)
153                         require.NotContains(t, err.Error(), testBotToken)
154                         require.NotContains(t, err.Error(), server.URL)
155
156                         retry, wait := retryable(err)
157                         require.Equal(t, tc.retry, retry)
158                         require.Equal(t, tc.wait, wait)
159                 })
160         }
161
162         server := httptest.NewServer(http.NotFoundHandler())
163         server.Close()
164
165         _, err := newBotAPI(server.URL, testBotToken).GetUpdates(t.Context(), 0)
166         require.ErrorIs(t, err, errBotNetwork)
167         require.NotContains(t, err.Error(), testBotToken)
168 }
169
170 // fakeBot is a fake Bot API server for testBotToken. getUpdates returns the
171 // added updates from the offset on, or waits a short time when there are none.
172 // The other methods succeed, unless fail gives an error answer for a call. An
173 // answer with HTTP 429 asks for a wait of one second. When stall is true for a
174 // call, the call gets no answer until the client gives it up or the test ends.
175 // Fail and stall run with the mutex held.
176 type fakeBot struct {
177         *httptest.Server
178
179         mutex   sync.Mutex
180         updates []botUpdate
181         calls   []fakeBotCall
182         fail    func(call fakeBotCall) (int, string)
183         stall   func(call fakeBotCall) bool
184         added   chan struct{}
185         stopped chan struct{}
186 }
187
188 // fakeBotCall is one call of the fake Bot API. OK tells if the call got a
189 // success answer.
190 type fakeBotCall struct {
191         method string
192         params map[string]any
193         ok     bool
194 }
195
196 // newFakeBot starts a fake Bot API server that stops at the end of the test.
197 func newFakeBot(t *testing.T) *fakeBot {
198         t.Helper()
199
200         f := &fakeBot{
201                 added:   make(chan struct{}, 1),
202                 stopped: make(chan struct{}),
203         }
204
205         f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
206         t.Cleanup(f.Close)
207         t.Cleanup(func() {
208                 close(f.stopped)
209         })
210
211         return f
212 }
213
214 // Add makes update available to getUpdates.
215 func (f *fakeBot) Add(update botUpdate) {
216         f.mutex.Lock()
217         f.updates = append(f.updates, update)
218         f.mutex.Unlock()
219
220         select {
221         case f.added <- struct{}{}:
222         default:
223         }
224 }
225
226 // Calls returns the calls so far.
227 func (f *fakeBot) Calls() []fakeBotCall {
228         f.mutex.Lock()
229         defer f.mutex.Unlock()
230
231         return append([]fakeBotCall(nil), f.calls...)
232 }
233
234 // Sent returns the texts of the sendMessage calls that succeeded.
235 func (f *fakeBot) Sent() []string {
236         var texts []string
237         for _, call := range f.Calls() {
238                 if call.method == "sendMessage" && call.ok {
239                         texts = append(texts, call.params["text"].(string))
240                 }
241         }
242
243         return texts
244 }
245
246 func (f *fakeBot) serve(w http.ResponseWriter, r *http.Request) {
247         method, found := strings.CutPrefix(r.URL.Path, "/bot"+testBotToken+"/")
248         if !found {
249                 http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
250                 return
251         }
252
253         var params map[string]any
254         err := json.NewDecoder(r.Body).Decode(&params)
255         if err != nil {
256                 http.Error(w, err.Error(), http.StatusBadRequest)
257                 return
258         }
259
260         call := fakeBotCall{
261                 method: method,
262                 params: params,
263         }
264
265         f.mutex.Lock()
266         status, description := 0, ""
267         if f.fail != nil {
268                 status, description = f.fail(call)
269         }
270
271         stall := f.stall != nil && f.stall(call)
272         f.mutex.Unlock()
273
274         if stall {
275                 f.record(call)
276                 select {
277                 case <-r.Context().Done():
278                 case <-f.stopped:
279                 }
280
281                 return
282         }
283
284         if status != 0 {
285                 f.record(call)
286                 w.WriteHeader(status)
287                 wait := 0
288                 if status == http.StatusTooManyRequests {
289                         wait = 1
290                 }
291
292                 _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
293
294                 return
295         }
296
297         call.ok = true
298         f.record(call)
299         if method != "getUpdates" {
300                 _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
301                 return
302         }
303
304         offset := int64(params["offset"].(float64))
305         updates := f.from(offset)
306         if len(updates) == 0 {
307                 select {
308                 case <-f.added:
309                 case <-time.After(50 * time.Millisecond):
310                 case <-r.Context().Done():
311                 }
312
313                 updates = f.from(offset)
314         }
315
316         _ = json.NewEncoder(w).Encode(map[string]any{
317                 "ok":     true,
318                 "result": updates,
319         })
320 }
321
322 // record keeps call.
323 func (f *fakeBot) record(call fakeBotCall) {
324         f.mutex.Lock()
325         defer f.mutex.Unlock()
326
327         f.calls = append(f.calls, call)
328 }
329
330 // from returns the updates from offset on, and forgets the ones before it, as
331 // Telegram does.
332 func (f *fakeBot) from(offset int64) []botUpdate {
333         f.mutex.Lock()
334         defer f.mutex.Unlock()
335
336         var updates []botUpdate
337         kept := f.updates[:0]
338         for _, update := range f.updates {
339                 if update.UpdateID >= offset {
340                         updates = append(updates, update)
341                         kept = append(kept, update)
342                 }
343         }
344
345         f.updates = kept
346
347         return updates
348 }
349
350 // ownerMessage returns a text message of the owner in the private chat.
351 func ownerMessage(text string) *botMessage {
352         return &botMessage{
353                 Chat: botChat{
354                         ID:   testOwner,
355                         Type: "private",
356                 },
357                 From: &botUser{
358                         ID: testOwner,
359                 },
360                 Text: text,
361         }
362 }