182 lines
6.5 KiB
Go
182 lines
6.5 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/iamxvbaba/td/clock"
|
||
"github.com/iamxvbaba/td/tg"
|
||
"go.uber.org/zap/zaptest"
|
||
|
||
appchannels "telesrv/internal/app/channels"
|
||
appusers "telesrv/internal/app/users"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/memory"
|
||
)
|
||
|
||
// TestChannelsReadHistoryAdvancesForumGeneralReadRPC 覆盖 forum「General 话题幽灵未读」修复:
|
||
// 频道级 channels.readHistory 推进频道级已读后,必须把 General(topic 1) 的话题级已读水位也推进
|
||
// (General 消息即频道根历史,被频道级已读覆盖),否则 getForumTopics 会一直报 General 未读。
|
||
// 同时验证反向不变量:读「非 General 子话题」(readDiscussion) 绝不污染 General —— 零话题间 cross-talk。
|
||
func TestChannelsReadHistoryAdvancesForumGeneralReadRPC(t *testing.T) {
|
||
ctx := context.Background()
|
||
userStore := memory.NewUserStore()
|
||
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550007301", FirstName: "Owner"})
|
||
member, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550007302", FirstName: "Member"})
|
||
channelStore := memory.NewChannelStore()
|
||
r := New(Config{}, Deps{
|
||
Users: appusers.NewService(userStore),
|
||
Channels: appchannels.NewService(channelStore),
|
||
}, zaptest.NewLogger(t), clock.System)
|
||
|
||
ownerCtx := WithUserID(ctx, owner.ID)
|
||
memberCtx := WithUserID(ctx, member.ID)
|
||
|
||
created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Forum", Megagroup: true})
|
||
if err != nil {
|
||
t.Fatalf("create channel: %v", err)
|
||
}
|
||
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
|
||
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||
forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||
|
||
if _, err := r.onChannelsInviteToChannel(ownerCtx, &tg.ChannelsInviteToChannelRequest{
|
||
Channel: input,
|
||
Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}},
|
||
}); err != nil {
|
||
t.Fatalf("invite member: %v", err)
|
||
}
|
||
if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}); err != nil {
|
||
t.Fatalf("toggle forum: %v", err)
|
||
}
|
||
|
||
// 一个非 General 子话题,用于 cross-talk 反例。
|
||
createdTopic, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{
|
||
Peer: forumPeer,
|
||
Title: "Side",
|
||
IconColor: domain.DefaultForumTopicIconColor,
|
||
RandomID: 7301001,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create forum topic: %v", err)
|
||
}
|
||
sideTopicID := forumTopicRootMessageID(t, createdTopic, "Side")
|
||
|
||
sendGeneral := func(text string, randomID int64) int {
|
||
sent, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{
|
||
Peer: forumPeer,
|
||
Message: text,
|
||
RandomID: randomID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send general %q: %v", text, err)
|
||
}
|
||
return forumNewChannelMessageID(t, sent, text)
|
||
}
|
||
sendSide := func(text string, randomID int64) int {
|
||
reply := &tg.InputReplyToMessage{ReplyToMsgID: 0}
|
||
reply.SetTopMsgID(sideTopicID)
|
||
req := &tg.MessagesSendMessageRequest{Peer: forumPeer, Message: text, RandomID: randomID}
|
||
req.SetReplyTo(reply)
|
||
sent, err := r.onMessagesSendMessage(ownerCtx, req)
|
||
if err != nil {
|
||
t.Fatalf("send side %q: %v", text, err)
|
||
}
|
||
return forumNewChannelMessageID(t, sent, text)
|
||
}
|
||
|
||
// id 序:Side 根 < g1 < g2 < side1 < g3。Side 消息夹在 General 消息中间,
|
||
// 是「读 Side 用 GREATEST 误清 General」这类污染的最小复现条件。
|
||
_ = sendGeneral("g1", 7301002)
|
||
_ = sendGeneral("g2", 7301003)
|
||
sideMsg := sendSide("side1", 7301004)
|
||
g3 := sendGeneral("g3", 7301005)
|
||
|
||
generalUnread := func() int {
|
||
topics, err := r.onMessagesGetForumTopicsByID(memberCtx, &tg.MessagesGetForumTopicsByIDRequest{
|
||
Peer: forumPeer,
|
||
Topics: []int{forumGeneralTopicID},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("member getForumTopicsByID General: %v", err)
|
||
}
|
||
for _, item := range topics.Topics {
|
||
if topic, ok := item.(*tg.ForumTopic); ok && topic.ID == forumGeneralTopicID {
|
||
return topic.UnreadCount
|
||
}
|
||
}
|
||
t.Fatalf("member getForumTopicsByID General missing in %+v", topics.Topics)
|
||
return -1
|
||
}
|
||
|
||
before := generalUnread()
|
||
if before == 0 {
|
||
t.Fatalf("General unread before any member read = 0, want owner's General messages counted as unread")
|
||
}
|
||
|
||
// 反向不变量:读「Side」子话题(readDiscussion)不得污染 General。readDiscussion 内部对频道级
|
||
// ReadHistory 的「保守叠加」只经 service,不触达 channels.readHistory 的 RPC handler,故不回灌 General。
|
||
if ok, err := r.onMessagesReadDiscussion(memberCtx, &tg.MessagesReadDiscussionRequest{
|
||
Peer: forumPeer,
|
||
MsgID: sideTopicID,
|
||
ReadMaxID: sideMsg,
|
||
}); err != nil || !ok {
|
||
t.Fatalf("member readDiscussion Side = ok %v err %v, want true", ok, err)
|
||
}
|
||
if after := generalUnread(); after != before {
|
||
t.Fatalf("General unread after reading Side topic = %d, want unchanged %d (no cross-topic pollution)", after, before)
|
||
}
|
||
|
||
// 修复点:直接的 channels.readHistory 把频道级读到顶后,General 话题级水位随之推进 → 未读清零。
|
||
if ok, err := r.onChannelsReadHistory(memberCtx, &tg.ChannelsReadHistoryRequest{
|
||
Channel: input,
|
||
MaxID: g3,
|
||
}); err != nil || !ok {
|
||
t.Fatalf("member channels.readHistory = ok %v err %v, want true", ok, err)
|
||
}
|
||
if after := generalUnread(); after != 0 {
|
||
t.Fatalf("General unread after channels.readHistory = %d, want 0 (channel-level read covers General)", after)
|
||
}
|
||
}
|
||
|
||
func forumNewChannelMessageID(t *testing.T, updates tg.UpdatesClass, text string) int {
|
||
t.Helper()
|
||
u, ok := updates.(*tg.Updates)
|
||
if !ok {
|
||
t.Fatalf("updates = %T, want *tg.Updates", updates)
|
||
}
|
||
for _, update := range u.Updates {
|
||
newMsg, ok := update.(*tg.UpdateNewChannelMessage)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == text {
|
||
return msg.ID
|
||
}
|
||
}
|
||
t.Fatalf("updates = %+v, want new channel message %q", u.Updates, text)
|
||
return 0
|
||
}
|
||
|
||
func forumTopicRootMessageID(t *testing.T, updates tg.UpdatesClass, title string) int {
|
||
t.Helper()
|
||
u, ok := updates.(*tg.Updates)
|
||
if !ok {
|
||
t.Fatalf("updates = %T, want *tg.Updates", updates)
|
||
}
|
||
for _, update := range u.Updates {
|
||
newMsg, ok := update.(*tg.UpdateNewChannelMessage)
|
||
if !ok {
|
||
continue
|
||
}
|
||
msg, ok := newMsg.Message.(*tg.MessageService)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if action, ok := msg.Action.(*tg.MessageActionTopicCreate); ok && action.Title == title {
|
||
return msg.ID
|
||
}
|
||
}
|
||
t.Fatalf("updates = %+v, want topic create service message %q", u.Updates, title)
|
||
return 0
|
||
}
|