REPOSITORY / ScuroNeko/Laniakea
Pull Requests
v1.0.0 #9
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user