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