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 } // memberUpdate builds an oldType -> member transition for userID in chatID. func memberUpdate(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}}}, } } // restrictedUpdate builds an oldType -> restricted transition for userID, the new // member's text-send permission set to canSend. A default-deny group reports a present // member as restricted with canSend=false. func restrictedUpdate(chatID, userID int64, oldType models.ChatMemberType, canSend bool) *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.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}, CanSendMessages: canSend}}, } } // leftUpdate builds a transition to left (a leave) for userID. func leftUpdate(chatID, userID int64) *models.ChatMemberUpdated { return &models.ChatMemberUpdated{ Chat: models.Chat{ID: chatID}, OldChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}}}, NewChatMember: models.ChatMember{Type: models.ChatMemberTypeLeft, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}}, } } // eligibleBot builds a gating bot whose resolver returns (eligible, err). func eligibleBot(t *testing.T, api *chatAPI, eligible bool, err error) *Bot { t.Helper() b := newChatBot(t, api) b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return eligible, err }) return b } func TestHandleChatMemberGrantsEligibleMemberJoin(t *testing.T) { api := &chatAPI{} b := eligibleBot(t, api, true, nil) b.handleChatMember(context.Background(), memberUpdate(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 TestHandleChatMemberGrantsRestrictedMember(t *testing.T) { // A default-deny group reports a present member as restricted with no send right — // the real-world case. Both a fresh join (left->restricted) and an already-present // restricted member (restricted->restricted) must be granted when eligible. for _, oldType := range []models.ChatMemberType{models.ChatMemberTypeLeft, models.ChatMemberTypeRestricted} { t.Run(string(oldType), func(t *testing.T) { api := &chatAPI{} b := eligibleBot(t, api, true, nil) b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, oldType, false)) if len(api.restricts) != 1 || !api.restricts[0].canSend { t.Fatalf("restricts = %+v, want one grant for a restricted member", api.restricts) } }) } } func TestHandleChatMemberSkipsAlreadyAllowed(t *testing.T) { // The bot's own grant re-fires a chat_member event (restricted, can_send=true); it // must not loop into another grant. api := &chatAPI{} b := eligibleBot(t, api, true, nil) b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true)) if len(api.restricts) != 0 { t.Fatalf("restricts = %+v, want none for an already-allowed member (no loop)", api.restricts) } } func TestHandleChatMemberSkipsIneligible(t *testing.T) { api := &chatAPI{} b := eligibleBot(t, api, false, nil) b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false)) if len(api.restricts) != 0 { t.Fatalf("an ineligible member was granted: %+v (want left muted)", api.restricts) } } func TestHandleChatMemberFailsClosedOnResolveError(t *testing.T) { api := &chatAPI{} b := eligibleBot(t, api, true, context.DeadlineExceeded) b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false)) if len(api.restricts) != 0 { t.Fatalf("a resolve error still granted write: %+v (want fail-closed)", api.restricts) } } func TestHandleChatMemberIgnoresOtherChatAndLeaves(t *testing.T) { api := &chatAPI{} b := eligibleBot(t, api, true, nil) ctx := context.Background() b.handleChatMember(ctx, restrictedUpdate(999, 777, models.ChatMemberTypeLeft, false)) // foreign chat b.handleChatMember(ctx, leftUpdate(testChatID, 777)) // a leave if len(api.restricts) != 0 { t.Fatalf("restricts = %+v, want none for a foreign chat / a leave", 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) } }) } }