Files
miti99bot/internal/modules/dispatcher_test.go
T

267 lines
7.1 KiB
Go

package modules
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"testing"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
)
func TestAuth_Permits(t *testing.T) {
const owner int64 = 100
const admin int64 = 200
const stranger int64 = 999
auth := Auth{
BotOwnerID: owner,
AdminUserIDs: map[int64]bool{admin: true},
}
updateFrom := func(id int64) *models.Update {
return &models.Update{Message: &models.Message{From: &models.User{ID: id}}}
}
callbackFrom := func(id int64) *models.Update {
return &models.Update{CallbackQuery: &models.CallbackQuery{From: models.User{ID: id}}}
}
cases := []struct {
name string
v Visibility
update *models.Update
expect bool
}{
{"public-no-message", VisibilityPublic, &models.Update{}, true},
{"public-stranger", VisibilityPublic, updateFrom(stranger), true},
{"protected-owner", VisibilityProtected, updateFrom(owner), true},
{"protected-callback-owner", VisibilityProtected, callbackFrom(owner), true},
{"protected-admin", VisibilityProtected, updateFrom(admin), true},
{"protected-stranger", VisibilityProtected, updateFrom(stranger), false},
{"private-owner", VisibilityPrivate, updateFrom(owner), true},
{"private-admin", VisibilityPrivate, updateFrom(admin), false},
{"private-stranger", VisibilityPrivate, updateFrom(stranger), false},
{"protected-nil-message", VisibilityProtected, &models.Update{}, false},
{"private-nil-from", VisibilityPrivate, &models.Update{Message: &models.Message{}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := auth.Permits(tc.v, tc.update)
if got != tc.expect {
t.Errorf("Permits(%v) = %v, want %v", tc.v, got, tc.expect)
}
})
}
}
func TestMatchCommand(t *testing.T) {
mkUpdate := func(text string, entities ...models.MessageEntity) *models.Update {
return &models.Update{
Message: &models.Message{
Text: text,
Entities: entities,
},
}
}
cmd := func(off, length int) models.MessageEntity {
return models.MessageEntity{Type: models.MessageEntityTypeBotCommand, Offset: off, Length: length}
}
cases := []struct {
name string
want string
update *models.Update
expect bool
}{
{
name: "dm bare slash-help",
want: "help",
update: mkUpdate("/help", cmd(0, 5)),
expect: true,
},
{
// The bug this fix addresses: group clients append @botname to
// the entity. The upstream library's MatchTypeCommand misses this.
name: "group slash-help-at-botname",
want: "help",
update: mkUpdate("/help@miti99bot", cmd(0, 15)),
expect: true,
},
{
name: "group slash-help-at-botname with trailing arg",
want: "help",
update: mkUpdate("/help@miti99bot arg", cmd(0, 15)),
expect: true,
},
{
name: "different command no match",
want: "help",
update: mkUpdate("/info", cmd(0, 5)),
expect: false,
},
{
name: "different command with botname no match",
want: "help",
update: mkUpdate("/info@miti99bot", cmd(0, 15)),
expect: false,
},
{
name: "non-command entity ignored",
want: "help",
update: mkUpdate("/help", models.MessageEntity{Type: models.MessageEntityTypeMention, Offset: 0, Length: 5}),
expect: false,
},
{
name: "command not at start matches (lib parity)",
want: "help",
update: mkUpdate("hi /help", cmd(3, 5)),
expect: true,
},
{
name: "uppercase command matches",
want: "help",
update: mkUpdate("/HELP", cmd(0, 5)),
expect: true,
},
{
name: "mixed-case command matches",
want: "help",
update: mkUpdate("/Help", cmd(0, 5)),
expect: true,
},
{
name: "uppercase command with botname matches",
want: "help",
update: mkUpdate("/HELP@miti99bot", cmd(0, 15)),
expect: true,
},
{
name: "case folding does not match a different command",
want: "help",
update: mkUpdate("/INFO", cmd(0, 5)),
expect: false,
},
{
name: "nil update",
want: "help",
update: nil,
expect: false,
},
{
name: "no message",
want: "help",
update: &models.Update{},
expect: false,
},
{
name: "no entities",
want: "help",
update: mkUpdate("/help"),
expect: false,
},
{
name: "out-of-bounds entity ignored",
want: "help",
update: mkUpdate("/help", cmd(0, 999)),
expect: false,
},
{
name: "zero-length entity ignored",
want: "help",
update: mkUpdate("/help", cmd(0, 0)),
expect: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := matchCommand(tc.want, tc.update)
if got != tc.expect {
t.Errorf("matchCommand(%q, ...) = %v, want %v", tc.want, got, tc.expect)
}
})
}
}
func TestLogCommand(t *testing.T) {
capture := func(update *models.Update, err error) map[string]any {
var buf bytes.Buffer
prev := log.Default()
log.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer log.SetDefault(prev)
logCommand("ping", update, err)
var out map[string]any
if e := json.Unmarshal(buf.Bytes(), &out); e != nil {
t.Fatalf("log line not JSON: %v (%q)", e, buf.String())
}
return out
}
t.Run("group success logs input, sender, chat", func(t *testing.T) {
update := &models.Update{Message: &models.Message{
Text: "/ping now",
From: &models.User{ID: 42, Username: "alice"},
Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup, Title: "Squad"},
}}
got := capture(update, nil)
if got["level"] != "INFO" {
t.Errorf("level = %v, want INFO", got["level"])
}
want := map[string]any{
"command": "ping", "input": "/ping now", "chat_type": "supergroup",
"chat_title": "Squad", "username": "alice",
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %v, want %v", k, got[k], v)
}
}
if got["user_id"].(float64) != 42 || got["chat_id"].(float64) != -100 {
t.Errorf("ids = user %v chat %v, want 42 / -100", got["user_id"], got["chat_id"])
}
})
t.Run("dm error logs at ERROR with err", func(t *testing.T) {
update := &models.Update{Message: &models.Message{
Text: "/ping",
From: &models.User{ID: 7},
Chat: models.Chat{ID: 7, Type: models.ChatTypePrivate},
}}
got := capture(update, errors.New("boom"))
if got["level"] != "ERROR" {
t.Errorf("level = %v, want ERROR", got["level"])
}
if got["err"] != "boom" {
t.Errorf("err = %v, want boom", got["err"])
}
if _, hasTitle := got["chat_title"]; hasTitle {
t.Error("DM should not log chat_title")
}
if _, hasUser := got["username"]; hasUser {
t.Error("missing username should be omitted")
}
})
}
func TestAuth_ZeroDeniesAllGated(t *testing.T) {
// Misconfigured deploy: zero-value Auth must deny every Protected/Private
// command without panicking, so an unconfigured bot cannot be hijacked
// just because an admin env var was forgotten.
var auth Auth
update := &models.Update{Message: &models.Message{From: &models.User{ID: 1}}}
if !auth.Permits(VisibilityPublic, update) {
t.Error("zero-Auth must still permit Public")
}
if auth.Permits(VisibilityProtected, update) {
t.Error("zero-Auth must deny Protected")
}
if auth.Permits(VisibilityPrivate, update) {
t.Error("zero-Auth must deny Private")
}
}