347 lines
13 KiB
Go
347 lines
13 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"github.com/gotd/td/clock"
|
||
"github.com/gotd/td/tg"
|
||
"go.uber.org/zap/zaptest"
|
||
"strings"
|
||
appchannels "telesrv/internal/app/channels"
|
||
appusers "telesrv/internal/app/users"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/memory"
|
||
"testing"
|
||
)
|
||
|
||
type reactionPolicyFixture struct {
|
||
router *Router
|
||
channelSvc *appchannels.Service
|
||
sessions *captureSessions
|
||
channel domain.Channel
|
||
messageID int
|
||
ownerID int64
|
||
memberID int64
|
||
member2ID int64
|
||
}
|
||
|
||
func newReactionPolicyFixture(t *testing.T, broadcast bool) reactionPolicyFixture {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
userStore := memory.NewUserStore()
|
||
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003001", FirstName: "Owner"})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550003002", FirstName: "Member"})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
member2, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550003003", FirstName: "Member2"})
|
||
if err != nil {
|
||
t.Fatalf("create member2: %v", err)
|
||
}
|
||
channelStore := memory.NewChannelStore()
|
||
channelSvc := appchannels.NewService(channelStore)
|
||
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||
Title: "Reaction Policy",
|
||
Broadcast: broadcast,
|
||
Megagroup: !broadcast,
|
||
MemberUserIDs: []int64{member.ID, member2.ID},
|
||
Date: 1000,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create channel: %v", err)
|
||
}
|
||
sent, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
|
||
ChannelID: created.Channel.ID,
|
||
RandomID: 9001,
|
||
Message: "react to me",
|
||
Date: 1100,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send message: %v", err)
|
||
}
|
||
sessions := &captureSessions{}
|
||
r := New(Config{}, Deps{
|
||
Users: appusers.NewService(userStore),
|
||
Channels: channelSvc,
|
||
Sessions: sessions,
|
||
}, zaptest.NewLogger(t), clock.System)
|
||
return reactionPolicyFixture{
|
||
router: r,
|
||
channelSvc: channelSvc,
|
||
sessions: sessions,
|
||
channel: created.Channel,
|
||
messageID: sent.Message.ID,
|
||
ownerID: owner.ID,
|
||
memberID: member.ID,
|
||
member2ID: member2.ID,
|
||
}
|
||
}
|
||
|
||
func (f reactionPolicyFixture) sendReaction(t *testing.T, userID int64, emoticons ...string) (tg.UpdatesClass, error) {
|
||
t.Helper()
|
||
reactions := make([]tg.ReactionClass, 0, len(emoticons))
|
||
if len(emoticons) > 0 {
|
||
for _, emoticon := range emoticons {
|
||
reactions = append(reactions, &tg.ReactionEmoji{Emoticon: emoticon})
|
||
}
|
||
}
|
||
return f.sendTLReactions(t, userID, reactions...)
|
||
}
|
||
|
||
func (f reactionPolicyFixture) sendTLReactions(t *testing.T, userID int64, reactions ...tg.ReactionClass) (tg.UpdatesClass, error) {
|
||
t.Helper()
|
||
req := &tg.MessagesSendReactionRequest{
|
||
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
|
||
MsgID: f.messageID,
|
||
}
|
||
if len(reactions) > 0 {
|
||
req.SetReaction(reactions)
|
||
}
|
||
return f.router.onMessagesSendReaction(WithUserID(context.Background(), userID), req)
|
||
}
|
||
|
||
func reactionUpdateFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.UpdateMessageReactions {
|
||
t.Helper()
|
||
box, ok := updates.(*tg.Updates)
|
||
if !ok || len(box.Updates) == 0 {
|
||
t.Fatalf("updates = %T %+v, want non-empty *tg.Updates", updates, updates)
|
||
}
|
||
update, ok := box.Updates[0].(*tg.UpdateMessageReactions)
|
||
if !ok {
|
||
t.Fatalf("update = %T, want *tg.UpdateMessageReactions", box.Updates[0])
|
||
}
|
||
return update
|
||
}
|
||
|
||
func TestSendReactionRespectsChannelReactionPolicy(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, false)
|
||
ctx := context.Background()
|
||
|
||
if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{
|
||
Type: domain.ChannelReactionPolicySome,
|
||
Emoticons: []string{"\U0001f44d"},
|
||
}); err != nil {
|
||
t.Fatalf("set whitelist policy: %v", err)
|
||
}
|
||
if _, err := f.sendReaction(t, f.memberID, "❤"); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") {
|
||
t.Fatalf("off-whitelist reaction err = %v, want REACTION_INVALID", err)
|
||
}
|
||
if _, err := f.sendReaction(t, f.memberID, "\U0001f44d"); err != nil {
|
||
t.Fatalf("whitelisted reaction: %v", err)
|
||
}
|
||
|
||
if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{
|
||
Type: domain.ChannelReactionPolicyNone,
|
||
}); err != nil {
|
||
t.Fatalf("set none policy: %v", err)
|
||
}
|
||
if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") {
|
||
t.Fatalf("reaction under none policy err = %v, want REACTION_INVALID", err)
|
||
}
|
||
// 策略收紧后撤销存量 reaction 必须仍然可行。
|
||
if _, err := f.sendReaction(t, f.memberID); err != nil {
|
||
t.Fatalf("retract reaction under none policy: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSendReactionAllowsCustomEmojiFromChannelPolicy(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, false)
|
||
ctx := context.Background()
|
||
const customDocumentID int64 = 7770001
|
||
|
||
updates, err := f.router.onMessagesSetChatAvailableReactions(WithUserID(ctx, f.ownerID), &tg.MessagesSetChatAvailableReactionsRequest{
|
||
Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash},
|
||
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
|
||
&tg.ReactionCustomEmoji{DocumentID: customDocumentID},
|
||
}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("set custom emoji reactions: %v", err)
|
||
}
|
||
if _, ok := updates.(*tg.Updates); !ok {
|
||
t.Fatalf("set custom emoji updates = %T, want *tg.Updates", updates)
|
||
}
|
||
|
||
if _, err := f.sendReaction(t, f.memberID, "\U0001f44d"); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") {
|
||
t.Fatalf("off-whitelist emoji err = %v, want REACTION_INVALID", err)
|
||
}
|
||
|
||
f.sessions.channelViewers = map[int64][]int64{f.channel.ID: {f.member2ID}}
|
||
sent, err := f.sendTLReactions(t, f.memberID, &tg.ReactionCustomEmoji{DocumentID: customDocumentID})
|
||
if err != nil {
|
||
t.Fatalf("send custom emoji reaction: %v", err)
|
||
}
|
||
update := reactionUpdateFromUpdates(t, sent)
|
||
if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 {
|
||
t.Fatalf("custom reaction results = %+v, want one chosen custom reaction", update.Reactions.Results)
|
||
}
|
||
reaction, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionCustomEmoji)
|
||
if !ok || reaction.DocumentID != customDocumentID {
|
||
t.Fatalf("custom reaction = %T %+v, want document %d", update.Reactions.Results[0].Reaction, update.Reactions.Results[0].Reaction, customDocumentID)
|
||
}
|
||
|
||
pushed := f.sessions.pushedUserIDs()
|
||
foundOtherViewer := false
|
||
for _, userID := range pushed {
|
||
if userID == f.member2ID {
|
||
foundOtherViewer = true
|
||
break
|
||
}
|
||
}
|
||
if !foundOtherViewer {
|
||
t.Fatalf("pushed users = %+v, want other online member %d", pushed, f.member2ID)
|
||
}
|
||
}
|
||
|
||
func TestSendReactionNormalizesDefaultReactionDocumentID(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, false)
|
||
f.router.deps.Files = &fakeFiles{reactions: []domain.AvailableReaction{
|
||
{Reaction: "\U0001f44d", ActivateAnimationID: 8801},
|
||
}}
|
||
ctx := context.Background()
|
||
|
||
if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{
|
||
Type: domain.ChannelReactionPolicySome,
|
||
Emoticons: []string{"\U0001f44d"},
|
||
}); err != nil {
|
||
t.Fatalf("set emoji whitelist policy: %v", err)
|
||
}
|
||
|
||
updates, err := f.sendTLReactions(t, f.memberID, &tg.ReactionCustomEmoji{DocumentID: 8801})
|
||
if err != nil {
|
||
t.Fatalf("send default reaction by document id: %v", err)
|
||
}
|
||
update := reactionUpdateFromUpdates(t, updates)
|
||
if len(update.Reactions.Results) != 1 {
|
||
t.Fatalf("reaction results = %+v, want one normalized emoji result", update.Reactions.Results)
|
||
}
|
||
emoji, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionEmoji)
|
||
if !ok || emoji.Emoticon != "\U0001f44d" {
|
||
t.Fatalf("normalized reaction = %T %+v, want reactionEmoji thumbs up", update.Reactions.Results[0].Reaction, update.Reactions.Results[0].Reaction)
|
||
}
|
||
}
|
||
|
||
func TestSendReactionEnforcesUniqueReactionsLimit(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, false)
|
||
ctx := context.Background()
|
||
|
||
// reactions_limit 是 appConfig reactions_uniq_max 的 per-chat 覆盖,用 1 触发上限。
|
||
if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{
|
||
Type: domain.ChannelReactionPolicyAll,
|
||
Limit: 1,
|
||
}); err != nil {
|
||
t.Fatalf("set uniq limit policy: %v", err)
|
||
}
|
||
if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err != nil {
|
||
t.Fatalf("first reaction: %v", err)
|
||
}
|
||
if _, err := f.sendReaction(t, f.memberID, "❤"); err == nil || !strings.Contains(err.Error(), "REACTIONS_TOO_MANY") {
|
||
t.Fatalf("new unique emoji past limit err = %v, want REACTIONS_TOO_MANY", err)
|
||
}
|
||
// 追加已存在的种类不受 uniq 上限约束。
|
||
updates, err := f.sendReaction(t, f.memberID, "\U0001f44d")
|
||
if err != nil {
|
||
t.Fatalf("existing emoji reaction: %v", err)
|
||
}
|
||
update := reactionUpdateFromUpdates(t, updates)
|
||
if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 2 {
|
||
t.Fatalf("results = %+v, want one emoji with count 2", update.Reactions.Results)
|
||
}
|
||
}
|
||
|
||
func TestSendReactionOverLimitMessageStillAllowsExistingKinds(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, false)
|
||
ctx := context.Background()
|
||
|
||
// 默认策略下先造出 {👍, ❤} 两个去重种类,再把 reactions_limit 调低到 1,
|
||
// 模拟「存量已超限」(管理员事后调低 / 部署前无 uniq 闸门的旧数据)。
|
||
if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err != nil {
|
||
t.Fatalf("seed owner reaction: %v", err)
|
||
}
|
||
if _, err := f.sendReaction(t, f.memberID, "❤"); err != nil {
|
||
t.Fatalf("seed member reaction: %v", err)
|
||
}
|
||
if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{
|
||
Type: domain.ChannelReactionPolicyAll,
|
||
Limit: 1,
|
||
}); err != nil {
|
||
t.Fatalf("lower uniq limit: %v", err)
|
||
}
|
||
|
||
// 重发自己已有的 reaction(no-op)必须放行。
|
||
if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err != nil {
|
||
t.Fatalf("owner re-send own reaction on over-limit message: %v", err)
|
||
}
|
||
if _, err := f.sendReaction(t, f.memberID, "❤"); err != nil {
|
||
t.Fatalf("member re-send own reaction on over-limit message: %v", err)
|
||
}
|
||
// 第三人给已有种类投票必须放行(不引入新种类)。
|
||
if _, err := f.sendReaction(t, f.member2ID, "\U0001f44d"); err != nil {
|
||
t.Fatalf("third user piles onto existing kind on over-limit message: %v", err)
|
||
}
|
||
// 引入新种类仍然 REACTIONS_TOO_MANY。
|
||
if _, err := f.sendReaction(t, f.member2ID, "\U0001f525"); err == nil || !strings.Contains(err.Error(), "REACTIONS_TOO_MANY") {
|
||
t.Fatalf("new kind on over-limit message err = %v, want REACTIONS_TOO_MANY", err)
|
||
}
|
||
}
|
||
|
||
func TestSendReactionTrimsVectorToPerUserMax(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, false)
|
||
|
||
// 超出 reactions_user_max_default 的向量保留尾部最新项,不报错。
|
||
updates, err := f.sendReaction(t, f.memberID, "\U0001f44d", "❤")
|
||
if err != nil {
|
||
t.Fatalf("send oversized reaction vector: %v", err)
|
||
}
|
||
update := reactionUpdateFromUpdates(t, updates)
|
||
if len(update.Reactions.Results) != 1 {
|
||
t.Fatalf("results = %+v, want single trimmed reaction", update.Reactions.Results)
|
||
}
|
||
emoji, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionEmoji)
|
||
if !ok || emoji.Emoticon != "❤" {
|
||
t.Fatalf("kept reaction = %+v, want newest vector entry kept", update.Reactions.Results[0].Reaction)
|
||
}
|
||
if update.Reactions.Results[0].ChosenOrder != 1 {
|
||
t.Fatalf("chosen_order = %d, want 1", update.Reactions.Results[0].ChosenOrder)
|
||
}
|
||
recent, ok := update.Reactions.GetRecentReactions()
|
||
if !ok || len(recent) != 1 {
|
||
t.Fatalf("megagroup recent reactions = %+v set=%v, want one entry", recent, ok)
|
||
}
|
||
}
|
||
|
||
func TestBroadcastReactionsHideRecentReactors(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, true)
|
||
|
||
updates, err := f.sendReaction(t, f.memberID, "\U0001f44d")
|
||
if err != nil {
|
||
t.Fatalf("send broadcast reaction: %v", err)
|
||
}
|
||
update := reactionUpdateFromUpdates(t, updates)
|
||
if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 {
|
||
t.Fatalf("broadcast results = %+v, want count-only aggregate", update.Reactions.Results)
|
||
}
|
||
if recent, ok := update.Reactions.GetRecentReactions(); ok && len(recent) > 0 {
|
||
t.Fatalf("broadcast recent reactions = %+v, want anonymous (absent)", recent)
|
||
}
|
||
if update.Reactions.CanSeeList {
|
||
t.Fatalf("broadcast can_see_list = true, want false")
|
||
}
|
||
}
|
||
|
||
func TestChannelRealtimeFanoutPassesCapToProvider(t *testing.T) {
|
||
f := newReactionPolicyFixture(t, false)
|
||
f.sessions.channelViewers = map[int64][]int64{f.channel.ID: {f.memberID}}
|
||
|
||
if _, err := f.sendReaction(t, f.memberID, "\U0001f44d"); err != nil {
|
||
t.Fatalf("send reaction: %v", err)
|
||
}
|
||
f.sessions.mu.Lock()
|
||
gotLimit := f.sessions.channelViewersLimit
|
||
f.sessions.mu.Unlock()
|
||
if gotLimit != domain.MaxChannelRealtimeFanout {
|
||
t.Fatalf("online viewers limit = %d, want MaxChannelRealtimeFanout %d", gotLimit, domain.MaxChannelRealtimeFanout)
|
||
}
|
||
}
|