Files
3x-ui/internal/web/service/tgbot/screens_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

1118 lines
35 KiB
Go

package tgbot
import (
"bytes"
"encoding/json"
"fmt"
"image/png"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
// recordingServer answers the Bot API calls the screen layer makes and keeps
// them in order, so a test can assert on the sequence, not just the outcome.
type apiCall struct {
Method string
Payload map[string]any
}
func (c apiCall) text() string {
if v, ok := c.Payload["text"].(string); ok {
return v
}
if v, ok := c.Payload["caption"].(string); ok {
return v
}
return ""
}
func recordingServer(t *testing.T, failEdit bool) (*httptest.Server, func() []apiCall) {
t.Helper()
var mu sync.Mutex
var calls []apiCall
nextID := 100
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
var payload map[string]any
_ = json.Unmarshal(body, &payload)
if strings.HasPrefix(string(body), "{") {
// JSON payload: keep it as decoded.
} else {
payload = map[string]any{}
}
mu.Lock()
calls = append(calls, apiCall{Method: method, Payload: payload})
nextID++
id := nextID
mu.Unlock()
fail := func(desc string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 400, "description": desc})
}
switch method {
case "editMessageMedia", "editMessageText", "editMessageCaption":
if failEdit {
fail("Bad Request: message to edit not found")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": id, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
"photo": []any{map[string]any{"file_id": "ph", "file_unique_id": "u", "width": 1, "height": 1}},
}})
case "sendPhoto", "sendMessage", "sendDocument":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": id, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
}})
case "copyMessage":
// Telegram answers with a MessageID object, not a boolean.
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": id,
}})
case "copyMessages":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": []any{
map[string]any{"message_id": id},
}})
case "getMe":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"id": 42, "is_bot": true, "first_name": "bot", "username": "bot",
}})
case "getChat":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"id": 42, "type": "private", "photo": map[string]any{"small_file_id": "s", "big_file_id": "big"},
}})
case "getUserProfilePhotos":
// sendPhoto rejects a getChat ChatPhoto id ("can't use file of type ChatPhoto as Photo"), so the
// avatar must arrive as a Photo file_id.
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"total_count": 1,
"photos": []any{[]any{
map[string]any{"file_id": "small-photo", "file_unique_id": "u1", "width": 160, "height": 160},
map[string]any{"file_id": "big-photo", "file_unique_id": "u2", "width": 640, "height": 640},
}},
}})
default:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true})
}
}))
return srv, func() []apiCall {
mu.Lock()
defer mu.Unlock()
return append([]apiCall(nil), calls...)
}
}
// newScreenTgbot points the package bot at a recording server and clears the
// process-wide screen state the previous test may have left behind.
func newScreenTgbot(t *testing.T, failEdit bool) (*Tgbot, func() []apiCall) {
t.Helper()
srv, calls := recordingServer(t, failEdit)
swapTestBot(t, srv.URL)
t.Cleanup(srv.Close)
resetScreenArt()
return &Tgbot{}, calls
}
func methods(calls []apiCall) []string {
out := make([]string, 0, len(calls))
for _, c := range calls {
out = append(out, c.Method)
}
return out
}
func indexOf(calls []apiCall, method string) int {
for i, c := range calls {
if c.Method == method {
return i
}
}
return -1
}
func TestScreenRendersOnceAndEditsAfterwards(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
tb.renderScreen(1, tb.newScreen("main", "first", tb.homeRows()...))
tb.renderScreen(1, tb.newScreen("main", "second", tb.homeRows()...))
got := calls()
if n := countMethod(got, "sendPhoto") + countMethod(got, "sendMessage"); n != 1 {
t.Fatalf("screen messages = %d, want 1: one screen per chat (calls: %v)", n, methods(got))
}
if n := countMethod(got, "editMessageMedia"); n != 1 {
t.Fatalf("editMessageMedia = %d, want 1: the second render must edit (calls: %v)", n, methods(got))
}
if n := countMethod(got, "deleteMessage"); n != 0 {
t.Errorf("deleteMessage = %d, want 0: an edit needs no replacement", n)
}
}
func TestScreenReplacesInSendThenDeleteOrder(t *testing.T) {
tb, calls := newScreenTgbot(t, true)
tb.renderScreen(1, tb.newScreen("main", "live", tb.homeRows()...))
before := len(calls())
tb.renderScreen(1, tb.newScreen("main", "refreshed", tb.homeRows()...))
got := calls()[before:]
send := indexOf(got, "sendPhoto")
if send < 0 {
send = indexOf(got, "sendMessage")
}
del := indexOf(got, "deleteMessage")
if send < 0 || del < 0 {
t.Fatalf("calls = %v, want a re-send and a delete", methods(got))
}
if del < send {
t.Errorf("calls = %v, want the new screen sent BEFORE the old one is deleted", methods(got))
}
}
func TestScreenTreatsNotModifiedAsSuccess(t *testing.T) {
var mu sync.Mutex
var calls []apiCall
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
mu.Lock()
calls = append(calls, apiCall{Method: method})
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch method {
case "editMessageMedia", "editMessageText":
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false, "error_code": 400,
"description": "Bad Request: message is not modified",
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
"message_id": 7, "date": 0, "chat": map[string]any{"id": 1, "type": "private"},
}})
}
}))
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
tb := &Tgbot{}
tb.renderScreen(1, tb.newScreen("main", "one", tb.homeRows()...))
mu.Lock()
before := len(calls)
mu.Unlock()
tb.renderScreen(1, tb.newScreen("main", "one", tb.homeRows()...))
mu.Lock()
after := append([]apiCall(nil), calls[before:]...)
mu.Unlock()
for _, c := range after {
if c.Method == "deleteMessage" || c.Method == "sendPhoto" || c.Method == "sendMessage" {
t.Fatalf("calls after the no-op edit = %v, want no replacement", methods(after))
}
}
}
func TestScreenCaptionFitsTelegramLimitAndPages(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
short := tb.newScreen("main", "hello", tb.homeRows()...)
if short.pageMax != 1 {
t.Fatalf("pageMax = %d, want 1 for a short body", short.pageMax)
}
tb.renderScreen(1, short)
if len(short.text) > botCaptionLimit {
t.Errorf("caption is %d chars, want at most %d", len(short.text), botCaptionLimit)
}
long := tb.newScreen("main", strings.Repeat("line of text\n", 300), tb.homeRows()...)
if long.pageMax < 2 {
t.Fatalf("pageMax = %d, want the long body paged", long.pageMax)
}
tb.renderScreen(1, long)
if len([]rune(long.text)) > botCaptionLimit {
t.Errorf("paged caption is %d runes, want at most %d", len([]rune(long.text)), botCaptionLimit)
}
// The pager must be reachable: a body nobody can page through is a dead end.
found := false
for _, row := range long.markup.InlineKeyboard {
for _, btn := range row {
if btn.CallbackData == cbPageNext {
found = true
}
}
}
if !found {
t.Error("paged screen has no next-page button")
}
}
func TestHideButtonDeletesItsOwnMessage(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
tb.hideMessage(&telego.CallbackQuery{
ID: "q1",
Message: &telego.Message{MessageID: 55, Chat: telego.Chat{ID: 1}},
})
got := calls()
for _, c := range got {
if c.Method != "deleteMessage" {
continue
}
if id, ok := c.Payload["message_id"].(float64); ok && int(id) == 55 {
return
}
}
t.Fatalf("calls = %v, want deleteMessage of the tapped message 55", methods(got))
}
func TestHomeScreenCarriesTheMenu(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
sc := tb.newScreen("main", "summary", tb.homeRows()...)
data := map[string]bool{}
for _, row := range sc.rows {
for _, btn := range row {
data[btn.CallbackData] = true
}
}
// The menu opens with categories only: each subject is one tap deeper, and
// the entry screen must not grow back into the wall of buttons.
for _, want := range []string{cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance, cbHome} {
if !data[want] {
t.Errorf("menu is missing the %q button", want)
}
}
for _, old := range []string{cbServer, cbInbounds, cbClients, cbOnlines, cbDeplete, cbReport, cbBackup, cbBanLogs, cbAddClient} {
if data[old] {
t.Errorf("menu still carries %q directly; it belongs in a category", old)
}
}
// Every category the menu offers must be drawn, and carry its own entries.
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
rows := tb.categoryRows(category)
if len(rows) < 2 {
t.Errorf("category %q has %d rows, want its entries plus a way back", category, len(rows))
continue
}
flat := map[string]bool{}
for _, row := range rows {
for _, btn := range row {
flat[btn.CallbackData] = true
}
}
if !flat[cbHome] {
t.Errorf("category %q has no way back to the menu", category)
}
}
}
// hasHomeRow reports whether a screen carries the way back to the menu.
func hasHomeRow(sc *screen) bool {
for _, row := range sc.rows {
for _, btn := range row {
if btn.CallbackData == cbHome {
return true
}
}
}
return false
}
// A screen whose only buttons are its own actions traps the user once one runs, as the reset
// confirmation did; every screen must offer a way out.
func TestEveryScreenOffersAWayOut(t *testing.T) {
tb := &Tgbot{}
ownActionOnly := tb.newScreen("main", "body",
tu.InlineKeyboardRow(tb.btn("tgbot.buttons.confirmResetTraffic", "reset_all_traffics_c")))
if !hasHomeRow(ownActionOnly) {
t.Error("a screen with only its own action got no way back")
}
// A screen that already ends with Back must not grow a second copy.
withBack := tb.newScreen("main", "body", tb.backRow())
backs := 0
for _, row := range withBack.rows {
for _, btn := range row {
if btn.CallbackData == cbHome {
backs++
}
}
}
if backs != 1 {
t.Errorf("back rows = %d, want exactly 1", backs)
}
// And the screens the menu actually draws.
for name, sc := range map[string]*screen{
"reset confirmation": tb.resetAllConfirm(),
"home": tb.newScreen("main", "x", tb.homeRows()...),
"category": tb.newScreen("main", "x", tb.categoryRows("maintenance")...),
} {
if !hasHomeRow(sc) {
t.Errorf("%s has no way back to the menu", name)
}
}
}
// The reset confirmation must not stay on screen after the reset runs: its
// Cancel was the way back and its only other button was the one just pressed.
func TestResetConfirmIsReplacedByTheMenu(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
setTestAdmins(t, 1)
initReportDB(t)
seedReportClients(t, "nl-fast", []string{"a@x"})
tb.answerCallback(&telego.CallbackQuery{
ID: "q1",
From: telego.User{ID: 1},
Data: "reset_all_traffics_c",
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
}, true)
if countMethod(calls(), "sendPhoto")+countMethod(calls(), "sendMessage") == 0 {
t.Fatal("the reset drew no message at all")
}
// The chat's tracked screen has to be the menu now, not the confirmation.
sc, ok := tb.screens().get(1)
if !ok {
t.Fatal("no screen is tracked after the reset")
}
if !hasHomeRow(sc) {
t.Error("the screen left behind after the reset has no way back")
}
if sc.kind != "main" {
t.Errorf("tracked screen kind = %q, want the menu", sc.kind)
}
}
// A client's /start must never render the admin menu, which advertised operations a client cannot run
// and listed every client's email.
func TestClientStartNeverShowsTheAdminMenu(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
setTestAdmins(t, 1)
initReportDB(t)
seedReportClients(t, "nl-fast", []string{"mine@x"})
// A client: owns a record, is not an admin.
screenArtMu.Lock()
artFromAvatar, artProbedAt = false, time.Now()
screenArtMu.Unlock()
tb.screenHome(500, 777777)
sc, ok := tb.screens().get(500)
if !ok || sc.markup == nil {
// No record at all: the client is told its id instead. That is fine.
return
}
for _, row := range sc.rows {
for _, btn := range row {
switch btn.CallbackData {
case cbCatServer, cbCatClients, cbCatTraffic, cbCatMaintenance, cbBroadcast, cbAddClient:
t.Errorf("a client's menu carries the admin-only %q button", btn.CallbackData)
}
}
}
}
// The broadcast the /broadcast command already drove must be reachable as a
// button, and only for an admin.
func TestBroadcastButtonIsAdminOnly(t *testing.T) {
tb := &Tgbot{}
found := false
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
for _, row := range tb.categoryRows(category) {
for _, btn := range row {
if btn.CallbackData == cbBroadcast {
found = true
if category != "clients" {
t.Errorf("broadcast sits in %q, want the clients category", category)
}
}
}
}
}
if !found {
t.Error("no category carries the broadcast button")
}
}
// A missing avatar is retried sooner than a present one: an operator checks right after uploading, and
// waiting for the next restart reads as the feature not working.
func TestScreenArtReprobeWindow(t *testing.T) {
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
defer srv.Close()
resetScreenArt()
tb := &Tgbot{}
// A miss two minutes ago is past the retry window: a probe must fire.
screenArtMu.Lock()
artFromAvatar = false
artProbedAt = time.Now().Add(-2 * time.Minute)
screenArtMu.Unlock()
tb.ensureScreenArt()
for range 50 {
if countMethod(calls(), "getUserProfilePhotos") > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if countMethod(calls(), "getUserProfilePhotos") == 0 {
t.Error("a stale missing avatar was not re-probed")
}
// A known avatar is inside its long window: no probe.
screenArtMu.Lock()
artFromAvatar = true
artProbedAt = time.Now().Add(-2 * time.Minute)
screenArtMu.Unlock()
before := countMethod(calls(), "getUserProfilePhotos")
tb.ensureScreenArt()
time.Sleep(80 * time.Millisecond)
if countMethod(calls(), "getUserProfilePhotos") != before {
t.Error("a freshly read avatar was re-probed too eagerly")
}
}
func TestScreenKeepsArtFromTheBotAvatar(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
tb.resolveScreenArt()
if _, ok := artFileID("main"); !ok {
t.Fatal("resolveScreenArt did not cache the bot avatar")
}
tb.renderScreen(1, tb.newScreen("main", "with art", tb.homeRows()...))
if countMethod(calls(), "getUserProfilePhotos") == 0 {
t.Error("the avatar probe never ran, so no picture can have been cached")
}
if countMethod(calls(), "sendPhoto") == 0 {
t.Errorf("calls = %v, want the screen sent as a photo", methods(calls()))
}
}
// sentText is what a Telegram call would display, plus its chat; a screen travels as a multipart photo,
// so reading only the JSON text would see nothing.
type sentText struct {
ChatID int64
Text string
}
// extractSentBody reads the displayable text and the target chat off a Bot API
// request, whichever shape the call used.
func extractSentBody(t *testing.T, r *http.Request) sentText {
t.Helper()
out := sentText{}
ctype := r.Header.Get("Content-Type")
if strings.HasPrefix(ctype, "multipart/form-data") {
if err := r.ParseMultipartForm(1 << 22); err != nil {
return out
}
out.Text = r.FormValue("caption")
if out.Text == "" {
out.Text = r.FormValue("text")
}
if media := r.FormValue("media"); media != "" {
var payload struct {
Caption string `json:"caption"`
}
if json.Unmarshal([]byte(media), &payload) == nil && payload.Caption != "" {
out.Text = payload.Caption
}
}
out.ChatID = parseInt64(r.FormValue("chat_id"))
return out
}
body, _ := io.ReadAll(r.Body)
var payload struct {
ChatID json.RawMessage `json:"chat_id"`
Text string `json:"text"`
Caption string `json:"caption"`
Media json.RawMessage `json:"media"`
}
if json.Unmarshal(body, &payload) != nil {
return out
}
out.Text = payload.Text
if out.Text == "" {
out.Text = payload.Caption
}
if len(payload.Media) > 0 {
var media struct {
Caption string `json:"caption"`
}
if json.Unmarshal(payload.Media, &media) == nil && media.Caption != "" {
out.Text = media.Caption
}
}
var chat any
if json.Unmarshal(payload.ChatID, &chat) == nil {
if f, ok := chat.(float64); ok {
out.ChatID = int64(f)
}
}
return out
}
func parseInt64(v string) int64 {
n, _ := strconv.ParseInt(v, 10, 64)
return n
}
func countMethod(calls []apiCall, method string) int {
n := 0
for _, c := range calls {
if c.Method == method {
n++
}
}
return n
}
// sheetBytesForTest reads the bytes back out of an uploadable file, so a test can
// decode the picture the bot would send.
func sheetBytesForTest(t *testing.T, file *telego.InputFile) []byte {
t.Helper()
// A byte-backed InputFile carries a NamedReader; reading it is what the
// upload would do.
if file.File == nil {
t.Fatal("the sheet carries no data")
}
data, err := io.ReadAll(file.File)
if err != nil {
t.Fatalf("read the sheet: %v", err)
}
return data
}
// ord is rune to int, for the emoji range checks in the menu tests.
func ord(ch rune) int { return int(ch) }
// localizeWithRealBundle points the bot localizer at the shipped en-US file, so
// a test sees the text a user would, not the bare key.
func localizeWithRealBundle(t *testing.T) {
t.Helper()
path := filepath.Join(repoRootForTest(t), "internal/web/translation/en-US.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
bundle := i18n.NewBundle(language.MustParse("en-US"))
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
if _, err := bundle.ParseMessageFileBytes(data, path); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
orig := locale.LocalizerBot
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
t.Cleanup(func() { locale.LocalizerBot = orig })
}
// seeding a client bound to a Telegram user, which the client screens key on
func seedClientForTgUser(t *testing.T, tgUserID int64, email string) {
t.Helper()
// The panel keeps a client's Telegram binding in the settings blob and the clients.tg_id
// column; GetClientTrafficTgBot looks it up by the indexed column.
settings := fmt.Sprintf(`{"clients":[{"email":%q,"tgId":%d,"subId":"sub-%s"}]}`, email, tgUserID, email)
inbound := &model.Inbound{
UserId: 1, Tag: "tg-" + email, Remark: "tg-" + email, Port: 8443,
Protocol: model.VLESS, Enable: true, Settings: settings,
}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("seed inbound for %s: %v", email, err)
}
if err := database.GetDB().Create(&xray.ClientTraffic{
InboundId: inbound.Id, Email: email, Enable: true, Up: 1, Down: 1,
}).Error; err != nil {
t.Fatalf("seed traffic for %s: %v", email, err)
}
record := (&model.Client{Email: email, Enable: true, SubID: "sub-" + email, TgID: tgUserID}).ToRecord()
if err := database.GetDB().Create(record).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
if err := database.GetDB().Create(&model.ClientInbound{ClientId: record.Id, InboundId: inbound.Id}).Error; err != nil {
t.Fatalf("seed client_inbounds for %s: %v", email, err)
}
}
// 1. The back button did nothing for a client: "home" was not one of the
// callbacks a non-admin was allowed to reach.
func TestClientBackButtonIsAllowed(t *testing.T) {
if !isClientSelfCallback("home") {
t.Fatal("a client's back button is still refused by the callback gate")
}
}
// A client with one subscription has nothing to choose: the links must open
// directly instead of asking the client to tap its own name.
func TestClientMenuWithOneSubscriptionOpensLinksDirectly(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t)
seedClientForTgUser(t, 4242, "solo@x")
tb.screenClientHome(777, 4242)
if countMethod(calls(), "sendPhoto") == 0 {
t.Fatal("nothing was rendered")
}
sc, ok := tb.screens().get(777)
if !ok {
t.Fatal("no screen was tracked")
}
markup, _ := json.Marshal(sc.markup)
// The client's own top level: its subscription's real actions, right there,
// and none of the controls that used to do nothing.
for _, want := range []string{"client_sub_links", "client_qr_links"} {
if !strings.Contains(string(markup), want) {
t.Errorf("the client's menu lacks %s: %s", want, markup)
}
}
for _, gone := range []string{"client_commands", "client_traffic", "pleaseChoose", "\"home\""} {
if strings.Contains(string(markup), gone) {
t.Errorf("the dead or pointless %s control is still offered: %s", gone, markup)
}
}
}
// Several subscriptions still need a choice, but one button per subscription
// rather than a "choose yourself" step.
func TestClientMenuWithSeveralSubscriptionsListsThem(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t)
seedClientForTgUser(t, 4343, "a@x")
seedClientForTgUser(t, 4343, "b@x")
tb.screenClientHome(777, 4343)
sc, ok := tb.screens().get(777)
if !ok {
t.Fatal("no screen was tracked")
}
markup, _ := json.Marshal(sc.markup)
for _, email := range []string{"a@x", "b@x"} {
if !strings.Contains(string(markup), email) {
t.Errorf("the client's own subscription %q is not offered: %s", email, markup)
}
}
}
// A QR screen shows the code as its own picture; it is not a file beside it.
func TestQRScreenCarriesTheCodeAsItsPicture(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t)
seedClientForTgUser(t, 4444, "qr@x")
tb.screenClientQR(777, "qr@x")
if got := countMethod(calls(), "sendDocument"); got != 0 {
t.Errorf("sendDocument calls = %d, want the QR on the screen instead of as a file", got)
}
if got := countMethod(calls(), "sendPhoto"); got == 0 {
t.Fatal("the QR screen sent no picture")
}
}
// /start is a reset: leaving the broadcast flow must not strand its prompt.
func TestStartClearsTheBroadcastPrompt(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t, 5555)
broadcastLocalizer(t)
t.Cleanup(func() { resetBroadcastState(t) })
setBroadcastRunning(t, true)
admin := chatUser{chatID: 5555, userID: 5555}
tb.startBroadcast(admin)
broadcastSetPrompt(admin, 4321)
before := countMethod(calls(), "deleteMessage")
tb.answerCommand(&telego.Message{
MessageID: 9, Chat: telego.Chat{ID: 5555}, From: &telego.User{ID: 5555},
Text: "/start",
}, 5555, true)
if got := countMethod(calls(), "deleteMessage"); got <= before {
t.Error("the broadcast prompt survived /start")
}
}
// Once /start has opened the menu the command itself is removed, and a second /start edits the live
// screen instead of leaving two of them.
func TestStartCommandIsRemovedAndEditsTheLiveScreen(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t, 6666)
// The handler deletes the command after answering, exactly as the router
// wires it; calling both keeps the test on the real path.
start := func(messageID int) {
msg := &telego.Message{
MessageID: messageID,
Chat: telego.Chat{ID: 6666},
From: &telego.User{ID: 6666},
Text: "/start",
}
tb.answerCommand(msg, 6666, true)
tb.deleteIncoming(msg)
}
start(12)
if countMethod(calls(), "sendPhoto") != 1 {
t.Fatalf("sendPhoto calls = %d, want 1 for the first /start", countMethod(calls(), "sendPhoto"))
}
if countMethod(calls(), "deleteMessage") < 1 {
t.Error("the /start command message was left in the chat")
}
// A second /start must redraw the tracked screen, not send another one.
start(13)
if got := countMethod(calls(), "sendPhoto"); got != 1 {
t.Errorf("sendPhoto calls = %d, want 1: a second /start duplicated the menu", got)
}
if countMethod(calls(), "editMessageMedia") == 0 {
t.Error("the second /start did not edit the live screen")
}
}
// Callback-data tests pass a button whose key is missing (its label is ""), which is how /start broke;
// this one loads the real en-US file and insists the client's menu has visible labels.
func TestClientMenuButtonsHaveRealLabels(t *testing.T) {
localizeWithRealBundle(t)
tb, _ := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t)
seedClientForTgUser(t, 4545, "labelled@x")
tb.screenClientHome(777, 4545)
sc, ok := tb.screens().get(777)
if !ok {
t.Fatal("the client's /start produced no screen")
}
if len(sc.rows) == 0 {
t.Fatal("the client's screen has no buttons")
}
for _, row := range sc.rows {
for _, button := range row {
if strings.TrimSpace(button.Text) == "" {
t.Errorf("a button on the client's menu has an empty label (callback %q)", button.CallbackData)
}
if strings.HasPrefix(button.Text, "tgbot.") {
t.Errorf("button %q shows its raw key instead of text: the key is missing", button.CallbackData)
}
}
}
if strings.TrimSpace(sc.pages[0]) == "" {
t.Error("the client's screen body is empty")
}
}
// 1. A finished broadcast is a message like any other: its run controls go, and
// what stays is the hide button.
func TestFinishedBroadcastCardKeepsOnlyHide(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
broadcastLocalizer(t)
runner := &broadcastRunner{actor: chatUser{chatID: 100}, chatID: 100, messageID: 5}
if !tb.finalizeBroadcastCard(runner, "done") {
t.Fatal("the summary card could not be edited")
}
edits := 0
for _, call := range calls() {
if call.Method != "editMessageText" {
continue
}
edits++
markup, _ := json.Marshal(call.Payload["reply_markup"])
if !strings.Contains(string(markup), "\"hide\"") {
t.Errorf("the finished card offers no hide button: %s", markup)
}
if strings.Contains(string(markup), "\"broadcast_cancel\"") {
t.Errorf("the finished card still offers the run controls: %s", markup)
}
}
if edits != 1 {
t.Errorf("editMessageText calls = %d, want 1", edits)
}
}
// 3. "Client card" on the links screen only repeats the client's own usage, so a
// client must not be offered it; an admin still gets it.
func TestLinksScreenHidesTheClientCardFromItsOwner(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t)
seedClientForTgUser(t, 4646, "mine@x")
tb.screenClientLinks(4646, "mine@x")
sc, ok := tb.screens().get(4646)
if !ok {
t.Fatal("no screen was tracked")
}
markup, _ := json.Marshal(sc.markup)
if strings.Contains(string(markup), "client_get_usage") {
t.Errorf("a client is still offered the admin's client card: %s", markup)
}
// The other links are what the client came for.
for _, want := range []string{"client_individual_links", "client_qr_links"} {
if !strings.Contains(string(markup), want) {
t.Errorf("the links screen lacks %s: %s", want, markup)
}
}
}
// 4. The QR screen carries every code the subscription has, as one picture.
func TestQRScreenCarriesEveryCodeOnOnePicture(t *testing.T) {
tb, calls := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t)
seedClientForTgUser(t, 4747, "codes@x")
tb.screenClientQR(777, "codes@x")
if got := countMethod(calls(), "sendDocument"); got != 0 {
t.Errorf("sendDocument calls = %d, want the codes on the screen's picture", got)
}
if countMethod(calls(), "sendPhoto") == 0 {
t.Fatal("the QR screen sent no picture")
}
sc, ok := tb.screens().get(777)
if !ok {
t.Fatal("no screen was tracked")
}
if sc.qrPicture == nil {
t.Fatal("the QR screen carries no sheet of codes")
}
}
// The sheet itself, independent of any service: every code given is drawn, and
// the legend numbers exactly that many.
func TestQRSheetDrawsEveryCodeAndNumbersIt(t *testing.T) {
codes := []subscriptionQRCode{
{content: "https://panel.example/sub/abc", kind: "sub"},
{content: "https://panel.example/json/abc", kind: "json"},
{content: "vless://uuid@host:443?type=tcp#app1", kind: "link", app: 1},
{content: "vless://uuid@host:8443?type=ws#app2", kind: "link", app: 2},
}
picture := qrSheetPicture(codes)
if picture == nil {
t.Fatal("the sheet could not be rendered")
}
// Decode the encoded picture so a broken one is caught here: four codes in three columns make a 3x2
// sheet with padding between and around the tiles.
img, err := png.Decode(bytes.NewReader(sheetBytesForTest(t, picture)))
if err != nil {
t.Fatalf("the sheet is not a valid PNG: %v", err)
}
wantW := 3*qrTileSize + 4*qrSheetPad
wantH := 2*qrTileSize + 3*qrSheetPad
if img.Bounds().Dx() != wantW || img.Bounds().Dy() != wantH {
t.Errorf("sheet is %dx%d, want %dx%d", img.Bounds().Dx(), img.Bounds().Dy(), wantW, wantH)
}
}
// ru-Locale labels for the menu, so these tests are independent of the localizer.
func TestAdminCategoriesHaveEmojiOnEveryButton(t *testing.T) {
localizeWithRealBundle(t)
tb := &Tgbot{}
hasEmoji := func(s string) bool {
for _, ch := range s {
o := ord(ch)
if (o >= 0x1F000 && o <= 0x1FAFF) || (o >= 0x2600 && o <= 0x27BF) ||
(o >= 0x2B00 && o <= 0x2BFF) || o == 0xFE0F {
return true
}
}
return false
}
for _, category := range []string{"server", "clients", "traffic", "maintenance"} {
rows := tb.categoryRows(category)
if len(rows) == 0 {
t.Fatalf("category %q has no buttons", category)
}
for _, row := range rows {
for _, button := range row {
if button.CallbackData == cbHome {
continue // the back row is a plain arrow by design
}
if !hasEmoji(button.Text) {
t.Errorf("category %q: button %q has no emoji: %q",
category, button.CallbackData, button.Text)
}
}
}
}
}
// A screen must not print "Refreshed on" twice: the card carries one and the
// caller used to append a second.
func TestClientScreensPrintRefreshedOnOnce(t *testing.T) {
tb, _ := newScreenTgbot(t, false)
initReportDB(t)
setTestAdmins(t)
seedClientForTgUser(t, 4848, "once@x")
// The admin's client card, which is where the duplicate was visible.
tb.searchClientScreen(777, "once@x")
sc, ok := tb.screens().get(777)
if !ok {
t.Fatal("no screen was tracked")
}
needle := tb.I18nBot("tgbot.messages.refreshedOn", "Time==x")
if needle == "" {
t.Skip("no localizer in this test environment")
}
// Compare on the key itself, which survives an empty localizer.
if got := strings.Count(sc.pages[0], "refreshedOn"); got > 1 {
t.Errorf("refreshedOn appears %d times on the client card", got)
}
if got := strings.Count(sc.body, "Refreshed"); got > 1 && needle != "" {
t.Errorf("the refreshed line is printed %d times", got)
}
}
// Inbounds belongs with the server, not under traffic: it says what the server
// listens on, and the traffic category is about quota.
func TestInboundsSitsInTheServerCategory(t *testing.T) {
tb := &Tgbot{}
in := func(category, want string) bool {
for _, row := range tb.categoryRows(category) {
for _, button := range row {
if button.CallbackData == want {
return true
}
}
}
return false
}
if !in("server", cbInbounds) {
t.Error("the Server category does not offer Inbounds")
}
if in("traffic", cbInbounds) {
t.Error("the Traffic category still offers Inbounds")
}
}
// A delivered broadcast copy carries the hide button when the recipient is one of
// the bot's admins: that recipient has no panel menu to clear it from.
func TestBroadcastCopyToAnotherAdminCarriesHide(t *testing.T) {
localizeWithRealBundle(t)
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
t.Cleanup(srv.Close)
setTestAdmins(t, 111, 222)
if _, err := deliverBroadcastCopy(222, broadcastDraft{FromChatID: 111, MessageIDs: []int{5}}); err != nil {
t.Fatalf("deliverBroadcastCopy: %v", err)
}
got := false
for _, call := range calls() {
if call.Method != "editMessageReplyMarkup" {
continue
}
markup, _ := json.Marshal(call.Payload["reply_markup"])
if strings.Contains(string(markup), "\"hide\"") {
got = true
}
}
if !got {
t.Error("the copy delivered to an admin carries no hide button")
}
}
// 1. EVERY recipient gets the hide button, not just another admin: a client has
// no panel menu, so without it the copy cannot be dismissed at all.
func TestBroadcastCopyToAClientCarriesHide(t *testing.T) {
localizeWithRealBundle(t)
srv, calls := recordingServer(t, false)
swapTestBot(t, srv.URL)
t.Cleanup(srv.Close)
setTestAdmins(t, 111) // 222 is an ordinary client, not an admin
if _, err := deliverBroadcastCopy(222, broadcastDraft{FromChatID: 111, MessageIDs: []int{5}}); err != nil {
t.Fatalf("deliverBroadcastCopy: %v", err)
}
got := ""
for _, call := range calls() {
if call.Method != "editMessageReplyMarkup" {
continue
}
markup, _ := json.Marshal(call.Payload["reply_markup"])
got = string(markup)
}
if !strings.Contains(got, "\"hide\"") {
t.Errorf("the copy delivered to a client carries no hide button: %s", got)
}
// A blank label would be worse than English: a client's language need not be
// one the panel ships.
if !strings.Contains(got, "Hide") {
t.Errorf("the hide button has no visible label: %s", got)
}
}
// The hide button must reach a client through the callback gate, or it would be
// drawn and then silently ignored.
func TestClientCanReachTheHideCallback(t *testing.T) {
if !isClientSelfCallback("hide") {
t.Fatal("a client's hide button is refused by the callback gate")
}
}
// 2. The individual-links screen offered "Links", which did what the back row
// already does.
func TestIndividualLinksScreenHasNoDuplicateLinksButton(t *testing.T) {
// The ownership tests' harness seeds a client whose subscription resolves, which the links need; the
// keyboard is read directly because drawing the full screen needs a subscription the DB harness lacks.
rows := (&Tgbot{}).individualLinksRows()
if len(rows) != 1 || len(rows[0]) != 1 {
t.Fatalf("the individual-links screen has %d row(s), want exactly the back row", len(rows))
}
if got := rows[0][0].CallbackData; got != cbHome {
t.Errorf("the only control is %q, want the back row %q", got, cbHome)
}
}