mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 08:35:00 +03:00
3a28d130a7
Every bot notice carries a Hide button, which deletes the message it sits on. On a backup that message is the file itself: one tap, with no confirmation, could remove the admin's only copy of the database. Backup files (the .db and config.json, from the backup button, the scheduled job and the panel's backup-to-Telegram endpoint) are now sent without a keyboard; other notices, including the ban log file, keep Hide. The backup button also left the menu above the files it sent, out of view at the bottom of the chat. It now sends the Maintenance menu again below them and removes the old one. A scheduled backup leaves the menu where it is.
157 lines
4.7 KiB
Go
157 lines
4.7 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
)
|
|
|
|
// noticeServer records sendMessage/sendDocument/editMessageText with the visible body, so a test can
|
|
// assert how many messages an event burst produced.
|
|
func noticeServer(t *testing.T) (*httptest.Server, func() []apiCall) {
|
|
t.Helper()
|
|
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:]
|
|
sent := extractSentBody(t, r)
|
|
mu.Lock()
|
|
calls = append(calls, apiCall{Method: method, Payload: map[string]any{
|
|
"text": sent.Text, "chat_id": float64(sent.ChatID),
|
|
}})
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch method {
|
|
case "sendMessage", "sendDocument", "sendPhoto":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"},
|
|
}})
|
|
default:
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
|
"message_id": 77, "date": 0, "chat": map[string]any{"id": sent.ChatID, "type": "private"},
|
|
}})
|
|
}
|
|
}))
|
|
return srv, func() []apiCall {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]apiCall(nil), calls...)
|
|
}
|
|
}
|
|
|
|
// Regression test: the same event twice must edit its live card, not post a
|
|
// second message — a flapping outbound used to bury the chat.
|
|
func TestRepeatedEventEditsItsLiveCard(t *testing.T) {
|
|
srv, calls := noticeServer(t)
|
|
swapTestBot(t, srv.URL)
|
|
defer srv.Close()
|
|
setTestAdmins(t, 1)
|
|
notices.reset()
|
|
|
|
tb := initReportDB(t)
|
|
event := eventbus.Event{Type: eventbus.EventOutboundDown, Source: "nl-node"}
|
|
|
|
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
|
|
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
|
|
tb.liveNotice(1, "outbound_down:nl-node", "Outbound nl-node is DOWN")
|
|
|
|
got := calls()
|
|
if n := countMethod(got, "sendMessage"); n != 1 {
|
|
t.Errorf("sendMessage = %d, want 1: repeats must edit, not post (calls: %v)", n, methods(got))
|
|
}
|
|
if n := countMethod(got, "editMessageText"); n != 2 {
|
|
t.Errorf("editMessageText = %d, want 2: each repeat redraws the card", n)
|
|
}
|
|
_ = event
|
|
}
|
|
|
|
// A different kind of event gets its own card: they tell unrelated stories.
|
|
func TestDifferentEventKindsKeepSeparateCards(t *testing.T) {
|
|
srv, calls := noticeServer(t)
|
|
swapTestBot(t, srv.URL)
|
|
defer srv.Close()
|
|
setTestAdmins(t, 1)
|
|
notices.reset()
|
|
|
|
tb := initReportDB(t)
|
|
tb.liveNotice(1, "outbound_down:a", "A is DOWN")
|
|
tb.liveNotice(1, "xray_crash:x", "Xray crashed")
|
|
|
|
if n := countMethod(calls(), "sendMessage"); n != 2 {
|
|
t.Errorf("sendMessage = %d, want 2: unrelated events need their own cards", n)
|
|
}
|
|
}
|
|
|
|
// The backup must arrive as one message: the file carries the summary.
|
|
func TestBackupArrivesAsOneMessageWithACaption(t *testing.T) {
|
|
srv, calls := noticeServer(t)
|
|
swapTestBot(t, srv.URL)
|
|
defer srv.Close()
|
|
notices.reset()
|
|
|
|
tb := initReportDB(t)
|
|
tb.sendBackupFile(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time")
|
|
|
|
got := calls()
|
|
sends := 0
|
|
for _, c := range got {
|
|
if c.Method == "sendMessage" || c.Method == "sendDocument" {
|
|
sends++
|
|
}
|
|
}
|
|
if sends != 1 {
|
|
t.Fatalf("messages = %d, want 1 (calls: %v)", sends, methods(got))
|
|
}
|
|
if countMethod(got, "sendDocument") != 1 {
|
|
t.Error("the backup did not travel as a document")
|
|
}
|
|
}
|
|
|
|
// A caption longer than Telegram's limit is cut on a line break, never mid-line.
|
|
func TestTrimCaptionCutsOnALineBreak(t *testing.T) {
|
|
body := strings.Repeat("line of a long report\n", 200)
|
|
got := trimCaption(body, botCaptionLimit)
|
|
if len([]rune(got)) > botCaptionLimit {
|
|
t.Fatalf("caption is %d runes, want at most %d", len([]rune(got)), botCaptionLimit)
|
|
}
|
|
if strings.HasSuffix(got, "\n") || strings.HasSuffix(got, "\r") {
|
|
t.Errorf("caption ends on a dangling line break: %q", got[max(0, len(got)-20):])
|
|
}
|
|
}
|
|
|
|
// Hiding a notice forgets its card, so the next event does not edit a message
|
|
// the user just deleted.
|
|
func TestHiddenNoticeIsForgotten(t *testing.T) {
|
|
srv, _ := noticeServer(t)
|
|
swapTestBot(t, srv.URL)
|
|
defer srv.Close()
|
|
setTestAdmins(t, 1)
|
|
notices.reset()
|
|
|
|
tb := initReportDB(t)
|
|
tb.liveNotice(1, "outbound_down:a", "A is DOWN")
|
|
key := noticeKey{chatID: 1, kind: "outbound_down:a"}
|
|
if _, ok := notices.get(key); !ok {
|
|
t.Fatal("the live card was not remembered")
|
|
}
|
|
tb.dropNoticeFor(1, 77)
|
|
if _, ok := notices.get(key); ok {
|
|
t.Error("a hidden notice is still remembered; the next event would edit a deleted message")
|
|
}
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
var _ = io.Discard
|