mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 02:16:31 +00:00
Three gaps made parts of the Telegram API untestable:
- Methods that decode into a struct (getStickerSet, getFile, getMe,
uploadStickerFile) only ever saw `{"ok":true,"result":true}`, so they
could return nothing but unmarshal errors. StubMethod supplies a real
result payload.
- The library classifies errors from the error_code in the response
body, not the HTTP status, so a codeless failure never took a sentinel
shape. FailMethodCode emits the code, letting handlers that branch on
errors.Is be tested at all.
- Parameterless calls send no body, and the unconditional form parse
rejected them before any stub applied.
The parse tolerance is scoped to an empty body rather than to any parse
failure: multipart reports "no parts" for both an absent body and a
corrupt one, and answering a corrupt request 200 with an empty form
would quietly satisfy tests elsewhere that assert a field is absent.
310 lines
10 KiB
Go
310 lines
10 KiB
Go
package testutil
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/go-telegram/bot"
|
|
)
|
|
|
|
// SentCall captures one outbound Telegram API call (sendMessage, sendSticker,
|
|
// etc.) made by the bot during dispatch. Method is the API method ("sendMessage")
|
|
// and Form is the multipart form fields collapsed to plain string→string —
|
|
// the bot library only emits primitive scalars for our handler call sites.
|
|
type SentCall struct {
|
|
Method string
|
|
Form map[string]string
|
|
}
|
|
|
|
// Text is a convenience accessor for the most common assertion: SendMessage's
|
|
// "text" field. Returns "" if the call wasn't a sendMessage.
|
|
func (c SentCall) Text() string { return c.Form["text"] }
|
|
|
|
// ChatID returns the "chat_id" form field as-is (string form). Empty string
|
|
// if absent.
|
|
func (c SentCall) ChatID() string { return c.Form["chat_id"] }
|
|
|
|
// RecordingBot wraps a *bot.Bot wired to an httptest server that captures
|
|
// outbound API calls instead of contacting Telegram. Always Close() in a
|
|
// defer to release the test server.
|
|
type RecordingBot struct {
|
|
Bot *bot.Bot
|
|
Server *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
calls []SentCall
|
|
failures map[string]failureResponse
|
|
stubs map[string]string
|
|
nextMessageID int
|
|
}
|
|
|
|
type failureResponse struct {
|
|
status int
|
|
body string
|
|
}
|
|
|
|
// NewRecordingBot constructs a recording bot. The bot uses a synthetic token
|
|
// and disables async handlers so dispatch is deterministic in tests.
|
|
func NewRecordingBot(t *testing.T) *RecordingBot {
|
|
t.Helper()
|
|
rb := &RecordingBot{}
|
|
rb.Server = httptest.NewServer(http.HandlerFunc(rb.handle))
|
|
t.Cleanup(rb.Server.Close)
|
|
|
|
b, err := bot.New("test-token",
|
|
bot.WithSkipGetMe(),
|
|
bot.WithNotAsyncHandlers(),
|
|
bot.WithServerURL(rb.Server.URL),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("recording bot init: %v", err)
|
|
}
|
|
rb.Bot = b
|
|
return rb
|
|
}
|
|
|
|
// Sent returns a copy of all calls captured so far, in chronological order.
|
|
func (rb *RecordingBot) Sent() []SentCall {
|
|
rb.mu.Lock()
|
|
defer rb.mu.Unlock()
|
|
out := make([]SentCall, len(rb.calls))
|
|
copy(out, rb.calls)
|
|
return out
|
|
}
|
|
|
|
// LastSent returns the most-recent recorded call, or zero-value SentCall if
|
|
// none have been made yet.
|
|
func (rb *RecordingBot) LastSent() SentCall {
|
|
rb.mu.Lock()
|
|
defer rb.mu.Unlock()
|
|
if len(rb.calls) == 0 {
|
|
return SentCall{}
|
|
}
|
|
return rb.calls[len(rb.calls)-1]
|
|
}
|
|
|
|
// Reset drops all captured calls.
|
|
//
|
|
// It deliberately does NOT clear registered failures or stubs — those are setup,
|
|
// not observations. A sub-test that needs different responses should register
|
|
// them explicitly or build its own bot.
|
|
func (rb *RecordingBot) Reset() {
|
|
rb.mu.Lock()
|
|
rb.calls = nil
|
|
rb.mu.Unlock()
|
|
}
|
|
|
|
// StubMethod makes method return resultJSON as the "result" field of an ok
|
|
// response, so methods that decode into a struct can be exercised at all.
|
|
//
|
|
// Without a stub, okResponseFor answers every non-message-producing method with
|
|
// `{"ok":true,"result":true}` — which getStickerSet, getFile, uploadStickerFile,
|
|
// and getMe cannot decode, so under the bare harness they can only ever return
|
|
// "json: cannot unmarshal bool" errors.
|
|
//
|
|
// resultJSON is the raw JSON value for "result" — an object, array, or scalar:
|
|
//
|
|
// rb.StubMethod("getStickerSet", `{"name":"p_by_bot","title":"P","sticker_type":"regular","stickers":[]}`)
|
|
//
|
|
// A failure registered for the same method wins, so a test can override a
|
|
// stubbed happy path without unregistering the stub.
|
|
func (rb *RecordingBot) StubMethod(method string, resultJSON string) {
|
|
rb.mu.Lock()
|
|
defer rb.mu.Unlock()
|
|
if rb.stubs == nil {
|
|
rb.stubs = map[string]string{}
|
|
}
|
|
rb.stubs[method] = resultJSON
|
|
}
|
|
|
|
// FailMethodCode makes method fail with a Telegram-shaped error carrying an
|
|
// error_code, so the library maps it to the same sentinel production emits
|
|
// (bot.ErrorBadRequest for 400, bot.ErrorForbidden for 403, and so on).
|
|
//
|
|
// This is the difference from FailMethod: the library switches on the
|
|
// error_code *in the response body* (raw_request.go:103-125), not the HTTP
|
|
// status, so a codeless failure never takes a sentinel shape. Handlers that
|
|
// classify errors with errors.Is must be tested through this method.
|
|
//
|
|
// rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERS_TOO_MUCH")
|
|
func (rb *RecordingBot) FailMethodCode(method string, errorCode int, description string) {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"ok": false,
|
|
"error_code": errorCode,
|
|
"description": description,
|
|
})
|
|
rb.FailMethod(method, errorCode, string(body))
|
|
}
|
|
|
|
// FailMethod makes the recording server return a Telegram API error for a
|
|
// specific method while still recording the attempted call.
|
|
//
|
|
// The body is emitted verbatim, so unless it carries an "error_code" field the
|
|
// resulting error is **codeless**: the library returns a generic decode/status
|
|
// error rather than bot.ErrorBadRequest or any other sentinel. Use
|
|
// FailMethodCode when the test asserts on the error's classification.
|
|
func (rb *RecordingBot) FailMethod(method string, status int, body string) {
|
|
rb.mu.Lock()
|
|
defer rb.mu.Unlock()
|
|
if rb.failures == nil {
|
|
rb.failures = map[string]failureResponse{}
|
|
}
|
|
if status == 0 {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
if body == "" {
|
|
body = `{"ok":false,"description":"forced test failure"}`
|
|
}
|
|
rb.failures[method] = failureResponse{status: status, body: body}
|
|
}
|
|
|
|
// handle is the httptest server's request handler. Path shape is
|
|
// "/bot<token>/<method>" per the go-telegram/bot URL builder. We extract the
|
|
// method, parse the multipart form, record, and respond with a minimal-ok
|
|
// JSON body shaped to satisfy whichever method was called.
|
|
func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) {
|
|
method := apiMethodFromPath(r.URL.Path)
|
|
|
|
// Parameterless methods (getMe) send no body at all, so a parse failure is
|
|
// not an error there — it just means there are no form fields to record.
|
|
// Failing the request would make those methods untestable no matter what
|
|
// the test registered.
|
|
//
|
|
// That tolerance is scoped to requests that carry no multipart body. A
|
|
// request that claims to be multipart and then fails to parse is a real
|
|
// fault, and answering it 200 with an empty Form would quietly satisfy
|
|
// every test that asserts a field is *absent*.
|
|
// Read the body before parsing, because an empty body and a corrupt one are
|
|
// otherwise indistinguishable: multipart reports both as "no parts".
|
|
//
|
|
// The distinction matters. Parameterless methods (getMe) genuinely send no
|
|
// body, and rejecting them would make those methods untestable. A body that
|
|
// is present but unparseable is a real fault, and answering it 200 with an
|
|
// empty form would quietly satisfy every test that asserts a field is
|
|
// *absent* — several outside this package do exactly that.
|
|
//
|
|
// 8 MiB cap: well above any realistic test payload, bounded for gosec.
|
|
const maxBody = 8 << 20
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
|
if err != nil {
|
|
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
form := map[string]string{}
|
|
if len(body) > 0 {
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
// #nosec G120 — bounded by maxBody above
|
|
if err := r.ParseMultipartForm(maxBody); err != nil {
|
|
http.Error(w, "bad multipart form: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
for k, vs := range r.MultipartForm.Value {
|
|
if len(vs) > 0 {
|
|
form[k] = vs[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
rb.mu.Lock()
|
|
rb.calls = append(rb.calls, SentCall{Method: method, Form: form})
|
|
failure, shouldFail := rb.failures[method]
|
|
stub, hasStub := rb.stubs[method]
|
|
messageID := rb.nextMessageID
|
|
if !shouldFail && isMessageProducingMethod(method) {
|
|
rb.nextMessageID++
|
|
messageID = rb.nextMessageID
|
|
}
|
|
rb.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Failures win over stubs so a test can override a stubbed happy path.
|
|
if shouldFail {
|
|
w.WriteHeader(failure.status)
|
|
_, _ = w.Write([]byte(failure.body))
|
|
return
|
|
}
|
|
if hasStub {
|
|
_, _ = w.Write([]byte(`{"ok":true,"result":` + stub + `}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(okResponseFor(method, messageID)))
|
|
}
|
|
|
|
// isMessageProducingMethod reports whether the API method returns a Message,
|
|
// so each successful call gets a distinct incrementing message_id — mirroring
|
|
// real Telegram chats, where handlers key follow-up edits on the returned ID.
|
|
func isMessageProducingMethod(method string) bool {
|
|
switch method {
|
|
case "sendMessage", "sendSticker", "sendPhoto", "sendDocument", "sendVideo", "sendAnimation":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// apiMethodFromPath extracts the API method from "/bot<token>/<method>".
|
|
// Returns "" on shapes that don't match (which the test still records as a
|
|
// call to "" — surfaces accidentally weird URLs in test output).
|
|
func apiMethodFromPath(p string) string {
|
|
idx := strings.LastIndex(p, "/")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
return p[idx+1:]
|
|
}
|
|
|
|
// okResponseFor returns a minimal `{ok:true, result:...}` payload that the
|
|
// bot library will accept for the named API method. SendMessage / SendSticker
|
|
// expect a Message; most others accept a bool.
|
|
func okResponseFor(method string, messageID int) string {
|
|
if isMessageProducingMethod(method) {
|
|
// Minimal shape: id, date, chat. Bot library decodes via json so
|
|
// extra fields are ignored.
|
|
msg := map[string]any{
|
|
"message_id": messageID,
|
|
"date": 0,
|
|
"chat": map[string]any{
|
|
"id": 1,
|
|
"type": "private",
|
|
},
|
|
}
|
|
body := map[string]any{"ok": true, "result": msg}
|
|
out, _ := json.Marshal(body)
|
|
return string(out)
|
|
}
|
|
return `{"ok":true,"result":true}`
|
|
}
|
|
|
|
// AssertSentText fails the test if no recorded sendMessage contains the
|
|
// substring needle. Matches the most common assertion pattern: "did the
|
|
// handler include this phrase?"
|
|
func (rb *RecordingBot) AssertSentText(t *testing.T, needle string) {
|
|
t.Helper()
|
|
for _, c := range rb.Sent() {
|
|
if c.Method == "sendMessage" && strings.Contains(c.Text(), needle) {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("no sendMessage contained %q. Sent calls: %s", needle, rb.dumpCalls())
|
|
}
|
|
|
|
// dumpCalls renders the captured calls for error messages.
|
|
func (rb *RecordingBot) dumpCalls() string {
|
|
calls := rb.Sent()
|
|
var parts []string
|
|
for i, c := range calls {
|
|
parts = append(parts, fmt.Sprintf("[%d] %s text=%q chat=%s", i, c.Method, c.Text(), c.ChatID()))
|
|
}
|
|
if len(parts) == 0 {
|
|
return "(no calls)"
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|