chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,626 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appcontacts "telesrv/internal/app/contacts"
appdialogs "telesrv/internal/app/dialogs"
appmessages "telesrv/internal/app/messages"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newSavedDialogsTestRouter(t *testing.T) (*Router, *memory.MessageStore, *memory.UserStore) {
t.Helper()
userStore := memory.NewUserStore()
dialogs := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogs)
contactStore := memory.NewContactStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Contacts: appcontacts.NewService(contactStore, userStore),
Messages: appmessages.NewService(messageStore, dialogs),
Dialogs: appdialogs.NewService(dialogs),
}, zaptest.NewLogger(t), clock.System)
return r, messageStore, userStore
}
// savedDialogsFixture 构造收藏夹两个子会话:直发笔记(saved=self)+
// 从 bob 私聊转发(saved=bob,转发更晚 top 更新)。
func savedDialogsFixture(t *testing.T) (*Router, *memory.MessageStore, domain.User, domain.User, int, int) {
t.Helper()
ctx := context.Background()
r, messageStore, userStore := newSavedDialogsTestRouter(t)
alice, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550009301", FirstName: "Alice"})
bob, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550009302", FirstName: "Bob"})
if _, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "my note",
RandomID: 93001,
}); err != nil {
t.Fatalf("send self note: %v", err)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, bob.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
Message: "from bob",
RandomID: 93002,
}); err != nil {
t.Fatalf("bob send: %v", err)
}
bobMsgID := privateTopMessageID(t, messageStore, alice.ID, bob.ID)
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, alice.ID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
ToPeer: &tg.InputPeerSelf{},
ID: []int{bobMsgID},
RandomID: []int64{93003},
}); err != nil {
t.Fatalf("forward to saved: %v", err)
}
noteID := savedTopMessageID(t, messageStore, alice.ID, domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID})
fwdID := savedTopMessageID(t, messageStore, alice.ID, domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID})
return r, messageStore, alice, bob, noteID, fwdID
}
func privateTopMessageID(t *testing.T, store *memory.MessageStore, ownerID, peerID int64) int {
t.Helper()
list, err := store.ListByUser(context.Background(), ownerID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
Limit: 1,
})
if err != nil || len(list.Messages) == 0 {
t.Fatalf("list private top: %+v err %v", list.Messages, err)
}
return list.Messages[0].ID
}
func savedTopMessageID(t *testing.T, store *memory.MessageStore, ownerID int64, savedPeer domain.Peer) int {
t.Helper()
list, err := store.ListByUser(context.Background(), ownerID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID},
SavedPeer: savedPeer,
Limit: 1,
})
if err != nil || len(list.Messages) == 0 {
t.Fatalf("list saved top: %+v err %v", list.Messages, err)
}
return list.Messages[0].ID
}
func savedDialogPage(t *testing.T, res tg.MessagesSavedDialogsClass) ([]*tg.SavedDialog, []tg.MessageClass, []tg.UserClass, int, bool) {
t.Helper()
var rawDialogs []tg.SavedDialogClass
var messages []tg.MessageClass
var users []tg.UserClass
count := 0
full := false
switch v := res.(type) {
case *tg.MessagesSavedDialogs:
rawDialogs, messages, users = v.Dialogs, v.Messages, v.Users
count = len(v.Dialogs)
full = true
case *tg.MessagesSavedDialogsSlice:
rawDialogs, messages, users = v.Dialogs, v.Messages, v.Users
count = v.Count
default:
t.Fatalf("saved dialogs = %T, want savedDialogs/slice", res)
}
dialogs := make([]*tg.SavedDialog, 0, len(rawDialogs))
for _, d := range rawDialogs {
sd, ok := d.(*tg.SavedDialog)
if !ok {
t.Fatalf("dialog = %T, want *tg.SavedDialog", d)
}
dialogs = append(dialogs, sd)
}
return dialogs, messages, users, count, full
}
func savedDialogPeerID(t *testing.T, d *tg.SavedDialog) int64 {
t.Helper()
switch p := d.Peer.(type) {
case *tg.PeerUser:
return p.UserID
case *tg.PeerChannel:
return p.ChannelID
default:
t.Fatalf("saved dialog peer = %T", d.Peer)
return 0
}
}
// TestMessagesGetSavedDialogsGroupsBySource 验证直发/转发分组、排序、
// saved_peer_id 下发与 users 投影。
func TestMessagesGetSavedDialogsGroupsBySource(t *testing.T) {
ctx := context.Background()
r, _, alice, bob, noteID, fwdID := savedDialogsFixture(t)
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20})
if err != nil {
t.Fatalf("getSavedDialogs: %v", err)
}
dialogs, messages, users, _, full := savedDialogPage(t, res)
if !full || len(dialogs) != 2 {
t.Fatalf("dialogs = %d full %v, want 2 full", len(dialogs), full)
}
// 转发更晚,bob 子会话在前。
if savedDialogPeerID(t, dialogs[0]) != bob.ID || dialogs[0].TopMessage != fwdID {
t.Fatalf("first dialog = peer %d top %d, want bob %d top %d", savedDialogPeerID(t, dialogs[0]), dialogs[0].TopMessage, bob.ID, fwdID)
}
if savedDialogPeerID(t, dialogs[1]) != alice.ID || dialogs[1].TopMessage != noteID {
t.Fatalf("second dialog = peer %d top %d, want self %d top %d", savedDialogPeerID(t, dialogs[1]), dialogs[1].TopMessage, alice.ID, noteID)
}
if len(messages) != 2 {
t.Fatalf("messages = %d, want 2 tops", len(messages))
}
for _, raw := range messages {
msg, ok := raw.(*tg.Message)
if !ok {
t.Fatalf("message = %T", raw)
}
saved, ok := msg.GetSavedPeerID()
if !ok {
t.Fatalf("message %d lacks saved_peer_id", msg.ID)
}
want := alice.ID
if msg.ID == fwdID {
want = bob.ID
}
if user, ok := saved.(*tg.PeerUser); !ok || user.UserID != want {
t.Fatalf("message %d saved_peer_id = %+v, want user %d", msg.ID, saved, want)
}
if msg.ID == fwdID {
fwd, ok := msg.GetFwdFrom()
if !ok {
t.Fatalf("forwarded top lacks fwd header")
}
savedFrom, ok := fwd.GetSavedFromPeer()
if !ok {
t.Fatalf("forwarded top lacks saved_from_peer")
}
if user, ok := savedFrom.(*tg.PeerUser); !ok || user.UserID != bob.ID {
t.Fatalf("saved_from_peer = %+v, want bob", savedFrom)
}
}
}
var hasSelf, hasBob bool
for _, raw := range users {
u, ok := raw.(*tg.User)
if !ok {
continue
}
if u.ID == alice.ID {
hasSelf = true
if !u.Self {
t.Fatalf("self user lacks self flag")
}
}
if u.ID == bob.ID {
hasBob = true
}
}
if !hasSelf || !hasBob {
t.Fatalf("users projection self=%v bob=%v, want both", hasSelf, hasBob)
}
}
// TestMessagesGetSavedDialogsPagination 验证分页:limit 截断 → slice+count,
// offset 翻页 → 尾页 savedDialogs;Android 首页 offset_id=int32 max 等价首页。
func TestMessagesGetSavedDialogsPagination(t *testing.T) {
ctx := context.Background()
r, _, alice, bob, noteID, fwdID := savedDialogsFixture(t)
first, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 1})
if err != nil {
t.Fatalf("first page: %v", err)
}
dialogs, _, _, count, full := savedDialogPage(t, first)
if full || count != 2 || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID {
t.Fatalf("first page = %d dialogs count %d full %v, want slice 1/2 bob first", len(dialogs), count, full)
}
second, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{
Limit: 1,
OffsetID: fwdID,
})
if err != nil {
t.Fatalf("second page: %v", err)
}
dialogs, _, _, _, full = savedDialogPage(t, second)
if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != alice.ID || dialogs[0].TopMessage != noteID {
t.Fatalf("second page = %+v full %v, want self note tail", dialogs, full)
}
androidFirst, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{
Limit: 20,
OffsetID: 2147483647,
})
if err != nil {
t.Fatalf("android first page: %v", err)
}
dialogs, _, _, _, full = savedDialogPage(t, androidFirst)
if !full || len(dialogs) != 2 {
t.Fatalf("android first page = %d full %v, want 2 full", len(dialogs), full)
}
}
// TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中
// 返回 savedDialogsNotModified{count}。
func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) {
ctx := context.Background()
r, _, alice, _, _, _ := savedDialogsFixture(t)
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20})
if err != nil {
t.Fatalf("getSavedDialogs: %v", err)
}
dialogs, messages, _, _, _ := savedDialogPage(t, res)
dateByID := map[int]int{}
for _, raw := range messages {
if msg, ok := raw.(*tg.Message); ok {
dateByID[msg.ID] = msg.Date
}
}
var hash uint64
for _, d := range dialogs {
pinned := int64(0)
if d.Pinned {
pinned = 1
}
hash = tdesktopHashUpdate(hash, pinned)
hash = tdesktopHashUpdate(hash, savedDialogPeerID(t, d))
hash = tdesktopHashUpdate(hash, int64(d.TopMessage))
hash = tdesktopHashUpdate(hash, int64(dateByID[d.TopMessage]))
}
cached, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{
Limit: 20,
Hash: int64(hash),
})
if err != nil {
t.Fatalf("getSavedDialogs hash: %v", err)
}
notModified, ok := cached.(*tg.MessagesSavedDialogsNotModified)
if !ok || notModified.Count != 2 {
t.Fatalf("hash hit = %#v, want notModified count 2", cached)
}
}
// TestMessagesSavedDialogPinLifecycle 验证置顶翻转/排序/排除全链路:
// updateSavedDialogPinned 推送、首页 pinned 在前、getPinnedSavedDialogs、
// exclude_pinned 口径、reorder 推 updatePinnedSavedDialogs。
func TestMessagesSavedDialogPinLifecycle(t *testing.T) {
ctx := context.Background()
r, _, alice, bob, noteID, _ := savedDialogsFixture(t)
pinSelfReq := &tg.MessagesToggleSavedDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}}}
pinSelfReq.SetPinned(true)
ok, err := r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), pinSelfReq)
if err != nil || !ok {
t.Fatalf("pin self sublist = %v err %v", ok, err)
}
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20})
if err != nil {
t.Fatalf("getSavedDialogs: %v", err)
}
dialogs, _, _, _, _ := savedDialogPage(t, res)
if len(dialogs) != 2 || savedDialogPeerID(t, dialogs[0]) != alice.ID || !dialogs[0].Pinned {
t.Fatalf("pinned self sublist not first: %+v", dialogs)
}
if dialogs[1].Pinned {
t.Fatalf("bob sublist unexpectedly pinned")
}
pinnedRes, err := r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID))
if err != nil {
t.Fatalf("getPinnedSavedDialogs: %v", err)
}
dialogs, _, _, _, full := savedDialogPage(t, pinnedRes)
if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != alice.ID || dialogs[0].TopMessage != noteID {
t.Fatalf("pinned list = %+v, want self note", dialogs)
}
excluded, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{
ExcludePinned: true,
Limit: 20,
})
if err != nil {
t.Fatalf("getSavedDialogs exclude: %v", err)
}
dialogs, _, _, count, full := savedDialogPage(t, excluded)
if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID {
t.Fatalf("exclude pinned = %+v, want only bob", dialogs)
}
if count != 1 {
t.Fatalf("exclude pinned count = %d, want 1", count)
}
pinBobReq := &tg.MessagesToggleSavedDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}}}
pinBobReq.SetPinned(true)
ok, err = r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), pinBobReq)
if err != nil || !ok {
t.Fatalf("pin bob sublist = %v err %v", ok, err)
}
// 新置顶插到最前。
pinnedRes, err = r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID))
if err != nil {
t.Fatalf("getPinnedSavedDialogs: %v", err)
}
dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes)
if len(dialogs) != 2 || savedDialogPeerID(t, dialogs[0]) != bob.ID {
t.Fatalf("pinned order after pin bob = %+v, want bob first", dialogs)
}
ok, err = r.onMessagesReorderPinnedSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesReorderPinnedSavedDialogsRequest{
Order: []tg.InputDialogPeerClass{
&tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}},
&tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}},
},
})
if err != nil || !ok {
t.Fatalf("reorder = %v err %v", ok, err)
}
pinnedRes, err = r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID))
if err != nil {
t.Fatalf("getPinnedSavedDialogs after reorder: %v", err)
}
dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes)
if len(dialogs) != 2 || savedDialogPeerID(t, dialogs[0]) != alice.ID || savedDialogPeerID(t, dialogs[1]) != bob.ID {
t.Fatalf("reorder result = %+v, want self then bob", dialogs)
}
ok, err = r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), &tg.MessagesToggleSavedDialogPinRequest{
Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}},
})
if err != nil || !ok {
t.Fatalf("unpin self = %v err %v", ok, err)
}
pinnedRes, err = r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID))
if err != nil {
t.Fatalf("getPinnedSavedDialogs after unpin: %v", err)
}
dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes)
if len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID {
t.Fatalf("after unpin = %+v, want only bob", dialogs)
}
}
// TestMessagesGetSavedDialogsByID 验证指定查询:命中返回、未知 peer 静默缺席。
func TestMessagesGetSavedDialogsByID(t *testing.T) {
ctx := context.Background()
r, _, alice, bob, _, fwdID := savedDialogsFixture(t)
res, err := r.onMessagesGetSavedDialogsByID(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsByIDRequest{
IDs: []tg.InputPeerClass{
&tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
&tg.InputPeerUser{UserID: 999999, AccessHash: 1},
},
})
if err != nil {
t.Fatalf("getSavedDialogsByID: %v", err)
}
dialogs, _, _, _, full := savedDialogPage(t, res)
if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID || dialogs[0].TopMessage != fwdID {
t.Fatalf("byID = %+v, want bob sublist only", dialogs)
}
}
// TestMessagesGetSavedHistoryFiltersBySavedPeer 验证子会话历史过滤、
// count 与 hash notModified。
func TestMessagesGetSavedHistoryFiltersBySavedPeer(t *testing.T) {
ctx := context.Background()
r, _, alice, bob, noteID, fwdID := savedDialogsFixture(t)
bobHistory, err := r.onMessagesGetSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedHistoryRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Limit: 20,
})
if err != nil {
t.Fatalf("getSavedHistory bob: %v", err)
}
bobMsgs := savedHistoryMessages(t, bobHistory)
if len(bobMsgs) != 1 || bobMsgs[0].ID != fwdID {
t.Fatalf("bob saved history = %+v, want forwarded message only", bobMsgs)
}
selfHistory, err := r.onMessagesGetSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedHistoryRequest{
Peer: &tg.InputPeerSelf{},
Limit: 20,
})
if err != nil {
t.Fatalf("getSavedHistory self: %v", err)
}
selfMsgs := savedHistoryMessages(t, selfHistory)
if len(selfMsgs) != 1 || selfMsgs[0].ID != noteID {
t.Fatalf("self saved history = %+v, want note only", selfMsgs)
}
// max_id 边界:低于转发 id 时空。
empty, err := r.onMessagesGetSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedHistoryRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
MaxID: fwdID,
Limit: 20,
})
if err != nil {
t.Fatalf("getSavedHistory max_id: %v", err)
}
if msgs := savedHistoryMessages(t, empty); len(msgs) != 0 {
t.Fatalf("max_id bounded history = %+v, want empty", msgs)
}
}
func savedHistoryMessages(t *testing.T, res tg.MessagesMessagesClass) []*tg.Message {
t.Helper()
var raw []tg.MessageClass
switch v := res.(type) {
case *tg.MessagesMessages:
raw = v.Messages
case *tg.MessagesMessagesSlice:
raw = v.Messages
default:
t.Fatalf("saved history = %T", res)
}
out := make([]*tg.Message, 0, len(raw))
for _, m := range raw {
msg, ok := m.(*tg.Message)
if !ok {
t.Fatalf("message = %T", m)
}
out = append(out, msg)
}
return out
}
// TestMessagesDeleteSavedHistoryRemovesSublist 验证删除子会话:带 pts 的
// affectedHistory、子会话从列表消失、置顶清理、self-chat 历史同步消失。
func TestMessagesDeleteSavedHistoryRemovesSublist(t *testing.T) {
ctx := context.Background()
r, messageStore, alice, bob, noteID, fwdID := savedDialogsFixture(t)
pinBobReq := &tg.MessagesToggleSavedDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}}}
pinBobReq.SetPinned(true)
if _, err := r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), pinBobReq); err != nil {
t.Fatalf("pin bob: %v", err)
}
affected, err := r.onMessagesDeleteSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesDeleteSavedHistoryRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
})
if err != nil {
t.Fatalf("deleteSavedHistory: %v", err)
}
if affected.Pts == 0 || affected.PtsCount != 1 || affected.Offset != 0 {
t.Fatalf("affected = %+v, want pts advance for 1 message, no continuation", affected)
}
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20})
if err != nil {
t.Fatalf("getSavedDialogs after delete: %v", err)
}
dialogs, _, _, _, _ := savedDialogPage(t, res)
if len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != alice.ID {
t.Fatalf("dialogs after delete = %+v, want only self note", dialogs)
}
pinnedRes, err := r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID))
if err != nil {
t.Fatalf("getPinnedSavedDialogs after delete: %v", err)
}
dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes)
if len(dialogs) != 0 {
t.Fatalf("pinned after delete = %+v, want empty (pin cleared)", dialogs)
}
list, err := messageStore.ListByUser(ctx, alice.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID},
Limit: 20,
})
if err != nil {
t.Fatalf("self-chat history: %v", err)
}
if len(list.Messages) != 1 || list.Messages[0].ID != noteID {
t.Fatalf("self-chat history after delete = %+v, want only note (fwd %d gone)", list.Messages, fwdID)
}
}
// TestModernForwardMessagesConstructorSavesToSelf 复现 Android「保存到收藏夹」
// 路径:DrKLO 12.7.x 发晚于 Layer 225 的 messages.forwardMessages#41d41ade,
// 服务端 500 时 tgnet 无限重试(UI 永久转圈)。compat 解码后必须等价于
// #13704a7c:转发成功、收藏夹出现来源子会话。
func TestModernForwardMessagesConstructorSavesToSelf(t *testing.T) {
ctx := context.Background()
r, messageStore, userStore := newSavedDialogsTestRouter(t)
alice, _ := userStore.Create(ctx, domain.User{AccessHash: 83, Phone: "15550009401", FirstName: "Alice"})
bob, _ := userStore.Create(ctx, domain.User{AccessHash: 84, Phone: "15550009402", FirstName: "Bob"})
if _, err := r.onMessagesSendMessage(WithUserID(ctx, bob.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
Message: "save me",
RandomID: 94001,
}); err != nil {
t.Fatalf("bob send: %v", err)
}
bobMsgID := privateTopMessageID(t, messageStore, alice.ID, bob.ID)
// 按 DrKLO TL_messages_forwardMessages.serializeToStream 的线格式手工编码:
// constructor + flags + from_peer + Vector<int> id + Vector<long> random_id + to_peer。
var raw bin.Buffer
raw.PutID(0x41d41ade)
raw.PutInt(0) // flags:保存到收藏夹不带任何可选字段
if err := (&tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}).Encode(&raw); err != nil {
t.Fatalf("encode from_peer: %v", err)
}
raw.PutVectorHeader(1)
raw.PutInt(bobMsgID)
raw.PutVectorHeader(1)
raw.PutLong(94002)
if err := (&tg.InputPeerSelf{}).Encode(&raw); err != nil {
t.Fatalf("encode to_peer: %v", err)
}
enc, err := r.Dispatch(WithUserID(androidClientContext(), alice.ID), [8]byte{}, 0, &raw)
if err != nil {
t.Fatalf("dispatch messages.forwardMessages#41d41ade: %v", err)
}
// Now routed through the unified layerwire client-alias (4-byte id swap) and
// the normal gotd dispatcher, which boxes a class result as *tg.UpdatesBox
// (wire-identical to the raw UpdatesClass the dedicated handler used to return).
switch v := enc.(type) {
case tg.UpdatesClass:
case *tg.UpdatesBox:
if v.Updates == nil {
t.Fatalf("forward result box has nil Updates")
}
default:
t.Fatalf("forward result = %T, want UpdatesClass or *tg.UpdatesBox", enc)
}
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20})
if err != nil {
t.Fatalf("getSavedDialogs after android forward: %v", err)
}
dialogs, messages, _, _, _ := savedDialogPage(t, res)
if len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID {
t.Fatalf("saved dialogs after android forward = %+v, want bob sublist", dialogs)
}
msg, ok := messages[0].(*tg.Message)
if !ok {
t.Fatalf("top message = %T", messages[0])
}
if saved, ok := msg.GetSavedPeerID(); !ok {
t.Fatalf("forwarded message lacks saved_peer_id")
} else if user, ok := saved.(*tg.PeerUser); !ok || user.UserID != bob.ID {
t.Fatalf("saved_peer_id = %+v, want bob", saved)
}
}
// TestSavedPeerForSelfChat 验证子会话分组键计算与 TDLib 语义对齐。
func TestSavedPeerForSelfChat(t *testing.T) {
self := int64(1000000001)
bobPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
cases := []struct {
name string
forward *domain.MessageForward
want domain.Peer
}{
{"direct note", nil, domain.Peer{Type: domain.PeerTypeUser, ID: self}},
{"forward with source", &domain.MessageForward{From: bobPeer, SavedFrom: bobPeer, SavedFromMsgID: 7}, bobPeer},
{"forward channel source", &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: 500}, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: 500}}, domain.Peer{Type: domain.PeerTypeChannel, ID: 500}},
{"hidden author without source", &domain.MessageForward{FromName: "Hidden"}, domain.Peer{Type: domain.PeerTypeUser, ID: domain.SavedHiddenAuthorUserID}},
{"visible author without source", &domain.MessageForward{From: bobPeer}, domain.Peer{Type: domain.PeerTypeUser, ID: self}},
}
for _, tc := range cases {
if got := domain.SavedPeerForSelfChat(self, tc.forward); got != tc.want {
t.Fatalf("%s: SavedPeerForSelfChat = %+v, want %+v", tc.name, got, tc.want)
}
}
}