package memory import ( "bytes" "context" "errors" "reflect" "testing" "telesrv/internal/domain" ) func TestChannelCreateCreatesPermanentInviteAndHasLink(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "private group with main link", Megagroup: true, Date: 1_700_000_090, }) if err != nil { t.Fatalf("create channel: %v", err) } if !created.Channel.HasLink { t.Fatalf("created channel HasLink = false, want true") } view, err := store.GetChannel(ctx, 1, created.Channel.ID) if err != nil { t.Fatalf("get channel: %v", err) } if !view.Channel.HasLink { t.Fatalf("stored channel HasLink = false, want true") } if view.ExportedInvite == nil || !view.ExportedInvite.Permanent || view.ExportedInvite.Revoked || view.ExportedInvite.AdminUserID != 1 { t.Fatalf("owner view exported invite = %+v, want creator permanent main link", view.ExportedInvite) } invites, err := store.ListExportedInvites(ctx, domain.ChannelInviteListRequest{ UserID: 1, ChannelID: created.Channel.ID, AdminUserID: 1, Limit: 10, }) if err != nil { t.Fatalf("list exported invites: %v", err) } if invites.Count != 1 || len(invites.Invites) != 1 || !invites.Invites[0].Permanent || invites.Invites[0].Revoked { t.Fatalf("invites = %+v, want one active permanent main link", invites) } } func TestChannelViewExportedInviteIsAdminOnly(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "private group with regular member", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_091, }) if err != nil { t.Fatalf("create channel: %v", err) } ownerView, err := store.GetChannel(ctx, 1, created.Channel.ID) if err != nil { t.Fatalf("get owner channel: %v", err) } if ownerView.ExportedInvite == nil || !ownerView.ExportedInvite.Permanent { t.Fatalf("owner exported invite = %+v, want permanent main link", ownerView.ExportedInvite) } memberView, err := store.GetChannel(ctx, 2, created.Channel.ID) if err != nil { t.Fatalf("get member channel: %v", err) } if memberView.ExportedInvite != nil { t.Fatalf("member exported invite = %+v, want nil", memberView.ExportedInvite) } } func TestChannelRealtimeRecipientsAreCapped(t *testing.T) { store := NewChannelStore() memberIDs := make([]int64, domain.MaxChannelRealtimeFanout+25) for i := range memberIDs { memberIDs[i] = int64(10_000 + i) } created, err := store.CreateChannel(context.Background(), domain.CreateChannelRequest{ CreatorUserID: 1, Title: "large realtime cap", Megagroup: true, MemberUserIDs: memberIDs, Date: 1_700_000_100, }) if err != nil { t.Fatalf("create channel: %v", err) } if got := len(created.Recipients); got != domain.MaxChannelRealtimeFanout { t.Fatalf("create recipients = %d, want capped %d", got, domain.MaxChannelRealtimeFanout) } recipients, err := store.ListActiveChannelMemberIDs(context.Background(), 1, created.Channel.ID, 0) if err != nil { t.Fatalf("list active members: %v", err) } if got := len(recipients); got != domain.MaxChannelRealtimeFanout { t.Fatalf("listed active members = %d, want capped %d", got, domain.MaxChannelRealtimeFanout) } } func TestChannelCreatorLeaveTransfersOwner(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "creator transfer", Megagroup: true, MemberUserIDs: []int64{2, 3}, Date: 1_700_000_110, }) if err != nil { t.Fatalf("create channel: %v", err) } if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ UserID: 1, ChannelID: created.Channel.ID, MemberID: 3, AdminRights: domain.ChannelAdminRights{ ChangeInfo: true, AddAdmins: true, }, Date: 1_700_000_111, }); err != nil { t.Fatalf("promote admin: %v", err) } future, err := store.FutureCreatorAfterLeave(ctx, created.Channel.ID, 1) if err != nil { t.Fatalf("future creator: %v", err) } if future.UserID != 3 { t.Fatalf("future creator = %+v, want admin user 3", future) } left, err := store.LeaveChannel(ctx, created.Channel.ID, 1, 1_700_000_112) if err != nil { t.Fatalf("creator leave: %v", err) } if left.Channel.CreatorUserID != 3 || left.Channel.ParticipantsCount != 2 || left.Channel.AdminsCount != 1 { t.Fatalf("channel after creator leave = %+v, want owner=3 participants=2 admins=1", left.Channel) } newOwner, err := store.GetParticipant(ctx, 3, created.Channel.ID, 3) if err != nil { t.Fatalf("get new owner: %v", err) } if newOwner.Role != domain.ChannelRoleCreator || newOwner.Status != domain.ChannelMemberActive { t.Fatalf("new owner = %+v, want active creator", newOwner) } oldOwner, err := store.GetParticipant(ctx, 3, created.Channel.ID, 1) if err != nil { t.Fatalf("get old owner: %v", err) } if oldOwner.Status != domain.ChannelMemberLeft || oldOwner.Role == domain.ChannelRoleCreator { t.Fatalf("old owner = %+v, want left non-creator", oldOwner) } if _, err := store.JoinChannel(ctx, created.Channel.ID, 1, 1_700_000_113); err != nil { t.Fatalf("old owner rejoin: %v", err) } rejoined, err := store.GetParticipant(ctx, 3, created.Channel.ID, 1) if err != nil { t.Fatalf("get rejoined old owner: %v", err) } if rejoined.Role != domain.ChannelRoleMember || rejoined.Status != domain.ChannelMemberActive || rejoined.Rank != "" { t.Fatalf("rejoined old owner = %+v, want active plain member", rejoined) } } func TestChannelCreatorCanEditOwnAdminRights(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "creator self admin", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_114, }) if err != nil { t.Fatalf("create channel: %v", err) } edited, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ UserID: 1, ChannelID: created.Channel.ID, MemberID: 1, AdminRights: domain.ChannelAdminRights{ Anonymous: true, }, Date: 1_700_000_115, }) if err != nil { t.Fatalf("creator edits own admin rights: %v", err) } if edited.Participant.Role != domain.ChannelRoleCreator || !edited.Participant.AdminRights.Anonymous || !edited.Participant.AdminRights.ChangeInfo || !edited.Participant.AdminRights.AddAdmins { t.Fatalf("edited creator = %+v, want creator with anonymous plus full creator projection", edited.Participant) } persisted, err := store.GetParticipant(ctx, 1, created.Channel.ID, 1) if err != nil { t.Fatalf("get creator participant: %v", err) } if persisted.Role != domain.ChannelRoleCreator || !persisted.AdminRights.Anonymous || !persisted.AdminRights.ChangeInfo || !persisted.AdminRights.AddAdmins { t.Fatalf("persisted creator = %+v, want creator with anonymous plus full creator projection", persisted) } if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ UserID: 1, ChannelID: created.Channel.ID, MemberID: 2, AdminRights: domain.ChannelAdminRights{ ChangeInfo: true, AddAdmins: true, }, Date: 1_700_000_116, }); err != nil { t.Fatalf("promote admin: %v", err) } if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ UserID: 2, ChannelID: created.Channel.ID, MemberID: 1, AdminRights: domain.ChannelAdminRights{ ChangeInfo: true, }, Date: 1_700_000_117, }); !errors.Is(err, domain.ErrChannelUserCreator) { t.Fatalf("admin edits creator err = %v, want ErrChannelUserCreator", err) } } func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "participant state no pts", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_120, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID := created.Channel.ID ptsFloor := created.Channel.Pts promoted, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ UserID: 1, ChannelID: channelID, MemberID: 2, AdminRights: domain.ChannelAdminRights{ InviteUsers: true, }, Date: 1_700_000_121, }) if err != nil { t.Fatalf("edit admin: %v", err) } if promoted.Event.Pts != 0 || promoted.Event.PtsCount != 0 || promoted.Channel.Pts != ptsFloor { t.Fatalf("edit admin pts = event(%d,%d) channel %d, want unchanged %d", promoted.Event.Pts, promoted.Event.PtsCount, promoted.Channel.Pts, ptsFloor) } muted, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ UserID: 1, ChannelID: channelID, Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, BannedRights: domain.ChannelBannedRights{ SendMessages: true, UntilDate: 1_700_001_121, }, Date: 1_700_000_122, }) if err != nil { t.Fatalf("edit banned mute: %v", err) } if muted.Event.Pts != 0 || muted.Event.PtsCount != 0 || muted.Channel.Pts != ptsFloor || muted.ServiceEvent.Pts != 0 { t.Fatalf("mute pts = event(%d,%d) channel %d, want unchanged %d without service message", muted.Event.Pts, muted.Event.PtsCount, muted.Channel.Pts, ptsFloor) } kicked, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ UserID: 1, ChannelID: channelID, Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, BannedRights: domain.ChannelBannedRights{ ViewMessages: true, UntilDate: 1_700_001_121, }, Date: 1_700_000_123, }) if err != nil { t.Fatalf("edit banned kick: %v", err) } // megagroup 踢人产生可见 "X removed Y" 服务消息并占 channel pts; // participant update 本身仍不占 pts。 if kicked.Event.Pts != 0 || kicked.Event.PtsCount != 0 { t.Fatalf("kick participant event = (%d,%d), must stay transient", kicked.Event.Pts, kicked.Event.PtsCount) } if kicked.ServiceEvent.Pts != ptsFloor+1 || kicked.Message.Action == nil || kicked.Message.Action.Type != domain.ChannelActionChatDelete { t.Fatalf("kick service = event %+v message %+v, want ChatDelete service message at pts %d", kicked.ServiceEvent, kicked.Message, ptsFloor+1) } if kicked.Channel.Pts != ptsFloor+1 { t.Fatalf("kick channel pts = %d, want %d", kicked.Channel.Pts, ptsFloor+1) } diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ UserID: 1, ChannelID: channelID, Pts: ptsFloor, Limit: 10, }) if err != nil { t.Fatalf("list difference: %v", err) } if len(diff.Events) != 1 || diff.Pts != ptsFloor+1 { t.Fatalf("difference after kick = %+v, want only the kick service message at pts %d", diff, ptsFloor+1) } } func TestChannelStoreGetMessagesNonMemberPublicPreview(t *testing.T) { ctx := context.Background() store := NewChannelStore() const owner, outsider int64 = 1, 99 // 公开广播频道:非成员可读取消息(查看他人资料里的公开「个人频道」时,DrKLO 经 // channels.getMessages 拉最新一帖依赖此;否则资料页个人频道整块不显示)。 pub, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "public", Broadcast: true, Date: 1_700_000_000}) if err != nil { t.Fatalf("create public: %v", err) } if _, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{UserID: owner, ChannelID: pub.Channel.ID, Username: "pub_preview_mem"}); err != nil { t.Fatalf("make public: %v", err) } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: pub.Channel.ID, RandomID: 111, Message: "hello", Date: 1_700_000_001}) if err != nil { t.Fatalf("send public: %v", err) } got, err := store.GetChannelMessages(ctx, outsider, pub.Channel.ID, []int{sent.Message.ID}) if err != nil { t.Fatalf("non-member getMessages on public channel: %v", err) } if len(got.Messages) != 1 || got.Messages[0].ID != sent.Message.ID { t.Fatalf("non-member should read public channel message, got %+v", got.Messages) } // 私有广播频道(无 username):非成员仍被拒(ErrChannelPrivate)。 priv, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "private", Broadcast: true, Date: 1_700_000_002}) if err != nil { t.Fatalf("create private: %v", err) } privSent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: priv.Channel.ID, RandomID: 222, Message: "secret", Date: 1_700_000_003}) if err != nil { t.Fatalf("send private: %v", err) } if _, err := store.GetChannelMessages(ctx, outsider, priv.Channel.ID, []int{privSent.Message.ID}); !errors.Is(err, domain.ErrChannelPrivate) { t.Fatalf("non-member getMessages on private channel = %v, want ErrChannelPrivate", err) } } func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollback(t *testing.T) { ctx := context.Background() store := NewChannelStore() source := domain.Peer{Type: domain.PeerTypeUser, ID: 42} media := &domain.MessageMedia{ Kind: domain.MessageMediaKindStory, Story: &domain.MessageStory{ Peer: source, ID: 7, Story: &domain.Story{ Owner: source, ID: 7, }, }, } publicCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "public story forwards", Broadcast: true, Date: 1_700_000_130, }) if err != nil { t.Fatalf("create public channel: %v", err) } publicChannel, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ UserID: 1, ChannelID: publicCreated.Channel.ID, Username: "story_forward_memory", }) if err != nil { t.Fatalf("make public channel: %v", err) } privateCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "private story forwards", Broadcast: true, Date: 1_700_000_131, }) if err != nil { t.Fatalf("create private channel: %v", err) } publicSent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: publicChannel.ID, RandomID: 9141301, Media: media, Date: 1_700_000_132, }) if err != nil { t.Fatalf("send public story message: %v", err) } if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: privateCreated.Channel.ID, RandomID: 9141302, Media: media, Date: 1_700_000_133, }); err != nil { t.Fatalf("send private story message: %v", err) } list, err := store.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ ViewerUserID: 42, Owner: source, StoryID: 7, Limit: 10, }) if err != nil { t.Fatalf("list story message forwards: %v", err) } if list.Count != 1 || len(list.Forwards) != 1 { t.Fatalf("story message forwards = %+v, want one public forward", list) } if got := list.Forwards[0].PublicForward.Message.ChannelID; got != publicChannel.ID { t.Fatalf("forward channel = %d, want public channel %d", got, publicChannel.ID) } if _, err := store.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ UserID: 1, ChannelID: publicChannel.ID, IDs: []int{publicSent.Message.ID}, Date: 1_700_000_134, }); err != nil { t.Fatalf("delete public story message: %v", err) } empty, err := store.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ ViewerUserID: 42, Owner: source, StoryID: 7, Limit: 10, }) if err != nil { t.Fatalf("list story message forwards after delete: %v", err) } if empty.Count != 0 || len(empty.Forwards) != 0 { t.Fatalf("story message forwards after delete = %+v, want empty", empty) } } func TestPendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "pending join requests", Megagroup: true, MemberUserIDs: []int64{2, 3, 4}, Date: 1_700_000_150, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID := created.Channel.ID if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ UserID: 1, ChannelID: channelID, MemberID: 2, AdminRights: domain.ChannelAdminRights{ InviteUsers: true, }, Date: 1_700_000_151, }); err != nil { t.Fatalf("promote invite admin: %v", err) } if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ UserID: 1, ChannelID: channelID, MemberID: 4, AdminRights: domain.ChannelAdminRights{ ChangeInfo: true, }, Date: 1_700_000_152, }); err != nil { t.Fatalf("promote change-info admin: %v", err) } invite, err := store.ExportInvite(ctx, domain.ExportChannelInviteRequest{ UserID: 1, ChannelID: channelID, Title: "approval", RequestNeeded: true, Date: 1_700_000_153, }) if err != nil { t.Fatalf("export invite: %v", err) } for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ { _, err := store.ImportInvite(ctx, domain.ImportChannelInviteRequest{ UserID: int64(10 + i), Hash: invite.Invite.Hash, Date: 1_700_000_160 + i, }) if !errors.Is(err, domain.ErrInviteRequestSent) { t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err) } } pending, err := store.PendingJoinRequests(ctx, channelID, 99) if err != nil { t.Fatalf("pending join requests: %v", err) } if pending.Count != domain.MaxChannelPendingJoinRecentRequesters+2 || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters { t.Fatalf("pending summary = %+v, want bounded recent with full count", pending) } if pending.RecentRequesters[0] != 16 || pending.RecentRequesters[len(pending.RecentRequesters)-1] != 12 { t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters) } admins, err := store.ListChannelInviteAdminMemberIDs(ctx, channelID, 0) if err != nil { t.Fatalf("invite admins: %v", err) } want := []int64{1, 2, 4} if !reflect.DeepEqual(admins, want) { t.Fatalf("invite admins = %+v, want %+v", admins, want) } } func TestCommonChannelsOnlySharedMegagroups(t *testing.T) { ctx := context.Background() store := NewChannelStore() first, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "common one", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_170, }) if err != nil { t.Fatalf("create first common channel: %v", err) } second, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "common two", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_171, }) if err != nil { t.Fatalf("create second common channel: %v", err) } if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "broadcast excluded", Broadcast: true, MemberUserIDs: []int64{2}, Date: 1_700_000_172, }); err != nil { t.Fatalf("create broadcast channel: %v", err) } left, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "left excluded", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_173, }) if err != nil { t.Fatalf("create left channel: %v", err) } if _, err := store.LeaveChannel(ctx, left.Channel.ID, 2, 1_700_000_174); err != nil { t.Fatalf("leave channel: %v", err) } if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "not shared", Megagroup: true, MemberUserIDs: []int64{3}, Date: 1_700_000_175, }); err != nil { t.Fatalf("create non-shared channel: %v", err) } page, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ UserID: 1, TargetUserID: 2, Limit: 10, }) if err != nil { t.Fatalf("list common channels: %v", err) } if page.Count != 2 || len(page.Channels) != 2 || page.Channels[0].ID != first.Channel.ID || page.Channels[1].ID != second.Channel.ID { t.Fatalf("common channels = %+v, want two shared megagroups in id order", page) } next, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ UserID: 1, TargetUserID: 2, MaxID: first.Channel.ID, Limit: 1, }) if err != nil { t.Fatalf("list common channels after max id: %v", err) } if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].ID != second.Channel.ID { t.Fatalf("paged common channels = %+v, want second channel with full count", next) } countOnly, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ UserID: 1, TargetUserID: 2, CountOnly: true, }) if err != nil { t.Fatalf("count common channels: %v", err) } if countOnly.Count != 2 || len(countOnly.Channels) != 0 { t.Fatalf("count-only common channels = %+v, want count without channels", countOnly) } } func TestLeftChannelsReturnsPagedLeftMemberships(t *testing.T) { ctx := context.Background() store := NewChannelStore() older, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "older left", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_180, }) if err != nil { t.Fatalf("create older channel: %v", err) } newer, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "newer left broadcast", Broadcast: true, MemberUserIDs: []int64{2}, Date: 1_700_000_181, }) if err != nil { t.Fatalf("create newer channel: %v", err) } if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "active excluded", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_182, }); err != nil { t.Fatalf("create active channel: %v", err) } if _, err := store.LeaveChannel(ctx, older.Channel.ID, 2, 1_700_000_183); err != nil { t.Fatalf("leave older channel: %v", err) } if _, err := store.LeaveChannel(ctx, newer.Channel.ID, 2, 1_700_000_184); err != nil { t.Fatalf("leave newer channel: %v", err) } page, err := store.ListLeftChannels(ctx, 2, 0, 1) if err != nil { t.Fatalf("list left channels: %v", err) } if page.Count != 2 || len(page.Channels) != 1 || page.Channels[0].Channel.ID != newer.Channel.ID || page.Channels[0].Self.Status != domain.ChannelMemberLeft { t.Fatalf("first left page = %+v, want newest left channel and full count", page) } next, err := store.ListLeftChannels(ctx, 2, 1, 1) if err != nil { t.Fatalf("list next left channels: %v", err) } if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].Channel.ID != older.Channel.ID { t.Fatalf("second left page = %+v, want older left channel", next) } empty, err := store.ListLeftChannels(ctx, 2, 2, 1) if err != nil { t.Fatalf("list empty left page: %v", err) } if empty.Count != 2 || len(empty.Channels) != 0 { t.Fatalf("empty left page = %+v, want full count and no chats", empty) } if _, err := store.ListLeftChannels(ctx, 2, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) { t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err) } } func TestDiscussionGroupLinksAreBidirectionalAndReplaceOldLinks(t *testing.T) { ctx := context.Background() store := NewChannelStore() broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "broadcast", Broadcast: true, Date: 1_700_000_190, }) if err != nil { t.Fatalf("create broadcast: %v", err) } firstGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "first group", Megagroup: true, Date: 1_700_000_191, }) if err != nil { t.Fatalf("create first group: %v", err) } secondGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "second group", Megagroup: true, Date: 1_700_000_192, }) if err != nil { t.Fatalf("create second group: %v", err) } if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "broadcast excluded", Broadcast: true, Date: 1_700_000_193, }); err != nil { t.Fatalf("create excluded broadcast: %v", err) } candidates, err := store.ListDiscussionGroups(ctx, 1, 10) if err != nil { t.Fatalf("list discussion groups: %v", err) } if len(candidates) != 2 || candidates[0].ID != secondGroup.Channel.ID || candidates[1].ID != firstGroup.Channel.ID { t.Fatalf("discussion candidates = %+v, want creator megagroups newest id first", candidates) } linked, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID) if err != nil { t.Fatalf("link first group: %v", err) } if len(linked.Channels) != 2 { t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels) } gotBroadcast, err := store.GetChannelByID(ctx, broadcast.Channel.ID) if err != nil { t.Fatalf("get linked broadcast: %v", err) } gotFirst, err := store.GetChannelByID(ctx, firstGroup.Channel.ID) if err != nil { t.Fatalf("get linked first group: %v", err) } if gotBroadcast.LinkedChatID != firstGroup.Channel.ID || gotFirst.LinkedChatID != broadcast.Channel.ID { t.Fatalf("first link = broadcast %+v group %+v, want bidirectional ids", gotBroadcast, gotFirst) } replaced, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, secondGroup.Channel.ID) if err != nil { t.Fatalf("replace discussion group: %v", err) } if len(replaced.Channels) != 3 { t.Fatalf("replace changed channels = %+v, want broadcast, old group, new group", replaced.Channels) } gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID) gotFirst, _ = store.GetChannelByID(ctx, firstGroup.Channel.ID) gotSecond, err := store.GetChannelByID(ctx, secondGroup.Channel.ID) if err != nil { t.Fatalf("get linked second group: %v", err) } if gotBroadcast.LinkedChatID != secondGroup.Channel.ID || gotSecond.LinkedChatID != broadcast.Channel.ID || gotFirst.LinkedChatID != 0 { t.Fatalf("replace link = broadcast %d first %d second %d, want old cleared and new bidirectional", gotBroadcast.LinkedChatID, gotFirst.LinkedChatID, gotSecond.LinkedChatID) } unlinked, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID) if err != nil { t.Fatalf("unlink from group side: %v", err) } if len(unlinked.Channels) != 2 { t.Fatalf("unlink changed channels = %+v, want broadcast and group", unlinked.Channels) } gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID) gotSecond, _ = store.GetChannelByID(ctx, secondGroup.Channel.ID) if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 { t.Fatalf("unlink = broadcast %d second %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID) } if _, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) { t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err) } if _, err := store.SetPreHistoryHidden(ctx, 1, firstGroup.Channel.ID, true); err != nil { t.Fatalf("hide first group prehistory: %v", err) } if _, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) { t.Fatalf("hidden group link err = %v, want ErrMegagroupPrehistoryHidden", err) } } func TestChannelDeleteHistoryCapsHugeMaxID(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "bounded delete history", Megagroup: true, Date: 1_700_000_200, }) if err != nil { t.Fatalf("create channel: %v", err) } totalMessages := domain.MaxDeleteHistoryBatch + 2 for i := 0; i < totalMessages; i++ { if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: int64(10_000 + i), Message: "bulk", Date: 1_700_000_201 + i, }); err != nil { t.Fatalf("send channel message %d: %v", i, err) } } first, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: 1, ChannelID: created.Channel.ID, MaxID: int(^uint(0) >> 1), ForEveryone: true, Date: 1_700_001_300, }) if err != nil { t.Fatalf("delete first batch: %v", err) } if first.Offset != 1 || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch || first.Event.PtsCount != domain.MaxDeleteHistoryBatch { t.Fatalf("first batch = %+v, want capped page with offset", first) } second, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: 1, ChannelID: created.Channel.ID, MaxID: int(^uint(0) >> 1), ForEveryone: true, Date: 1_700_001_301, }) if err != nil { t.Fatalf("delete second batch: %v", err) } if second.Offset != 0 || len(second.DeletedIDs) != 2 || second.Event.PtsCount != 2 { t.Fatalf("second batch = %+v, want final bounded page keeping create service message", second) } if second.Channel.TopMessageID != created.Message.ID { t.Fatalf("top after full clear = %d, want create service message %d", second.Channel.TopMessageID, created.Message.ID) } history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) if err != nil { t.Fatalf("list history after full clear: %v", err) } if len(history.Messages) != 1 || history.Messages[0].ID != created.Message.ID || history.Messages[0].Action == nil { t.Fatalf("history after full clear = %+v, want only create service message", history.Messages) } } func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "monotonic local clear", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_250, }) if err != nil { t.Fatalf("create channel: %v", err) } first, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 30_001, Message: "first visible", Date: 1_700_000_251, }) if err != nil { t.Fatalf("send first message: %v", err) } second, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 30_002, Message: "second visible", Date: 1_700_000_252, }) if err != nil { t.Fatalf("send second message: %v", err) } high, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: 1, ChannelID: created.Channel.ID, MaxID: second.Message.ID, Date: 1_700_000_253, }) if err != nil { t.Fatalf("clear high watermark: %v", err) } if high.AvailableMinID != second.Message.ID { t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID) } if !high.AvailableMinChanged { t.Fatal("high clear did not report an advanced owner-local boundary") } dirtyAfterClear, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_253, 0, 10) if err != nil { t.Fatalf("list dirty channels after owner-local clear: %v", err) } if len(dirtyAfterClear) != 1 || dirtyAfterClear[0].ChannelID != created.Channel.ID || dirtyAfterClear[0].AvailableMinID != second.Message.ID || dirtyAfterClear[0].HistoryClearDate != 1_700_000_253 || dirtyAfterClear[0].ChannelUpdatesDirty { t.Fatalf("dirty owner-local clear = %+v, want only absolute boundary %d at date 1700000253", dirtyAfterClear, second.Message.ID) } stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: 1, ChannelID: created.Channel.ID, MaxID: first.Message.ID, Date: 1_700_000_254, }) if err != nil { t.Fatalf("clear stale low watermark: %v", err) } if stale.AvailableMinID != second.Message.ID { t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID) } if stale.AvailableMinChanged { t.Fatal("stale clear unexpectedly replaced the owner-local anchor") } dirtyAfterStale, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_254, 0, 10) if err != nil { t.Fatalf("list dirty channels after stale owner-local clear: %v", err) } if len(dirtyAfterStale) != 0 { t.Fatalf("stale clear refreshed recovery timestamp: %+v", dirtyAfterStale) } history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) if err != nil { t.Fatalf("list history: %v", err) } if len(history.Messages) != 0 { t.Fatalf("unprojected history after stale clear = %+v, want no shared messages", history.Messages) } history, err = store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ ChannelID: created.Channel.ID, Limit: 10, IncludeHistoryClearAnchor: true, }) if err != nil { t.Fatalf("list projected history: %v", err) } if len(history.Messages) != 1 || history.Messages[0].ID != second.Message.ID || !domain.IsChannelHistoryClearMessage(history.Messages[0]) || history.Messages[0].Body != "" || history.Messages[0].Media != nil { t.Fatalf("projected history after stale clear = %+v, want sanitized history-clear anchor %d", history.Messages, second.Message.ID) } dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) if err != nil { t.Fatalf("get channel dialog: %v", err) } if len(dialogs.Dialogs) != 1 { t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs) } if dialogs.Dialogs[0].TopMessage != second.Message.ID || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 || len(dialogs.Messages) != 1 || !domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) { t.Fatalf("dialog after stale clear = %+v messages=%+v, want anchored top=%d read=%d unread=0", dialogs.Dialogs[0], dialogs.Messages, second.Message.ID, second.Message.ID) } otherDialogs, err := store.GetChannelDialogs(ctx, 2, []int64{created.Channel.ID}) if err != nil { t.Fatalf("get other member dialog: %v", err) } if len(otherDialogs.Dialogs) != 1 || otherDialogs.Dialogs[0].TopMessage != second.Message.ID || len(otherDialogs.Messages) != 1 || otherDialogs.Messages[0].Body != "second visible" || otherDialogs.Messages[0].Action != nil { t.Fatalf("other member projection changed by owner clear: dialogs=%+v messages=%+v", otherDialogs.Dialogs, otherDialogs.Messages) } } func TestChannelDeleteHistoryLocalClearKeepsMegagroupAndBroadcastDialogs(t *testing.T) { for _, tc := range []struct { name string megagroup bool broadcast bool }{ {name: "megagroup", megagroup: true}, {name: "broadcast", broadcast: true}, } { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 11, Title: tc.name + " local clear", Megagroup: tc.megagroup, Broadcast: tc.broadcast, Date: 1_700_000_270, }) if err != nil { t.Fatalf("create channel: %v", err) } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 11, ChannelID: created.Channel.ID, RandomID: 31_001, Message: "clear this", Date: 1_700_000_271, }) if err != nil { t.Fatalf("send channel message: %v", err) } channelPts := sent.Channel.Pts cleared, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: 11, ChannelID: created.Channel.ID, MaxID: sent.Message.ID, Date: 1_700_000_272, }) if err != nil { t.Fatalf("clear local history: %v", err) } if cleared.Channel.Pts != channelPts || cleared.Event.Pts != 0 { t.Fatalf("local clear changed channel pts: before=%d result=%+v", channelPts, cleared) } dialogs, err := store.GetChannelDialogs(ctx, 11, []int64{created.Channel.ID}) if err != nil { t.Fatalf("get dialogs after clear: %v", err) } if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].TopMessage != sent.Message.ID || len(dialogs.Messages) != 1 || !domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) { t.Fatalf("dialog disappeared after %s clear: dialogs=%+v messages=%+v", tc.name, dialogs.Dialogs, dialogs.Messages) } }) } } func TestChannelPrehistoryBoundaryDoesNotCreateHistoryClearAnchor(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 21, Title: "hidden prehistory", Megagroup: true, Date: 1_700_000_280, }) if err != nil { t.Fatalf("create channel: %v", err) } old, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 21, ChannelID: created.Channel.ID, RandomID: 32_001, Message: "prehistory", Date: 1_700_000_281, }) if err != nil { t.Fatalf("send prehistory message: %v", err) } if _, err := store.SetPreHistoryHidden(ctx, 21, created.Channel.ID, true); err != nil { t.Fatalf("hide prehistory: %v", err) } invited, err := store.InviteToChannel(ctx, created.Channel.ID, 21, []int64{22}, 1_700_000_282) if err != nil { t.Fatalf("invite member: %v", err) } if len(invited.Members) != 1 || invited.Members[0].AvailableMinID != old.Message.ID || invited.Members[0].HistoryClearAnchorID != 0 || invited.Members[0].HistoryClearAnchorDate != 0 { t.Fatalf("invited member = %+v, want prehistory boundary without local-clear anchor", invited.Members) } history, err := store.ListChannelHistory(ctx, 22, domain.ChannelHistoryFilter{ ChannelID: created.Channel.ID, Limit: 10, IncludeHistoryClearAnchor: true, }) if err != nil { t.Fatalf("list invited member history: %v", err) } for _, message := range history.Messages { if message.ID == old.Message.ID || domain.IsChannelHistoryClearMessage(message) { t.Fatalf("prehistory boundary leaked a local-clear marker: %+v", history.Messages) } } byID, err := store.GetChannelMessages(ctx, 22, created.Channel.ID, []int{old.Message.ID}) if err != nil { t.Fatalf("get prehistory message by id: %v", err) } if len(byID.Messages) != 0 { t.Fatalf("prehistory message by id = %+v, want hidden without fabricated marker", byID.Messages) } } func TestChannelListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "single copy dialog top", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_300, }) if err != nil { t.Fatalf("create channel: %v", err) } if _, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ UserID: 2, ChannelID: created.Channel.ID, MaxID: created.Message.ID, Date: 1_700_000_301, }); err != nil { t.Fatalf("read initial service message: %v", err) } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 88, Message: "visible without write fanout", Date: 1_700_000_302, }) if err != nil { t.Fatalf("send channel message: %v", err) } list, err := store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 10}) if err != nil { t.Fatalf("list recipient channel dialogs: %v", err) } if len(list.Dialogs) != 1 { t.Fatalf("dialogs = %+v, want one channel dialog", list.Dialogs) } dialog := list.Dialogs[0] if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date || dialog.UnreadCount != 1 { t.Fatalf("recipient dialog = %+v, want top sent message and unread=1", dialog) } if len(list.Messages) != 1 || list.Messages[0].ID != sent.Message.ID { t.Fatalf("dialog messages = %+v, want sent top message", list.Messages) } } func TestChannelUnreadExcludesOwnOutgoing(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "own outgoing unread", Megagroup: true, Date: 1_700_000_360, }) if err != nil { t.Fatalf("create channel: %v", err) } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 36_001, Message: "own outgoing only", Date: 1_700_000_361, }) if err != nil { t.Fatalf("send channel message: %v", err) } store.mu.Lock() member := store.members[created.Channel.ID][1] member.ReadInboxMaxID = sent.Message.ID - 1 store.members[created.Channel.ID][1] = member dialog := store.dialogs[1][created.Channel.ID] dialog.ReadInboxMaxID = sent.Message.ID - 1 dialog.UnreadCount = 99 store.dialogs[1][created.Channel.ID] = dialog store.mu.Unlock() dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) if err != nil { t.Fatalf("get channel dialogs: %v", err) } if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 0 { t.Fatalf("dialogs = %+v, want own outgoing excluded from unread", dialogs.Dialogs) } read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ UserID: 1, ChannelID: created.Channel.ID, MaxID: sent.Message.ID, Date: 1_700_000_362, }) if err != nil { t.Fatalf("read channel history: %v", err) } if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 { t.Fatalf("read result = %+v, want no own-outgoing unread", read) } } func TestChannelMessageReplyMarkupSurvivesReadPaths(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "channel reply markup", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_380, }) if err != nil { t.Fatalf("create channel: %v", err) } markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ {Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}}, }}} assertMarkup := func(name string, got *domain.MessageReplyMarkup) { t.Helper() if got == nil || len(got.Inline) != 1 || len(got.Inline[0]) != 1 { t.Fatalf("%s markup = %+v, want one callback button", name, got) } btn := got.Inline[0][0] if btn.Type != domain.MarkupButtonCallback || btn.Text != "Open" || !bytes.Equal(btn.Data, []byte{0x00, 0xff, 0x42}) { t.Fatalf("%s button = %+v, want callback Open bytes", name, btn) } } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 38_001, Message: "via inline keyboard", ViaBotID: 99, ReplyMarkup: markup, Date: 1_700_000_381, }) if err != nil { t.Fatalf("send channel message: %v", err) } markup.Inline[0][0].Text = "Changed" markup.Inline[0][0].Data[0] = 0x7f assertMarkup("send result", sent.Message.ReplyMarkup) assertMarkup("send event", sent.Event.Message.ReplyMarkup) duplicate, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 38_001, Message: "via inline keyboard", ViaBotID: 99, ReplyMarkup: &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ {Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}}, }}}, Date: 1_700_000_382, }) if err != nil { t.Fatalf("duplicate send: %v", err) } if !duplicate.Duplicate || duplicate.Message.ViaBotID != 99 { t.Fatalf("duplicate = %+v, want original via bot", duplicate.Message) } assertMarkup("duplicate message", duplicate.Message.ReplyMarkup) assertMarkup("duplicate event", duplicate.Event.Message.ReplyMarkup) history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) if err != nil { t.Fatalf("list history: %v", err) } if len(history.Messages) == 0 || history.Messages[0].ViaBotID != 99 { t.Fatalf("history messages = %+v, want via bot 99", history.Messages) } assertMarkup("history message", history.Messages[0].ReplyMarkup) byID, err := store.GetChannelMessages(ctx, 1, created.Channel.ID, []int{sent.Message.ID}) if err != nil { t.Fatalf("get channel messages: %v", err) } if len(byID.Messages) != 1 || byID.Messages[0].ViaBotID != 99 { t.Fatalf("get messages = %+v, want via bot 99", byID.Messages) } assertMarkup("get messages", byID.Messages[0].ReplyMarkup) diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 1, ChannelID: created.Channel.ID, Pts: created.Channel.Pts, Limit: 10}) if err != nil { t.Fatalf("list difference: %v", err) } if len(diff.NewMessages) != 1 || diff.NewMessages[0].ViaBotID != 99 { t.Fatalf("difference messages = %+v, want via bot 99", diff.NewMessages) } assertMarkup("difference message", diff.NewMessages[0].ReplyMarkup) dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 10}) if err != nil { t.Fatalf("list dialogs: %v", err) } if len(dialogs.Messages) == 0 || dialogs.Messages[0].ViaBotID != 99 { t.Fatalf("dialog messages = %+v, want via bot 99", dialogs.Messages) } assertMarkup("dialog top message", dialogs.Messages[0].ReplyMarkup) } func TestChannelMessageViaBotEditUpdatesReplyMarkup(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "channel via bot edit", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_390, }) if err != nil { t.Fatalf("create channel: %v", err) } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 39_001, Message: "before", ViaBotID: 99, Date: 1_700_000_391, }) if err != nil { t.Fatalf("send channel message: %v", err) } markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ {Type: domain.MarkupButtonCallback, Text: "Done", Data: []byte("v2")}, }}} if _, err := store.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, ID: sent.Message.ID, Message: "wrong", ViaBotEditBotID: 100, EditDate: 1_700_000_392, SetReplyMarkup: true, ReplyMarkup: markup, }); err != domain.ErrMessageAuthorRequired { t.Fatalf("wrong via bot edit err = %v, want ErrMessageAuthorRequired", err) } edited, err := store.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, ID: sent.Message.ID, Message: "after", ViaBotEditBotID: 99, EditDate: 1_700_000_393, SetReplyMarkup: true, ReplyMarkup: markup, }) if err != nil { t.Fatalf("via bot edit: %v", err) } if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Message.Body != "after" || edited.Message.ViaBotID != 99 { t.Fatalf("edited = %+v event=%+v, want edit via bot", edited.Message, edited.Event) } if edited.Message.ReplyMarkup == nil || edited.Message.ReplyMarkup.Inline[0][0].Text != "Done" || !bytes.Equal(edited.Message.ReplyMarkup.Inline[0][0].Data, []byte("v2")) { t.Fatalf("edited markup = %+v, want Done/v2", edited.Message.ReplyMarkup) } diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 1, ChannelID: created.Channel.ID, Pts: sent.Event.Pts, Limit: 10}) if err != nil { t.Fatalf("list edit difference: %v", err) } if len(diff.OtherUpdates) != 1 || diff.OtherUpdates[0].Message.Body != "after" { t.Fatalf("edit difference = %+v, want one edited message", diff.OtherUpdates) } } func TestBroadcastChannelReactionsAreAnonymousAndSkipUnread(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "broadcast reaction", Broadcast: true, MemberUserIDs: []int64{2}, Date: 1_700_000_500, }) if err != nil { t.Fatalf("create channel: %v", err) } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 50_001, Message: "broadcast post", Date: 1_700_000_501, }) if err != nil { t.Fatalf("send channel message: %v", err) } res, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ UserID: 2, ChannelID: created.Channel.ID, MessageID: sent.Message.ID, Reactions: []domain.MessageReaction{{ Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d", }}, Date: 1_700_000_502, }) if err != nil { t.Fatalf("set channel reaction: %v", err) } if len(res.Reactions.Results) != 1 || res.Reactions.Results[0].Count != 1 { t.Fatalf("broadcast reaction results = %+v, want count-only aggregate", res.Reactions.Results) } if len(res.Reactions.Recent) != 0 { t.Fatalf("broadcast recent reactors = %+v, want anonymous (empty)", res.Reactions.Recent) } if res.Reactions.CanSeeList { t.Fatalf("broadcast can_see_list = true, want false") } for _, rows := range store.reactions[created.Channel.ID][sent.Message.ID] { for _, row := range rows { if row.Unread { t.Fatalf("broadcast reaction row = %+v, want unread bookkeeping skipped", row) } } } dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) if err != nil { t.Fatalf("get owner channel dialogs: %v", err) } if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 0 { t.Fatalf("owner dialogs = %+v, want no unread reaction badge on broadcast", dialogs.Dialogs) } } func TestChannelReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) { ctx := context.Background() store := NewChannelStore() created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: 1, Title: "visible unread reaction", Megagroup: true, MemberUserIDs: []int64{2}, Date: 1_700_000_400, }) if err != nil { t.Fatalf("create channel: %v", err) } sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: 1, ChannelID: created.Channel.ID, RandomID: 40_001, Message: "react to this", Date: 1_700_000_401, }) if err != nil { t.Fatalf("send channel message: %v", err) } if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ UserID: 2, ChannelID: created.Channel.ID, MessageID: sent.Message.ID, Reactions: []domain.MessageReaction{{ Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525", }}, Date: 1_700_000_402, }); err != nil { t.Fatalf("set channel reaction: %v", err) } dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) if err != nil { t.Fatalf("get owner channel dialogs: %v", err) } if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 1 { t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs) } unread, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{ ChannelID: created.Channel.ID, Limit: 10, }) if err != nil { t.Fatalf("list unread reactions: %v", err) } if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID { t.Fatalf("unread reactions = %+v, want sent message", unread.Messages) } if unread.Messages[0].Reactions == nil || !hasUnreadChannelReaction(*unread.Messages[0].Reactions) { t.Fatalf("unread message reactions = %+v, want unread recent reaction", unread.Messages[0].Reactions) } read, err := store.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{ UserID: 1, ChannelID: created.Channel.ID, IDs: []int{sent.Message.ID}, }) if err != nil { t.Fatalf("read channel message contents: %v", err) } if !reflect.DeepEqual(read.ClearedUnreadReactionMessageIDs, []int{sent.Message.ID}) { t.Fatalf("cleared reaction ids = %+v, want [%d]", read.ClearedUnreadReactionMessageIDs, sent.Message.ID) } if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || hasUnreadChannelReaction(*read.Messages[0].Reactions) { t.Fatalf("read messages = %+v, want reaction returned as read", read.Messages) } unreadAfter, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{ ChannelID: created.Channel.ID, Limit: 10, }) if err != nil { t.Fatalf("list unread reactions after read contents: %v", err) } if len(unreadAfter.Messages) != 0 { t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter.Messages) } dialogsAfter, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) if err != nil { t.Fatalf("get dialogs after read contents: %v", err) } if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 { t.Fatalf("dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs) } } func hasUnreadChannelReaction(reactions domain.ChannelMessageReactions) bool { for _, recent := range reactions.Recent { if recent.Unread { return true } } return false }