package telegram
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// newTestBot — клиент на фейковом Bot API: сервер видит метод и форму,
// клиент видит только свой APIURL. Токен обязателен, иначе бот «выключен».
func newTestBot(t *testing.T, handler http.HandlerFunc) *Bot {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return New(Config{Token: "test-token", APIURL: srv.URL, HTTP: srv.Client()})
}
// formHandler — роутер по последнему сегменту пути (…/sendMessage и т.д.)
// с доступом к отправленной форме из теста.
func formHandler(t *testing.T, method *string, form *url.Values, reply string) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
*method = strings.TrimPrefix(r.URL.Path, "/bottest-token/")
if err := r.ParseForm(); err != nil {
t.Errorf("parse form: %v", err)
}
*form = r.PostForm
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(reply))
}
}
// TestDisabled — пустой токен: фича выключена, сетевых вызовов нет,
// ошибки распознаются как «постоянная» (повтор бессмыслен).
func TestDisabled(t *testing.T) {
b := New(Config{})
if b.Enabled() {
t.Fatal("Enabled = true, want false (no token)")
}
ctx := context.Background()
if err := b.SendMessage(ctx, "123", "hi"); !errors.Is(err, ErrDisabled) {
t.Errorf("SendMessage err = %v, want ErrDisabled", err)
}
if err := b.GetChat(ctx, "@group"); !errors.Is(err, ErrDisabled) {
t.Errorf("GetChat err = %v, want ErrDisabled", err)
}
if _, err := b.GetUpdates(ctx, 0, time.Second); !errors.Is(err, ErrDisabled) {
t.Errorf("GetUpdates err = %v, want ErrDisabled", err)
}
if err := b.Init(ctx); !errors.Is(err, ErrDisabled) {
t.Errorf("Init err = %v, want ErrDisabled", err)
}
if !Permanent(ErrDisabled) {
t.Error("Permanent(ErrDisabled) = false, want true")
}
}
// TestSendMessage — форма уходит POST'ом (текст не в URL), путь — с токеном.
func TestSendMessage(t *testing.T) {
var method string
var form url.Values
b := newTestBot(t, formHandler(t, &method, &form,
`{"ok":true,"result":{"message_id":1}}`))
if err := b.SendMessage(context.Background(), "-1001", "hi"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
if method != "sendMessage" {
t.Errorf("method = %q, want sendMessage", method)
}
if got := form.Get("chat_id"); got != "-1001" {
t.Errorf("chat_id = %q, want -1001", got)
}
if got := form.Get("text"); got != "hi" {
t.Errorf("text = %q", got)
}
if got := form.Get("parse_mode"); got != "HTML" {
t.Errorf("parse_mode = %q, want HTML", got)
}
}
// TestSendMessageErrors — классификация ошибок Bot API: «чат недоступен»
// и «бот заблокирован» — постоянные (повтор не поможет), 429 — временная.
func TestSendMessageErrors(t *testing.T) {
for _, tc := range []struct {
name string
reply string
permanent bool
want error
}{
{"chat not found", `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`, true, ErrChatNotFound},
{"bot blocked", `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}`, true, ErrForbidden},
{"too many", `{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 10"}`, false, nil},
{"server error", `{"ok":false,"error_code":500,"description":"Internal Server Error"}`, false, nil},
} {
t.Run(tc.name, func(t *testing.T) {
b := newTestBot(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(tc.reply))
})
err := b.SendMessage(context.Background(), "1", "text")
if err == nil {
t.Fatal("err = nil, want error")
}
if tc.want != nil && !errors.Is(err, tc.want) {
t.Errorf("err = %v, want %v", err, tc.want)
}
if got := Permanent(err); got != tc.permanent {
t.Errorf("Permanent = %v, want %v (err: %v)", got, tc.permanent, err)
}
})
}
}
// TestSendMessageEmptyChat — пустая цель не должна уходить в API:
// это ошибка вызывающего кода, и она сразу «постоянная».
func TestSendMessageEmptyChat(t *testing.T) {
called := false
b := newTestBot(t, func(_ http.ResponseWriter, _ *http.Request) {
called = true
})
err := b.SendMessage(context.Background(), " ", "text")
if !errors.Is(err, ErrChatNotFound) {
t.Errorf("err = %v, want ErrChatNotFound", err)
}
if called {
t.Error("API called for empty chat")
}
}
// TestGetChat — проверка вручную введённой группы: путь, параметр и
// ошибка «чата нет» для несуществующей цели.
func TestGetChat(t *testing.T) {
var method string
var form url.Values
b := newTestBot(t, formHandler(t, &method, &form,
`{"ok":true,"result":{"id":-1001,"type":"group","username":"buildings"}}`))
if err := b.GetChat(context.Background(), "@buildings"); err != nil {
t.Fatalf("GetChat: %v", err)
}
if method != "getChat" || form.Get("chat_id") != "@buildings" {
t.Errorf("method = %q, chat_id = %q", method, form.Get("chat_id"))
}
bad := newTestBot(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`))
})
if err := bad.GetChat(context.Background(), "@nope"); !errors.Is(err, ErrChatNotFound) {
t.Errorf("err = %v, want ErrChatNotFound", err)
}
}
// TestGetUpdates — разбор очереди: offset, таймаут и фильтр allowed_updates
// уходят формой, апдейты приходят структурой.
func TestGetUpdates(t *testing.T) {
var method string
var form url.Values
b := newTestBot(t, formHandler(t, &method, &form,
`{"ok":true,"result":[
{"update_id":41,"message":{"text":"/start ABCD2345","chat":{"id":777,"type":"private"}}},
{"update_id":42,"message":{"text":"просто сообщение","chat":{"id":-1002,"type":"group"}}}
]}`))
updates, err := b.GetUpdates(context.Background(), 40, 30*time.Second)
if err != nil {
t.Fatalf("GetUpdates: %v", err)
}
if method != "getUpdates" {
t.Errorf("method = %q, want getUpdates", method)
}
if got := form.Get("offset"); got != "40" {
t.Errorf("offset = %q, want 40", got)
}
if got := form.Get("timeout"); got != "30" {
t.Errorf("timeout = %q, want 30", got)
}
if got := form.Get("allowed_updates"); got != `["message"]` {
t.Errorf("allowed_updates = %q", got)
}
if len(updates) != 2 {
t.Fatalf("updates = %d, want 2", len(updates))
}
if updates[0].Message == nil || updates[0].Message.Text != "/start ABCD2345" {
t.Errorf("first message = %+v", updates[0].Message)
}
if updates[0].Message.Chat.ID != 777 {
t.Errorf("chat id = %d, want 777", updates[0].Message.Chat.ID)
}
if updates[0].UpdateID != 41 {
t.Errorf("update_id = %d, want 41", updates[0].UpdateID)
}
}
// TestGetUpdatesEmpty — пустая очередь (длинный поллинг истёк): не ошибка,
// пустой срез.
func TestGetUpdatesEmpty(t *testing.T) {
b := newTestBot(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"result":[]}`))
})
updates, err := b.GetUpdates(context.Background(), 0, time.Second)
if err != nil {
t.Fatalf("GetUpdates: %v", err)
}
if len(updates) != 0 {
t.Errorf("updates = %d, want 0", len(updates))
}
}
// TestInitAndGetLink — getMe даёт имя бота, из него собирается ссылка
// привязки; мусорный ответ — ошибка, а не пустое имя.
func TestInitAndGetLink(t *testing.T) {
var method string
var form url.Values
b := newTestBot(t, formHandler(t, &method, &form,
`{"ok":true,"result":{"id":1,"username":"my_build_bot"}}`))
if err := b.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
if method != "getMe" {
t.Errorf("method = %q, want getMe", method)
}
if got := b.Username(); got != "my_build_bot" {
t.Errorf("Username = %q, want my_build_bot", got)
}
if got := Link(b.Username(), "ABCD2345"); got != "https://t.me/my_build_bot?start=ABCD2345" {
t.Errorf("Link = %q", got)
}
if got := Link("", "ABCD2345"); got != "" {
t.Errorf("Link without username = %q, want empty", got)
}
broken := newTestBot(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`not a json`))
})
if err := broken.Init(context.Background()); err == nil {
t.Error("Init on garbage response: err = nil, want error")
}
if broken.Username() != "" {
t.Errorf("Username after failure = %q, want empty", broken.Username())
}
}
// TestCodeHash — хеш стабилен, 64 hex-знака: в базу кладётся он.
func TestCodeHash(t *testing.T) {
h := CodeHash("ABCD2345")
if len(h) != 64 {
t.Errorf("len = %d, want 64", len(h))
}
if h != CodeHash("ABCD2345") {
t.Error("hash is not stable")
}
if h == CodeHash("ABCD2346") {
t.Error("different codes give the same hash")
}
for _, r := range h {
if !strings.ContainsRune("0123456789abcdef", r) {
t.Fatalf("non-hex character %q in %q", r, h)
}
}
}