(tests): PollTimeout round-trip, uploader ResponseError, panic ErrorEvent, status secret compare, parseCommand + botname
(doc): TODO test checklist closed
This commit is contained in:
@@ -41,11 +41,11 @@ Findings from the full-repo review against `AGENTS.md` priorities. Build, vet, t
|
|||||||
|
|
||||||
### Tests to add after the fixes
|
### Tests to add after the fixes
|
||||||
|
|
||||||
- `BotOptsFileJSON` round-trip for `PollTimeout` (after M4).
|
- [X] `BotOptsFileJSON` round-trip for `PollTimeout` (after M4).
|
||||||
- Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3).
|
- [X] Uploader 4xx/429 surfaces `*tgapi.ResponseError` (after M3).
|
||||||
- `Bot.handle` panic → observer receives `ErrorEvent` (after panic-recovery fix).
|
- [X] `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.
|
- [X] 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] Table-driven `parseCommand` cases for `/cmd@botname` and stripping behavior.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func TestBotOptsFileJSONCodecRoundTrip(t *testing.T) {
|
|||||||
UseTestServer: true,
|
UseTestServer: true,
|
||||||
APIURL: "https://api.example.invalid",
|
APIURL: "https://api.example.invalid",
|
||||||
RateLimit: 42,
|
RateLimit: 42,
|
||||||
|
PollTimeout: 7,
|
||||||
DropRateLimitOverflow: true,
|
DropRateLimitOverflow: true,
|
||||||
StrictPayloadType: true,
|
StrictPayloadType: true,
|
||||||
MaxWorkers: 64,
|
MaxWorkers: 64,
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "missing auth", wantStatus: http.StatusNotFound},
|
{name: "missing auth", wantStatus: http.StatusNotFound},
|
||||||
{name: "wrong auth", headerName: "Authorization", headerVal: "wrong", 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},
|
{name: "matching telegram header", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secret", wantStatus: http.StatusOK},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+155
@@ -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) {
|
func TestHandleCallbackObserverEmitsDecodeErrors(t *testing.T) {
|
||||||
observer := &recordingObserver{}
|
observer := &recordingObserver{}
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
|
|||||||
+1
-1
@@ -107,7 +107,7 @@ func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
|
|||||||
}
|
}
|
||||||
scene.pluginName = p.name
|
scene.pluginName = p.name
|
||||||
if _, exists := p.scenes[scene.name]; exists && p.logger != nil {
|
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
|
p.scenes[scene.name] = scene
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tgapi
|
package tgapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"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) {
|
func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user