package postgres import ( "bytes" "context" "errors" "fmt" "sync" "telesrv/internal/domain" "testing" "time" ) func TestChannelStoreSendMessageFansOutDialogRows(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 31, Phone: "+1777" + suffix + "01", FirstName: "ChannelOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := users.Create(ctx, domain.User{ AccessHash: 32, Phone: "+1777" + suffix + "02", FirstName: "ChannelFriend", }) if err != nil { t.Fatalf("create friend: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Dialog Top " + suffix, Megagroup: true, MemberUserIDs: []int64{friend.ID}, Date: 1700000300, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 901, Message: "first visible channel text", Date: 1700000301, }) if err != nil { t.Fatalf("send channel message: %v", err) } var friendTop, friendReadInbox, friendUnread int if err := pool.QueryRow(ctx, ` SELECT top_message_id, read_inbox_max_id, unread_count FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2`, channelID, friend.ID).Scan(&friendTop, &friendReadInbox, &friendUnread); err != nil { t.Fatalf("read friend dialog row after send: %v", err) } if friendTop != sent.Message.ID || friendReadInbox != 0 || friendUnread != 2 { t.Fatalf("friend dialog row top=%d read=%d unread=%d, want top %d read 0 unread 2", friendTop, friendReadInbox, friendUnread, sent.Message.ID) } var ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread int if err := pool.QueryRow(ctx, ` SELECT top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerTop, &ownerReadInbox, &ownerReadOutbox, &ownerUnread); err != nil { t.Fatalf("read owner dialog row after send: %v", err) } // 发送者自己的 outbox 回执水位不得随发送推进:同账号另一设备会把它当成 // "对端已读"渲染双勾。推进只能来自 peer 的 readHistory(见下方断言)。 if ownerTop != sent.Message.ID || ownerReadInbox != sent.Message.ID || ownerReadOutbox != 0 || ownerUnread != 0 { t.Fatalf("owner dialog row top=%d read_in=%d read_out=%d unread=%d, want top/read_in %d, read_out 0 before peer read, unread 0", ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread, sent.Message.ID) } var ownerMemberReadInbox, ownerMemberReadOutbox int if err := pool.QueryRow(ctx, ` SELECT read_inbox_max_id, read_outbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberReadInbox, &ownerMemberReadOutbox); err != nil { t.Fatalf("read owner member row after send: %v", err) } if ownerMemberReadInbox != sent.Message.ID || ownerMemberReadOutbox != 0 { t.Fatalf("owner member read_in=%d read_out=%d, want read_in %d and read_out unchanged before peer read", ownerMemberReadInbox, ownerMemberReadOutbox, sent.Message.ID) } dialogs, err := channels.ListChannelDialogs(ctx, friend.ID, domain.DialogFilter{Limit: 10}) if err != nil { t.Fatalf("list friend dialogs: %v", err) } if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].TopMessage != sent.Message.ID { t.Fatalf("friend dialogs = %+v, want top message %d", dialogs.Dialogs, sent.Message.ID) } if len(dialogs.Messages) != 1 || dialogs.Messages[0].Body != "first visible channel text" { t.Fatalf("friend dialog messages = %+v, want latest channel text", dialogs.Messages) } if dialogs.Dialogs[0].UnreadCount != 2 { t.Fatalf("friend unread = %d, want create service + latest text", dialogs.Dialogs[0].UnreadCount) } read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ UserID: friend.ID, ChannelID: channelID, MaxID: sent.Message.ID, Date: 1700000302, }) if err != nil { t.Fatalf("read history: %v", err) } if read.Dialog.UnreadCount != 0 || read.Dialog.ReadInboxMaxID != sent.Message.ID { t.Fatalf("read dialog = %+v, want fully read through latest", read.Dialog) } if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != owner.ID || read.OutboxUpdates[0].MaxID != sent.Message.ID { t.Fatalf("read outbox updates = %+v, want owner max id %d", read.OutboxUpdates, sent.Message.ID) } ownerView, err := channels.GetChannel(ctx, owner.ID, channelID) if err != nil { t.Fatalf("get owner channel after read: %v", err) } if ownerView.Dialog.ReadOutboxMaxID != sent.Message.ID { t.Fatalf("owner dialog read_outbox = %d, want %d", ownerView.Dialog.ReadOutboxMaxID, sent.Message.ID) } if changed, folderID, err := channels.SetChannelDialogPinned(ctx, owner.ID, channelID, true); err != nil || !changed || folderID != domain.DialogMainFolderID { t.Fatalf("set owner channel pinned = changed %v folder %d err %v, want changed in main folder", changed, folderID, err) } if changed, err := channels.ReorderChannelPinnedDialogs(ctx, owner.ID, domain.DialogMainFolderID, []domain.Peer{ {Type: domain.PeerTypeChannel, ID: channelID}, }, true); err != nil || changed { t.Fatalf("reorder owner channel pinned = changed %v err %v, want no-op", changed, err) } if changed, err := channels.SetChannelDialogUnreadMark(ctx, owner.ID, channelID, true); err != nil || !changed { t.Fatalf("set owner channel unread mark = changed %v err %v, want changed", changed, err) } ownerDialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) if err != nil { t.Fatalf("get owner channel dialogs before archive: %v", err) } if len(ownerDialogs.Dialogs) != 1 || !ownerDialogs.Dialogs[0].Pinned || ownerDialogs.Dialogs[0].PinnedOrder != 1 || !ownerDialogs.Dialogs[0].UnreadMark { t.Fatalf("owner channel dialog settings = %+v, want pinned order 1 with unread mark", ownerDialogs.Dialogs) } if err := channels.EditChannelPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{ {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, FolderID: domain.DialogArchiveFolderID}, }); err != nil { t.Fatalf("edit owner channel folder: %v", err) } ownerDialogs, err = channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) if err != nil { t.Fatalf("get owner channel dialogs after archive: %v", err) } // 归档清除 pinned(对齐 TDesktop History::setFolderPointer 的本地 // unpin),unread_mark 与 folder_id 保留。 if len(ownerDialogs.Dialogs) != 1 || ownerDialogs.Dialogs[0].Pinned || ownerDialogs.Dialogs[0].PinnedOrder != 0 || !ownerDialogs.Dialogs[0].UnreadMark || ownerDialogs.Dialogs[0].FolderID != domain.DialogArchiveFolderID { t.Fatalf("owner channel dialog settings = %+v, want unpinned after archive with unread mark kept", ownerDialogs.Dialogs) } unreadMarks, err := channels.ListChannelUnreadMarked(ctx, owner.ID) if err != nil { t.Fatalf("list owner channel unread marks: %v", err) } if len(unreadMarks) != 1 || unreadMarks[0].ID != channelID || unreadMarks[0].Type != domain.PeerTypeChannel { t.Fatalf("channel unread marks = %+v, want channel", unreadMarks) } readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ UserID: owner.ID, ChannelID: channelID, MessageID: sent.Message.ID, Limit: domain.MaxChannelReadParticipants, Date: 1700000303, }) if err != nil { t.Fatalf("list message read participants: %v", err) } if len(readers.Participants) != 1 || readers.Participants[0].UserID != friend.ID || readers.Participants[0].Date != 1700000302 { t.Fatalf("read participants = %+v, want friend read date", readers.Participants) } cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: friend.ID, ChannelID: channelID, MaxID: sent.Message.ID, Date: 1700000302, }) if err != nil { t.Fatalf("local clear history: %v", err) } if cleared.AvailableMinID != sent.Message.ID { t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) } staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: friend.ID, ChannelID: channelID, MaxID: created.Message.ID, Date: 1700000303, }) if err != nil { t.Fatalf("stale local clear history: %v", err) } if staleClear.AvailableMinID != sent.Message.ID { t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID) } afterClear, err := channels.GetChannel(ctx, friend.ID, channelID) if err != nil { t.Fatalf("get channel after clear: %v", err) } if afterClear.Dialog.TopMessageID != 0 { t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog) } next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 902, Message: "after local clear", Date: 1700000304, }) if err != nil { t.Fatalf("send after clear: %v", err) } afterNext, err := channels.GetChannel(ctx, friend.ID, channelID) if err != nil { t.Fatalf("get channel after next: %v", err) } if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 { t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID) } } func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollbackPostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 914130, Phone: "+1777" + suffix + "91", FirstName: "StoryForwardOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } channels := NewChannelStore(pool) var channelIDs []int64 t.Cleanup(func() { if len(channelIDs) > 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) }) publicCreated, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "PG Story Forward Public " + suffix, Broadcast: true, Date: 1700000330, }) if err != nil { t.Fatalf("create public channel: %v", err) } channelIDs = append(channelIDs, publicCreated.Channel.ID) publicChannel, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ UserID: owner.ID, ChannelID: publicCreated.Channel.ID, Username: "storyfw" + suffix, }) if err != nil { t.Fatalf("make public channel: %v", err) } privateCreated, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "PG Story Forward Private " + suffix, Broadcast: true, Date: 1700000331, }) if err != nil { t.Fatalf("create private channel: %v", err) } channelIDs = append(channelIDs, privateCreated.Channel.ID) source := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} media := &domain.MessageMedia{ Kind: domain.MessageMediaKindStory, Story: &domain.MessageStory{ Peer: source, ID: 71, Story: &domain.Story{ Owner: source, ID: 71, }, }, } publicSent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: publicChannel.ID, RandomID: 9141301, Media: media, Date: 1700000332, }) if err != nil { t.Fatalf("send public story message: %v", err) } if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: privateCreated.Channel.ID, RandomID: 9141302, Media: media, Date: 1700000333, }); err != nil { t.Fatalf("send private story message: %v", err) } list, err := channels.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ ViewerUserID: owner.ID, Owner: source, StoryID: 71, 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 := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ UserID: owner.ID, ChannelID: publicChannel.ID, IDs: []int{publicSent.Message.ID}, Date: 1700000334, }); err != nil { t.Fatalf("delete public story message: %v", err) } empty, err := channels.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ ViewerUserID: owner.ID, Owner: source, StoryID: 71, 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 TestChannelStoreSendMessageViaBotIDSurvivesReadPaths(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{AccessHash: 3101, Phone: "+1888" + suffix + "01", FirstName: "InlineChannelOwner"}) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := users.Create(ctx, domain.User{AccessHash: 3102, Phone: "+1888" + suffix + "02", FirstName: "InlineChannelFriend"}) if err != nil { t.Fatalf("create friend: %v", err) } bot, err := users.Create(ctx, domain.User{AccessHash: 3103, FirstName: "InlineViaBot", Username: "inline_via_pg_" + suffix + "_bot", Bot: true, BotInfoVersion: 1}) if err != nil { t.Fatalf("create bot user: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, bot.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Inline Via Channel " + suffix, Megagroup: true, MemberUserIDs: []int64{friend.ID}, Date: 1700001100, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID 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 := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 990301, Message: "inline channel via", ViaBotID: bot.ID, ReplyMarkup: markup, Date: 1700001101, }) if err != nil { t.Fatalf("send channel via: %v", err) } if sent.Message.ViaBotID != bot.ID || sent.Event.Message.ViaBotID != bot.ID { t.Fatalf("sent via_bot_id = msg %d event %d, want %d", sent.Message.ViaBotID, sent.Event.Message.ViaBotID, bot.ID) } assertMarkup("sent message", sent.Message.ReplyMarkup) assertMarkup("sent event", sent.Event.Message.ReplyMarkup) duplicate, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 990301, Message: "changed text must not win", Date: 1700001102, }) if err != nil { t.Fatalf("duplicate channel via: %v", err) } if !duplicate.Duplicate || duplicate.Message.ViaBotID != bot.ID || duplicate.Event.Message.ViaBotID != bot.ID { t.Fatalf("duplicate = %+v event=%+v, want original via bot %d", duplicate.Message, duplicate.Event, bot.ID) } assertMarkup("duplicate message", duplicate.Message.ReplyMarkup) assertMarkup("duplicate event", duplicate.Event.Message.ReplyMarkup) history, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 10}) if err != nil { t.Fatalf("history channel via: %v", err) } if len(history.Messages) == 0 || history.Messages[0].ViaBotID != bot.ID { t.Fatalf("history messages = %+v, want top via bot %d", history.Messages, bot.ID) } assertMarkup("history message", history.Messages[0].ReplyMarkup) byID, err := channels.GetChannelMessages(ctx, owner.ID, channelID, []int{sent.Message.ID}) if err != nil { t.Fatalf("get channel messages via: %v", err) } if len(byID.Messages) != 1 || byID.Messages[0].ViaBotID != bot.ID { t.Fatalf("get messages = %+v, want via bot %d", byID.Messages, bot.ID) } assertMarkup("get messages", byID.Messages[0].ReplyMarkup) diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 10}) if err != nil { t.Fatalf("channel difference via: %v", err) } if len(diff.NewMessages) != 1 || diff.NewMessages[0].ViaBotID != bot.ID { t.Fatalf("difference messages = %+v, want via bot %d", diff.NewMessages, bot.ID) } assertMarkup("difference message", diff.NewMessages[0].ReplyMarkup) dialogs, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 10}) if err != nil { t.Fatalf("list dialogs via: %v", err) } if len(dialogs.Messages) == 0 || dialogs.Messages[0].ViaBotID != bot.ID { t.Fatalf("dialog top messages = %+v, want via bot %d", dialogs.Messages, bot.ID) } assertMarkup("dialog top message", dialogs.Messages[0].ReplyMarkup) editedMarkup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ {Type: domain.MarkupButtonCallback, Text: "Done", Data: []byte("v2")}, }}} if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, ID: sent.Message.ID, Message: "wrong bot edit", ViaBotEditBotID: bot.ID + 1, EditDate: 1700001103, SetReplyMarkup: true, ReplyMarkup: editedMarkup, }); err != domain.ErrMessageAuthorRequired { t.Fatalf("wrong channel via bot edit err = %v, want ErrMessageAuthorRequired", err) } edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, ID: sent.Message.ID, Message: "inline channel edited", ViaBotEditBotID: bot.ID, EditDate: 1700001104, SetReplyMarkup: true, ReplyMarkup: editedMarkup, }) if err != nil { t.Fatalf("channel via bot edit: %v", err) } if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Message.Body != "inline channel edited" || edited.Message.ViaBotID != bot.ID { 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) } editDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: owner.ID, ChannelID: channelID, Pts: sent.Event.Pts, Limit: 10}) if err != nil { t.Fatalf("channel edit difference: %v", err) } if len(editDiff.OtherUpdates) != 1 || editDiff.OtherUpdates[0].Type != domain.ChannelUpdateEditMessage { t.Fatalf("channel edit difference = %+v, want one edit", editDiff.OtherUpdates) } if editDiff.OtherUpdates[0].Message.ReplyMarkup == nil || editDiff.OtherUpdates[0].Message.ReplyMarkup.Inline[0][0].Text != "Done" { t.Fatalf("channel edit difference markup = %+v, want Done", editDiff.OtherUpdates[0].Message.ReplyMarkup) } } func TestChannelStoreSendMessageSkipDeliveryAdvancesReadBoundary(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{AccessHash: 731, Phone: "+1778" + suffix + "01", FirstName: "SkipOwner"}) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := users.Create(ctx, domain.User{AccessHash: 732, Phone: "+1778" + suffix + "02", FirstName: "SkipFriend"}) if err != nil { t.Fatalf("create friend: %v", err) } bot, err := users.Create(ctx, domain.User{AccessHash: 733, Phone: "+1778" + suffix + "03", FirstName: "SkipBot", Bot: true, BotInfoVersion: 1}) if err != nil { t.Fatalf("create bot-like user: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, bot.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Skip Delivery " + suffix, Megagroup: true, MemberUserIDs: []int64{friend.ID, bot.ID}, Date: 1700000400, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID hidden, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 941, Message: "hidden for privacy bot", SkipDeliveryUserIDs: []int64{bot.ID}, Date: 1700000401, }) if err != nil { t.Fatalf("send hidden: %v", err) } if containsInt64ForPostgresTest(hidden.Recipients, bot.ID) { t.Fatalf("hidden recipients = %+v, want bot skipped", hidden.Recipients) } var botDialogTop, botReadInbox, botUnread int if err := pool.QueryRow(ctx, ` SELECT top_message_id, read_inbox_max_id, unread_count FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&botDialogTop, &botReadInbox, &botUnread); err != nil { t.Fatalf("read bot dialog after hidden: %v", err) } if botDialogTop != created.Message.ID { t.Fatalf("bot dialog after hidden top=%d, want unchanged create top %d", botDialogTop, created.Message.ID) } var memberReadInbox int if err := pool.QueryRow(ctx, ` SELECT read_inbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&memberReadInbox); err != nil { t.Fatalf("read bot member boundary: %v", err) } if memberReadInbox != hidden.Message.ID { t.Fatalf("bot member read_inbox = %d, want hidden message id %d", memberReadInbox, hidden.Message.ID) } visible, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 942, Message: "/visible", Date: 1700000402, }) if err != nil { t.Fatalf("send visible: %v", err) } if !containsInt64ForPostgresTest(visible.Recipients, bot.ID) { t.Fatalf("visible recipients = %+v, want bot", visible.Recipients) } if err := pool.QueryRow(ctx, ` SELECT top_message_id, read_inbox_max_id, unread_count FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&botDialogTop, &botReadInbox, &botUnread); err != nil { t.Fatalf("read bot dialog after visible: %v", err) } if botDialogTop != visible.Message.ID || botReadInbox != hidden.Message.ID || botUnread != 1 { t.Fatalf("bot dialog after visible top=%d read=%d unread=%d, want top %d read hidden %d unread visible-only", botDialogTop, botReadInbox, botUnread, visible.Message.ID, hidden.Message.ID) } } func containsInt64ForPostgresTest(ids []int64, target int64) bool { for _, id := range ids { if id == target { return true } } return false } func TestChannelStoreConcurrentSendAndReadHistoryDoNotSurfaceDeadlock(t *testing.T) { pool := testPool(t) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 37, Phone: "+1777" + suffix + "21", FirstName: "ConcurrentOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } member, err := users.Create(ctx, domain.User{ AccessHash: 38, Phone: "+1777" + suffix + "22", FirstName: "ConcurrentMember", }) if err != nil { t.Fatalf("create member: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(context.Background(), "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(context.Background(), "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Send Read Race " + suffix, Megagroup: true, MemberUserIDs: []int64{member.ID}, Date: 1700000450, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 1700000451, Message: "seed", Date: 1700000451, }) if err != nil { t.Fatalf("seed send: %v", err) } for i := 0; i < 20; i++ { start := make(chan struct{}) errs := make(chan error, 2) var wg sync.WaitGroup wg.Add(2) go func(iter int) { defer wg.Done() <-start _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: member.ID, ChannelID: channelID, RandomID: int64(1700000500 + iter), Message: fmt.Sprintf("race send %d", iter), Date: 1700000500 + iter, }) errs <- err }(i) go func() { defer wg.Done() <-start _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ UserID: member.ID, ChannelID: channelID, MaxID: first.Message.ID, Date: 1700000600, }) errs <- err }() close(start) wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("concurrent send/read iteration %d: %v", i, err) } } } } func TestChannelStoreJoinInitialReadWatermarkSkipsExistingHistory(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 131, Phone: "+1777" + suffix + "11", FirstName: "JoinOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := users.Create(ctx, domain.User{ AccessHash: 132, Phone: "+1777" + suffix + "12", FirstName: "JoinFriend", }) if err != nil { t.Fatalf("create friend: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Join Watermark " + suffix, Megagroup: true, Date: 1700000320, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 905, Message: "before join", Date: 1700000321, }) if err != nil { t.Fatalf("send existing message: %v", err) } joined, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000322) if err != nil { t.Fatalf("join channel: %v", err) } if _, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000323); !errors.Is(err, domain.ErrUserAlreadyParticipant) { t.Fatalf("duplicate join err = %v, want ErrUserAlreadyParticipant", err) } if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID { t.Fatalf("joined member = %+v message=%+v, want read watermark at self join service", joined.Members, joined.Message) } view, err := channels.GetChannel(ctx, friend.ID, channelID) if err != nil { t.Fatalf("get joined channel: %v", err) } if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { t.Fatalf("joined view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) } readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ UserID: owner.ID, ChannelID: channelID, MessageID: first.Message.ID, Limit: domain.MaxChannelReadParticipants, Date: 1700000323, }) if err != nil { t.Fatalf("list read participants existing message: %v", err) } if len(readers.Participants) != 0 { t.Fatalf("existing message readers after join = %+v, want none from initial watermark", readers.Participants) } future, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 906, Message: "after join", Date: 1700000323, }) if err != nil { t.Fatalf("send future message: %v", err) } after, err := channels.GetChannel(ctx, friend.ID, channelID) if err != nil { t.Fatalf("get channel after future: %v", err) } if after.Dialog.TopMessageID != future.Message.ID || after.Dialog.UnreadCount != 1 { t.Fatalf("joined dialog after future = %+v, want top %d unread 1", after.Dialog, future.Message.ID) } } func TestChannelStoreInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 41, Phone: "+1888" + suffix + "01", FirstName: "InviteOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } invited, err := users.Create(ctx, domain.User{ AccessHash: 42, Phone: "+1888" + suffix + "02", FirstName: "InviteMember", }) if err != nil { t.Fatalf("create invited: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, invited.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Invite Watermark " + suffix, Megagroup: true, Date: 1700000320, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 902, Message: "already visible before invite", Date: 1700000321, }) if err != nil { t.Fatalf("send existing channel message: %v", err) } if _, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{invited.ID}, 1700000322); err != nil { t.Fatalf("invite to channel: %v", err) } view, err := channels.GetChannel(ctx, invited.ID, channelID) if err != nil { t.Fatalf("get invited channel: %v", err) } if view.Self.ReadInboxMaxID != first.Message.ID || view.Dialog.ReadInboxMaxID != first.Message.ID { t.Fatalf("invited read watermark self/dialog = %d/%d, want existing top %d", view.Self.ReadInboxMaxID, view.Dialog.ReadInboxMaxID, first.Message.ID) } if view.Dialog.UnreadCount != 1 { t.Fatalf("invited unread = %d, want only invite service message unread", view.Dialog.UnreadCount) } } func TestChannelStoreImportInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 51, Phone: "+1888" + suffix + "11", FirstName: "ImportOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } joiner, err := users.Create(ctx, domain.User{ AccessHash: 52, Phone: "+1888" + suffix + "12", FirstName: "ImportJoiner", }) if err != nil { t.Fatalf("create joiner: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, joiner.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Import Watermark " + suffix, Megagroup: true, Date: 1700000330, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 912, Message: "already visible before import", Date: 1700000331, }) if err != nil { t.Fatalf("send existing channel message: %v", err) } invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ UserID: owner.ID, ChannelID: channelID, Title: "join", Date: 1700000332, }) if err != nil { t.Fatalf("export invite: %v", err) } joined, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ UserID: joiner.ID, Hash: invite.Invite.Hash, Date: 1700000333, }) if err != nil { t.Fatalf("import invite: %v", err) } if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID || joined.Members[0].ReadOutboxMaxID != joined.Message.ID { t.Fatalf("imported member = %+v message=%+v, want read watermarks at self join service", joined.Members, joined.Message) } view, err := channels.GetChannel(ctx, joiner.ID, channelID) if err != nil { t.Fatalf("get imported channel: %v", err) } if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { t.Fatalf("imported view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) } readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ UserID: owner.ID, ChannelID: channelID, MessageID: first.Message.ID, Limit: domain.MaxChannelReadParticipants, Date: 1700000334, }) if err != nil { t.Fatalf("list read participants existing message: %v", err) } if len(readers.Participants) != 0 { t.Fatalf("existing message readers after import = %+v, want none from initial watermark", readers.Participants) } } func TestChannelStoreSendMessageResolvesReplyTopID(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 41, Phone: "+1778" + suffix + "01", FirstName: "ReplyOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := users.Create(ctx, domain.User{ AccessHash: 42, Phone: "+1778" + suffix + "02", FirstName: "ReplyFriend", }) if err != nil { t.Fatalf("create friend: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Reply Top " + suffix, Megagroup: true, MemberUserIDs: []int64{friend.ID}, Date: 1700000350, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID root, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 911, Message: "root", Date: 1700000351, }) if err != nil { t.Fatalf("send root: %v", err) } reply, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: friend.ID, ChannelID: channelID, RandomID: 912, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: root.Message.ID, QuoteText: "root"}, Date: 1700000352, }) if err != nil { t.Fatalf("send reply: %v", err) } channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} if reply.Message.ReplyTo == nil || reply.Message.ReplyTo.Peer != channelPeer || reply.Message.ReplyTo.TopMessageID != root.Message.ID { t.Fatalf("reply metadata = %+v, want channel peer and top id %d", reply.Message.ReplyTo, root.Message.ID) } nested, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 913, Message: "nested", ReplyTo: &domain.MessageReply{MessageID: reply.Message.ID}, Date: 1700000353, }) if err != nil { t.Fatalf("send nested reply: %v", err) } if nested.Message.ReplyTo == nil || nested.Message.ReplyTo.TopMessageID != root.Message.ID { t.Fatalf("nested reply metadata = %+v, want inherited top id %d", nested.Message.ReplyTo, root.Message.ID) } _, err = channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 914, Message: "bad quote offset", ReplyTo: &domain.MessageReply{ MessageID: root.Message.ID, QuoteText: "root", QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, }, Date: 1700000354, }) if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) } } func TestChannelStoreHistorySupportsOffsetDateOnly(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 37, Phone: "+1778" + suffix + "03", FirstName: "HistoryDateOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "History Date " + suffix, Megagroup: true, Date: 1700000360, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 921, Message: "old", Date: 1700000361, }); err != nil { t.Fatalf("send old: %v", err) } if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: owner.ID, ChannelID: channelID, RandomID: 922, Message: "new", Date: 1700000362, }); err != nil { t.Fatalf("send new: %v", err) } history, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ ChannelID: channelID, OffsetDate: 1700000362, Limit: 10, }) if err != nil { t.Fatalf("list channel history: %v", err) } if len(history.Messages) != 2 || history.Messages[0].Body != "old" || history.Messages[1].Action == nil { t.Fatalf("history = %+v, want only messages older than offset date", history.Messages) } } func TestChannelStoreDeleteHistoryForEveryoneBatchesHugeMaxID(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) owner, err := users.Create(ctx, domain.User{ AccessHash: 71, Phone: "+1998" + suffix + "01", FirstName: "BulkChannelOwner", }) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := users.Create(ctx, domain.User{ AccessHash: 72, Phone: "+1998" + suffix + "02", FirstName: "BulkChannelFriend", }) if err != nil { t.Fatalf("create friend: %v", err) } var channelID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) } _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) }) channels := NewChannelStore(pool) created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: owner.ID, Title: "Bulk Delete " + suffix, Megagroup: true, MemberUserIDs: []int64{friend.ID}, Date: 1700000600, }) if err != nil { t.Fatalf("create channel: %v", err) } channelID = created.Channel.ID total := domain.MaxDeleteHistoryBatch + 2 if _, err := pool.Exec(ctx, ` WITH src AS ( SELECT generate_series(2, $3::int + 1) AS id ), msgs AS ( INSERT INTO channel_messages ( channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, message_date, body, entities, pts ) SELECT $1::bigint, id, 920000000 + id, $2::bigint, 'user', $2::bigint, 1700000600 + id, 'bulk channel history', '[]'::jsonb, id FROM src RETURNING id, message_date ) INSERT INTO channel_update_events ( channel_id, pts, pts_count, date, event_type, message_id, sender_user_id, payload ) SELECT $1::bigint, id, 1, message_date, 'new_channel_message', id, $2::bigint, '{}'::jsonb FROM msgs `, channelID, owner.ID, total); err != nil { t.Fatalf("seed bulk channel messages: %v", err) } if _, err := pool.Exec(ctx, ` UPDATE channels SET top_message_id = $2, pts = $2, updated_at = now() WHERE id = $1`, channelID, total+1); err != nil { t.Fatalf("update channel bulk top: %v", err) } first, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: owner.ID, ChannelID: channelID, MaxID: int(^uint(0) >> 1), ForEveryone: true, Date: 1700000700, }) if err != nil { t.Fatalf("DeleteChannelHistory first batch: %v", err) } wantFirstPts := total + 1 + domain.MaxDeleteHistoryBatch if first.Offset != 1 || first.Event.Pts != wantFirstPts || first.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch { t.Fatalf("first batch = %+v, want offset=1 pts=%d pts_count=%d", first, wantFirstPts, domain.MaxDeleteHistoryBatch) } var remaining int if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&remaining); err != nil { t.Fatalf("count remaining after first batch: %v", err) } if remaining != 3 { t.Fatalf("remaining after first batch = %d, want create service + two oldest messages", remaining) } second, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ UserID: owner.ID, ChannelID: channelID, MaxID: int(^uint(0) >> 1), ForEveryone: true, Date: 1700000701, }) if err != nil { t.Fatalf("DeleteChannelHistory second batch: %v", err) } if second.Offset != 0 || second.Event.Pts != wantFirstPts+2 || second.Event.PtsCount != 2 || len(second.DeletedIDs) != 2 { t.Fatalf("second batch = %+v, want final offset=0 pts=%d pts_count=2 keeping create service message", second, wantFirstPts+2) } if second.Channel.TopMessageID != 1 { t.Fatalf("top after full clear = %d, want create service message 1", second.Channel.TopMessageID) } var keptID int if err := pool.QueryRow(ctx, `SELECT id FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&keptID); err != nil { t.Fatalf("query kept message after full clear: %v", err) } if keptID != 1 { t.Fatalf("kept message id = %d, want create service message 1", keptID) } var dialogTops []int rows, err := pool.Query(ctx, `SELECT top_message_id FROM channel_dialogs WHERE channel_id = $1 ORDER BY user_id`, channelID) if err != nil { t.Fatalf("query member dialogs after full clear: %v", err) } for rows.Next() { var top int if err := rows.Scan(&top); err != nil { rows.Close() t.Fatalf("scan member dialog top: %v", err) } dialogTops = append(dialogTops, top) } rows.Close() if len(dialogTops) != 2 || dialogTops[0] != 1 || dialogTops[1] != 1 { t.Fatalf("member dialog tops after full clear = %v, want create service message visible for both members", dialogTops) } }