From 950ce6b88c3552ccb63dfbb90dbe099026c81afe Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Wed, 20 May 2026 12:42:36 +0300 Subject: [PATCH] (tests): PollTimeout round-trip, uploader ResponseError, panic ErrorEvent, status secret compare, parseCommand + botname (doc): TODO test checklist closed --- TODO.md | 10 +-- bot_opts_loader_test.go | 1 + bot_webhook_test.go | 3 + handler_test.go | 155 +++++++++++++++++++++++++++++++++++++ plugins.go | 2 +- tgapi/uploader_api_test.go | 52 +++++++++++++ 6 files changed, 217 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 63099cf..1e67ab4 100644 --- a/TODO.md +++ b/TODO.md @@ -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. --- diff --git a/bot_opts_loader_test.go b/bot_opts_loader_test.go index 63419ca..7655366 100644 --- a/bot_opts_loader_test.go +++ b/bot_opts_loader_test.go @@ -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, diff --git a/bot_webhook_test.go b/bot_webhook_test.go index 88e90d1..6e4ec52 100644 --- a/bot_webhook_test.go +++ b/bot_webhook_test.go @@ -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}, } diff --git a/handler_test.go b/handler_test.go index 97c093c..f754741 100644 --- a/handler_test.go +++ b/handler_test.go @@ -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]{ diff --git a/plugins.go b/plugins.go index 122a137..2cfc800 100644 --- a/plugins.go +++ b/plugins.go @@ -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 diff --git a/tgapi/uploader_api_test.go b/tgapi/uploader_api_test.go index f5a2915..170a359 100644 --- a/tgapi/uploader_api_test.go +++ b/tgapi/uploader_api_test.go @@ -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