Files
3x-ui/internal/web/service/tgbot/screens_test.go
T
MHSanaei f1e1b0fb0c fix(i18n): publish the translation bundle only once it is complete
InitLocalizer filled the shared bundle in place, and the Telegram bot
runs it again while the panel is serving, so a request could read the
bundle's maps while they were being written and the process died on a
concurrent map access. Build the bundle privately and publish it, and
the bot and web localizers, through atomic pointers; the lazy load in
the middleware publishes only if nothing else did first.
2026-10-31 09:11:54 +00:00

1117 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.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
t.Cleanup(func() { locale.SwapBotLocalizer(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)
}
}