owpengram-server/internal/rpc/dialogs_rpc_test.go
2026-09-01 12:06:31 +03:00

1498 lines
56 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
"reflect"
"strings"
"sync"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
func TestDialogFilterFromGetDialogsRequestUsesAllParameters(t *testing.T) {
r := New(Config{}, Deps{
Users: staticUsersService{user: domain.User{ID: 1000000001, AccessHash: 42}},
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetDialogsRequest{
ExcludePinned: true,
OffsetDate: 1700000000,
OffsetID: 22,
OffsetPeer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash},
Limit: 999,
Hash: 123456,
}
req.SetFolderID(7)
filter, err := r.dialogFilterFromRequest(context.Background(), 1000000001, req)
if err != nil {
t.Fatalf("dialogFilterFromRequest: %v", err)
}
if !filter.ExcludePinned || !filter.HasFolderID || filter.FolderID != 7 {
t.Fatalf("folder/pinned filter = %+v", filter)
}
if filter.OffsetDate != req.OffsetDate || filter.OffsetID != req.OffsetID || !filter.HasOffsetPeer || filter.OffsetPeer.ID != domain.OfficialSystemUserID {
t.Fatalf("offset filter = %+v", filter)
}
if filter.Limit != 500 || filter.Hash != req.Hash {
t.Fatalf("limit/hash filter = %+v", filter)
}
}
func TestMessagesGetDialogsUnknownHashReturnsFullListToRefreshPeerMetadata(t *testing.T) {
dialogs := &captureDialogs{list: domain.DialogList{Count: 3, Hash: 77}}
r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
Hash: 77,
}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if _, ok := enc.(*tg.MessagesDialogsNotModified); ok {
t.Fatalf("response = %T, want full dialogs after unknown/invalidation hash", enc)
}
if dialogs.filter.Hash != 77 || dialogs.getDialogsCalls != 1 {
t.Fatalf("filter %+v full calls %d, want one authoritative load", dialogs.filter, dialogs.getDialogsCalls)
}
}
func TestMessagesGetDialogsHashHitSkipsFullListLoad(t *testing.T) {
dialogs := &captureDialogs{hashCheck: domain.DialogHashCheck{
Known: true,
Matched: true,
Hash: 77,
Count: 5,
}}
r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
Hash: 77,
}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
got, ok := enc.(*tg.MessagesDialogsNotModified)
if !ok {
t.Fatalf("response = %T, want *tg.MessagesDialogsNotModified", enc)
}
if got.Count != 5 {
t.Fatalf("not modified count = %d, want 5", got.Count)
}
if dialogs.hashCalls != 1 || dialogs.getDialogsCalls != 0 {
t.Fatalf("hash/full calls = %d/%d, want 1/0", dialogs.hashCalls, dialogs.getDialogsCalls)
}
if dialogs.hashFilter.Hash != 77 || dialogs.hashFilter.OffsetID != 0 || dialogs.hashFilter.HasOffsetPeer {
t.Fatalf("hash filter = %+v, want request hash without offset", dialogs.hashFilter)
}
}
func TestMessagesGetPeerDialogsReturnsRequestedDialogsAndState(t *testing.T) {
msg := domain.Message{
ID: 3,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000100,
Body: "Login code: 12345",
}
dialogs := &captureDialogs{peerList: domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: msg.Peer,
TopMessage: msg.ID,
TopMessageDate: msg.Date,
UnreadCount: 1,
}},
Messages: []domain.Message{msg},
Users: []domain.User{domain.OfficialSystemUser()},
}}
updates := &captureUpdates{state: domain.UpdateState{Pts: 8, Date: 1700000200, Seq: 4}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetPeerDialogsRequest{
Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{
Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash},
}},
}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
got, ok := enc.(*tg.MessagesPeerDialogs)
if !ok {
t.Fatalf("response = %T, want *tg.MessagesPeerDialogs", enc)
}
if len(dialogs.peers) != 1 || dialogs.peers[0].ID != domain.OfficialSystemUserID {
t.Fatalf("requested peers = %+v, want official peer", dialogs.peers)
}
if got.State.Pts != 8 || got.State.Seq != 4 {
t.Fatalf("state = %+v, want updates state", got.State)
}
if len(got.Dialogs) != 1 || len(got.Messages) != 1 || len(got.Users) != 1 {
t.Fatalf("peer dialogs = %+v, want one dialog/message/user", got)
}
}
func TestDialogSettingRPCsRecordDurableUpdates(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 9
rawAuthKeyID := [8]byte{9, 7}
ctx := WithRawAuthKeyID(
WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55),
rawAuthKeyID,
)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}
dialogs := &captureDialogs{}
updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
toggle := &tg.MessagesToggleDialogPinRequest{Peer: dialogPeer}
toggle.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok {
t.Fatalf("toggle pin = %v, %v", ok, err)
}
if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned || updates.events[0].Peer != peer || !updates.events[0].Bool || updates.excludeSessionID != 55 {
t.Fatalf("pin event = %+v, want durable dialog_pinned", updates.events)
}
if updates.authKeyID != authKeyID || updates.excludeAuthKeyID != rawAuthKeyID {
t.Fatalf("durable update keys = state:%x exclude:%x, want business:%x raw:%x", updates.authKeyID, updates.excludeAuthKeyID, authKeyID, rawAuthKeyID)
}
if ok, err := r.onMessagesReorderPinnedDialogs(ctx, &tg.MessagesReorderPinnedDialogsRequest{Order: []tg.InputDialogPeerClass{dialogPeer}}); err != nil || !ok {
t.Fatalf("reorder pinned = %v, %v", ok, err)
}
if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventPinnedDialogs || len(updates.events[1].Peers) != 1 || updates.events[1].Peers[0] != peer {
t.Fatalf("reorder event = %+v, want durable pinned_dialogs", updates.events)
}
mark := &tg.MessagesMarkDialogUnreadRequest{Peer: dialogPeer}
mark.SetUnread(true)
if ok, err := r.onMessagesMarkDialogUnread(ctx, mark); err != nil || !ok {
t.Fatalf("mark unread = %v, %v", ok, err)
}
if len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogUnreadMark || updates.events[2].Peer != peer || !updates.events[2].Bool {
t.Fatalf("unread event = %+v, want durable dialog_unread_mark", updates.events)
}
parentMark := &tg.MessagesMarkDialogUnreadRequest{
ParentPeer: &tg.InputPeerChannel{ChannelID: 9001},
Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}},
}
parentMark.SetUnread(true)
parentMark.SetParentPeer(parentMark.ParentPeer)
if ok, err := r.onMessagesMarkDialogUnread(ctx, parentMark); err != nil || !ok {
t.Fatalf("mark monoforum unread compat = %v, %v", ok, err)
}
if len(updates.events) != 3 {
t.Fatalf("parent_peer unread compat recorded events = %+v, want no durable event", updates.events)
}
getParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerChannel{ChannelID: 9001}}
getParentMarks.SetParentPeer(getParentMarks.ParentPeer)
marks, err := r.onMessagesGetDialogUnreadMarks(ctx, getParentMarks)
if err != nil {
t.Fatalf("get monoforum unread marks compat: %v", err)
}
if len(marks) != 0 {
t.Fatalf("monoforum unread marks = %+v, want empty compat list", marks)
}
invalidParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerSelf{}}
invalidParentMarks.SetParentPeer(invalidParentMarks.ParentPeer)
if _, err := r.onMessagesGetDialogUnreadMarks(ctx, invalidParentMarks); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") {
t.Fatalf("get unread marks invalid parent err = %v, want PARENT_PEER_INVALID", err)
}
if ok, err := r.onMessagesHidePeerSettingsBar(ctx, &tg.InputPeerUser{UserID: peer.ID}); err != nil || !ok {
t.Fatalf("hide peer settings = %v, %v", ok, err)
}
if len(updates.events) != 4 || updates.events[3].Type != domain.UpdateEventPeerSettings || updates.events[3].Peer != peer || !updates.events[3].Settings.HiddenPeerSettingsBar {
t.Fatalf("peer settings event = %+v, want durable peer_settings", updates.events)
}
}
func TestReorderPinnedDialogsNoopDoesNotRecordUpdate(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 9
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}
dialogs := &captureDialogs{reorderNoChange: true}
updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
if ok, err := r.onMessagesReorderPinnedDialogs(ctx, &tg.MessagesReorderPinnedDialogsRequest{
Order: []tg.InputDialogPeerClass{dialogPeer},
}); err != nil || !ok {
t.Fatalf("reorder pinned noop = %v, %v", ok, err)
}
if len(updates.events) != 0 {
t.Fatalf("noop reorder recorded events = %+v, want none", updates.events)
}
}
func TestDialogUnreadMarksIncludeChannelDialogs(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, 1000000001, domain.CreateChannelRequest{
Title: "Unread Mark Channel",
Date: 1700001000,
})
if err != nil {
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
}
dialogs := appdialogs.NewService(memory.NewDialogStore(), channelStore)
r := New(Config{}, Deps{
Channels: channelSvc,
Dialogs: dialogs,
}, zaptest.NewLogger(t), clock.System)
rpcCtx := WithUserID(ctx, 1000000001)
input := &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{
ChannelID: created.Channel.ID,
AccessHash: created.Channel.AccessHash,
}}
mark := &tg.MessagesMarkDialogUnreadRequest{Peer: input}
mark.SetUnread(true)
if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, mark); err != nil || !ok {
t.Fatalf("mark channel unread = %v, %v", ok, err)
}
marks, err := r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{})
if err != nil {
t.Fatalf("get channel unread marks: %v", err)
}
if len(marks) != 1 {
t.Fatalf("channel unread marks len = %d, want 1 (%+v)", len(marks), marks)
}
got, ok := marks[0].(*tg.DialogPeer)
if !ok {
t.Fatalf("channel unread mark = %T, want dialogPeer", marks[0])
}
peer, ok := got.Peer.(*tg.PeerChannel)
if !ok || peer.ChannelID != created.Channel.ID {
t.Fatalf("channel unread mark peer = %#v, want channel %d", got.Peer, created.Channel.ID)
}
dialogsList, err := dialogs.GetPeerDialogs(ctx, 1000000001, []domain.Peer{{Type: domain.PeerTypeChannel, ID: created.Channel.ID}})
if err != nil {
t.Fatalf("GetPeerDialogs: %v", err)
}
var dialog domain.Dialog
for _, item := range dialogsList.Dialogs {
if item.Peer.Type == domain.PeerTypeChannel && item.Peer.ID == created.Channel.ID {
dialog = item
break
}
}
if dialog.Peer.ID == 0 {
t.Fatalf("channel dialog missing from %+v", dialogsList.Dialogs)
}
if !dialog.UnreadMark {
t.Fatalf("channel dialog unread mark = false, want true")
}
clear := &tg.MessagesMarkDialogUnreadRequest{Peer: input}
if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, clear); err != nil || !ok {
t.Fatalf("clear channel unread = %v, %v", ok, err)
}
marks, err = r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{})
if err != nil {
t.Fatalf("get channel unread marks after clear: %v", err)
}
if len(marks) != 0 {
t.Fatalf("channel unread marks after clear = %+v, want empty", marks)
}
}
func TestDialogFolderRPCsPersistAndRecordUpdates(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 12
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 66)
dialogs := &captureDialogs{}
updates := &captureUpdates{state: domain.UpdateState{Pts: 21, Date: 1700000500, Seq: 0}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesUpdateDialogFilterRequest{ID: 2}
req.SetFilter(&tg.DialogFilter{
ID: 2,
Contacts: true,
Title: tg.TextWithEntities{Text: "Work"},
IncludePeers: []tg.InputPeerClass{&tg.InputPeerUser{
UserID: 1000000002,
AccessHash: 222,
}},
})
if ok, err := r.onMessagesUpdateDialogFilter(ctx, req); err != nil || !ok {
t.Fatalf("update filter = %v, %v", ok, err)
}
if dialogs.savedFolder.ID != 2 || dialogs.savedFolder.Title != "Work" || !dialogs.savedFolder.Contacts || len(dialogs.savedFolder.IncludePeers) != 1 {
t.Fatalf("saved folder = %+v, want parsed dialog folder", dialogs.savedFolder)
}
if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogFilter || updates.events[0].FilterID != 2 || updates.excludeSessionID != 66 {
t.Fatalf("filter event = %+v, want durable dialog_filter", updates.events)
}
if ok, err := r.onMessagesUpdateDialogFiltersOrder(ctx, []int{2, 2, 0, 3}); err != nil || !ok {
t.Fatalf("update filter order = %v, %v", ok, err)
}
if len(dialogs.folderOrder) != 2 || dialogs.folderOrder[0] != 2 || dialogs.folderOrder[1] != 3 {
t.Fatalf("folder order = %+v, want cleaned custom IDs", dialogs.folderOrder)
}
if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventDialogFilterOrder {
t.Fatalf("order event = %+v, want durable dialog_filter_order", updates.events)
}
if ok, err := r.onMessagesToggleDialogFilterTags(ctx, true); err != nil || !ok {
t.Fatalf("toggle filter tags = %v, %v", ok, err)
}
if !dialogs.tagsEnabled || len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogFilters {
t.Fatalf("tags/update = tags %v events %+v, want reload update", dialogs.tagsEnabled, updates.events)
}
}
func TestFoldersEditPeerFoldersPersistsArchiveAndReturnsPtsUpdate(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 13
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 77)
dialogs := &captureDialogs{}
updates := &captureUpdates{state: domain.UpdateState{Pts: 31, Date: 1700000600, Seq: 0}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
got, err := r.onFoldersEditPeerFolders(ctx, []tg.InputFolderPeer{{
Peer: &tg.InputPeerUser{UserID: 1000000002},
FolderID: domain.DialogArchiveFolderID,
}})
if err != nil {
t.Fatalf("folders.editPeerFolders: %v", err)
}
if len(dialogs.folderPeers) != 1 || dialogs.folderPeers[0].FolderID != domain.DialogArchiveFolderID {
t.Fatalf("folder peers = %+v, want archive update", dialogs.folderPeers)
}
out, ok := got.(*tg.Updates)
if !ok || len(out.Updates) != 1 {
t.Fatalf("updates = %T %+v, want tg.Updates with updateFolderPeers", got, got)
}
update, ok := out.Updates[0].(*tg.UpdateFolderPeers)
if !ok || update.Pts != 31 || update.PtsCount != 1 || len(update.FolderPeers) != 1 {
t.Fatalf("update = %T %+v, want updateFolderPeers pts=31", out.Updates[0], out.Updates[0])
}
}
func TestDialogSettingRPCsSkipManualPushWhenReliableDispatch(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 11
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
dialogs := &captureDialogs{}
updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}, reliableDispatch: true}
sessions := &captureSessions{}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}}
toggle.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok {
t.Fatalf("toggle pin = %v, %v", ok, err)
}
if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned {
t.Fatalf("events = %+v, want durable event recorded", updates.events)
}
// reliable outbox 仍是 update 本体的唯一在线投递者;当前 session 只
// 收到一条纯 pts 簿记信封outbox 排除了它,事件占用的账号 pts 必须
// 显式同步,否则当前设备水位落后)。
got := sessions.snapshot()
if got.message == nil {
t.Fatalf("manual push = nil, want pts bookkeeping envelope for the current session")
}
envelope, ok := got.message.(*tg.Updates)
if !ok || len(envelope.Updates) != 1 {
t.Fatalf("manual push = %T %+v, want a single bookkeeping update", got.message, got.message)
}
if bookkeeping, ok := envelope.Updates[0].(*tg.UpdateDeleteMessages); !ok || len(bookkeeping.Messages) != 0 {
t.Fatalf("manual push update = %#v, want empty updateDeleteMessages bookkeeping", envelope.Updates[0])
}
}
func TestMessagesSaveDraftPushesDraftUpdateToOtherSessions(t *testing.T) {
const (
userID = int64(1000000001)
peerID = int64(1000000002)
)
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
r := New(Config{}, Deps{
Sessions: sessions,
Users: mapUsersService{users: map[int64]domain.User{
userID: {ID: userID, FirstName: "Alice"},
peerID: {ID: peerID, FirstName: "Bob"},
}},
}, zaptest.NewLogger(t), clock.System)
var authKeyID [8]byte
authKeyID[0] = 8
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKeyID), 66)
ok, err := r.onMessagesSaveDraft(ctx, &tg.MessagesSaveDraftRequest{
Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22},
Message: "1111",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 4},
},
})
if err != nil {
t.Fatalf("save draft: %v", err)
}
if !ok {
t.Fatalf("save draft = false, want true")
}
got := sessions.snapshot()
if got.userID != userID || got.sessionID != 66 || got.messageType != proto.MessageFromServer {
t.Fatalf("push = user %d exclude session %d type %v, want self/exclude/from_server", got.userID, got.sessionID, got.messageType)
}
if gotAuthKeyID := sessions.scopedAuthKey(); gotAuthKeyID != authKeyID {
t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID)
}
updates, ok := got.message.(*tg.Updates)
if !ok {
t.Fatalf("pushed message = %T, want *tg.Updates", got.message)
}
if len(updates.Updates) != 1 {
t.Fatalf("updates = %+v, want one update", updates.Updates)
}
update, ok := updates.Updates[0].(*tg.UpdateDraftMessage)
if !ok {
t.Fatalf("update = %T, want *tg.UpdateDraftMessage", updates.Updates[0])
}
peer, ok := update.Peer.(*tg.PeerUser)
if !ok || peer.UserID != peerID {
t.Fatalf("draft peer = %#v, want peer user %d", update.Peer, peerID)
}
draft, ok := update.Draft.(*tg.DraftMessage)
if !ok || draft.Message != "1111" || len(draft.Entities) != 1 {
t.Fatalf("draft = %#v, want message and entities", update.Draft)
}
if len(updates.Users) != 2 {
t.Fatalf("users = %+v, want self and peer", updates.Users)
}
}
func TestMessagesSaveDraftPersistsAndGetAllDraftsReturnsForumDraft(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Forum",
Forum: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
dialogStore := memory.NewDialogStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
Dialogs: appdialogs.NewService(dialogStore, channelStore),
}, zaptest.NewLogger(t), clock.System)
reply := &tg.InputReplyToMessage{}
reply.SetTopMsgID(123)
ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{
Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash},
ReplyTo: reply,
Message: "topic draft",
Media: &tg.InputMediaWebPage{
URL: "https://example.test/draft",
Optional: true,
},
})
if err != nil || !ok {
t.Fatalf("save forum draft = %v, %v", ok, err)
}
drafts, err := dialogStore.ListDrafts(ctx, owner.ID, 10)
if err != nil {
t.Fatalf("list stored drafts: %v", err)
}
if len(drafts) != 1 || drafts[0].TopMessageID != 123 || drafts[0].Message != "topic draft" || drafts[0].WebPage == nil {
t.Fatalf("stored drafts = %+v, want forum draft with webpage", drafts)
}
got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID))
if err != nil {
t.Fatalf("get all drafts: %v", err)
}
updates, ok := got.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("get all drafts = %T %+v, want one updates", got, got)
}
update, ok := updates.Updates[0].(*tg.UpdateDraftMessage)
if !ok {
t.Fatalf("draft update = %T", updates.Updates[0])
}
if top, ok := update.GetTopMsgID(); !ok || top != 123 {
t.Fatalf("top_msg_id = %d/%v, want 123/true", top, ok)
}
draft, ok := update.Draft.(*tg.DraftMessage)
if !ok || draft.Message != "topic draft" {
t.Fatalf("draft = %#v, want message draft", update.Draft)
}
if media, ok := draft.Media.(*tg.InputMediaWebPage); !ok || media.URL != "https://example.test/draft" {
t.Fatalf("draft media = %#v, want webpage", draft.Media)
}
if len(updates.Chats) != 1 {
t.Fatalf("chats = %+v, want channel context", updates.Chats)
}
}
func TestMessagesGetAllDraftsUsesSingleBatchUserLookup(t *testing.T) {
const (
userID = int64(1000000001)
firstID = int64(1000000002)
secondID = int64(1000000003)
channelID = int64(2000000001)
)
dialogs := &captureDialogs{drafts: []domain.DialogDraft{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: firstID}, Date: 1700000100, Message: "first"},
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, TopMessageID: 77, Date: 1700000101, Message: "topic"},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: firstID}, Date: 1700000102, Message: "duplicate"},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: secondID}, Date: 1700000103, Message: "second"},
}}
users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{
userID: {ID: userID, FirstName: "Alice"},
firstID: {ID: firstID, FirstName: "Bob"},
secondID: {ID: secondID, FirstName: "Carol"},
}}}
r := New(Config{}, Deps{
Dialogs: dialogs,
Users: users,
}, zaptest.NewLogger(t), clock.System)
got, err := r.onMessagesGetAllDrafts(WithUserID(context.Background(), userID))
if err != nil {
t.Fatalf("get all drafts: %v", err)
}
updates := got.(*tg.Updates)
if len(updates.Updates) != len(dialogs.drafts) {
t.Fatalf("updates = %+v, want one update per draft", updates.Updates)
}
if users.selfCalls != 1 || users.byIDsCalls != 1 || users.byIDCalls != 0 {
t.Fatalf("user lookups self=%d byIDs=%d byID=%d, want one Self, one ByIDs and no ByID", users.selfCalls, users.byIDsCalls, users.byIDCalls)
}
if len(users.lastByIDs) != 2 || users.lastByIDs[0] != firstID || users.lastByIDs[1] != secondID {
t.Fatalf("ByIDs ids = %+v, want [%d %d]", users.lastByIDs, firstID, secondID)
}
if ids := gotUserIDs(updates.Users); len(ids) != 3 || ids[0] != userID || ids[1] != firstID || ids[2] != secondID {
t.Fatalf("users = %+v, want self and two peer users", updates.Users)
}
channelDraft := updates.Updates[1].(*tg.UpdateDraftMessage)
if top, ok := channelDraft.GetTopMsgID(); !ok || top != 77 {
t.Fatalf("channel draft top_msg_id = %d/%v, want 77/true", top, ok)
}
}
func TestMessagesGetAllDraftsUsesSingleBatchChannelLookup(t *testing.T) {
ctx := context.Background()
const userID = int64(1000000001)
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
first, err := channelService.CreateChannel(ctx, userID, domain.CreateChannelRequest{
CreatorUserID: userID,
Title: "Draft Channel One",
Broadcast: true,
Date: 1700000200,
})
if err != nil {
t.Fatalf("create first channel: %v", err)
}
second, err := channelService.CreateChannel(ctx, userID, domain.CreateChannelRequest{
CreatorUserID: userID,
Title: "Draft Channel Two",
Megagroup: true,
Date: 1700000210,
})
if err != nil {
t.Fatalf("create second channel: %v", err)
}
dialogs := &captureDialogs{drafts: []domain.DialogDraft{
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}, Date: 1700000220, Message: "first"},
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}, TopMessageID: 77, Date: 1700000221, Message: "topic"},
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}, Date: 1700000222, Message: "duplicate"},
}}
counting := &countingChannelsService{Service: channelService}
r := New(Config{}, Deps{
Dialogs: dialogs,
Channels: counting,
}, zaptest.NewLogger(t), clock.System)
got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, userID))
if err != nil {
t.Fatalf("get all drafts: %v", err)
}
updates := got.(*tg.Updates)
if len(updates.Updates) != len(dialogs.drafts) {
t.Fatalf("updates = %+v, want one update per draft", updates.Updates)
}
if counting.getChannelsCalls != 1 || counting.getChannelCalls != 0 {
t.Fatalf("channel lookups GetChannels=%d GetChannel=%d, want one batch and no single lookups", counting.getChannelsCalls, counting.getChannelCalls)
}
if len(updates.Chats) != 2 {
t.Fatalf("chats = %+v, want two unique channel refs", updates.Chats)
}
firstChat, ok := updates.Chats[0].(*tg.Channel)
if !ok || firstChat.ID != first.Channel.ID {
t.Fatalf("first chat = %#v, want first channel", updates.Chats[0])
}
secondChat, ok := updates.Chats[1].(*tg.Channel)
if !ok || secondChat.ID != second.Channel.ID {
t.Fatalf("second chat = %#v, want second channel", updates.Chats[1])
}
channelDraft := updates.Updates[1].(*tg.UpdateDraftMessage)
if top, ok := channelDraft.GetTopMsgID(); !ok || top != 77 {
t.Fatalf("channel draft top_msg_id = %d/%v, want 77/true", top, ok)
}
}
func TestMessagesGetDialogsIncludesCloudDraft(t *testing.T) {
ctx := context.Background()
const (
userID = int64(1000000001)
peerID = int64(1000000002)
)
dialogStore := memory.NewDialogStore()
if err := dialogStore.Upsert(ctx, userID, domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
TopMessage: 10,
TopMessageDate: 1700000000,
}); err != nil {
t.Fatalf("upsert dialog: %v", err)
}
if err := dialogStore.SaveDraft(ctx, userID, domain.DialogDraft{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
Date: 1700000100,
Message: "saved draft",
}); err != nil {
t.Fatalf("save draft: %v", err)
}
r := New(Config{}, Deps{
Users: mapUsersService{users: map[int64]domain.User{
userID: {ID: userID, FirstName: "Alice"},
peerID: {ID: peerID, FirstName: "Bob"},
}},
Dialogs: appdialogs.NewService(dialogStore),
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 10,
}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
got, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("get dialogs: %v", err)
}
dialogs, ok := got.(*tg.MessagesDialogs)
if !ok {
t.Fatalf("response = %T, want *tg.MessagesDialogs", got)
}
if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one messages.dialogs", dialogs)
}
dialog, ok := dialogs.Dialogs[0].(*tg.Dialog)
if !ok {
t.Fatalf("dialog = %T", dialogs.Dialogs[0])
}
draft, ok := dialog.Draft.(*tg.DraftMessage)
if !ok || draft.Message != "saved draft" {
t.Fatalf("dialog draft = %#v, want saved draft", dialog.Draft)
}
}
func TestMessagesClearAllDraftsClearsAndPushesEmptyUpdates(t *testing.T) {
const (
userID = int64(1000000001)
peerID = int64(1000000002)
)
dialogs := &captureDialogs{drafts: []domain.DialogDraft{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
Date: 1700000100,
Message: "draft",
}}}
sessions := &captureScopedSessions{captureSessions: &captureSessions{}}
r := New(Config{}, Deps{
Dialogs: dialogs,
Sessions: sessions,
Users: mapUsersService{users: map[int64]domain.User{
userID: {ID: userID, FirstName: "Alice"},
peerID: {ID: peerID, FirstName: "Bob"},
}},
}, zaptest.NewLogger(t), clock.System)
ok, err := r.onMessagesClearAllDrafts(WithSessionID(WithUserID(context.Background(), userID), 9))
if err != nil || !ok {
t.Fatalf("clear all drafts = %v, %v", ok, err)
}
got := sessions.snapshot()
updates, ok := got.message.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("pushed = %T %+v, want one update", got.message, got.message)
}
update, ok := updates.Updates[0].(*tg.UpdateDraftMessage)
if !ok {
t.Fatalf("update = %T", updates.Updates[0])
}
if _, ok := update.Draft.(*tg.DraftMessageEmpty); !ok {
t.Fatalf("draft = %#v, want draftMessageEmpty", update.Draft)
}
if len(dialogs.drafts) != 0 {
t.Fatalf("capture drafts = %+v, want cleared", dialogs.drafts)
}
}
func TestAccountUpdateStatusPushesPresenceToOnlinePrivateDialogPeers(t *testing.T) {
ctx := context.Background()
alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"}
dialogs := memory.NewDialogStore()
// 私聊 dialog 双向建行alice↔bob 两侧都存。presence 接收者由「主体自己的
// dialog 对端 ∩ 在线」算出,故 bob 改状态时需 bob 侧 dialog 含 alice。
if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
TopMessage: 10,
TopMessageDate: 1700000000,
}},
Users: []domain.User{bob},
}); err != nil {
t.Fatalf("save dialogs: %v", err)
}
if err := dialogs.SaveList(ctx, bob.ID, domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID},
TopMessage: 10,
TopMessageDate: 1700000000,
}},
Users: []domain.User{alice},
}); err != nil {
t.Fatalf("save bob dialogs: %v", err)
}
sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}}
r := New(Config{}, Deps{
Dialogs: appdialogs.NewService(dialogs),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false)
if err != nil || !ok {
t.Fatalf("bob account.updateStatus online = %v, %v", ok, err)
}
gotPushes := sessions.pushedUserIDs()
if !reflect.DeepEqual(gotPushes, []int64{bob.ID, alice.ID}) {
t.Fatalf("pushed users = %+v, want self and online private dialog peer", gotPushes)
}
update := pushedUserStatus(t, sessions.snapshot().message)
if update.UserID != bob.ID {
t.Fatalf("status user = %d, want bob", update.UserID)
}
if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) {
t.Fatalf("status = %#v, want online with future expires", update.Status)
}
}
func TestDialogPinFolderScoping(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 9
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}
dialogs := &captureDialogs{folderID: domain.DialogArchiveFolderID}
updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
// 归档内会话置顶durable 事件必须携带 folder_id=1否则离线设备
// 重放时会把置顶应用到主列表。
toggle := &tg.MessagesToggleDialogPinRequest{Peer: dialogPeer}
toggle.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok {
t.Fatalf("toggle pin = %v, %v", ok, err)
}
if len(updates.events) != 1 || updates.events[0].FolderID != domain.DialogArchiveFolderID {
t.Fatalf("pin event = %+v, want folder_id=1", updates.events)
}
// 归档列表内重排req.folder_id 必须传到 service 与 durable 事件。
reorder := &tg.MessagesReorderPinnedDialogsRequest{Order: []tg.InputDialogPeerClass{dialogPeer}}
reorder.FolderID = domain.DialogArchiveFolderID
if ok, err := r.onMessagesReorderPinnedDialogs(ctx, reorder); err != nil || !ok {
t.Fatalf("reorder pinned = %v, %v", ok, err)
}
if dialogs.folderID != domain.DialogArchiveFolderID {
t.Fatalf("reorder folder = %d, want archive", dialogs.folderID)
}
if len(updates.events) != 2 || updates.events[1].FolderID != domain.DialogArchiveFolderID {
t.Fatalf("reorder event = %+v, want folder_id=1", updates.events)
}
// 差分重放转换:归档内置顶 update 带 folder_id flag。
pinnedUpdate := tgOtherUpdateFromEvent(updates.events[0])
dialogPinned, ok := pinnedUpdate.(*tg.UpdateDialogPinned)
if !ok {
t.Fatalf("replayed update = %T, want *tg.UpdateDialogPinned", pinnedUpdate)
}
if folderID, has := dialogPinned.GetFolderID(); !has || folderID != domain.DialogArchiveFolderID {
dialogPinned.SetFlags()
if folderID, has = dialogPinned.GetFolderID(); !has || folderID != domain.DialogArchiveFolderID {
t.Fatalf("replayed updateDialogPinned folder = %d (%v), want 1", folderID, has)
}
}
}
func TestToggleDialogPinArchiveFolderRow(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 9
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55)
dialogs := &captureDialogs{}
updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
// archive folder 行自身置顶TDesktop 归档行右键 Pin
toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerFolder{FolderID: domain.DialogArchiveFolderID}}
toggle.SetPinned(true)
if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok {
t.Fatalf("toggle archive folder pin = %v, %v", ok, err)
}
if !dialogs.archivePinned {
t.Fatalf("archive pinned not persisted")
}
if len(updates.events) != 1 || updates.events[0].Peer.Type != domain.PeerTypeFolder || updates.events[0].Peer.ID != domain.DialogArchiveFolderID {
t.Fatalf("archive pin event = %+v, want folder peer", updates.events)
}
// 重放转换dialogPeerFolder 且不带 folder_id flagTDesktop 会把带
// flag 的 folder peer 置顶视为 "Nested folders" 协议错误)。
update := tgOtherUpdateFromEvent(updates.events[0])
dialogPinned, ok := update.(*tg.UpdateDialogPinned)
if !ok {
t.Fatalf("replayed update = %T, want *tg.UpdateDialogPinned", update)
}
folderPeer, ok := dialogPinned.Peer.(*tg.DialogPeerFolder)
if !ok || folderPeer.FolderID != domain.DialogArchiveFolderID {
t.Fatalf("replayed peer = %+v, want dialogPeerFolder(1)", dialogPinned.Peer)
}
dialogPinned.SetFlags()
if folderID, has := dialogPinned.GetFolderID(); has {
t.Fatalf("folder peer pin carries folder_id=%d flag, want absent", folderID)
}
// 非归档 folder id 一律 FOLDER_ID_INVALID。
badToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerFolder{FolderID: 0}}
badToggle.SetPinned(true)
if _, err := r.onMessagesToggleDialogPin(ctx, badToggle); err == nil || !strings.Contains(err.Error(), "FOLDER_ID_INVALID") {
t.Fatalf("toggle folder 0 err = %v, want FOLDER_ID_INVALID", err)
}
}
func TestMessagesDialogsIncludesArchiveFolderEntry(t *testing.T) {
list := domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
TopMessage: 9,
TopMessageDate: 40,
}},
ArchiveSummary: &domain.DialogArchiveSummary{
TopPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003},
TopMessage: 7,
UnreadPeersCount: 2,
UnreadMessagesCount: 5,
Pinned: true,
},
}
out, ok := tgMessagesDialogs(1000000001, list).(*tg.MessagesDialogs)
if !ok {
t.Fatalf("response type = %T, want *tg.MessagesDialogs", tgMessagesDialogs(1000000001, list))
}
if len(out.Dialogs) != 2 {
t.Fatalf("dialogs = %d, want folder entry + dialog", len(out.Dialogs))
}
folder, ok := out.Dialogs[0].(*tg.DialogFolder)
if !ok {
t.Fatalf("first dialog = %T, want *tg.DialogFolder", out.Dialogs[0])
}
if folder.Folder.ID != domain.DialogArchiveFolderID || !folder.Pinned {
t.Fatalf("folder entry = %+v, want archive id pinned", folder)
}
if folder.TopMessage != 7 || folder.UnreadUnmutedPeersCount != 2 || folder.UnreadUnmutedMessagesCount != 5 {
t.Fatalf("folder counters = %+v, want top 7 / peers 2 / messages 5", folder)
}
peer, ok := folder.Peer.(*tg.PeerUser)
if !ok || peer.UserID != 1000000003 {
t.Fatalf("folder peer = %+v, want archived top peer", folder.Peer)
}
// 无归档时不输出条目。
plain := domain.DialogList{Dialogs: list.Dialogs}
plainOut, _ := tgMessagesDialogs(1000000001, plain).(*tg.MessagesDialogs)
if len(plainOut.Dialogs) != 1 {
t.Fatalf("plain dialogs = %d, want no folder entry", len(plainOut.Dialogs))
}
}
func TestPeerDialogsIncludesArchiveFolderEntry(t *testing.T) {
list := domain.DialogList{
ArchiveSummary: &domain.DialogArchiveSummary{
TopPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 18},
TopMessage: 2,
UnreadPeersCount: 1,
UnreadMessagesCount: 4,
Pinned: true,
},
}
out := tgPeerDialogs(1000000001, list, domain.UpdateState{Pts: 5})
if len(out.Dialogs) != 1 {
t.Fatalf("pinned dialogs = %d entries, want folder entry", len(out.Dialogs))
}
folder, ok := out.Dialogs[0].(*tg.DialogFolder)
if !ok {
t.Fatalf("first dialog = %T, want *tg.DialogFolder", out.Dialogs[0])
}
// DrKLO fetchFolderInLoadedPinnedDialogs 要求 top_message 与 peer 非零,
// 否则条目被丢弃。
if folder.TopMessage != 2 {
t.Fatalf("folder top message = %d, want 2", folder.TopMessage)
}
peer, ok := folder.Peer.(*tg.PeerChannel)
if !ok || peer.ChannelID != 18 {
t.Fatalf("folder peer = %+v, want channel 18", folder.Peer)
}
}
func TestMessagesGetDialogsTDesktopInitialPageMergesPinnedHeader(t *testing.T) {
viewer := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"}
charlie := domain.User{ID: 1000000003, AccessHash: 33, FirstName: "Charlie"}
dialogs := &captureDialogs{
list: domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: charlie.ID},
TopMessage: 31,
TopMessageDate: 1781597300,
}},
Messages: []domain.Message{{
ID: 31,
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: charlie.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: charlie.ID},
Date: 1781597300,
Body: "normal",
}},
Users: []domain.User{viewer, charlie},
Count: 1,
},
pinnedList: domain.DialogList{
ArchiveSummary: &domain.DialogArchiveSummary{
TopPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 13},
TopMessage: 7,
UnreadPeersCount: 1,
UnreadMessagesCount: 2,
Pinned: true,
},
Dialogs: []domain.Dialog{
{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9},
TopMessage: 16,
TopMessageDate: 1781541965,
Pinned: true,
PinnedOrder: 2,
Pts: 16,
},
{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
TopMessage: 77,
TopMessageDate: 1781597372,
Pinned: true,
PinnedOrder: 1,
},
},
ChannelMessages: []domain.ChannelMessage{
{ChannelID: 13, ID: 7, SenderUserID: bob.ID, From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, Date: 1781540000, Body: "archived top", Pts: 7},
{ChannelID: 9, ID: 16, SenderUserID: bob.ID, From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, Date: 1781541965, Body: "pinned channel", Pts: 16},
},
Messages: []domain.Message{{
ID: 77,
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewer.ID},
Date: 1781597372,
Out: true,
Body: "pinned private",
}},
Users: []domain.User{viewer, bob},
Channels: []domain.Channel{
{ID: 13, AccessHash: 1313, CreatorUserID: viewer.ID, Title: "Archive Top", Megagroup: true, TopMessageID: 7, Pts: 7},
{ID: 9, AccessHash: 9090, CreatorUserID: viewer.ID, Title: "Pinned Channel", Megagroup: true, TopMessageID: 16, Pts: 16},
},
Count: 2,
},
hasPinnedList: true,
}
r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetDialogsRequest{
ExcludePinned: true,
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
Hash: 0,
}
req.SetFolderID(domain.DialogMainFolderID)
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
ctx := WithClientInfo(WithUserID(context.Background(), viewer.ID), ClientInfo{Type: ClientTypeTDesktop})
enc, err := r.Dispatch(ctx, [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch get dialogs: %v", err)
}
out, ok := enc.(*tg.MessagesDialogs)
if !ok {
t.Fatalf("response = %T, want *tg.MessagesDialogs", enc)
}
if dialogs.getDialogsCalls != 2 || len(dialogs.filters) != 2 {
t.Fatalf("GetDialogs calls = %d filters %+v, want normal + pinned", dialogs.getDialogsCalls, dialogs.filters)
}
var excludePinnedCalls, pinnedOnlyCalls int
for _, filter := range dialogs.filters {
if filter.ExcludePinned {
excludePinnedCalls++
}
if filter.PinnedOnly {
pinnedOnlyCalls++
}
}
if excludePinnedCalls != 1 || pinnedOnlyCalls != 1 {
t.Fatalf("filters = %+v, want one exclude-pinned and one pinned-only load", dialogs.filters)
}
if len(out.Dialogs) != 4 {
t.Fatalf("dialogs = %d, want archive + two pinned + normal", len(out.Dialogs))
}
if _, ok := out.Dialogs[0].(*tg.DialogFolder); !ok {
t.Fatalf("first dialog = %T, want archive folder", out.Dialogs[0])
}
pinnedChannel := out.Dialogs[1].(*tg.Dialog)
if peer, ok := pinnedChannel.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != 9 || !pinnedChannel.Pinned {
t.Fatalf("second dialog = %+v peer %+v, want pinned channel 9", pinnedChannel, pinnedChannel.Peer)
}
pinnedUser := out.Dialogs[2].(*tg.Dialog)
if peer, ok := pinnedUser.Peer.(*tg.PeerUser); !ok || peer.UserID != bob.ID || !pinnedUser.Pinned {
t.Fatalf("third dialog = %+v peer %+v, want pinned user %d", pinnedUser, pinnedUser.Peer, bob.ID)
}
normal := out.Dialogs[3].(*tg.Dialog)
if peer, ok := normal.Peer.(*tg.PeerUser); !ok || peer.UserID != charlie.ID || normal.Pinned {
t.Fatalf("fourth dialog = %+v peer %+v, want normal user %d", normal, normal.Peer, charlie.ID)
}
if !messagesDialogsHasChannel(out, 13) || !messagesDialogsHasChannel(out, 9) {
t.Fatalf("chats = %+v, want archive and pinned channels", out.Chats)
}
if !messagesDialogsHasUser(out, bob.ID) || !messagesDialogsHasUser(out, charlie.ID) {
t.Fatalf("users = %+v, want pinned and normal users", out.Users)
}
if !messagesDialogsHasChannelMessage(out, 13, 7) || !messagesDialogsHasChannelMessage(out, 9, 16) {
t.Fatalf("messages = %+v, want archive and pinned channel top messages", out.Messages)
}
if !messagesDialogsHasPrivateMessage(out, bob.ID, 77) || !messagesDialogsHasPrivateMessage(out, charlie.ID, 31) {
t.Fatalf("messages = %+v, want pinned and normal private top messages", out.Messages)
}
}
func TestMessagesGetDialogsNonTDesktopKeepsExcludePinnedResponse(t *testing.T) {
viewer := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"}
dialogs := &captureDialogs{
list: domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
TopMessage: 31,
TopMessageDate: 1781597300,
}},
Messages: []domain.Message{{
ID: 31,
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
Date: 1781597300,
Body: "normal",
}},
Users: []domain.User{viewer, bob},
Count: 1,
},
pinnedList: domain.DialogList{
Dialogs: []domain.Dialog{{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9},
TopMessage: 16,
TopMessageDate: 1781541965,
Pinned: true,
}},
Channels: []domain.Channel{{ID: 9, AccessHash: 9090, Title: "Pinned Channel", Megagroup: true}},
Count: 1,
},
hasPinnedList: true,
}
r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetDialogsRequest{
ExcludePinned: true,
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
req.SetFolderID(domain.DialogMainFolderID)
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
enc, err := r.Dispatch(WithUserID(context.Background(), viewer.ID), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch get dialogs: %v", err)
}
out := enc.(*tg.MessagesDialogs)
if dialogs.getDialogsCalls != 1 {
t.Fatalf("GetDialogs calls = %d, want no pinned compatibility load", dialogs.getDialogsCalls)
}
if len(out.Dialogs) != 1 {
t.Fatalf("dialogs = %d, want only exclude-pinned response", len(out.Dialogs))
}
normal := out.Dialogs[0].(*tg.Dialog)
if peer, ok := normal.Peer.(*tg.PeerUser); !ok || peer.UserID != bob.ID || normal.Pinned {
t.Fatalf("dialog = %+v peer %+v, want normal user %d", normal, normal.Peer, bob.ID)
}
}
func TestPinnedDialogsListSingleflightsConcurrentStartupLoads(t *testing.T) {
const userID int64 = 1000000001
dialogs := &blockingPinnedDialogs{
captureDialogs: captureDialogs{
pinnedList: domain.DialogList{Dialogs: []domain.Dialog{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
TopMessage: 11,
TopMessageDate: 1781597372,
Pinned: true,
}}},
hasPinnedList: true,
},
entered: make(chan struct{}),
release: make(chan struct{}),
}
communities := &countingPinnedCommunities{}
r := New(Config{}, Deps{Dialogs: dialogs, Communities: communities}, zaptest.NewLogger(t), clock.System)
ctx := WithLayer(context.Background(), communitiesLayer)
errs := make(chan error, 2)
results := make(chan domain.DialogList, 2)
call := func() {
list, err := r.pinnedDialogsList(ctx, userID, domain.DialogMainFolderID)
errs <- err
results <- list
}
go call()
<-dialogs.entered
go call()
time.Sleep(10 * time.Millisecond)
close(dialogs.release)
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatalf("pinnedDialogsList call %d: %v", i, err)
}
if got := <-results; len(got.Dialogs) != 1 || got.Dialogs[0].TopMessage != 11 {
t.Fatalf("result %d = %+v, want shared pinned list", i, got)
}
}
if calls := dialogs.pinnedCalls(); calls != 1 {
t.Fatalf("pinned GetDialogs calls = %d, want singleflight to share one in-flight load", calls)
}
if calls := communities.listJoinedCalls(); calls != 1 {
t.Fatalf("pinned community ListJoined calls = %d, want projected singleflight to share one aggregate load", calls)
}
}
type countingPinnedCommunities struct {
CommunitiesService
mu sync.Mutex
calls int
}
func (s *countingPinnedCommunities) ListJoined(context.Context, int64) ([]domain.CommunityView, error) {
s.mu.Lock()
s.calls++
s.mu.Unlock()
return nil, nil
}
func (s *countingPinnedCommunities) listJoinedCalls() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
type blockingPinnedDialogs struct {
captureDialogs
mu sync.Mutex
calls int
entered chan struct{}
release chan struct{}
}
func (s *blockingPinnedDialogs) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
if !filter.PinnedOnly {
return s.captureDialogs.GetDialogs(ctx, userID, filter)
}
s.mu.Lock()
s.calls++
calls := s.calls
s.mu.Unlock()
if calls == 1 {
close(s.entered)
<-s.release
}
return s.captureDialogs.GetDialogs(ctx, userID, filter)
}
func (s *blockingPinnedDialogs) pinnedCalls() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func TestMessagesGetPinnedDialogsIncludesArchiveAndReferencedPeers(t *testing.T) {
viewer := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"}
dialogs := &captureDialogs{list: domain.DialogList{
ArchiveSummary: &domain.DialogArchiveSummary{
TopPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 13},
TopMessage: 7,
UnreadPeersCount: 2,
UnreadMessagesCount: 5,
Pinned: true,
},
Dialogs: []domain.Dialog{
{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9},
TopMessage: 16,
TopMessageDate: 1781541965,
Pinned: true,
PinnedOrder: 2,
Pts: 16,
},
{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
TopMessage: 77,
TopMessageDate: 1781597372,
Pinned: true,
PinnedOrder: 1,
},
},
ChannelMessages: []domain.ChannelMessage{
{
ChannelID: 13,
ID: 7,
SenderUserID: bob.ID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
Date: 1781540000,
Body: "archived top",
Pts: 7,
},
{
ChannelID: 9,
ID: 16,
SenderUserID: bob.ID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
Date: 1781541965,
Body: "pinned channel",
Pts: 16,
},
},
Messages: []domain.Message{
{
ID: 77,
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewer.ID},
Date: 1781597372,
Out: true,
Body: "pinned private",
Pts: 77,
},
},
Users: []domain.User{viewer, bob},
Channels: []domain.Channel{
{ID: 13, AccessHash: 1313, CreatorUserID: viewer.ID, Title: "Archive Top", Megagroup: true, TopMessageID: 7, Pts: 7},
{ID: 9, AccessHash: 9090, CreatorUserID: viewer.ID, Title: "Pinned Channel", Megagroup: true, TopMessageID: 16, Pts: 16},
},
}}
updates := &captureUpdates{state: domain.UpdateState{Pts: 55, Qts: 1, Date: 1781597400}}
r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesGetPinnedDialogsRequest{FolderID: 0}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode get pinned dialogs: %v", err)
}
authKeyID := [8]byte{9}
enc, err := r.Dispatch(WithUserID(context.Background(), viewer.ID), authKeyID, 0, &in)
if err != nil {
t.Fatalf("dispatch get pinned dialogs: %v", err)
}
out, ok := enc.(*tg.MessagesPeerDialogs)
if !ok {
t.Fatalf("response = %T, want *tg.MessagesPeerDialogs", enc)
}
if dialogs.filter != (domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: 0, Limit: 100}) {
t.Fatalf("filter = %+v, want pinned folder 0 limit 100", dialogs.filter)
}
if updates.authKeyID != authKeyID || updates.userID != viewer.ID {
t.Fatalf("state lookup auth/user = %x/%d, want %x/%d", updates.authKeyID, updates.userID, authKeyID, viewer.ID)
}
if len(out.Dialogs) != 3 {
t.Fatalf("dialogs = %d, want archive + two pinned peers", len(out.Dialogs))
}
folder, ok := out.Dialogs[0].(*tg.DialogFolder)
if !ok {
t.Fatalf("first dialog = %T, want *tg.DialogFolder", out.Dialogs[0])
}
archivePeer, ok := folder.Peer.(*tg.PeerChannel)
if !ok || archivePeer.ChannelID != 13 || folder.TopMessage != 7 || !folder.Pinned {
t.Fatalf("archive folder = %+v peer %+v, want pinned channel 13 top 7", folder, folder.Peer)
}
pinnedChannel, ok := out.Dialogs[1].(*tg.Dialog)
if !ok || !pinnedChannel.Pinned {
t.Fatalf("second dialog = %T %+v, want pinned channel dialog", out.Dialogs[1], out.Dialogs[1])
}
channelPeer, ok := pinnedChannel.Peer.(*tg.PeerChannel)
if !ok || channelPeer.ChannelID != 9 || pinnedChannel.TopMessage != 16 {
t.Fatalf("second peer = %+v top %d, want channel 9 top 16", pinnedChannel.Peer, pinnedChannel.TopMessage)
}
pinnedUser, ok := out.Dialogs[2].(*tg.Dialog)
if !ok || !pinnedUser.Pinned {
t.Fatalf("third dialog = %T %+v, want pinned user dialog", out.Dialogs[2], out.Dialogs[2])
}
userPeer, ok := pinnedUser.Peer.(*tg.PeerUser)
if !ok || userPeer.UserID != bob.ID || pinnedUser.TopMessage != 77 {
t.Fatalf("third peer = %+v top %d, want user %d top 77", pinnedUser.Peer, pinnedUser.TopMessage, bob.ID)
}
if !peerDialogsHasChannel(out, 13) || !peerDialogsHasChannel(out, 9) {
t.Fatalf("chats = %+v, want archive top channel 13 and pinned channel 9", out.Chats)
}
if !peerDialogsHasUser(out, bob.ID) {
t.Fatalf("users = %+v, want pinned private user %d", out.Users, bob.ID)
}
if !peerDialogsHasChannelMessage(out, 13, 7) || !peerDialogsHasChannelMessage(out, 9, 16) {
t.Fatalf("messages = %+v, want channel messages 13/7 and 9/16", out.Messages)
}
if !peerDialogsHasPrivateMessage(out, bob.ID, 77) {
t.Fatalf("messages = %+v, want private dialog top message %d/%d", out.Messages, bob.ID, 77)
}
}
func peerDialogsHasChannel(out *tg.MessagesPeerDialogs, id int64) bool {
for _, item := range out.Chats {
if ch, ok := item.(*tg.Channel); ok && ch.ID == id {
return true
}
}
return false
}
func peerDialogsHasUser(out *tg.MessagesPeerDialogs, id int64) bool {
for _, item := range out.Users {
if user, ok := item.(*tg.User); ok && user.ID == id {
return true
}
}
return false
}
func peerDialogsHasChannelMessage(out *tg.MessagesPeerDialogs, channelID int64, messageID int) bool {
for _, item := range out.Messages {
if msg, ok := item.(*tg.Message); ok && msg.ID == messageID {
if peer, ok := msg.PeerID.(*tg.PeerChannel); ok && peer.ChannelID == channelID {
return true
}
}
}
return false
}
func peerDialogsHasPrivateMessage(out *tg.MessagesPeerDialogs, userID int64, messageID int) bool {
for _, item := range out.Messages {
if msg, ok := item.(*tg.Message); ok && msg.ID == messageID {
if peer, ok := msg.PeerID.(*tg.PeerUser); ok && peer.UserID == userID {
return true
}
}
}
return false
}
func messagesDialogsHasChannel(out *tg.MessagesDialogs, id int64) bool {
for _, item := range out.Chats {
if ch, ok := item.(*tg.Channel); ok && ch.ID == id {
return true
}
}
return false
}
func messagesDialogsHasUser(out *tg.MessagesDialogs, id int64) bool {
for _, item := range out.Users {
if user, ok := item.(*tg.User); ok && user.ID == id {
return true
}
}
return false
}
func messagesDialogsHasChannelMessage(out *tg.MessagesDialogs, channelID int64, messageID int) bool {
for _, item := range out.Messages {
if msg, ok := item.(*tg.Message); ok && msg.ID == messageID {
if peer, ok := msg.PeerID.(*tg.PeerChannel); ok && peer.ChannelID == channelID {
return true
}
}
}
return false
}
func messagesDialogsHasPrivateMessage(out *tg.MessagesDialogs, userID int64, messageID int) bool {
for _, item := range out.Messages {
if msg, ok := item.(*tg.Message); ok && msg.ID == messageID {
if peer, ok := msg.PeerID.(*tg.PeerUser); ok && peer.UserID == userID {
return true
}
}
}
return false
}