Files
3x-ui/internal/web/service/tgbot/tgbot_stale_button_test.go
T
DIMFLIX 15fee2a035 feat(tgbot): rebuild the Telegram bot around editable screens and inline lists (#6760)
The bot answered every action with a new message, so a chat filled with stale
menus whose buttons still acted, and long client or inbound lists were paged
through dozens of buttons. Each chat now has one live screen that is edited in
place: the menu is grouped into Server, Clients, Traffic and Maintenance, lists
open through inline search, the add-client flow starts from a draft card, QR
codes arrive as one labelled sheet, and repeated notices collapse into one with
a counter. A client's /start and /usage show only that client's own links and
usage, groups are refused, and the settings page warns when the bot's inline
mode is off. Admin topics, the indexed Telegram lookup and every locale,
Slovak included, carry over.
2026-10-24 12:39:56 +02:00

146 lines
4.1 KiB
Go

package tgbot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mymmrac/telego"
)
// staleButtonServer serves canned Telegram API responses so tests can drive
// bot-dependent paths; the returned func reports per-method call counts.
func staleButtonServer(t *testing.T, responses map[string]any) (*httptest.Server, func(string) int) {
t.Helper()
var mu sync.Mutex
counts := map[string]int{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for method, body := range responses {
if r.URL.Path == "/bot"+testBotToken+"/"+method {
mu.Lock()
counts[method]++
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(body)
return
}
}
w.WriteHeader(http.StatusNotFound)
}))
return srv, func(method string) int {
mu.Lock()
defer mu.Unlock()
return counts[method]
}
}
// setTestBot swaps the process-wide connection the way EnsureBot does: under the
// mutex, so the race detector sees a well-defined handover.
func setTestBot(b *telego.Bot) {
botMu.Lock()
bot = b
botMu.Unlock()
}
func swapTestBot(t *testing.T, url string) {
t.Helper()
origBot := liveBot()
origPool := messageWorkerPool
t.Cleanup(func() {
setTestBot(origBot)
messageWorkerPool = origPool
})
newBot, err := telego.NewBot(testBotToken, telego.WithAPIServer(url))
if err != nil {
t.Fatalf("NewBot: %v", err)
}
setTestBot(newBot)
messageWorkerPool = make(chan struct{}, 10)
// The screen store is process-wide state: a screen left by an earlier test
// would make this one edit an unrelated message instead of sending.
screens.reset()
resetScreenArt()
}
func newStaleButtonTgbot(t *testing.T) *Tgbot {
t.Helper()
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
return &Tgbot{}
}
// Regression test: a stale get_clients_for_* tap on a deleted inbound must
// answer an error, not panic on inbound.Remark; removing the guard fails here.
func TestChooseInboundClientStaleInbound(t *testing.T) {
mock, calls := staleButtonServer(t, map[string]any{
"answerCallbackQuery": map[string]any{"ok": true, "result": true},
})
swapTestBot(t, mock.URL)
defer mock.Close()
tb := newStaleButtonTgbot(t)
q := &telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: 999999},
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
}
tb.chooseInboundClient(q, 1, 42, "client_sub_links")
if n := calls("answerCallbackQuery"); n != 1 {
t.Errorf("answerCallbackQuery calls = %d, want 1: a stale tap must be answered with an error", n)
}
}
// The keyboard builder must consume the caller's inbound row; a second DB read
// reintroduces the stale-row window the guard closed.
func TestGetInboundClientsForUsesProvidedInbound(t *testing.T) {
mock, _ := staleButtonServer(t, map[string]any{
"answerCallbackQuery": map[string]any{"ok": true, "result": true},
})
swapTestBot(t, mock.URL)
defer mock.Close()
tb := newStaleButtonTgbot(t)
inbound := &model.Inbound{Id: 7, Remark: "in-7", Settings: `{"clients":[{"email":"a@b.c"}]}`}
kb, err := tb.getInboundClientsFor(inbound, "client_sub_links")
if err != nil {
t.Fatalf("getInboundClientsFor: %v", err)
}
if kb == nil || len(kb.InlineKeyboard) == 0 || len(kb.InlineKeyboard[0]) == 0 {
t.Fatalf("getInboundClientsFor returned no keyboard")
}
if email := kb.InlineKeyboard[0][0].Text; email != "a@b.c" {
t.Errorf("keyboard button text = %q, want %q", email, "a@b.c")
}
}
// A panicking handler must be contained by runBotHandler; without the
// recover() the panic escapes and fails this test.
func TestRunBotHandlerRecoversPanic(t *testing.T) {
origPool := messageWorkerPool
t.Cleanup(func() { messageWorkerPool = origPool })
messageWorkerPool = make(chan struct{}, 10)
ran := false
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("panic escaped runBotHandler: %v", r)
}
}()
runBotHandler(func() {
ran = true
panic("boom")
})
}()
if !ran {
t.Errorf("handler body did not run")
}
}