| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941 |
- package tgbot
- import (
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "slices"
- "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"
- telegoapi "github.com/mymmrac/telego/telegoapi"
- "github.com/mymmrac/telego"
- "github.com/nicksnyder/go-i18n/v2/i18n"
- "golang.org/x/text/language"
- )
- // newBroadcastMock serves ok:true and records per-method call counts and
- // bodies; copyMessages answers with an array of ids, as the real API does.
- func newBroadcastMock(t *testing.T) (url string, calls func(string) int, bodies func(string) []map[string]any) {
- t.Helper()
- var mu sync.Mutex
- counts := map[string]int{}
- sent := map[string][]map[string]any{}
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- raw, _ := io.ReadAll(r.Body)
- payload := map[string]any{}
- _ = json.Unmarshal(raw, &payload)
- method := strings.TrimPrefix(r.URL.Path, "/bot"+testBotToken+"/")
- message := map[string]any{"message_id": 7, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}}
- result := any(message)
- if method == "copyMessages" {
- result = []any{message, message}
- }
- mu.Lock()
- counts[method]++
- sent[method] = append(sent[method], payload)
- mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
- }))
- t.Cleanup(srv.Close)
- return srv.URL,
- func(method string) int {
- mu.Lock()
- defer mu.Unlock()
- return counts[method]
- },
- func(method string) []map[string]any {
- mu.Lock()
- defer mu.Unlock()
- return append([]map[string]any(nil), sent[method]...)
- }
- }
- func setBroadcastAdmins(t *testing.T, ids []int64) {
- t.Helper()
- tgBotMutex.Lock()
- orig := adminIds
- adminIds = ids
- tgBotMutex.Unlock()
- t.Cleanup(func() {
- tgBotMutex.Lock()
- adminIds = orig
- tgBotMutex.Unlock()
- })
- }
- func setBroadcastRunning(t *testing.T, running bool) {
- t.Helper()
- tgBotMutex.Lock()
- orig := isRunning
- isRunning = running
- tgBotMutex.Unlock()
- t.Cleanup(func() {
- tgBotMutex.Lock()
- isRunning = orig
- tgBotMutex.Unlock()
- })
- }
- func swapBroadcastSender(t *testing.T, sender func(int64, broadcastDraft) error, pause func(time.Duration)) {
- t.Helper()
- origSend, origPause := broadcastSender, broadcastPause
- t.Cleanup(func() {
- broadcastSender, broadcastPause = origSend, origPause
- })
- broadcastSender = sender
- if pause != nil {
- broadcastPause = pause
- }
- }
- // broadcastLocalizer renders the broadcast keys a test asserts on; without it
- // I18n returns the bare key instead of the template output.
- func broadcastLocalizer(t *testing.T) {
- t.Helper()
- bundle := i18n.NewBundle(language.MustParse("en-US"))
- bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
- _ = bundle.AddMessages(language.MustParse("en-US"),
- &i18n.Message{ID: "tgbot.messages.broadcastPreview", Other: "📤 This message will go to {{ .Count }} recipients. Send it?"},
- &i18n.Message{ID: "tgbot.messages.broadcastNotCopyable", Other: "❗ This message can't be copied for broadcast."},
- &i18n.Message{ID: "tgbot.messages.broadcastAskText", Other: "send the message"},
- &i18n.Message{ID: "tgbot.messages.broadcastAlreadyRunning", Other: "already running"},
- &i18n.Message{ID: "tgbot.messages.broadcastProgress", Other: "progress {{ .Sent }}/{{ .Total }} failed {{ .Failed }}"},
- &i18n.Message{ID: "tgbot.messages.broadcastFinished", Other: "finished"},
- &i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"},
- &i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."},
- )
- orig := locale.LocalizerBot
- t.Cleanup(func() { locale.LocalizerBot = orig })
- locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
- }
- func createBroadcastInbound(t *testing.T, tag, settings string) {
- t.Helper()
- inbound := &model.Inbound{Tag: tag, Settings: settings, Enable: true}
- if err := database.GetDB().Create(inbound).Error; err != nil {
- t.Fatalf("create inbound %s: %v", tag, err)
- }
- }
- // broadcastClientsJSON renders an inbound settings blob with the given tgIds.
- func broadcastClientsJSON(t *testing.T, tgIDs ...int64) string {
- t.Helper()
- clients := make([]string, 0, len(tgIDs))
- for i, tgID := range tgIDs {
- clients = append(clients, fmt.Sprintf(`{"email":"user%d@x","tgId":%d}`, i, tgID))
- }
- return `{"clients":[` + strings.Join(clients, ",") + `]}`
- }
- // resetBroadcastState clears the shared broadcast globals before a test
- // asserts on them: shuffled tests may inherit state from an earlier test.
- func resetBroadcastState(t *testing.T) {
- t.Helper()
- broadcastResetAll()
- userStateMgr.reset()
- }
- // composeBroadcast hands a message to the composition step the way the router
- // does: keyed by its sender, who is awaiting broadcast input.
- func composeBroadcast(tb *Tgbot, message telego.Message) {
- actor := messageActor(message)
- userStateMgr.set(actor, broadcastAwaitingText)
- tb.handleBroadcastInput(&message, actor)
- }
- func swapAlbumDebounce(t *testing.T, d time.Duration) {
- t.Helper()
- orig := broadcastAlbumDebounce
- t.Cleanup(func() { broadcastAlbumDebounce = orig })
- broadcastAlbumDebounce = d
- }
- // waitBroadcastPending polls until the debounce finalizer has stored a draft
- // and returns its ids and card token.
- func waitBroadcastPending(t *testing.T, actor chatUser) ([]int, string) {
- t.Helper()
- deadline := time.Now().Add(2 * time.Second)
- for time.Now().Before(deadline) {
- if ids, token, ok := broadcastPendingDraft(actor); ok {
- return ids, token
- }
- time.Sleep(2 * time.Millisecond)
- }
- t.Fatal("album draft was never finalized in time")
- return nil, ""
- }
- func TestCollectBroadcastRecipients(t *testing.T) {
- tb := newStaleButtonTgbot(t)
- setBroadcastAdmins(t, []int64{222})
- // 111 appears on both inbounds, 222 is an admin, 0 has no Telegram ID.
- createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 111, 111, 222))
- createBroadcastInbound(t, "in-2", broadcastClientsJSON(t, 111, 333, 0, 444))
- got := tb.collectBroadcastRecipients()
- slices.Sort(got)
- if !slices.Equal(got, []int64{111, 333, 444}) {
- t.Fatalf("collectBroadcastRecipients() = %v, want [111 333 444]", got)
- }
- }
- func assertBroadcastResult(t *testing.T, got, want broadcastResult) {
- t.Helper()
- got.Elapsed, want.Elapsed = 0, 0
- if got != want {
- t.Errorf("result = %+v, want %+v", got, want)
- }
- }
- func TestRunBroadcastCounters(t *testing.T) {
- broadcastLocalizer(t)
- url, _, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot was blocked by the user"}
- tests := []struct {
- name string
- recipients []int64
- outcomes map[int64]error
- delivered int
- failed int
- skipped int
- unreachable int
- }{
- {"all delivered", []int64{1, 2, 3}, map[int64]error{1: nil, 2: nil, 3: nil}, 3, 0, 0, 0},
- {
- "a blocked chat is skipped and a transient error fails",
- []int64{1, 2, 3, 4},
- map[int64]error{1: nil, 2: blocked, 3: nil, 4: errors.New("connection reset")},
- 2, 1, 1, 1,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error {
- return tt.outcomes[chatID]
- }, func(time.Duration) {})
- runner := &broadcastRunner{chatID: 100, messageID: 5}
- tb := &Tgbot{}
- tb.runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, tt.recipients)
- assertBroadcastResult(t, runner.getResult(), broadcastResult{
- Total: len(tt.recipients),
- Delivered: tt.delivered,
- Failed: tt.failed,
- Skipped: tt.skipped,
- Unreachable: tt.unreachable,
- })
- summary := tb.broadcastSummaryText(runner.getResult())
- if tt.unreachable == 0 {
- if strings.Contains(summary, "press Start") {
- t.Errorf("summary = %q, want no unreachable note", summary)
- }
- } else if !strings.Contains(summary, "1 recipients cannot be messaged") {
- t.Errorf("summary = %q, want the unreachable note with the count", summary)
- }
- })
- }
- }
- func TestRunBroadcastCancelsMidway(t *testing.T) {
- url, _, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- runner := &broadcastRunner{chatID: 100, messageID: 5}
- swapBroadcastSender(t, func(chatID int64, _ broadcastDraft) error {
- if chatID == 1 {
- runner.cancel.Store(true)
- }
- return nil
- }, func(time.Duration) {})
- (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
- assertBroadcastResult(t, runner.getResult(), broadcastResult{
- Total: 5,
- Delivered: 1,
- Failed: 0,
- Skipped: 4,
- Canceled: true,
- })
- if broadcastCurrentRunner() != nil {
- t.Errorf("broadcast slot still registered after the run finished")
- }
- }
- // Regression: a 403 left the progress counter where it was, so a streak of
- // unreachable chats at a multiple of broadcastProgressEvery edited the card per chat.
- func TestRunBroadcastUnreachableKeepsProgressThrottled(t *testing.T) {
- broadcastLocalizer(t)
- url, calls, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- blocked := &telegoapi.Error{ErrorCode: 403, Description: "Forbidden: bot can't initiate conversation with a user"}
- swapBroadcastSender(t, func(int64, broadcastDraft) error { return blocked }, func(time.Duration) {})
- runner := &broadcastRunner{chatID: 100, messageID: 5}
- (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, []int64{1, 2, 3, 4, 5})
- // Five recipients sit under both throttle thresholds: only the summary edits the card.
- if got := calls("editMessageText"); got != 1 {
- t.Errorf("editMessageText calls = %d, want 1 (the summary alone)", got)
- }
- }
- // A long retry_after must not park the runner slot: the wait is slept in
- // slices and an abort between them ends the recipient immediately.
- func TestBroadcastFloodWaitSlicesLongWaits(t *testing.T) {
- flood := &telegoapi.Error{
- ErrorCode: 429,
- Description: "Too Many Requests: retry after 30",
- Parameters: &telegoapi.ResponseParameters{RetryAfter: 30},
- }
- tests := []struct {
- name string
- abortAfter int // abort checks answered false before aborting; -1 never aborts
- wantPauses int
- wantErr string
- }{
- {"a 30 s wait becomes six 5 s slices", -1, 30, `429 "Too Many Requests: retry after 30", migrate to chat ID: 0, retry after: 30`},
- {"an abort between slices ends the wait", 1, 1, errBroadcastAborted.Error()},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var mu sync.Mutex
- var pauses []time.Duration
- swapBroadcastSender(t, func(int64, broadcastDraft) error { return flood }, func(d time.Duration) {
- mu.Lock()
- defer mu.Unlock()
- pauses = append(pauses, d)
- })
- checks := 0
- aborted := func() bool {
- if tt.abortAfter < 0 {
- return false
- }
- checks++
- return checks > tt.abortAfter
- }
- err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, aborted)
- mu.Lock()
- defer mu.Unlock()
- if err == nil || err.Error() != tt.wantErr {
- t.Errorf("broadcastDeliverOne() error = %v, want %q", err, tt.wantErr)
- }
- if len(pauses) != tt.wantPauses {
- t.Fatalf("pauses = %d slices, want %d", len(pauses), tt.wantPauses)
- }
- for i, p := range pauses {
- if p != broadcastFloodWaitSlice {
- t.Errorf("pauses[%d] = %v, want %v", i, p, broadcastFloodWaitSlice)
- }
- }
- })
- }
- }
- // Regression: broadcast state used to survive a stop, leaving an armed album
- // timer, a confirmable token and a held runner slot behind.
- func TestStopBotResetsBroadcastState(t *testing.T) {
- broadcastLocalizer(t)
- url, calls, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- swapAlbumDebounce(t, 20*time.Millisecond)
- setBroadcastAdmins(t, []int64{5000})
- const chatID = int64(9116)
- resetBroadcastState(t)
- origRunning := isRunning
- t.Cleanup(func() {
- tgBotMutex.Lock()
- isRunning = origRunning
- tgBotMutex.Unlock()
- })
- composeBroadcast(&Tgbot{}, telego.Message{
- Chat: telego.Chat{ID: chatID},
- From: &telego.User{ID: 5000},
- MessageID: 1,
- MediaGroupID: "grpR",
- })
- runner := broadcastRegisterRunner(chatID)
- if runner == nil {
- t.Fatal("broadcastRegisterRunner() = nil before the stop")
- }
- StopBot()
- if broadcastCurrentRunner() != nil {
- t.Errorf("broadcast slot survived StopBot")
- }
- if !runner.cancel.Load() {
- t.Errorf("the active run was not cancelled on stop")
- }
- if _, _, ok := broadcastPendingDraft(chatUser{chatID: chatID, userID: 5000}); ok {
- t.Errorf("a composition survived StopBot")
- }
- time.Sleep(60 * time.Millisecond)
- if got := calls("copyMessage") + calls("sendMessage"); got != 0 {
- t.Errorf("an armed album timer fired after StopBot (%d calls)", got)
- }
- }
- // The per-recipient pause scales with the copied batch size so an album does
- // not multiply the messages per second on the wire.
- func TestRunBroadcastAlbumPacing(t *testing.T) {
- url, _, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- var pauses []time.Duration
- swapBroadcastSender(t, func(int64, broadcastDraft) error { return nil }, func(d time.Duration) {
- pauses = append(pauses, d)
- })
- runner := &broadcastRunner{chatID: 100, messageID: 5}
- (&Tgbot{}).runBroadcast(runner, broadcastDraft{FromChatID: 100, MessageIDs: []int{1, 2, 3}}, []int64{1, 2})
- if len(pauses) != 1 || pauses[0] != 3*broadcastSendDelay {
- t.Errorf("pauses = %v, want one pause of %v for a three-message album", pauses, 3*broadcastSendDelay)
- }
- }
- func TestBroadcastDeliverOneRetries429(t *testing.T) {
- flood := func(after int) error {
- return &telegoapi.Error{
- ErrorCode: 429,
- Description: "Too Many Requests: retry after " + fmt.Sprint(after),
- Parameters: &telegoapi.ResponseParameters{RetryAfter: after},
- }
- }
- tests := []struct {
- name string
- responses []error
- canceled bool
- wantErr string
- wantCalls int
- wantPauses []time.Duration
- }{
- {
- name: "flood control waits and retries the same recipient",
- responses: []error{flood(2), nil},
- wantCalls: 2,
- wantPauses: []time.Duration{2 * time.Second},
- },
- {
- name: "gives up after the retry budget",
- responses: []error{flood(1), flood(1), flood(1), flood(1), flood(1), flood(1), nil},
- wantErr: `429 "Too Many Requests: retry after 1", migrate to chat ID: 0, retry after: 1`,
- wantCalls: 6,
- wantPauses: []time.Duration{time.Second, time.Second, time.Second, time.Second, time.Second},
- },
- {
- name: "non-429 errors are returned without retrying",
- responses: []error{errors.New("connection reset")},
- wantErr: "connection reset",
- wantCalls: 1,
- },
- {
- name: "a cancel during a flood wait abandons the recipient",
- responses: []error{flood(2), nil},
- canceled: true,
- wantErr: errBroadcastAborted.Error(),
- wantCalls: 1,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var mu sync.Mutex
- callNum := 0
- var pauses []time.Duration
- swapBroadcastSender(t, func(int64, broadcastDraft) error {
- mu.Lock()
- defer mu.Unlock()
- callNum++
- if callNum > len(tt.responses) {
- return nil
- }
- return tt.responses[callNum-1]
- }, func(d time.Duration) {
- mu.Lock()
- defer mu.Unlock()
- pauses = append(pauses, d)
- })
- canceled := func() bool { return tt.canceled }
- err := broadcastDeliverOne(9, broadcastDraft{FromChatID: 100, MessageIDs: []int{7}}, canceled)
- mu.Lock()
- defer mu.Unlock()
- if tt.wantErr != "" {
- if err == nil || err.Error() != tt.wantErr {
- t.Errorf("broadcastDeliverOne() error = %v, want %q", err, tt.wantErr)
- }
- } else if err != nil {
- t.Errorf("broadcastDeliverOne() error = %v, want nil", err)
- }
- if callNum != tt.wantCalls {
- t.Errorf("sender calls = %d, want %d", callNum, tt.wantCalls)
- }
- if len(pauses) != len(tt.wantPauses) {
- t.Fatalf("pauses = %v, want %v", pauses, tt.wantPauses)
- }
- for i, p := range tt.wantPauses {
- if pauses[i] != p {
- t.Errorf("pauses[%d] = %v, want %v", i, pauses[i], p)
- }
- }
- })
- }
- }
- func TestBroadcastRegisterRunnerSingleSlot(t *testing.T) {
- first := broadcastRegisterRunner(1)
- if first == nil {
- t.Fatal("broadcastRegisterRunner() = nil for an idle bot")
- }
- t.Cleanup(func() { broadcastUnregisterRunner(first) })
- if second := broadcastRegisterRunner(2); second != nil {
- t.Fatalf("broadcastRegisterRunner() = %v while a broadcast is running, want nil", second)
- }
- broadcastUnregisterRunner(first)
- if broadcastCurrentRunner() != nil {
- t.Fatalf("slot still registered after unregister")
- }
- }
- func TestStartBroadcastRefusesWhileRunning(t *testing.T) {
- broadcastLocalizer(t)
- const chatID = int64(9102)
- resetBroadcastState(t)
- runner := broadcastRegisterRunner(chatID)
- t.Cleanup(func() {
- broadcastUnregisterRunner(runner)
- })
- admin := chatUser{chatID: chatID, userID: chatID}
- (&Tgbot{}).startBroadcast(admin)
- if state, ok := userStateMgr.get(admin); ok {
- t.Fatalf("state = %q while a broadcast is running, want none", state)
- }
- }
- func TestBroadcastCommandRequiresAdmin(t *testing.T) {
- const chatID = int64(9101)
- resetBroadcastState(t)
- message := &telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: chatID}, Text: "/broadcast"}
- (&Tgbot{}).answerCommand(message, chatID, false)
- if state, ok := userStateMgr.get(messageActor(*message)); ok {
- t.Fatalf("non-admin /broadcast set state %q", state)
- }
- if _, _, ok := broadcastPendingDraft(messageActor(*message)); ok {
- t.Fatalf("non-admin /broadcast produced a draft")
- }
- if broadcastCurrentRunner() != nil {
- t.Fatalf("non-admin /broadcast started a runner")
- }
- }
- func TestBroadcastStartCommandSetsState(t *testing.T) {
- broadcastLocalizer(t)
- const chatID = int64(9103)
- resetBroadcastState(t)
- defer func() {
- userStateMgr.reset()
- broadcastResetAll()
- }()
- (&Tgbot{}).answerCommand(&telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: chatID}, Text: "/broadcast"}, chatID, true)
- state, ok := userStateMgr.get(chatUser{chatID: chatID, userID: chatID})
- if !ok || state != broadcastAwaitingText {
- t.Fatalf("state = %q (ok=%v), want %q", state, ok, broadcastAwaitingText)
- }
- }
- func TestDeliverBroadcastCopy(t *testing.T) {
- tests := []struct {
- name string
- draft broadcastDraft
- wantMethods map[string]int
- check func(t *testing.T, bodies func(string) []map[string]any)
- }{
- {
- name: "a single message rides copyMessage",
- draft: broadcastDraft{FromChatID: 55, MessageIDs: []int{7}},
- wantMethods: map[string]int{"copyMessage": 1},
- check: func(t *testing.T, bodies func(string) []map[string]any) {
- body := bodies("copyMessage")[0]
- if fmt.Sprint(body["from_chat_id"]) != "55" || fmt.Sprint(body["message_id"]) != "7" {
- t.Errorf("copy body = %v, want from 55 message 7", body)
- }
- },
- },
- {
- name: "an album rides one copyMessages call",
- draft: broadcastDraft{FromChatID: 55, MessageIDs: []int{1, 2, 3}},
- wantMethods: map[string]int{"copyMessages": 1, "copyMessage": 0},
- check: func(t *testing.T, bodies func(string) []map[string]any) {
- if fmt.Sprint(bodies("copyMessages")[0]["message_ids"]) != "[1 2 3]" {
- t.Errorf("message_ids = %v, want [1 2 3]", bodies("copyMessages")[0]["message_ids"])
- }
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // A fresh mock per case keeps the per-method counts independent.
- url, calls, bodies := newBroadcastMock(t)
- swapTestBot(t, url)
- if err := deliverBroadcastCopy(66, tt.draft); err != nil {
- t.Fatalf("deliverBroadcastCopy() error = %v", err)
- }
- for method, want := range tt.wantMethods {
- if got := calls(method); got != want {
- t.Errorf("%s calls = %d, want %d", method, got, want)
- }
- }
- if tt.check != nil {
- tt.check(t, bodies)
- }
- })
- }
- }
- // Regression: a media group used to produce one draft per photo, so three
- // photos meant three previews and only the last tapped one was delivered.
- func TestHandleBroadcastInputMediaGroup(t *testing.T) {
- broadcastLocalizer(t)
- url, calls, bodies := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- swapAlbumDebounce(t, 20*time.Millisecond)
- tb := newStaleButtonTgbot(t)
- setBroadcastAdmins(t, []int64{5000})
- const chatID = int64(9109)
- resetBroadcastState(t)
- createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 601))
- // Updates of one album arrive out of order and copyMessages demands
- // strictly increasing ids, so the draft must sort them.
- for _, id := range []int{103, 101, 102} {
- composeBroadcast(tb, telego.Message{
- Chat: telego.Chat{ID: chatID},
- From: &telego.User{ID: 5000},
- MessageID: id,
- MediaGroupID: "grp9",
- Photo: []telego.PhotoSize{{FileID: "unused"}},
- })
- }
- ids, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
- if !slices.Equal(ids, []int{101, 102, 103}) {
- t.Fatalf("album draft ids = %v, want [101 102 103]", ids)
- }
- if token == "" {
- t.Fatalf("album draft has no confirmation token")
- }
- if state, ok := userStateMgr.get(chatUser{chatID: chatID, userID: 5000}); ok {
- t.Errorf("state = %q after the album was accepted, want cleared", state)
- }
- if got := calls("copyMessages"); got != 1 {
- t.Errorf("copyMessages calls = %d, want 1 self-copy of the whole album", got)
- }
- // copyMessages rejects ids that are not strictly increasing.
- if got := fmt.Sprint(bodies("copyMessages")[0]["message_ids"]); got != "[101 102 103]" {
- t.Errorf("self-copy message_ids = %v, want [101 102 103]", got)
- }
- deadline := time.Now().Add(2 * time.Second)
- for time.Now().Before(deadline) && calls("sendMessage") == 0 {
- time.Sleep(2 * time.Millisecond)
- }
- if calls("sendMessage") != 1 {
- t.Errorf("sendMessage calls = %d, want 1 confirmation card", calls("sendMessage"))
- }
- }
- func TestHandleBroadcastInputSingleMessage(t *testing.T) {
- broadcastLocalizer(t)
- url, calls, bodies := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- tb := newStaleButtonTgbot(t)
- setBroadcastAdmins(t, []int64{5000})
- const chatID = int64(9104)
- resetBroadcastState(t)
- createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 602))
- composeBroadcast(tb, telego.Message{
- Chat: telego.Chat{ID: chatID},
- From: &telego.User{ID: 5000},
- MessageID: 42,
- Text: "hello all",
- })
- ids, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
- if !slices.Equal(ids, []int{42}) {
- t.Fatalf("draft ids = %v, want a reference to message 42", ids)
- }
- if got := calls("copyMessage"); got != 1 {
- t.Errorf("copyMessage calls = %d, want 1 self-copy preview", got)
- }
- card := bodies("sendMessage")[0]
- confirmData := card["reply_markup"].(map[string]any)["inline_keyboard"].([]any)[0].([]any)[0].(map[string]any)["callback_data"]
- if confirmData != "broadcast_confirm "+token {
- t.Errorf("card button = %v, want a confirm bound to the pending token %q", confirmData, token)
- }
- }
- // Regression: tapping Send on a superseded preview card delivered whatever
- // draft happened to be pending instead of the card's own composition.
- func TestBroadcastConfirmStaleTokenRejected(t *testing.T) {
- broadcastLocalizer(t)
- url, calls, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- tb := newStaleButtonTgbot(t)
- setBroadcastAdmins(t, []int64{5000})
- const chatID = int64(9112)
- resetBroadcastState(t)
- createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 603))
- composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 11, Text: "first"})
- _, staleToken := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
- // A second composition replaces the first, so the first card goes stale.
- composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 12, Text: "second"})
- ids, liveToken := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
- if !slices.Equal(ids, []int{12}) {
- t.Fatalf("draft ids = %v, want only the second message", ids)
- }
- previewCopies := calls("copyMessage")
- tb.answerCallback(&telego.CallbackQuery{
- ID: "stale",
- From: telego.User{ID: 5000},
- Data: "broadcast_confirm " + staleToken,
- Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
- }, true)
- if calls("copyMessage") != previewCopies {
- t.Fatalf("a stale token started deliveries")
- }
- if broadcastCurrentRunner() != nil {
- t.Fatalf("a stale token started a runner")
- }
- tb.answerCallback(&telego.CallbackQuery{
- ID: "live",
- From: telego.User{ID: 5000},
- Data: "broadcast_confirm " + liveToken,
- Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 6},
- }, true)
- waitBroadcastFinished(t)
- if calls("copyMessage") != previewCopies+1 {
- t.Errorf("copyMessage calls = %d, want %d (the live draft delivered once)", calls("copyMessage"), previewCopies+1)
- }
- }
- // Regression: composition stayed keyed by chat after the state moved to the
- // admin, so a second admin's draft in a group dropped the first admin's.
- func TestBroadcastComposesArePerAdmin(t *testing.T) {
- broadcastLocalizer(t)
- url, _, bodies := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- tb := newStaleButtonTgbot(t)
- const groupChat, adminA, adminB = int64(-1009113), int64(5000), int64(5001)
- setBroadcastAdmins(t, []int64{adminA, adminB})
- resetBroadcastState(t)
- createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 604))
- composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: groupChat}, From: &telego.User{ID: adminA}, MessageID: 21, Text: "from A"})
- composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: groupChat}, From: &telego.User{ID: adminB}, MessageID: 22, Text: "from B"})
- cards := bodies("sendMessage")
- if len(cards) != 2 {
- t.Fatalf("sendMessage calls = %d, want one preview card per admin", len(cards))
- }
- // Each admin confirms their own card, A first; each run must deliver its own draft.
- for i, admin := range []int64{adminA, adminB} {
- confirm := cards[i]["reply_markup"].(map[string]any)["inline_keyboard"].([]any)[0].([]any)[0].(map[string]any)["callback_data"].(string)
- tb.answerCallback(&telego.CallbackQuery{
- ID: "q",
- From: telego.User{ID: admin},
- Data: confirm,
- Message: &telego.Message{Chat: telego.Chat{ID: groupChat}, MessageID: 30 + i},
- }, true)
- waitBroadcastFinished(t)
- }
- var delivered []string
- for _, body := range bodies("copyMessage") {
- if fmt.Sprint(body["chat_id"]) == "604" {
- delivered = append(delivered, fmt.Sprint(body["message_id"]))
- }
- }
- if !slices.Equal(delivered, []string{"21", "22"}) {
- t.Errorf("messages delivered to the client = %v, want [21 22]: each admin's own draft", delivered)
- }
- }
- func waitBroadcastFinished(t *testing.T) {
- t.Helper()
- deadline := time.Now().Add(2 * time.Second)
- for time.Now().Before(deadline) {
- if broadcastCurrentRunner() == nil {
- return
- }
- time.Sleep(2 * time.Millisecond)
- }
- t.Fatal("broadcast did not finish in time")
- }
- func TestConfirmBroadcastEndToEnd(t *testing.T) {
- broadcastLocalizer(t)
- url, calls, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- setBroadcastRunning(t, true)
- tb := newStaleButtonTgbot(t)
- setBroadcastAdmins(t, []int64{5000})
- const chatID = int64(9105)
- resetBroadcastState(t)
- createBroadcastInbound(t, "in-1", broadcastClientsJSON(t, 501, 502))
- composeBroadcast(tb, telego.Message{Chat: telego.Chat{ID: chatID}, From: &telego.User{ID: 5000}, MessageID: 9, Text: "hi"})
- _, token := waitBroadcastPending(t, chatUser{chatID: chatID, userID: 5000})
- tb.answerCallback(&telego.CallbackQuery{
- ID: "q1",
- From: telego.User{ID: 5000},
- Data: "broadcast_confirm " + token,
- Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
- }, true)
- waitBroadcastFinished(t)
- // One preview self-copy plus two deliveries; the card is edited into the
- // progress card and then into the summary, so nothing is sent twice.
- if got := calls("copyMessage"); got != 3 {
- t.Errorf("copyMessage calls = %d, want 3 (preview + 2 deliveries)", got)
- }
- if got := calls("sendMessage"); got != 1 {
- t.Errorf("sendMessage calls = %d, want 1 confirmation card", got)
- }
- if got := calls("editMessageText"); got != 2 {
- t.Errorf("editMessageText calls = %d, want 2 (progress + summary)", got)
- }
- if got := calls("answerCallbackQuery"); got != 1 {
- t.Errorf("answerCallbackQuery calls = %d, want 1", got)
- }
- }
- func TestConfirmBroadcastWithoutDraftAnswersError(t *testing.T) {
- url, calls, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- tb := newStaleButtonTgbot(t)
- const chatID = int64(9106)
- resetBroadcastState(t)
- tb.answerCallback(&telego.CallbackQuery{
- ID: "q1",
- From: telego.User{ID: chatID},
- Data: "broadcast_confirm sometoken",
- Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
- }, true)
- if calls("answerCallbackQuery") != 1 {
- t.Errorf("answerCallbackQuery calls = %d, want 1 error answer", calls("answerCallbackQuery"))
- }
- if broadcastCurrentRunner() != nil {
- t.Errorf("a confirm without a draft must not start a broadcast")
- }
- }
- func TestBroadcastCancelCallbackClearsDraft(t *testing.T) {
- url, calls, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- tb := newStaleButtonTgbot(t)
- const chatID = int64(9107)
- resetBroadcastState(t)
- admin := chatUser{chatID: chatID, userID: chatID}
- userStateMgr.set(admin, broadcastAwaitingText)
- broadcastComposes[admin] = &broadcastCompose{messageIDs: []int{9}, token: "tok9"}
- tb.answerCallback(&telego.CallbackQuery{
- ID: "q1",
- From: telego.User{ID: chatID},
- Data: "broadcast_cancel",
- Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 9},
- }, true)
- if _, ok := userStateMgr.get(admin); ok {
- t.Errorf("state survived the cancel tap")
- }
- if _, _, ok := broadcastPendingDraft(admin); ok {
- t.Errorf("draft survived the cancel tap")
- }
- if got := calls("deleteMessage"); got != 1 {
- t.Errorf("deleteMessage calls = %d, want 1", got)
- }
- if got := calls("answerCallbackQuery"); got != 1 {
- t.Errorf("answerCallbackQuery calls = %d, want 1", got)
- }
- }
- func TestBroadcastCallbacksDeniedToNonAdmin(t *testing.T) {
- url, calls, _ := newBroadcastMock(t)
- swapTestBot(t, url)
- tb := newStaleButtonTgbot(t)
- const chatID = int64(9108)
- resetBroadcastState(t)
- for _, data := range []string{"broadcast_confirm sometoken", "broadcast_cancel"} {
- tb.answerCallback(&telego.CallbackQuery{
- ID: "q1",
- From: telego.User{ID: 999999},
- Data: data,
- Message: &telego.Message{Chat: telego.Chat{ID: chatID}, MessageID: 5},
- }, false)
- if calls("answerCallbackQuery") != 0 {
- t.Fatalf("%s answered a non-admin callback", data)
- }
- if broadcastCurrentRunner() != nil {
- t.Fatalf("%s started a broadcast for a non-admin", data)
- }
- }
- }
|