(tests): PollTimeout round-trip, uploader ResponseError, panic ErrorEvent, status secret compare, parseCommand + botname
Golang lint / lint (push) Successful in 2m42s
Golang lint / lint (pull_request) Successful in 2m42s

(doc): TODO test checklist closed
This commit is contained in:
2026-05-20 12:42:36 +03:00
parent 8a3f2cedf2
commit 950ce6b88c
6 changed files with 217 additions and 6 deletions
+5 -5
View File
@@ -41,11 +41,11 @@ Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, t
### Tests to add after the fixes
- `BotOptsFileJSON` round-trip for `PollTimeout` (after M4).
- Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3).
- `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix).
- Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke.
- Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior.
- [X] `BotOptsFileJSON` round-trip for `PollTimeout` (after M4).
- [X] Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3).
- [X] `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix).
- [X] Webhook `/status` with wrong `SecretToken` returns 403 / `403`-equivalent (after M11), incl. a constant-time-compare smoke.
- [X] Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior.
---
+1
View File
@@ -24,6 +24,7 @@ func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) {
UseTestServer: true,
APIURL: "https://api.example.invalid",
RateLimit: 42,
PollTimeout: 7,
DropRateLimitOverflow: true,
StrictPayloadType: true,
MaxWorkers: 64,
+3
View File
@@ -311,6 +311,9 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
}{
{name: "missing auth", wantStatus: http.StatusNotFound},
{name: "wrong auth", headerName: "Authorization", headerVal: "wrong", wantStatus: http.StatusNotFound},
{name: "matching length wrong content", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secres", wantStatus: http.StatusNotFound},
{name: "shared prefix shorter", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secre", wantStatus: http.StatusNotFound},
{name: "shared prefix longer", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secretxx", wantStatus: http.StatusNotFound},
{name: "matching telegram header", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secret", wantStatus: http.StatusOK},
}
+155
View File
@@ -1220,6 +1220,161 @@ func TestHandleCallbackObserverEmitsPayloadErrors(t *testing.T) {
}
}
func TestParseCommandTable(t *testing.T) {
bot := &Bot[NoData]{prefixes: []string{"/", "!"}}
tests := []struct {
name string
text string
wantPrefix string
wantCmd string
wantArgs string
}{
{name: "plain text", text: "hello", wantPrefix: "", wantCmd: "", wantArgs: ""},
{name: "command no args", text: "/start", wantPrefix: "/", wantCmd: "start", wantArgs: ""},
{name: "command with args", text: "/ban 42 reason", wantPrefix: "/", wantCmd: "ban", wantArgs: "42 reason"},
{name: "alternate prefix", text: "!ping", wantPrefix: "!", wantCmd: "ping", wantArgs: ""},
{name: "leading space after prefix", text: "/ start now", wantPrefix: "/", wantCmd: "start", wantArgs: "now"},
{name: "command with botname", text: "/start@mybot extra", wantPrefix: "/", wantCmd: "start@mybot", wantArgs: "extra"},
{name: "trailing whitespace", text: "/start ", wantPrefix: "/", wantCmd: "start", wantArgs: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prefix, cmd, args := bot.parseCommand(tt.text)
if prefix != tt.wantPrefix {
t.Fatalf("unexpected prefix: got %q want %q", prefix, tt.wantPrefix)
}
if cmd != tt.wantCmd {
t.Fatalf("unexpected cmd: got %q want %q", cmd, tt.wantCmd)
}
if args != tt.wantArgs {
t.Fatalf("unexpected args: got %q want %q", args, tt.wantArgs)
}
})
}
}
func TestHandleMessageStripsBotUsernameSuffix(t *testing.T) {
tests := []struct {
name string
botUsername string
text string
wantCalled bool
}{
{name: "matching botname", botUsername: "mybot", text: "/start@mybot hello", wantCalled: true},
{name: "matching botname no args", botUsername: "mybot", text: "/start@mybot", wantCalled: true},
{name: "other botname", botUsername: "mybot", text: "/start@otherbot hello", wantCalled: false},
{name: "no botname", botUsername: "mybot", text: "/start hello", wantCalled: true},
{name: "bot has no username", botUsername: "", text: "/start@mybot hello", wantCalled: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
called := false
plugin := NewPlugin[NoData]("test")
plugin.Command("start", func(ctx *MessageContext, db NoData) error {
called = true
return nil
})
bot := &Bot[NoData]{
logger: sneklog.NewLogger(),
prefixes: []string{"/"},
username: tt.botUsername,
plugins: []Plugin[NoData]{clonePlugin(plugin)},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 200,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: tt.text,
From: &tgapi.User{ID: 1},
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
},
})
if called != tt.wantCalled {
t.Fatalf("unexpected handler invocation: got called=%v want %v", called, tt.wantCalled)
}
})
}
}
func TestHandlePanicEmitsErrorEvent(t *testing.T) {
tests := []struct {
name string
panicWith any
matchErr func(error) bool
}{
{
name: "error value",
panicWith: errors.New("boom"),
matchErr: func(err error) bool {
return err != nil && err.Error() == "boom"
},
},
{
name: "string value",
panicWith: "kaboom",
matchErr: func(err error) bool {
return err != nil && err.Error() == "kaboom"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("test")
plugin.Command("boom", func(ctx *MessageContext, db NoData) error {
panic(tt.panicWith)
})
bot := &Bot[NoData]{
logger: sneklog.NewLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoData]{clonePlugin(plugin)},
observer: observer,
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 100,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: "/boom",
From: &tgapi.User{ID: 1},
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
},
})
panicEvent := (*ErrorEvent)(nil)
for i := range observer.errors {
ev := observer.errors[i]
if ev.Plugin == "" && ev.HandlerKind == "" && ev.UpdateID == 100 {
panicEvent = &ev
break
}
}
if panicEvent == nil {
t.Fatalf("expected ErrorEvent from panic recovery, got events: %#v", observer.errors)
}
if panicEvent.UpdateType != tgapi.UpdateTypeMessage {
t.Fatalf("unexpected UpdateType: %q", panicEvent.UpdateType)
}
if panicEvent.UserFacing {
t.Fatal("panic ErrorEvent must not be marked user-facing")
}
if !tt.matchErr(panicEvent.Err) {
t.Fatalf("unexpected panic Err: %v", panicEvent.Err)
}
})
}
}
func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
observer := &recordingObserver{}
bot := &Bot[NoData]{
+1 -1
View File
@@ -107,7 +107,7 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
}
scene.pluginName = p.name
if _, exists := p.scenes[scene.name]; exists && p.logger != nil {
p.logger.Warnf("scene '%s' already registered in plugin '%s'; overwriting", scene.name, p.name)
p.logger.Warnf("scene '%s'да already registered in plugin '%s'; overwriting", scene.name, p.name)
}
p.scenes[scene.name] = scene
return p
+52
View File
@@ -1,6 +1,7 @@
package tgapi
import (
"errors"
"fmt"
"io"
"mime"
@@ -104,6 +105,57 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
}
}
func TestUploaderSurfacesResponseErrorForTelegramFailure(t *testing.T) {
const responseBody = `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
client := &http.Client{
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(responseBody)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
uploader := NewUploader(api)
defer func() {
if err := uploader.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
_, err := uploader.SendPhoto(
UploadPhoto{ChatID: 42},
NewUploaderFile("photo.jpg", []byte("img")),
)
if err == nil {
t.Fatal("expected error, got nil")
}
var respErr *ResponseError
if !errors.As(err, &respErr) {
t.Fatalf("expected *ResponseError, got %T: %v", err, err)
}
if respErr.Code != 400 {
t.Fatalf("unexpected ResponseError.Code: got %d want 400", respErr.Code)
}
if !strings.Contains(respErr.Description, "chat not found") {
t.Fatalf("unexpected ResponseError.Description: %q", respErr.Description)
}
}
func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) {
tests := []struct {
name string