Files
tiennm99bot/internal/testutil/recording_bot.go
T
tiennm99 19dec60aa6 test(testutil): stub struct results and coded failures in the recording bot
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.
2026-08-25 15:54:12 +07:00

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, "; ")
}