package main
import (
+ "context"
"encoding/json"
"fmt"
"io"
func TestSendMessageSendsPlainText(t *testing.T) {
server := newFakeBot(t)
- err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "*not bold*")
+ err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "*not bold*", nil)
require.NoError(t, err)
require.Equal(t, map[string]any{
}, server.Calls()[0].params)
}
+func TestSendMessageSendsTheEntities(t *testing.T) {
+ server := newFakeBot(t)
+ err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "a link", []botEntity{
+ {
+ Type: "text_link",
+ Offset: 2,
+ Length: 4,
+ URL: "https://example.com",
+ },
+ })
+ require.NoError(t, err)
+
+ require.Equal(t, []any{
+ map[string]any{
+ "type": "text_link",
+ "offset": float64(2),
+ "length": float64(4),
+ "url": "https://example.com",
+ },
+ }, server.Calls()[0].params["entities"])
+}
+
+func TestSendRichMessageSendsTheMarkdownUnchanged(t *testing.T) {
+ server := newFakeBot(t)
+ markdown := "| a | b |\n|---|---|\n| **1** | `2` |"
+ err := newBotAPI(server.URL, testBotToken).SendRichMessage(t.Context(), testOwner, markdown)
+ require.NoError(t, err)
+
+ call := server.Calls()[0]
+ require.Equal(t, "sendRichMessage", call.method)
+ require.Equal(t, map[string]any{
+ "chat_id": float64(testOwner),
+ "rich_message": map[string]any{
+ "markdown": markdown,
+ },
+ }, call.params)
+}
+
+func TestSendCallsTimeOutOnAStalledConnection(t *testing.T) {
+ testCases := []struct {
+ method string
+ send func(ctx context.Context, api *botAPI) error
+ }{
+ {
+ method: "sendMessage",
+ send: func(ctx context.Context, api *botAPI) error {
+ return api.SendMessage(ctx, testOwner, "hi", nil)
+ },
+ },
+ {
+ method: "sendRichMessage",
+ send: func(ctx context.Context, api *botAPI) error {
+ return api.SendRichMessage(ctx, testOwner, "hi")
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.method, func(t *testing.T) {
+ server := newFakeBot(t)
+ server.stall = func(call fakeBotCall) bool {
+ return call.method == tc.method
+ }
+
+ api := newBotAPI(server.URL, testBotToken)
+ api.sendTimeout = 50 * time.Millisecond
+ start := time.Now()
+ err := tc.send(t.Context(), api)
+ require.ErrorIs(t, err, errBotNetwork)
+ require.Less(t, time.Since(start), 5*time.Second)
+ require.NotContains(t, err.Error(), testBotToken)
+ require.NotContains(t, err.Error(), server.URL)
+
+ retry, _ := retryable(err)
+ require.True(t, retry)
+
+ api.sendTimeout = time.Hour
+ ctx, cancel := context.WithCancel(t.Context())
+ time.AfterFunc(50*time.Millisecond, cancel)
+ start = time.Now()
+ err = tc.send(ctx, api)
+ require.Error(t, err)
+ require.Less(t, time.Since(start), 5*time.Second)
+ })
+ }
+}
+
func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
testCases := []struct {
name string
reply func(w http.ResponseWriter)
retry bool
wait time.Duration
+ rejected bool
errorText string
}{
{
retry: false,
errorText: "Unauthorized: botTOKEN",
},
+ {
+ name: "bad request is a refusal",
+ reply: func(w http.ResponseWriter) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, `{"ok":false,"error_code":400,"description":"Bad Request: can't parse"}`)
+ },
+ retry: false,
+ rejected: true,
+ errorText: "answered 400: Bad Request: can't parse",
+ },
+ {
+ name: "unknown method is a refusal",
+ reply: func(w http.ResponseWriter) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(w, `{"ok":false,"error_code":404,"description":"Not Found"}`)
+ },
+ retry: false,
+ rejected: true,
+ errorText: "answered 404: Not Found",
+ },
+ {
+ name: "not found page of a proxy is not a refusal",
+ reply: func(w http.ResponseWriter) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(w, "<html>not found</html>")
+ },
+ retry: false,
+ errorText: "answered 404: Not Found",
+ },
{
name: "broken answer is retried",
reply: func(w http.ResponseWriter) {
}))
t.Cleanup(server.Close)
- err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi")
+ err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi", nil)
require.ErrorContains(t, err, tc.errorText)
require.NotContains(t, err.Error(), testBotToken)
require.NotContains(t, err.Error(), server.URL)
retry, wait := retryable(err)
require.Equal(t, tc.retry, retry)
require.Equal(t, tc.wait, wait)
+ require.Equal(t, tc.rejected, rejected(err))
})
}
// fakeBot is a fake Bot API server for testBotToken. getUpdates returns the
// added updates from the offset on, or waits a short time when there are none.
-// The other methods succeed, unless fail gives an error answer for a call.
+// The other methods succeed, unless fail gives an error answer for a call. An
+// answer with HTTP 429 asks for a wait of one second. When stall is true for a
+// call, the call gets no answer until the client gives it up or the test ends.
+// Fail and stall run with the mutex held.
type fakeBot struct {
*httptest.Server
updates []botUpdate
calls []fakeBotCall
fail func(call fakeBotCall) (int, string)
+ stall func(call fakeBotCall) bool
added chan struct{}
+ stopped chan struct{}
}
// fakeBotCall is one call of the fake Bot API. OK tells if the call got a
t.Helper()
f := &fakeBot{
- added: make(chan struct{}, 1),
+ added: make(chan struct{}, 1),
+ stopped: make(chan struct{}),
}
f.Server = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.Close)
+ t.Cleanup(func() {
+ close(f.stopped)
+ })
return f
}
}
f.mutex.Lock()
- fail := f.fail
+ status, description := 0, ""
+ if f.fail != nil {
+ status, description = f.fail(call)
+ }
+
+ stall := f.stall != nil && f.stall(call)
f.mutex.Unlock()
- status, description := 0, ""
- if fail != nil {
- status, description = fail(call)
+ if stall {
+ f.record(call)
+ select {
+ case <-r.Context().Done():
+ case <-f.stopped:
+ }
+
+ return
}
if status != 0 {
f.record(call)
w.WriteHeader(status)
- _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":1}}`, status, description)
+ wait := 0
+ if status == http.StatusTooManyRequests {
+ wait = 1
+ }
+
+ _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait)
return
}