package rpc import ( "context" "testing" "time" "github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" "go.uber.org/zap/zaptest" appchannels "telesrv/internal/app/channels" appcommunities "telesrv/internal/app/communities" appdialogs "telesrv/internal/app/dialogs" appstories "telesrv/internal/app/stories" appusers "telesrv/internal/app/users" "telesrv/internal/domain" "telesrv/internal/store/memory" ) func communityRPCChannel(t *testing.T, service *appchannels.Service, creator domain.User, title string, members ...domain.User) domain.Channel { t.Helper() memberIDs := make([]int64, 0, len(members)) for _, member := range members { memberIDs = append(memberIDs, member.ID) } created, err := service.CreateChannel(context.Background(), creator.ID, domain.CreateChannelRequest{ CreatorUserID: creator.ID, Title: title, Megagroup: true, MemberUserIDs: memberIDs, Date: 1_800_100_000, }) if err != nil { t.Fatalf("create channel %q: %v", title, err) } return created.Channel } func TestCommunityDialogsSharePinnedLimit(t *testing.T) { ctx := WithLayer(context.Background(), communitiesLayer) users := memory.NewUserStore() owner, err := users.Create(ctx, domain.User{AccessHash: 711, Phone: "15552000011", FirstName: "Pin Owner"}) if err != nil { t.Fatal(err) } channels := memory.NewChannelStore() channelService := appchannels.NewService(channels) communityService := appcommunities.NewService(memory.NewCommunityStore(users, channels, nil, nil)) r := New(Config{}, Deps{ Users: appusers.NewService(users), Channels: channelService, Communities: communityService, Dialogs: appdialogs.NewService(memory.NewDialogStore(), channels), }, zaptest.NewLogger(t), clock.System) inputs := make([]*tg.InputChannel, 0, domain.MaxPinnedDialogsMainFolder) for i := 0; i < domain.MaxPinnedDialogsMainFolder; i++ { channel := communityRPCChannel(t, channelService, owner, "Pinned Community Channel") view, err := communityService.Create(ctx, owner.ID, domain.CreateCommunityRequest{ Title: "Pinned Community", InitialPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Visibility: domain.CommunityPeerVisible, Date: 1_800_110_000 + i, }) if err != nil { t.Fatalf("create community %d: %v", i, err) } if _, _, err := communityService.SetCollapsed(ctx, owner.ID, view.Community.ID, true); err != nil { t.Fatalf("collapse community %d: %v", i, err) } inputs = append(inputs, &tg.InputChannel{ChannelID: view.Community.ID, AccessHash: view.Community.AccessHash}) } for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ { input := inputs[i] toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: input}} toggle.SetPinned(true) ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), toggle) if err != nil || !ok { t.Fatalf("pin community %d = %v, %v", i, ok, err) } } joined, err := communityService.ListJoined(ctx, owner.ID) if err != nil || len(joined) != domain.MaxPinnedDialogsMainFolder { t.Fatalf("joined Communities before ordinary pin = %+v, %v", joined, err) } for i := 0; i < domain.MaxPinnedDialogsMainFolder-1; i++ { if !joined[i].State.Pinned || !joined[i].State.Collapsed { t.Fatalf("joined Community %d state before ordinary pin = %+v", i, joined[i].State) } } ordinary := communityRPCChannel(t, channelService, owner, "Ordinary Pinned Channel") ordinaryToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ ChannelID: ordinary.ID, AccessHash: ordinary.AccessHash, }}} ordinaryToggle.SetPinned(true) if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), ordinaryToggle); err != nil || !ok { t.Fatalf("pin ordinary dialog at shared limit = %v, %v", ok, err) } pinned, err := r.pinnedDialogsList(ctx, owner.ID, domain.DialogMainFolderID) if err != nil { t.Fatal(err) } order := combinedPinnedDialogPeers(pinned) if len(order) != domain.MaxPinnedDialogsMainFolder || order[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: ordinary.ID}) { t.Fatalf("combined pinned order = %+v (dialogs=%+v communities=%+v count=%d), want ordinary dialog promoted above Communities", order, pinned.Dialogs, pinned.Communities, pinned.Count) } legacyPinned, err := r.pinnedDialogsList(WithLayer(ctx, 227), owner.ID, domain.DialogMainFolderID) if err != nil { t.Fatal(err) } if len(legacyPinned.Communities) != 0 || len(legacyPinned.Dialogs) != 1 || legacyPinned.Count != 1 { t.Fatalf("Layer 227 pinned dialogs = %+v, want only the ordinary pinned dialog", legacyPinned) } legacyOrdinary := communityRPCChannel(t, channelService, owner, "Legacy Ordinary Pinned Channel") legacyToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ ChannelID: legacyOrdinary.ID, AccessHash: legacyOrdinary.AccessHash, }}} legacyToggle.SetPinned(true) if ok, err := r.onMessagesToggleDialogPin(WithLayer(WithUserID(ctx, owner.ID), 227), legacyToggle); err == nil || ok || !tgerr.Is(err, "PINNED_DIALOGS_TOO_MUCH") { t.Fatalf("Layer 227 pin beyond shared account limit = %v, %v", ok, err) } overLimit := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerCommunity{Community: inputs[len(inputs)-1]}} overLimit.SetPinned(true) if ok, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), overLimit); err == nil || ok || !tgerr.Is(err, "PINNED_DIALOGS_TOO_MUCH") { t.Fatalf("pin over shared limit = %v, %v", ok, err) } if _, err := r.onMessagesReorderPinnedDialogs(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedDialogsRequest{ FolderID: domain.DialogArchiveFolderID, Order: []tg.InputDialogPeerClass{&tg.InputDialogPeerCommunity{Community: inputs[0]}}, }); err == nil || !tgerr.Is(err, "FOLDER_ID_INVALID") { t.Fatalf("archive Community reorder error = %v", err) } } func TestCommunitiesRPCLayer228Lifecycle(t *testing.T) { ctx := WithLayer(context.Background(), communitiesLayer) userStore := memory.NewUserStore() owner, _ := userStore.Create(ctx, domain.User{AccessHash: 701, Phone: "15552000001", FirstName: "Owner"}) member, _ := userStore.Create(ctx, domain.User{AccessHash: 702, Phone: "15552000002", FirstName: "Member"}) channelStore := memory.NewChannelStore() channelService := appchannels.NewService(channelStore) initial := communityRPCChannel(t, channelService, owner, "Initial", member) communityService := appcommunities.NewService(memory.NewCommunityStore(userStore, channelStore, nil, nil)) storyStore := memory.NewStoryStore() if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, ID: 1, Date: 1_800_100_001, ExpireDate: 1_900_100_001, Public: true, }}); err != nil { t.Fatalf("seed owner story: %v", err) } r := New(Config{}, Deps{ Users: appusers.NewService(userStore), Channels: channelService, Communities: communityService, Stories: appstories.NewService(storyStore), }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1_800_100_100, 0)}) createdResult, err := r.onCommunitiesCreate(WithUserID(ctx, owner.ID), &tg.CommunitiesCreateRequest{ Hidden: true, Title: "Official Community", About: "Layer 228", Peer: &tg.InputPeerChannel{ChannelID: initial.ID, AccessHash: initial.AccessHash}, }) if err != nil { t.Fatalf("communities.create: %v", err) } created, ok := createdResult.(*tg.Updates) if !ok || len(created.Chats) != 1 { t.Fatalf("create result = %#v, want Updates with Community", createdResult) } community, ok := created.Chats[0].(*tg.Community) if !ok || community.Title != "Official Community" || !community.Creator { t.Fatalf("create chat = %#v", created.Chats[0]) } inputCommunity := &tg.InputChannel{ChannelID: community.ID, AccessHash: community.AccessHash} joinedResult, err := r.onCommunitiesGetJoined(WithUserID(ctx, member.ID)) if err != nil { t.Fatalf("communities.getJoinedCommunities: %v", err) } joined := joinedResult.(*tg.MessagesChats) if len(joined.Chats) != 1 || joined.Chats[0].(*tg.Community).ID != community.ID { t.Fatalf("joined communities = %+v", joined.Chats) } full, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), inputCommunity) if err != nil { t.Fatalf("channels.getFullChannel community: %v", err) } communityFull, ok := full.FullChat.(*tg.CommunityFull) if !ok || communityFull.About != "Layer 228" || len(communityFull.LinkedPeers) != 1 || len(full.Chats) != 2 { t.Fatalf("community full = %#v chats=%+v", full.FullChat, full.Chats) } collapsedResult, err := r.onCommunitiesToggleCollapsed(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleCommunityCollapsedInDialogsRequest{ Collapsed: true, Community: inputCommunity, }) if err != nil { t.Fatalf("toggle collapsed: %v", err) } collapsed := collapsedResult.(*tg.Updates) if len(collapsed.Chats) == 0 || !collapsed.Chats[0].(*tg.Community).CollapsedInDialogs { t.Fatalf("collapsed updates = %+v", collapsed.Chats) } legacyList, err := r.withCommunityDialogList(WithLayer(ctx, 227), owner.ID, domain.DialogFilter{}, domain.DialogList{Count: 7}) if err != nil || len(legacyList.Communities) != 0 || legacyList.Count != 7 { t.Fatalf("Layer 227 community dialog projection = %+v err=%v, want unchanged list", legacyList, err) } list, err := r.withCommunityDialogList(ctx, owner.ID, domain.DialogFilter{}, domain.DialogList{}) if err != nil || len(list.Communities) != 1 || list.Count != 1 { t.Fatalf("community dialog list = %+v err=%v", list, err) } dialogs := tgMessagesDialogs(owner.ID, list).(*tg.MessagesDialogs) if len(dialogs.Dialogs) != 1 { t.Fatalf("dialogs = %+v, want dialogCommunity", dialogs.Dialogs) } if dialog, ok := dialogs.Dialogs[0].(*tg.DialogCommunity); !ok || dialog.CommunityID != community.ID { t.Fatalf("dialog = %#v, want community %d", dialogs.Dialogs[0], community.ID) } ownedOne := communityRPCChannel(t, channelService, member, "Owned One") ownedTwo := communityRPCChannel(t, channelService, member, "Owned Two") requestLink := func(channel domain.Channel) { t.Helper() ok, err := r.onCommunitiesTogglePeerLink(WithUserID(ctx, member.ID), &tg.CommunitiesTogglePeerLinkRequest{ Visible: true, Community: inputCommunity, Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, }) if err == nil || ok || !tgerr.Is(err, "COMMUNITY_REQUEST_CREATED") { t.Fatalf("request link %d = %v, %v", channel.ID, ok, err) } } requestLink(ownedOne) requestLink(ownedTwo) requests, err := r.onCommunitiesGetPeerLinkRequests(WithUserID(ctx, owner.ID), &tg.CommunitiesGetPeerLinkRequestsRequest{ Community: inputCommunity, Limit: 20, }) if err != nil || requests.TotalCount != 2 || len(requests.Requests) != 2 { t.Fatalf("peer link requests = %+v err=%v", requests, err) } // The Community owner is deliberately not a member of Owned One. Approval // must use the request's validated ownership rather than ordinary channel // membership access. approved, err := r.onCommunitiesTogglePeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesTogglePeerLinkRequestApprovalRequest{ Community: inputCommunity, Peer: &tg.InputPeerChannel{ChannelID: ownedOne.ID, AccessHash: ownedOne.AccessHash}, }) if err != nil || !approved { t.Fatalf("approve peer link = %v, %v", approved, err) } approved, err = r.onCommunitiesToggleAllPeerLinkRequestApproval(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleAllPeerLinkRequestApprovalRequest{Community: inputCommunity}) if err != nil || !approved { t.Fatalf("approve all peer links = %v, %v", approved, err) } joinedChats, err := r.onCommunitiesGetParticipantJoinedChats(WithUserID(ctx, owner.ID), &tg.CommunitiesGetParticipantJoinedChatsRequest{ Community: inputCommunity, Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash}, }) if err != nil || len(joinedChats.JoinedChatIDs) != 3 || len(joinedChats.CreatorChatIDs) != 2 { t.Fatalf("participant joined chats = %+v err=%v", joinedChats, err) } participantsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ Channel: inputCommunity, Filter: &tg.ChannelParticipantsSearch{Q: "memBER"}, Limit: 20, }) if err != nil { t.Fatalf("community participants search: %v", err) } participants := participantsResult.(*tg.ChannelsChannelParticipants) if participants.Count != 1 || len(participants.Participants) != 1 { t.Fatalf("community participants = %+v", participants) } adminsResult, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{ Channel: inputCommunity, Filter: &tg.ChannelParticipantsAdmins{}, Limit: 100, }) if err != nil { t.Fatalf("ordinary member Community admins: %v", err) } admins := adminsResult.(*tg.ChannelsChannelParticipants) if admins.Count != 1 || len(admins.Participants) != 1 { t.Fatalf("ordinary member Community admins = %+v, want creator", admins) } if _, err := r.onChannelsGetParticipants(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantsRequest{ Channel: inputCommunity, Filter: &tg.ChannelParticipantsBanned{Q: ""}, Limit: 100, }); err == nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") { t.Fatalf("ordinary member Community banned list err = %v, want CHAT_ADMIN_REQUIRED", err) } recent, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{ &tg.InputPeerSelf{}, &tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash}, &tg.InputPeerSelf{}, }) if err != nil { t.Fatalf("stories.getPeerMaxIDs with Community slot: %v", err) } if len(recent) != 3 { t.Fatalf("stories.getPeerMaxIDs slots = %d, want 3", len(recent)) } for _, index := range []int{0, 2} { if maxID, ok := recent[index].GetMaxID(); !ok || maxID != 1 { t.Fatalf("stories.getPeerMaxIDs[%d] max_id = %d ok=%v, want 1 true", index, maxID, ok) } } if maxID, ok := recent[1].GetMaxID(); ok || maxID != 0 || recent[1].Live { t.Fatalf("stories.getPeerMaxIDs Community slot = %+v, want empty recentStory", recent[1]) } if _, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, owner.ID), []tg.InputPeerClass{ &tg.InputPeerChannel{ChannelID: community.ID, AccessHash: community.AccessHash + 1}, }); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") { t.Fatalf("stories.getPeerMaxIDs Community wrong hash err = %v, want CHANNEL_PRIVATE", err) } banned, err := r.onCommunitiesToggleParticipantBanned(WithUserID(ctx, owner.ID), &tg.CommunitiesToggleParticipantBannedRequest{ Community: inputCommunity, Participant: &tg.InputPeerUser{UserID: member.ID, AccessHash: member.AccessHash}, }) if err != nil || !banned { t.Fatalf("toggle participant banned = %v, %v", banned, err) } joinedResult, err = r.onCommunitiesGetJoined(WithUserID(ctx, member.ID)) if err != nil || len(joinedResult.(*tg.MessagesChats).Chats) != 0 { t.Fatalf("banned member joined communities = %#v err=%v", joinedResult, err) } }