package bot import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "github.com/go-telegram/bot/models" "go.uber.org/zap" "scrabble/platform/telegram/internal/support" ) const supportChatID = -1001000000000 // copyRec records one copyMessage call. type copyRec struct { chatID, fromChatID, threadID, messageID string } // supportAPI is a fake Bot API for the support-relay handlers: it answers the // methods they call with incrementing ids and records the requests for assertions. type supportAPI struct { mu sync.Mutex adminIDs []int64 nextThread int nextMsg int topicsOpened int copies []copyRec headerThread string // message_thread_id of the last header sendMessage deleted [][]int editedMsgIDs []string answers []string } func newSupportAPI(adminIDs ...int64) *supportAPI { return &supportAPI{adminIDs: adminIDs, nextThread: 1000, nextMsg: 5000} } func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() path := r.URL.Path switch { case strings.HasSuffix(path, "/getMe"): io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"bot","username":"b"}}`) case strings.HasSuffix(path, "/createForumTopic"): s.topicsOpened++ tid := s.nextThread s.nextThread++ fmt.Fprintf(w, `{"ok":true,"result":{"message_thread_id":%d,"name":%q}}`, tid, r.FormValue("name")) case strings.HasSuffix(path, "/sendMessage"): s.headerThread = r.FormValue("message_thread_id") mid := s.nextMsg s.nextMsg++ fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid) case strings.HasSuffix(path, "/copyMessage"): s.copies = append(s.copies, copyRec{ chatID: r.FormValue("chat_id"), fromChatID: r.FormValue("from_chat_id"), threadID: r.FormValue("message_thread_id"), messageID: r.FormValue("message_id"), }) mid := s.nextMsg s.nextMsg++ fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid) case strings.HasSuffix(path, "/deleteMessages"): var ids []int _ = json.Unmarshal([]byte(r.FormValue("message_ids")), &ids) s.deleted = append(s.deleted, ids) io.WriteString(w, `{"ok":true,"result":true}`) case strings.HasSuffix(path, "/editMessageReplyMarkup"): s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id")) io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) case strings.HasSuffix(path, "/answerCallbackQuery"): s.answers = append(s.answers, r.FormValue("text")) io.WriteString(w, `{"ok":true,"result":true}`) case strings.HasSuffix(path, "/getChatAdministrators"): var b strings.Builder b.WriteString(`{"ok":true,"result":[`) for i, id := range s.adminIDs { if i > 0 { b.WriteString(",") } fmt.Fprintf(&b, `{"status":"administrator","user":{"id":%d,"is_bot":false}}`, id) } b.WriteString(`]}`) io.WriteString(w, b.String()) default: io.WriteString(w, `{"ok":true,"result":true}`) } } func (s *supportAPI) copyCount() int { s.mu.Lock() defer s.mu.Unlock() return len(s.copies) } // newSupportBot builds a support-enabled bot against the fake API and returns it with // its backing store. func newSupportBot(t *testing.T, api http.Handler) (*Bot, *support.Store) { t.Helper() srv := httptest.NewServer(api) t.Cleanup(srv.Close) st := support.New(filepath.Join(t.TempDir(), "support.json")) b, err := New(Config{ Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/telegram/", SupportChatID: supportChatID, SupportStore: st, }, zap.NewNop()) if err != nil { t.Fatalf("new support bot: %v", err) } return b, st } // userMsg builds a private direct message from the given user. func userMsg(userID int64, text string) *models.Message { return &models.Message{ ID: int(userID)*10 + 1, Chat: models.Chat{ID: userID, Type: models.ChatTypePrivate}, From: &models.User{ID: userID, FirstName: "Ann", Username: "annlee", LanguageCode: "ru"}, Text: text, } } func TestSupportFirstContactOpensTopicAndRelays(t *testing.T) { api := newSupportAPI() b, st := newSupportBot(t, api) b.handleSupportUserMessage(context.Background(), userMsg(7, "hello")) if api.topicsOpened != 1 { t.Fatalf("topics opened = %d, want 1", api.topicsOpened) } rec, ok := st.Get(7) if !ok || rec.TopicID != 1000 || rec.HeaderMsgID != 5000 { t.Fatalf("store = %+v ok=%v, want topic 1000 / header 5000", rec, ok) } if api.headerThread != "1000" { t.Errorf("header posted to thread %q, want 1000", api.headerThread) } if len(api.copies) != 1 { t.Fatalf("copies = %d, want 1", len(api.copies)) } c := api.copies[0] if c.threadID != "1000" || c.fromChatID != "7" || c.chatID != fmt.Sprint(supportChatID) { t.Errorf("copy = %+v, want thread 1000 from 7 into the support chat", c) } if len(rec.RelayedMsgIDs) != 1 { t.Errorf("relayed ids = %v, want 1 tracked", rec.RelayedMsgIDs) } } func TestSupportSubsequentReusesTopic(t *testing.T) { api := newSupportAPI() b, st := newSupportBot(t, api) b.handleSupportUserMessage(context.Background(), userMsg(7, "first")) b.handleSupportUserMessage(context.Background(), userMsg(7, "second")) if api.topicsOpened != 1 { t.Errorf("topics opened = %d, want 1 (topic reused)", api.topicsOpened) } if len(api.copies) != 2 { t.Errorf("copies = %d, want 2", len(api.copies)) } rec, _ := st.Get(7) if len(rec.RelayedMsgIDs) != 2 { t.Errorf("relayed ids = %v, want 2", rec.RelayedMsgIDs) } } func TestSupportBlockedUserDropped(t *testing.T) { api := newSupportAPI() b, st := newSupportBot(t, api) if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { t.Fatalf("seed topic: %v", err) } if err := st.SetBlocked(7, true); err != nil { t.Fatalf("block: %v", err) } b.handleSupportUserMessage(context.Background(), userMsg(7, "hello?")) if api.copyCount() != 0 { t.Errorf("a blocked user's message was relayed (%d copies)", api.copyCount()) } if api.topicsOpened != 0 { t.Errorf("a blocked user opened a topic (%d)", api.topicsOpened) } } // supportGroupMsg builds an operator message inside a topic of the support chat. func supportGroupMsg(fromID int64, threadID int, isBot bool) *models.Message { return &models.Message{ ID: 700 + threadID, Chat: models.Chat{ID: supportChatID, Type: models.ChatTypeSupergroup}, From: &models.User{ID: fromID, IsBot: isBot}, MessageThreadID: threadID, Text: "operator reply", } } func TestSupportOperatorReplyRelayed(t *testing.T) { api := newSupportAPI(50) // user 50 is an admin b, st := newSupportBot(t, api) if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { t.Fatalf("seed topic: %v", err) } b.handleSupportGroupMessage(context.Background(), supportGroupMsg(50, 1000, false)) if len(api.copies) != 1 { t.Fatalf("copies = %d, want 1 (reply relayed to user)", len(api.copies)) } if api.copies[0].chatID != "7" || api.copies[0].fromChatID != fmt.Sprint(supportChatID) { t.Errorf("copy = %+v, want from the support chat to user 7", api.copies[0]) } rec, _ := st.Get(7) if len(rec.RelayedMsgIDs) != 1 { t.Errorf("operator message not tracked for clear: %v", rec.RelayedMsgIDs) } } func TestSupportGroupMessageIgnored(t *testing.T) { cases := []struct { name string msg *models.Message admins []int64 }{ {"bot's own copy (loop guard)", supportGroupMsg(1, 1000, true), []int64{50}}, {"non-admin sender", supportGroupMsg(999, 1000, false), []int64{50}}, {"outside any topic", supportGroupMsg(50, 0, false), []int64{50}}, {"unknown topic", supportGroupMsg(50, 4242, false), []int64{50}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { api := newSupportAPI(tc.admins...) b, st := newSupportBot(t, api) if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { t.Fatalf("seed topic: %v", err) } b.handleSupportGroupMessage(context.Background(), tc.msg) if api.copyCount() != 0 { t.Errorf("message relayed (%d copies); it should be ignored", api.copyCount()) } }) } } // supportCallback builds a callback-query update for the given data and presser. func supportCallback(data string, fromID int64) *models.Update { return &models.Update{CallbackQuery: &models.CallbackQuery{ ID: "cb1", From: models.User{ID: fromID}, Data: data, }} } func TestSupportCallbackBlockToggle(t *testing.T) { api := newSupportAPI(50) b, st := newSupportBot(t, api) if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { t.Fatalf("seed topic: %v", err) } b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 50)) if !st.Blocked(7) { t.Error("user not blocked after sup:block") } if len(api.editedMsgIDs) != 1 || api.editedMsgIDs[0] != "5000" { t.Errorf("edited markup msg ids = %v, want [5000]", api.editedMsgIDs) } b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:unblock:7", 50)) if st.Blocked(7) { t.Error("user still blocked after sup:unblock") } if len(api.answers) != 2 || api.answers[0] != "Заблокирован" || api.answers[1] != "Разблокирован" { t.Errorf("answers = %v, want [Заблокирован Разблокирован]", api.answers) } } func TestSupportCallbackNonAdminDenied(t *testing.T) { api := newSupportAPI(50) b, st := newSupportBot(t, api) if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { t.Fatalf("seed topic: %v", err) } b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 999)) if st.Blocked(7) { t.Error("a non-admin managed to block the user") } if len(api.answers) != 1 || api.answers[0] != "Недостаточно прав" { t.Errorf("answers = %v, want a permission denial", api.answers) } } func TestSupportCallbackClearDeletesTracked(t *testing.T) { api := newSupportAPI(50) b, st := newSupportBot(t, api) if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil { t.Fatalf("seed topic: %v", err) } for _, id := range []int{501, 502, 503} { if err := st.AppendMsg(7, id); err != nil { t.Fatalf("append: %v", err) } } b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:clear:7", 50)) if len(api.deleted) != 1 || len(api.deleted[0]) != 3 { t.Fatalf("deleted batches = %v, want one batch of 3", api.deleted) } rec, _ := st.Get(7) if len(rec.RelayedMsgIDs) != 0 { t.Errorf("relayed ids after clear = %v, want empty", rec.RelayedMsgIDs) } if rec.HeaderMsgID != 5000 { t.Errorf("clear disturbed the header (%d)", rec.HeaderMsgID) } } func TestParseSupportCallback(t *testing.T) { cases := []struct { data string wantAction string wantUID int64 wantOK bool }{ {"sup:block:7", "block", 7, true}, {"sup:clear:-100", "clear", -100, true}, {"sup:unblock:42", "unblock", 42, true}, {"block:7", "", 0, false}, {"sup:block", "", 0, false}, {"sup:block:notanumber", "", 0, false}, } for _, tc := range cases { t.Run(tc.data, func(t *testing.T) { action, uid, ok := parseSupportCallback(tc.data) if action != tc.wantAction || uid != tc.wantUID || ok != tc.wantOK { t.Errorf("parseSupportCallback(%q) = %q,%d,%v; want %q,%d,%v", tc.data, action, uid, ok, tc.wantAction, tc.wantUID, tc.wantOK) } }) } } func TestSupportCardTextEscapes(t *testing.T) { u := &models.User{ID: 7, FirstName: "Ann", Username: "ann", LanguageCode: "ru", IsPremium: true} card := supportCardText(u) if strings.Contains(card, "Ann") { t.Error("user-supplied first name was not HTML-escaped in the card") } if !strings.Contains(card, "<b>Ann</b>") { t.Errorf("card = %q, want the escaped name", card) } if !strings.Contains(card, "tg://user?id=7") { t.Error("card missing the profile deep link") } if !strings.Contains(card, "Premium: да") { t.Error("card missing the premium flag") } } func TestSupportTopicName(t *testing.T) { if got := supportTopicName(&models.User{ID: 7, FirstName: "Ann", LastName: "Lee", Username: "annlee"}); got != "Ann Lee @annlee" { t.Errorf("topic name = %q, want %q", got, "Ann Lee @annlee") } if got := supportTopicName(&models.User{ID: 7}); got != "user 7" { t.Errorf("nameless topic = %q, want %q", got, "user 7") } long := strings.Repeat("x", 200) if got := supportTopicName(&models.User{ID: 7, FirstName: long}); len([]rune(got)) != supportTopicNameMax { t.Errorf("topic name length = %d, want %d (trimmed)", len([]rune(got)), supportTopicNameMax) } } func TestSupportDisabledFallsThrough(t *testing.T) { // With no SupportChatID the relay is off and supportEnabled is false. srv := httptest.NewServer(&supportAPI{nextThread: 1000, nextMsg: 5000}) t.Cleanup(srv.Close) b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/"}, zap.NewNop()) if err != nil { t.Fatalf("new bot: %v", err) } if b.supportEnabled() { t.Error("supportEnabled() = true without a SupportChatID") } }