mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-17 02:14:47 +00:00
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.
This commit is contained in:
1 parent
25b952a9b7
commit
19dec60aa6
2 files changed
+202
-9
No files matched your search
@@ -1,8 +1,10 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -39,6 +41,7 @@ type RecordingBot struct {
|
||||
mu sync.Mutex
|
||||
calls []SentCall
|
||||
failures map[string]failureResponse
|
||||
stubs map[string]string
|
||||
nextMessageID int
|
||||
}
|
||||
|
||||
@@ -87,15 +90,66 @@ func (rb *RecordingBot) LastSent() SentCall {
|
||||
return rb.calls[len(rb.calls)-1]
|
||||
}
|
||||
|
||||
// Reset drops all captured calls. Useful between sub-tests sharing one bot.
|
||||
// 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()
|
||||
@@ -118,22 +172,51 @@ func (rb *RecordingBot) FailMethod(method string, status int, body string) {
|
||||
func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) {
|
||||
method := apiMethodFromPath(r.URL.Path)
|
||||
|
||||
// 8 MiB cap — well above any realistic test payload but bounded for gosec.
|
||||
// #nosec G120 — explicit upper bound above
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
// 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 := make(map[string]string, len(r.MultipartForm.Value))
|
||||
for k, vs := range r.MultipartForm.Value {
|
||||
if len(vs) > 0 {
|
||||
form[k] = vs[0]
|
||||
|
||||
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++
|
||||
@@ -142,11 +225,16 @@ func (rb *RecordingBot) handle(w http.ResponseWriter, r *http.Request) {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
@@ -76,3 +79,105 @@ func TestUpdateBuilders_BotCommandEntity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StubMethod exists because the bare harness answers every non-message method
|
||||
// with `{"ok":true,"result":true}`, which any struct-decoding method rejects.
|
||||
// Without it, getStickerSet/getFile/uploadStickerFile/getMe can only error.
|
||||
func TestRecordingBot_StubMethodDecodesIntoStruct(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.StubMethod("getStickerSet", `{"name":"pack_by_bot","title":"My Pack","sticker_type":"regular","stickers":[{"file_id":"f1","file_unique_id":"u1","type":"regular"}]}`)
|
||||
|
||||
set, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "pack_by_bot"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetStickerSet: %v", err)
|
||||
}
|
||||
if set.Name != "pack_by_bot" || set.Title != "My Pack" {
|
||||
t.Errorf("set = %+v, want name pack_by_bot / title My Pack", set)
|
||||
}
|
||||
if len(set.Stickers) != 1 || set.Stickers[0].FileID != "f1" {
|
||||
t.Errorf("stickers = %+v, want one sticker f1", set.Stickers)
|
||||
}
|
||||
}
|
||||
|
||||
// A registered failure must beat a registered stub, so a test can override a
|
||||
// stubbed happy path without unregistering it.
|
||||
func TestRecordingBot_FailureWinsOverStub(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.StubMethod("getStickerSet", `{"name":"pack_by_bot"}`)
|
||||
rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
|
||||
if _, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "pack_by_bot"}); err == nil {
|
||||
t.Fatal("GetStickerSet succeeded; want the registered failure to win over the stub")
|
||||
}
|
||||
}
|
||||
|
||||
// The library classifies errors by the error_code in the response body, not the
|
||||
// HTTP status (raw_request.go), so only a coded failure produces the sentinel
|
||||
// that production error handling matches on.
|
||||
func TestRecordingBot_FailMethodCodeYieldsSentinel(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.FailMethodCode("getStickerSet", 400, "Bad Request: STICKERSET_INVALID")
|
||||
|
||||
_, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "gone_by_bot"})
|
||||
if err == nil {
|
||||
t.Fatal("GetStickerSet succeeded; want an error")
|
||||
}
|
||||
if !errors.Is(err, bot.ErrorBadRequest) {
|
||||
t.Errorf("err = %v, want errors.Is(err, bot.ErrorBadRequest)", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "STICKERSET_INVALID") {
|
||||
t.Errorf("err = %v, want it to carry the MTProto code", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The contrast that FailMethod's doc comment promises: a codeless failure does
|
||||
// NOT take the sentinel shape. Handlers that classify with errors.Is must be
|
||||
// tested through FailMethodCode instead.
|
||||
func TestRecordingBot_FailMethodIsCodeless(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.FailMethod("getStickerSet", 400, `{"ok":false,"description":"Bad Request: STICKERSET_INVALID"}`)
|
||||
|
||||
_, err := rb.Bot.GetStickerSet(context.Background(), &bot.GetStickerSetParams{Name: "gone_by_bot"})
|
||||
if err == nil {
|
||||
t.Fatal("GetStickerSet succeeded; want an error")
|
||||
}
|
||||
if errors.Is(err, bot.ErrorBadRequest) {
|
||||
t.Errorf("err = %v is bot.ErrorBadRequest; a codeless failure must not classify", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed multipart body must not be answered 200 with an empty form.
|
||||
//
|
||||
// The harness tolerates a parse failure only for parameterless calls, which
|
||||
// send no parts at all. Widening that to every parse failure would make the
|
||||
// server answer a corrupt request as though it carried no fields — quietly
|
||||
// satisfying any test that asserts a field is absent.
|
||||
func TestRecordingBot_RejectsMalformedMultipart(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
|
||||
resp, err := http.Post(rb.Server.URL+"/bottest-token/sendMessage",
|
||||
"multipart/form-data; boundary=zzz", strings.NewReader("not a multipart body at all"))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d for a corrupt multipart body", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// A parameterless call sends a multipart content type with no parts, and must
|
||||
// still be served — several API methods take no arguments.
|
||||
func TestRecordingBot_ServesParameterlessCall(t *testing.T) {
|
||||
rb := NewRecordingBot(t)
|
||||
rb.StubMethod("getMe", `{"id":7,"is_bot":true,"first_name":"T","username":"testbot"}`)
|
||||
|
||||
me, err := rb.Bot.GetMe(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetMe: %v", err)
|
||||
}
|
||||
if me.Username != "testbot" {
|
||||
t.Errorf("username = %q, want testbot", me.Username)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user