]> git.giacomolli.uk - onechat/blob - botapi_test.go
9dfbcad8a11d2734bb2b2ba16ea47ce5c633c846
[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 that succeeded.
326 func (f *fakeBot) Sent() []string {
327         var texts []string
328         for _, call := range f.Calls() {
329                 if call.method == "sendMessage" && call.ok {
330                         texts = append(texts, call.params["text"].(string))
331                 }
332         }
333
334         return texts
335 }
336
337 func (f *fakeBot) serve(w http.ResponseWriter, r *http.Request) {
338         method, found := strings.CutPrefix(r.URL.Path, "/bot"+testBotToken+"/")
339         if !found {
340                 http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
341                 return
342         }
343
344         var params map[string]any
345         err := json.NewDecoder(r.Body).Decode(&params)
346         if err != nil {
347                 http.Error(w, err.Error(), http.StatusBadRequest)
348                 return
349         }
350
351         call := fakeBotCall{
352                 method: method,
353                 params: params,
354         }
355
356         f.mutex.Lock()
357         status, description := 0, ""
358         if f.fail != nil {
359                 status, description = f.fail(call)
360         }
361
362         stall := f.stall != nil && f.stall(call)
363         f.mutex.Unlock()
364
365         if stall {
366                 f.record(call)
367                 select {
368                 case <-r.Context().Done():
369                 case <-f.stopped:
370                 }
371
372                 return
373         }
374
375         if status != 0 {
376                 f.record(call)
377                 w.WriteHeader(status)
378                 wait := 0
379                 if status == http.StatusTooManyRequests {
380                         wait = 1
381                 }
382
383                 _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
384
385                 return
386         }
387
388         call.ok = true
389         f.record(call)
390         if method != "getUpdates" {
391                 _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
392                 return
393         }
394
395         offset := int64(params["offset"].(float64))
396         updates := f.from(offset)
397         if len(updates) == 0 {
398                 select {
399                 case <-f.added:
400                 case <-time.After(50 * time.Millisecond):
401                 case <-r.Context().Done():
402                 }
403
404                 updates = f.from(offset)
405         }
406
407         _ = json.NewEncoder(w).Encode(map[string]any{
408                 "ok":     true,
409                 "result": updates,
410         })
411 }
412
413 // record keeps call.
414 func (f *fakeBot) record(call fakeBotCall) {
415         f.mutex.Lock()
416         defer f.mutex.Unlock()
417
418         f.calls = append(f.calls, call)
419 }
420
421 // from returns the updates from offset on, and forgets the ones before it, as
422 // Telegram does.
423 func (f *fakeBot) from(offset int64) []botUpdate {
424         f.mutex.Lock()
425         defer f.mutex.Unlock()
426
427         var updates []botUpdate
428         kept := f.updates[:0]
429         for _, update := range f.updates {
430                 if update.UpdateID >= offset {
431                         updates = append(updates, update)
432                         kept = append(kept, update)
433                 }
434         }
435
436         f.updates = kept
437
438         return updates
439 }
440
441 // ownerMessage returns a text message of the owner in the private chat.
442 func ownerMessage(text string) *botMessage {
443         return &botMessage{
444                 Chat: botChat{
445                         ID:   testOwner,
446                         Type: "private",
447                 },
448                 From: &botUser{
449                         ID: testOwner,
450                 },
451                 Text: text,
452         }
453 }