]> git.giacomolli.uk - onechat/blob - botapi_test.go
Send the Telegram replies as rich messages
[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*", nil)
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 TestSendMessageSendsTheEntities(t *testing.T) {
61         server := newFakeBot(t)
62         err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "a link", []botEntity{
63                 {
64                         Type:   "text_link",
65                         Offset: 2,
66                         Length: 4,
67                         URL:    "https://example.com",
68                 },
69         })
70         require.NoError(t, err)
71
72         require.Equal(t, []any{
73                 map[string]any{
74                         "type":   "text_link",
75                         "offset": float64(2),
76                         "length": float64(4),
77                         "url":    "https://example.com",
78                 },
79         }, server.Calls()[0].params["entities"])
80 }
81
82 func TestSendRichMessageSendsTheMarkdownUnchanged(t *testing.T) {
83         server := newFakeBot(t)
84         markdown := "| a | b |\n|---|---|\n| **1** | `2` |"
85         err := newBotAPI(server.URL, testBotToken).SendRichMessage(t.Context(), testOwner, markdown)
86         require.NoError(t, err)
87
88         call := server.Calls()[0]
89         require.Equal(t, "sendRichMessage", call.method)
90         require.Equal(t, map[string]any{
91                 "chat_id": float64(testOwner),
92                 "rich_message": map[string]any{
93                         "markdown": markdown,
94                 },
95         }, call.params)
96 }
97
98 func TestSendCallsTimeOutOnAStalledConnection(t *testing.T) {
99         testCases := []struct {
100                 method string
101                 send   func(ctx context.Context, api *botAPI) error
102         }{
103                 {
104                         method: "sendMessage",
105                         send: func(ctx context.Context, api *botAPI) error {
106                                 return api.SendMessage(ctx, testOwner, "hi", nil)
107                         },
108                 },
109                 {
110                         method: "sendRichMessage",
111                         send: func(ctx context.Context, api *botAPI) error {
112                                 return api.SendRichMessage(ctx, testOwner, "hi")
113                         },
114                 },
115         }
116
117         for _, tc := range testCases {
118                 t.Run(tc.method, func(t *testing.T) {
119                         server := newFakeBot(t)
120                         server.stall = func(call fakeBotCall) bool {
121                                 return call.method == tc.method
122                         }
123
124                         api := newBotAPI(server.URL, testBotToken)
125                         api.sendTimeout = 50 * time.Millisecond
126                         start := time.Now()
127                         err := tc.send(t.Context(), api)
128                         require.ErrorIs(t, err, errBotNetwork)
129                         require.Less(t, time.Since(start), 5*time.Second)
130                         require.NotContains(t, err.Error(), testBotToken)
131                         require.NotContains(t, err.Error(), server.URL)
132
133                         retry, _ := retryable(err)
134                         require.True(t, retry)
135
136                         api.sendTimeout = time.Hour
137                         ctx, cancel := context.WithCancel(t.Context())
138                         time.AfterFunc(50*time.Millisecond, cancel)
139                         start = time.Now()
140                         err = tc.send(ctx, api)
141                         require.Error(t, err)
142                         require.Less(t, time.Since(start), 5*time.Second)
143                 })
144         }
145 }
146
147 func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
148         testCases := []struct {
149                 name      string
150                 reply     func(w http.ResponseWriter)
151                 retry     bool
152                 wait      time.Duration
153                 rejected  bool
154                 errorText string
155         }{
156                 {
157                         name: "refused connection is retried",
158                         reply: func(w http.ResponseWriter) {
159                                 conn, _, err := http.NewResponseController(w).Hijack()
160                                 if err == nil {
161                                         _ = conn.Close()
162                                 }
163                         },
164                         retry:     true,
165                         errorText: "no valid answer",
166                 },
167                 {
168                         name: "rate limit waits as asked",
169                         reply: func(w http.ResponseWriter) {
170                                 w.WriteHeader(http.StatusTooManyRequests)
171                                 _, _ = io.WriteString(w, `{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 3","parameters":{"retry_after":3}}`)
172                         },
173                         retry:     true,
174                         wait:      3 * time.Second,
175                         errorText: "answered 429",
176                 },
177                 {
178                         name: "server error page is retried",
179                         reply: func(w http.ResponseWriter) {
180                                 w.WriteHeader(http.StatusBadGateway)
181                                 _, _ = io.WriteString(w, "<html>bad gateway</html>")
182                         },
183                         retry:     true,
184                         errorText: "answered 502: Bad Gateway",
185                 },
186                 {
187                         name: "token echoed in a description is hidden",
188                         reply: func(w http.ResponseWriter) {
189                                 w.WriteHeader(http.StatusUnauthorized)
190                                 _, _ = io.WriteString(w, `{"ok":false,"error_code":401,"description":"Unauthorized: bot`+testBotToken+`"}`)
191                         },
192                         retry:     false,
193                         errorText: "Unauthorized: botTOKEN",
194                 },
195                 {
196                         name: "bad request is a refusal",
197                         reply: func(w http.ResponseWriter) {
198                                 w.WriteHeader(http.StatusBadRequest)
199                                 _, _ = io.WriteString(w, `{"ok":false,"error_code":400,"description":"Bad Request: can't parse"}`)
200                         },
201                         retry:     false,
202                         rejected:  true,
203                         errorText: "answered 400: Bad Request: can't parse",
204                 },
205                 {
206                         name: "unknown method is a refusal",
207                         reply: func(w http.ResponseWriter) {
208                                 w.WriteHeader(http.StatusNotFound)
209                                 _, _ = io.WriteString(w, `{"ok":false,"error_code":404,"description":"Not Found"}`)
210                         },
211                         retry:     false,
212                         rejected:  true,
213                         errorText: "answered 404: Not Found",
214                 },
215                 {
216                         name: "not found page of a proxy is not a refusal",
217                         reply: func(w http.ResponseWriter) {
218                                 w.WriteHeader(http.StatusNotFound)
219                                 _, _ = io.WriteString(w, "<html>not found</html>")
220                         },
221                         retry:     false,
222                         errorText: "answered 404: Not Found",
223                 },
224                 {
225                         name: "broken answer is retried",
226                         reply: func(w http.ResponseWriter) {
227                                 _, _ = io.WriteString(w, `{"ok":`)
228                         },
229                         retry:     true,
230                         errorText: "decode answer",
231                 },
232         }
233
234         for _, tc := range testCases {
235                 t.Run(tc.name, func(t *testing.T) {
236                         server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
237                                 tc.reply(w)
238                         }))
239                         t.Cleanup(server.Close)
240
241                         err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi", nil)
242                         require.ErrorContains(t, err, tc.errorText)
243                         require.NotContains(t, err.Error(), testBotToken)
244                         require.NotContains(t, err.Error(), server.URL)
245
246                         retry, wait := retryable(err)
247                         require.Equal(t, tc.retry, retry)
248                         require.Equal(t, tc.wait, wait)
249                         require.Equal(t, tc.rejected, rejected(err))
250                 })
251         }
252
253         server := httptest.NewServer(http.NotFoundHandler())
254         server.Close()
255
256         _, err := newBotAPI(server.URL, testBotToken).GetUpdates(t.Context(), 0)
257         require.ErrorIs(t, err, errBotNetwork)
258         require.NotContains(t, err.Error(), testBotToken)
259 }
260
261 // fakeBot is a fake Bot API server for testBotToken. getUpdates returns the
262 // added updates from the offset on, or waits a short time when there are none.
263 // The other methods succeed, unless fail gives an error answer for a call. An
264 // answer with HTTP 429 asks for a wait of one second. When stall is true for a
265 // call, the call gets no answer until the client gives it up or the test ends.
266 // Fail and stall run with the mutex held.
267 type fakeBot struct {
268         *httptest.Server
269
270         mutex   sync.Mutex
271         updates []botUpdate
272         calls   []fakeBotCall
273         fail    func(call fakeBotCall) (int, string)
274         stall   func(call fakeBotCall) bool
275         added   chan struct{}
276         stopped chan struct{}
277 }
278
279 // fakeBotCall is one call of the fake Bot API. OK tells if the call got a
280 // success answer.
281 type fakeBotCall struct {
282         method string
283         params map[string]any
284         ok     bool
285 }
286
287 // newFakeBot starts a fake Bot API server that stops at the end of the test.
288 func newFakeBot(t *testing.T) *fakeBot {
289         t.Helper()
290
291         f := &fakeBot{
292                 added:   make(chan struct{}, 1),
293                 stopped: make(chan struct{}),
294         }
295
296         f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
297         t.Cleanup(f.Close)
298         t.Cleanup(func() {
299                 close(f.stopped)
300         })
301
302         return f
303 }
304
305 // Add makes update available to getUpdates.
306 func (f *fakeBot) Add(update botUpdate) {
307         f.mutex.Lock()
308         f.updates = append(f.updates, update)
309         f.mutex.Unlock()
310
311         select {
312         case f.added <- struct{}{}:
313         default:
314         }
315 }
316
317 // Calls returns the calls so far.
318 func (f *fakeBot) Calls() []fakeBotCall {
319         f.mutex.Lock()
320         defer f.mutex.Unlock()
321
322         return append([]fakeBotCall(nil), f.calls...)
323 }
324
325 // Sent returns the texts of the sendMessage calls and the Markdown of the
326 // sendRichMessage calls that succeeded, in order.
327 func (f *fakeBot) Sent() []string {
328         var texts []string
329         for _, call := range f.Calls() {
330                 switch {
331                 case !call.ok:
332                 case call.method == "sendMessage":
333                         texts = append(texts, call.params["text"].(string))
334                 case call.method == "sendRichMessage":
335                         texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string))
336                 }
337         }
338
339         return texts
340 }
341
342 func (f *fakeBot) serve(w http.ResponseWriter, r *http.Request) {
343         method, found := strings.CutPrefix(r.URL.Path, "/bot"+testBotToken+"/")
344         if !found {
345                 http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
346                 return
347         }
348
349         var params map[string]any
350         err := json.NewDecoder(r.Body).Decode(&params)
351         if err != nil {
352                 http.Error(w, err.Error(), http.StatusBadRequest)
353                 return
354         }
355
356         call := fakeBotCall{
357                 method: method,
358                 params: params,
359         }
360
361         f.mutex.Lock()
362         status, description := 0, ""
363         if f.fail != nil {
364                 status, description = f.fail(call)
365         }
366
367         stall := f.stall != nil && f.stall(call)
368         f.mutex.Unlock()
369
370         if stall {
371                 f.record(call)
372                 select {
373                 case <-r.Context().Done():
374                 case <-f.stopped:
375                 }
376
377                 return
378         }
379
380         if status != 0 {
381                 f.record(call)
382                 w.WriteHeader(status)
383                 wait := 0
384                 if status == http.StatusTooManyRequests {
385                         wait = 1
386                 }
387
388                 _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
389
390                 return
391         }
392
393         call.ok = true
394         f.record(call)
395         if method != "getUpdates" {
396                 _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
397                 return
398         }
399
400         offset := int64(params["offset"].(float64))
401         updates := f.from(offset)
402         if len(updates) == 0 {
403                 select {
404                 case <-f.added:
405                 case <-time.After(50 * time.Millisecond):
406                 case <-r.Context().Done():
407                 }
408
409                 updates = f.from(offset)
410         }
411
412         _ = json.NewEncoder(w).Encode(map[string]any{
413                 "ok":     true,
414                 "result": updates,
415         })
416 }
417
418 // record keeps call.
419 func (f *fakeBot) record(call fakeBotCall) {
420         f.mutex.Lock()
421         defer f.mutex.Unlock()
422
423         f.calls = append(f.calls, call)
424 }
425
426 // from returns the updates from offset on, and forgets the ones before it, as
427 // Telegram does.
428 func (f *fakeBot) from(offset int64) []botUpdate {
429         f.mutex.Lock()
430         defer f.mutex.Unlock()
431
432         var updates []botUpdate
433         kept := f.updates[:0]
434         for _, update := range f.updates {
435                 if update.UpdateID >= offset {
436                         updates = append(updates, update)
437                         kept = append(kept, update)
438                 }
439         }
440
441         f.updates = kept
442
443         return updates
444 }
445
446 // ownerMessage returns a text message of the owner in the private chat.
447 func ownerMessage(text string) *botMessage {
448         return &botMessage{
449                 Chat: botChat{
450                         ID:   testOwner,
451                         Type: "private",
452                 },
453                 From: &botUser{
454                         ID: testOwner,
455                 },
456                 Text: text,
457         }
458 }