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) } } }