]> git.giacomolli.uk - onechat/blob - telegram_api_test.go
Name and order the model client code
[onechat] / telegram_api_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         // telegramTestToken is the bot token of the tests. It has the form of a
20         // real token.
21         telegramTestToken = "123456:TEST-secret_token"
22
23         // telegramTestOwner is the user ID of the owner of the test bot.
24         telegramTestOwner = 4242
25 )
26
27 func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) {
28         server := newTelegramFake(t)
29         server.Add(TelegramUpdate{
30                 UpdateID: 7,
31                 Message:  telegramOwnerMessage("hello"),
32         })
33
34         updates, err := newTelegramAPI(server.URL, telegramTestToken).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(telegramPollSeconds),
45                 "allowed_updates": []any{"message"},
46         }, call.params)
47 }
48
49 func TestSendMessageSendsPlainText(t *testing.T) {
50         server := newTelegramFake(t)
51         err := newTelegramAPI(server.URL, telegramTestToken).SendMessage(t.Context(), telegramTestOwner, "*not bold*", nil)
52         require.NoError(t, err)
53
54         require.Equal(t, map[string]any{
55                 "chat_id": float64(telegramTestOwner),
56                 "text":    "*not bold*",
57         }, server.Calls()[0].params)
58 }
59
60 func TestSendMessageSendsTheEntities(t *testing.T) {
61         server := newTelegramFake(t)
62         err := newTelegramAPI(server.URL, telegramTestToken).SendMessage(t.Context(), telegramTestOwner, "a link", []TelegramEntity{
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 := newTelegramFake(t)
84         markdown := "| a | b |\n|---|---|\n| **1** | `2` |"
85         err := newTelegramAPI(server.URL, telegramTestToken).SendRichMessage(t.Context(), telegramTestOwner, 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(telegramTestOwner),
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 *TelegramAPI) error
102         }{
103                 {
104                         method: "sendMessage",
105                         send: func(ctx context.Context, api *TelegramAPI) error {
106                                 return api.SendMessage(ctx, telegramTestOwner, "hi", nil)
107                         },
108                 },
109                 {
110                         method: "sendRichMessage",
111                         send: func(ctx context.Context, api *TelegramAPI) error {
112                                 return api.SendRichMessage(ctx, telegramTestOwner, "hi")
113                         },
114                 },
115         }
116
117         for _, tc := range testCases {
118                 t.Run(tc.method, func(t *testing.T) {
119                         server := newTelegramFake(t)
120                         server.stall = func(call TelegramFakeCall) bool {
121                                 return call.method == tc.method
122                         }
123
124                         api := newTelegramAPI(server.URL, telegramTestToken)
125                         api.sendTimeout = 50 * time.Millisecond
126                         start := time.Now()
127                         err := tc.send(t.Context(), api)
128                         require.ErrorIs(t, err, errTelegramNetwork)
129                         require.Less(t, time.Since(start), 5*time.Second)
130                         require.NotContains(t, err.Error(), telegramTestToken)
131                         require.NotContains(t, err.Error(), server.URL)
132
133                         retry, _ := telegramRetryable(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`+telegramTestToken+`"}`)
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 := newTelegramAPI(server.URL, telegramTestToken).SendMessage(t.Context(), telegramTestOwner, "hi", nil)
242                         require.ErrorContains(t, err, tc.errorText)
243                         require.NotContains(t, err.Error(), telegramTestToken)
244                         require.NotContains(t, err.Error(), server.URL)
245
246                         retry, wait := telegramRetryable(err)
247                         require.Equal(t, tc.retry, retry)
248                         require.Equal(t, tc.wait, wait)
249                         require.Equal(t, tc.rejected, telegramRejected(err))
250                 })
251         }
252
253         server := httptest.NewServer(http.NotFoundHandler())
254         server.Close()
255
256         _, err := newTelegramAPI(server.URL, telegramTestToken).GetUpdates(t.Context(), 0)
257         require.ErrorIs(t, err, errTelegramNetwork)
258         require.NotContains(t, err.Error(), telegramTestToken)
259 }
260
261 // TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a
262 // success answer.
263 type TelegramFakeCall struct {
264         method string
265         params map[string]any
266         ok     bool
267 }
268
269 // TelegramFake is a fake Bot API server for telegramTestToken. getUpdates
270 // returns the added updates from the offset on, or waits a short time when
271 // there are none. The other methods succeed, unless fail gives an error answer
272 // for a call. An answer with HTTP 429 asks for a wait of one second. When stall
273 // is true for a call, the call gets no answer until the client gives it up or
274 // the test ends. Fail and stall run with the mutex held.
275 type TelegramFake struct {
276         *httptest.Server
277
278         mutex   sync.Mutex
279         updates []TelegramUpdate
280         calls   []TelegramFakeCall
281         fail    func(call TelegramFakeCall) (int, string)
282         stall   func(call TelegramFakeCall) bool
283         added   chan struct{}
284         stopped chan struct{}
285 }
286
287 // Add makes update available to getUpdates.
288 func (f *TelegramFake) Add(update TelegramUpdate) {
289         f.mutex.Lock()
290         f.updates = append(f.updates, update)
291         f.mutex.Unlock()
292
293         select {
294         case f.added <- struct{}{}:
295         default:
296         }
297 }
298
299 // Calls returns the calls so far.
300 func (f *TelegramFake) Calls() []TelegramFakeCall {
301         f.mutex.Lock()
302         defer f.mutex.Unlock()
303
304         return append([]TelegramFakeCall(nil), f.calls...)
305 }
306
307 // Sent returns the texts of the sendMessage calls and the Markdown of the
308 // sendRichMessage calls that succeeded, in order.
309 func (f *TelegramFake) Sent() []string {
310         var texts []string
311         for _, call := range f.Calls() {
312                 switch {
313                 case !call.ok:
314                 case call.method == "sendMessage":
315                         texts = append(texts, call.params["text"].(string))
316                 case call.method == "sendRichMessage":
317                         texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string))
318                 }
319         }
320
321         return texts
322 }
323
324 func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) {
325         method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/")
326         if !found {
327                 http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound)
328                 return
329         }
330
331         var params map[string]any
332         err := json.NewDecoder(r.Body).Decode(&params)
333         if err != nil {
334                 http.Error(w, err.Error(), http.StatusBadRequest)
335                 return
336         }
337
338         call := TelegramFakeCall{
339                 method: method,
340                 params: params,
341         }
342
343         f.mutex.Lock()
344         status, description := 0, ""
345         if f.fail != nil {
346                 status, description = f.fail(call)
347         }
348
349         stall := f.stall != nil && f.stall(call)
350         f.mutex.Unlock()
351
352         if stall {
353                 f.record(call)
354                 select {
355                 case <-r.Context().Done():
356                 case <-f.stopped:
357                 }
358
359                 return
360         }
361
362         if status != 0 {
363                 f.record(call)
364                 w.WriteHeader(status)
365                 wait := 0
366                 if status == http.StatusTooManyRequests {
367                         wait = 1
368                 }
369
370                 _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
371
372                 return
373         }
374
375         call.ok = true
376         f.record(call)
377         if method != "getUpdates" {
378                 _, _ = io.WriteString(w, `{"ok":true,"result":true}`)
379                 return
380         }
381
382         offset := int64(params["offset"].(float64))
383         updates := f.from(offset)
384         if len(updates) == 0 {
385                 select {
386                 case <-f.added:
387                 case <-time.After(50 * time.Millisecond):
388                 case <-r.Context().Done():
389                 }
390
391                 updates = f.from(offset)
392         }
393
394         _ = json.NewEncoder(w).Encode(map[string]any{
395                 "ok":     true,
396                 "result": updates,
397         })
398 }
399
400 // record keeps call.
401 func (f *TelegramFake) record(call TelegramFakeCall) {
402         f.mutex.Lock()
403         defer f.mutex.Unlock()
404
405         f.calls = append(f.calls, call)
406 }
407
408 // from returns the updates from offset on, and forgets the ones before it, as
409 // Telegram does.
410 func (f *TelegramFake) from(offset int64) []TelegramUpdate {
411         f.mutex.Lock()
412         defer f.mutex.Unlock()
413
414         var updates []TelegramUpdate
415         kept := f.updates[:0]
416         for _, update := range f.updates {
417                 if update.UpdateID >= offset {
418                         updates = append(updates, update)
419                         kept = append(kept, update)
420                 }
421         }
422
423         f.updates = kept
424
425         return updates
426 }
427
428 // newTelegramFake starts a fake Bot API server that stops at the end of the
429 // test.
430 func newTelegramFake(t *testing.T) *TelegramFake {
431         t.Helper()
432
433         f := &TelegramFake{
434                 added:   make(chan struct{}, 1),
435                 stopped: make(chan struct{}),
436         }
437
438         f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
439         t.Cleanup(f.Close)
440         t.Cleanup(func() {
441                 close(f.stopped)
442         })
443
444         return f
445 }
446
447 // telegramOwnerMessage returns a text message of the owner in the private chat.
448 func telegramOwnerMessage(text string) *TelegramMessage {
449         return &TelegramMessage{
450                 Chat: TelegramChat{
451                         ID:   telegramTestOwner,
452                         Type: "private",
453                 },
454                 From: &TelegramUser{
455                         ID: telegramTestOwner,
456                 },
457                 Text: text,
458         }
459 }