package bot import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-telegram/bot/models" "go.uber.org/zap" ) const testChatID = 555 // chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a // scripted getChatMember status, and records restrictChatMember calls. type chatAPI struct { memberStatus string // the status getChatMember reports (default "left") restricts []restrictCall } type restrictCall struct { userID string canSend bool // can_send_messages in the applied permissions } func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/getMe"): io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"tb"}}`) case strings.HasSuffix(r.URL.Path, "/getChatMember"): status := a.memberStatus if status == "" { status = "left" } io.WriteString(w, `{"ok":true,"result":{"status":"`+status+`","user":{"id":`+r.FormValue("user_id")+`,"is_bot":false,"first_name":"u"}}}`) case strings.HasSuffix(r.URL.Path, "/restrictChatMember"): var perms struct { CanSendMessages bool `json:"can_send_messages"` } _ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms) a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages}) io.WriteString(w, `{"ok":true,"result":true}`) default: io.WriteString(w, `{"ok":true,"result":true}`) } } // newChatBot builds a gating bot (ChatID set) over the fake API, without an // eligibility resolver — each test wires the one it needs. func newChatBot(t *testing.T, api *chatAPI) *Bot { t.Helper() srv := httptest.NewServer(api) t.Cleanup(srv.Close) b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/", ChatID: testChatID}, zap.NewNop()) if err != nil { t.Fatalf("new bot: %v", err) } return b } // chatMemberUpdate builds a status transition oldType -> member for userID in chatID. func chatMemberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.ChatMemberUpdated { return &models.ChatMemberUpdated{ Chat: models.Chat{ID: chatID}, OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}}, NewChatMember: models.ChatMember{Type: models.ChatMemberTypeMember, Member: &models.ChatMemberMember{User: &models.User{ID: userID}}}, } } func TestHandleChatMemberGrantsEligibleJoin(t *testing.T) { api := &chatAPI{} b := newChatBot(t, api) b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil }) b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) if len(api.restricts) != 1 || api.restricts[0].userID != "777" || !api.restricts[0].canSend { t.Fatalf("restricts = %+v, want one grant (can_send=true) for 777", api.restricts) } } func TestHandleChatMemberSkipsIneligibleJoin(t *testing.T) { api := &chatAPI{} b := newChatBot(t, api) b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return false, nil }) b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) if len(api.restricts) != 0 { t.Fatalf("an ineligible joiner was restricted: %+v (want left muted, no call)", api.restricts) } } func TestHandleChatMemberFailsClosedOnResolveError(t *testing.T) { api := &chatAPI{} b := newChatBot(t, api) b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, context.DeadlineExceeded }) b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) if len(api.restricts) != 0 { t.Fatalf("a resolve error still granted write: %+v (want fail-closed)", api.restricts) } } func TestHandleChatMemberIgnoresOtherChatAndNonJoins(t *testing.T) { api := &chatAPI{} b := newChatBot(t, api) b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil }) ctx := context.Background() // A different chat is ignored. b.handleChatMember(ctx, chatMemberUpdate(999, 777, models.ChatMemberTypeLeft)) // A non-join transition (already a member) is ignored. b.handleChatMember(ctx, chatMemberUpdate(testChatID, 777, models.ChatMemberTypeMember)) if len(api.restricts) != 0 { t.Fatalf("restricts = %+v, want none for a foreign chat / non-join", api.restricts) } } func TestApplyChatGatePresentMember(t *testing.T) { for _, tc := range []struct { name string allow bool }{{"grant", true}, {"mute", false}} { t.Run(tc.name, func(t *testing.T) { api := &chatAPI{memberStatus: "member"} b := newChatBot(t, api) applied, err := b.ApplyChatGate(context.Background(), 777, tc.allow) if err != nil { t.Fatalf("apply: %v", err) } if !applied { t.Fatal("applied = false, want true for a present member") } if len(api.restricts) != 1 || api.restricts[0].canSend != tc.allow { t.Fatalf("restricts = %+v, want one with can_send=%v", api.restricts, tc.allow) } }) } } func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) { for _, status := range []string{"left", "kicked", "administrator", "creator"} { t.Run(status, func(t *testing.T) { api := &chatAPI{memberStatus: status} b := newChatBot(t, api) applied, err := b.ApplyChatGate(context.Background(), 777, false) if err != nil { t.Fatalf("apply: %v", err) } if applied { t.Errorf("applied = true for status %q, want a no-op", status) } if len(api.restricts) != 0 { t.Errorf("restricts = %+v for status %q, want none", api.restricts, status) } }) } }