owpengram-server/internal/rpc/channels_messages_rpc_test.go
iamxvbaba 3fa0c6f1e4 fix(rpc): sync verification icon envelope coverage
Follow up gramsrv#27 with the remaining peer-bearing messages.Messages boundaries and route-level wire regression coverage from the telesrv canonical implementation.

Origin-PR: gramsrv#27
Origin-Head: f0bd6c9699
Origin-Merge: 7713847b2a
Canonical: f1ba406015a2d5e37802b963757a5cac8ee3eb0e

Co-authored-by: epilepticseizureee <business.egor.sg@gmail.com>
2026-07-30 14:06:39 +08:00

1046 lines
45 KiB
Go
Raw 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"
"strconv"
"strings"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appupdates "telesrv/internal/app/updates"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
func TestChannelsDeleteChannelReturnsForbiddenChatAndHidesDialogRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
channelStore := memory.NewChannelStore()
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Delete Me",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
getDialogs := func(userID int64) *tg.MessagesDialogs {
t.Helper()
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs: %v", err)
}
dialogs, ok := enc.(*tg.MessagesDialogs)
if !ok {
t.Fatalf("dialogs response = %T, want *tg.MessagesDialogs", enc)
}
return dialogs
}
if got := getDialogs(owner.ID); len(got.Dialogs) != 1 || len(got.Chats) != 1 {
t.Fatalf("dialogs before delete = %+v, want one channel dialog", got)
}
deleted, err := r.onChannelsDeleteChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{
ChannelID: channel.ID,
AccessHash: channel.AccessHash,
})
if err != nil {
t.Fatalf("delete channel: %v", err)
}
updates, ok := deleted.(*tg.Updates)
if !ok || len(updates.Updates) != 1 || len(updates.Chats) != 1 {
t.Fatalf("delete response = %T %+v, want updateChannel + channelForbidden", deleted, deleted)
}
if update, ok := updates.Updates[0].(*tg.UpdateChannel); !ok || update.ChannelID != channel.ID {
t.Fatalf("delete update = %#v, want updateChannel %d", updates.Updates[0], channel.ID)
}
forbidden, ok := updates.Chats[0].(*tg.ChannelForbidden)
if !ok || forbidden.ID != channel.ID || forbidden.AccessHash != channel.AccessHash || forbidden.Title != channel.Title || !forbidden.Megagroup {
t.Fatalf("delete chat = %#v, want channelForbidden tombstone", updates.Chats[0])
}
pushed := sessions.snapshot()
if pushed.messageType != proto.MessageFromServer || pushed.userID == 0 {
t.Fatalf("push snapshot = %+v, want server update to a channel member", pushed)
}
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Chats) != 1 {
t.Fatalf("pushed update = %T %+v, want channelForbidden chat", pushed.message, pushed.message)
}
if pushedForbidden, ok := pushedUpdates.Chats[0].(*tg.ChannelForbidden); !ok || pushedForbidden.ID != channel.ID {
t.Fatalf("pushed chat = %#v, want channelForbidden %d", pushedUpdates.Chats[0], channel.ID)
}
if got := getDialogs(owner.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 {
t.Fatalf("owner dialogs after delete = %+v, want hidden deleted channel", got)
}
if got := getDialogs(friend.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 {
t.Fatalf("friend dialogs after delete = %+v, want hidden deleted channel", got)
}
}
func TestChannelGetMessagesReturnsSparseIDs(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002101", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002102", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Sparse RPC Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
var firstID, lastID int
for i := 0; i < 5; i++ {
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "sparse-" + strconv.Itoa(i),
RandomID: int64(100 + i),
})
if err != nil {
t.Fatalf("send channel message %d: %v", i, err)
}
msg := updates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if i == 0 {
firstID = msg.ID
}
lastID = msg.ID
}
got, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []tg.InputMessageClass{
&tg.InputMessageID{ID: firstID},
&tg.InputMessageID{ID: lastID},
},
})
if err != nil {
t.Fatalf("get sparse channel messages: %v", err)
}
messages := got.(*tg.MessagesMessages).Messages
if len(messages) != 2 {
t.Fatalf("messages len = %d, want 2", len(messages))
}
first := messages[0].(*tg.Message)
last := messages[1].(*tg.Message)
if first.ID != firstID || first.Message != "sparse-0" || last.ID != lastID || last.Message != "sparse-4" {
t.Fatalf("sparse messages = %#v %#v, want first and last exact ids", first, last)
}
}
func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Updates: updateSvc,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Clear Channel",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "clear me",
RandomID: 401,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
newChannelUpdate := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage)
msg := newChannelUpdate.Message.(*tg.Message)
clearSince := int(time.Now().Unix()) - 1
stateBefore, err := updateSvc.CurrentState(ctx, owner.ID)
if err != nil {
t.Fatalf("account state before clear: %v", err)
}
pushesBefore := len(sessions.pushedUserIDs())
cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID,
})
if err != nil {
t.Fatalf("delete channel history local: %v", err)
}
updates, ok := cleared.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("clear response = %T %+v, want one no-pts available update", cleared, cleared)
}
available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID {
t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID)
}
stateAfter, err := updateSvc.CurrentState(ctx, owner.ID)
if err != nil {
t.Fatalf("account state after clear: %v", err)
}
if stateAfter.Pts != stateBefore.Pts {
t.Fatalf("account pts advanced on local channel clear: before=%d after=%d", stateBefore.Pts, stateAfter.Pts)
}
pushed := sessions.snapshot()
if pushed.userID != owner.ID {
t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID)
}
pushedUpdates, ok := pushed.message.(*tg.Updates)
if !ok || len(pushedUpdates.Updates) != 1 {
t.Fatalf("pushed clear update = %T %+v, want one no-pts available update", pushed.message, pushed.message)
}
if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok {
t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0])
}
if got := len(sessions.pushedUserIDs()) - pushesBefore; got != 1 {
t.Fatalf("clear online pushes = %d, want exactly one owner-session fanout", got)
}
diff, err := updateSvc.GetDifference(ctx, [8]byte{}, owner.ID, stateBefore)
if err != nil {
t.Fatalf("get difference: %v", err)
}
if len(diff.Events) != 0 || diff.State.Pts != stateBefore.Pts {
t.Fatalf("difference after no-pts clear = %+v, want no durable account event", diff)
}
offline, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{
Pts: stateBefore.Pts,
Date: clearSince,
})
if err != nil {
t.Fatalf("account difference after offline local clear: %v", err)
}
offlineDiff, ok := offline.(*tg.UpdatesDifference)
if !ok {
t.Fatalf("offline account difference = %T %+v, want updates.difference", offline, offline)
}
var accountAvailable *tg.UpdateChannelAvailableMessages
for _, update := range offlineDiff.OtherUpdates {
if value, ok := update.(*tg.UpdateChannelAvailableMessages); ok {
accountAvailable = value
break
}
}
if accountAvailable == nil ||
accountAvailable.ChannelID != channel.ID ||
accountAvailable.AvailableMinID != msg.ID {
t.Fatalf("offline account updates = %+v, want updateChannelAvailableMessages channel=%d min=%d",
offlineDiff.OtherUpdates, channel.ID, msg.ID)
}
if offlineDiff.State.Pts != stateBefore.Pts || offlineDiff.State.Date < clearSince {
t.Fatalf("offline account state = %+v, want unchanged pts=%d and non-regressing date", offlineDiff.State, stateBefore.Pts)
}
for attempt := 1; attempt <= 2; attempt++ {
channelOffline, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: newChannelUpdate.Pts,
Limit: 100,
})
if err != nil {
t.Fatalf("channel difference attempt %d after offline local clear: %v", attempt, err)
}
channelDiff, ok := channelOffline.(*tg.UpdatesChannelDifferenceEmpty)
if !ok || channelDiff.Pts != newChannelUpdate.Pts {
t.Fatalf("channel difference attempt %d = %T %+v, want empty with unchanged channel pts=%d",
attempt, channelOffline, channelOffline, newChannelUpdate.Pts)
}
}
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs after clear: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs after clear: %v", err)
}
dialogs, ok := enc.(*tg.MessagesDialogs)
if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Messages) != 1 {
t.Fatalf("dialogs after cold projection = %T %+v, want one anchored dialog/message", enc, enc)
}
dialog := dialogs.Dialogs[0].(*tg.Dialog)
service, ok := dialogs.Messages[0].(*tg.MessageService)
if !ok || dialog.TopMessage != msg.ID || service.ID != msg.ID {
t.Fatalf("cold dialog projection = dialog=%+v message=%T %+v, want history-clear top %d", dialog, dialogs.Messages[0], dialogs.Messages[0], msg.ID)
}
if _, ok := service.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold dialog service action = %T, want messageActionHistoryClear", service.Action)
}
historyReq := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Limit: 20,
}
b.Reset()
if err := historyReq.Encode(&b); err != nil {
t.Fatalf("encode get history after clear: %v", err)
}
historyEnc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get history after clear: %v", err)
}
history, ok := historyEnc.(*tg.MessagesChannelMessages)
if !ok || len(history.Messages) != 1 {
t.Fatalf("history after cold projection = %T %+v, want one history-clear marker", historyEnc, historyEnc)
}
historyService, ok := history.Messages[0].(*tg.MessageService)
if !ok || historyService.ID != msg.ID {
t.Fatalf("cold history projection = %T %+v, want service marker %d", history.Messages[0], history.Messages[0], msg.ID)
}
if _, ok := historyService.Action.(*tg.MessageActionHistoryClear); !ok {
t.Fatalf("cold history service action = %T, want messageActionHistoryClear", historyService.Action)
}
stalePushesBefore := len(sessions.pushedUserIDs())
stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
MaxID: msg.ID - 1,
})
if err != nil {
t.Fatalf("stale delete channel history local: %v", err)
}
staleUpdates, ok := stale.(*tg.Updates)
if !ok || len(staleUpdates.Updates) != 1 {
t.Fatalf("stale clear response = %T %+v, want monotonic absolute update", stale, stale)
}
staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages)
if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID {
t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID)
}
if got := len(sessions.pushedUserIDs()); got != stalePushesBefore {
t.Fatalf("stale clear pushed another online update: before=%d after=%d", stalePushesBefore, got)
}
}
func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 43, Phone: "15550002143", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 44, Phone: "15550002144", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Invalid Delete IDs",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
for _, maxID := range []int{-1, domain.MaxMessageBoxID + 1} {
if _, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{Channel: input, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("channels.deleteHistory max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err)
}
if _, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteHistoryRequest{Peer: peer, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("messages.deleteHistory channel max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err)
}
}
if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: []int{domain.MaxMessageBoxID + 1}}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("channels.deleteMessages huge id err = %v, want MESSAGE_ID_INVALID", err)
}
tooMany := make([]int, domain.MaxDeleteMessageIDs+1)
for i := range tooMany {
tooMany[i] = i + 1
}
if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: tooMany}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.deleteMessages too many ids err = %v, want LIMIT_INVALID", err)
}
}
func TestChannelsDeleteHistoryForEveryoneDrainsBatchesAndKeepsDialogVisible(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 45, Phone: "15550002145", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 46, Phone: "15550002146", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
sessions := &captureSessions{}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Drain Clear",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
total := domain.MaxDeleteHistoryBatch + 1
for i := 0; i < total; i++ {
if _, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channel.ID,
RandomID: int64(50_000 + i),
Message: "drain",
Date: 1_700_000_400 + i,
}); err != nil {
t.Fatalf("seed channel message %d: %v", i, err)
}
}
pushedBefore := len(sessions.pushedUserIDs())
clearReq := &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
}
clearReq.SetForEveryone(true)
cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), clearReq)
if err != nil {
t.Fatalf("delete channel history for everyone: %v", err)
}
updates, ok := cleared.(*tg.Updates)
if !ok || len(updates.Updates) != 1 {
t.Fatalf("clear response = %T %+v, want final delete batch updates", cleared, cleared)
}
// 最后一批删除 invite 服务消息与剩余文本id=1 建群服务消息保留为
// 会话兜底 top message不进入删除批次。
deleteUpdate, ok := updates.Updates[0].(*tg.UpdateDeleteChannelMessages)
if !ok || deleteUpdate.ChannelID != channel.ID || deleteUpdate.PtsCount != len(deleteUpdate.Messages) || len(deleteUpdate.Messages) == 0 {
t.Fatalf("final batch update = %#v, want trailing delete batch sparing the creation service message", updates.Updates[0])
}
for _, id := range deleteUpdate.Messages {
if id <= 1 {
t.Fatalf("final batch deleted id %d, creation service message must be spared", id)
}
}
// 两批1000+1删除每批向 owner 和 friend 各推送一次。
if pushedAfter := len(sessions.pushedUserIDs()); pushedAfter-pushedBefore != 4 {
t.Fatalf("pushed fanout count = %d, want 4 (two batches to both members)", pushedAfter-pushedBefore)
}
history, err := channelStore.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channel.ID, Limit: 10})
if err != nil {
t.Fatalf("list history after clear: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != 1 || history.Messages[0].Action == nil {
t.Fatalf("history after clear = %+v, want only create service message", history.Messages)
}
getDialogs := func(userID int64) *tg.MessagesDialogs {
t.Helper()
req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}
var b bin.Buffer
if err := req.Encode(&b); err != nil {
t.Fatalf("encode get dialogs: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b)
if err != nil {
t.Fatalf("dispatch get dialogs: %v", err)
}
dialogs, ok := enc.(*tg.MessagesDialogs)
if !ok {
t.Fatalf("dialogs response = %T, want messages.dialogs", enc)
}
return dialogs
}
for _, viewer := range []int64{owner.ID, friend.ID} {
got := getDialogs(viewer)
if len(got.Dialogs) != 1 || len(got.Chats) != 1 {
t.Fatalf("dialogs for %d after clear = %+v, want channel dialog kept visible", viewer, got)
}
dialog := got.Dialogs[0].(*tg.Dialog)
if dialog.TopMessage != 1 {
t.Fatalf("dialog top for %d = %d, want create service message 1", viewer, dialog.TopMessage)
}
if len(got.Messages) != 1 {
t.Fatalf("dialog top messages for %d = %+v, want create service message attached", viewer, got.Messages)
}
if _, ok := got.Messages[0].(*tg.MessageService); !ok {
t.Fatalf("dialog top message for %d = %T, want messageService", viewer, got.Messages[0])
}
}
againReq := &tg.ChannelsDeleteHistoryRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
}
againReq.SetForEveryone(true)
again, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), againReq)
if err != nil {
t.Fatalf("repeat delete channel history: %v", err)
}
if repeat, ok := again.(*tg.Updates); !ok || len(repeat.Updates) != 0 {
t.Fatalf("repeat clear response = %T %+v, want empty idempotent updates", again, again)
}
}
func TestBroadcastChannelPostRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002171", FirstName: "Owner"})
member, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002172", FirstName: "Member"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "RPC Broadcast",
About: "news",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
if !channel.Broadcast || channel.Megagroup {
t.Fatalf("channel flags = broadcast:%v megagroup:%v, want broadcast only", channel.Broadcast, channel.Megagroup)
}
if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}},
}); err != nil {
t.Fatalf("invite member to broadcast: %v", err)
}
posted, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "broadcast post",
RandomID: 7071,
})
if err != nil {
t.Fatalf("owner send broadcast post: %v", err)
}
postUpdate := posted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage)
postMsg := postUpdate.Message.(*tg.Message)
if !postMsg.Post || postMsg.FromID != nil || postMsg.Message != "broadcast post" {
t.Fatalf("broadcast post message = %#v, want post without from_id", postMsg)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "member post",
RandomID: 7072,
}); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") {
t.Fatalf("member broadcast send err = %v, want CHAT_WRITE_FORBIDDEN", err)
}
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
AdminRights: tg.ChatAdminRights{ChangeInfo: true},
}); err != nil {
t.Fatalf("promote member without post_messages: %v", err)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "admin without post right",
RandomID: 7073,
}); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") {
t.Fatalf("admin without post right send err = %v, want CHAT_WRITE_FORBIDDEN", err)
}
if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
AdminRights: tg.ChatAdminRights{PostMessages: true},
}); err != nil {
t.Fatalf("grant member post_messages: %v", err)
}
adminPosted, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "admin post",
RandomID: 7074,
})
if err != nil {
t.Fatalf("admin send broadcast post: %v", err)
}
adminPostMsg := adminPosted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if !adminPostMsg.Post || adminPostMsg.FromID != nil || adminPostMsg.Message != "admin post" {
t.Fatalf("admin broadcast post message = %#v, want post without from_id", adminPostMsg)
}
diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, member.ID), &tg.UpdatesGetChannelDifferenceRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelMessagesFilterEmpty{},
Pts: 1,
Limit: 10,
})
if err != nil {
t.Fatalf("member channel difference: %v", err)
}
fullDiff := diff.(*tg.UpdatesChannelDifference)
foundPost := false
for _, msg := range fullDiff.NewMessages {
if item, ok := msg.(*tg.Message); ok && item.Message == "broadcast post" {
foundPost = item.Post && item.FromID == nil
}
}
if !foundPost {
t.Fatalf("diff new messages = %+v, want broadcast post without from_id", fullDiff.NewMessages)
}
}
func TestChannelSendMessageRPCResolvesReplyHeader(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002031", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002032", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "RPC Reply Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
rootUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "root",
RandomID: 3001,
})
if err != nil {
t.Fatalf("send root: %v", err)
}
rootMsg := rootUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
replyTo := &tg.InputReplyToMessage{ReplyToMsgID: rootMsg.ID}
replyTo.SetQuoteText("root")
replyTo.SetQuoteOffset(0)
replyReq := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "reply",
RandomID: 3002,
}
replyReq.SetReplyTo(replyTo)
replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), replyReq)
if err != nil {
t.Fatalf("send reply: %v", err)
}
replyMsg := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
header, ok := replyMsg.ReplyTo.(*tg.MessageReplyHeader)
if !ok {
t.Fatalf("reply header = %#v, want messageReplyHeader", replyMsg.ReplyTo)
}
topID, topOK := header.GetReplyToTopID()
quoteText, quoteOK := header.GetQuoteText()
if header.ReplyToMsgID != rootMsg.ID || !topOK || topID != rootMsg.ID || !quoteOK || quoteText != "root" {
t.Fatalf("reply header = %#v, want msg/top %d and quote", header, rootMsg.ID)
}
badReq := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "bad",
RandomID: 3003,
}
badReq.SetReplyTo(&tg.InputReplyToMessage{ReplyToMsgID: 999})
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), badReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") {
t.Fatalf("bad reply err = %v, want REPLY_MESSAGE_ID_INVALID", err)
}
}
func TestChannelBoostUnlocksDefaultSendRestrictionRPC(t *testing.T) {
f := newRPCChannelFixture(t)
r := f.router
owner := f.user(9101, "15550009101", "Owner")
member := f.user(9102, "15550009102", "Member")
channel := f.createLegacyMegagroup(owner, "Boost Gate", member)
ownerCtx := f.userCtx(owner)
memberCtx := f.userCtx(member)
if _, err := r.onMessagesEditChatDefaultBannedRights(ownerCtx, &tg.MessagesEditChatDefaultBannedRightsRequest{
Peer: inputPeerChannel(channel),
BannedRights: tg.ChatBannedRights{
SendMessages: true,
},
}); err != nil {
t.Fatalf("edit default banned rights: %v", err)
}
if _, err := r.onMessagesSendMessage(memberCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(channel),
Message: "blocked",
RandomID: 9102001,
}); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") {
t.Fatalf("member send before boost err = %v, want CHAT_WRITE_FORBIDDEN", err)
}
if _, err := r.onChannelsSetBoostsToUnblockRestrictions(ownerCtx, &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{
Channel: inputChannel(channel),
Boosts: 1,
}); err != nil {
t.Fatalf("set boosts to unblock: %v", err)
}
if _, err := f.users.SetPremiumUntil(f.ctx, member.ID, int(time.Now().Add(time.Hour).Unix())); err != nil {
t.Fatalf("grant member premium: %v", err)
}
applyReq := &tg.PremiumApplyBoostRequest{Peer: inputPeerChannel(channel)}
applyReq.SetSlots([]int{domain.DefaultPremiumBoostSlotID})
if _, err := r.onPremiumApplyBoost(memberCtx, applyReq); err != nil {
t.Fatalf("apply member boost: %v", err)
}
full, err := r.onChannelsGetFullChannel(memberCtx, inputChannel(channel))
if err != nil {
t.Fatalf("get full channel after boost: %v", err)
}
channelFull := full.FullChat.(*tg.ChannelFull)
if boosts, ok := channelFull.GetBoostsApplied(); !ok || boosts != 1 {
t.Fatalf("full channel boosts_applied = %d ok %v, want 1", boosts, ok)
}
if threshold, ok := channelFull.GetBoostsUnrestrict(); !ok || threshold != 1 {
t.Fatalf("full channel boosts_unrestrict = %d ok %v, want 1", threshold, ok)
}
sent, err := r.onMessagesSendMessage(memberCtx, &tg.MessagesSendMessageRequest{
Peer: inputPeerChannel(channel),
Message: "boosted ok",
RandomID: 9102002,
})
if err != nil {
t.Fatalf("member send after boost: %v", err)
}
msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if applied, ok := msg.GetFromBoostsApplied(); !ok || applied != 1 {
t.Fatalf("message from_boosts_applied = %d ok %v, want 1", applied, ok)
}
}
func TestChannelSendAsCurrentChannelRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002111", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002112", FirstName: "Friend"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}},
Title: "Send As Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel)
sendAs := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "as channel",
RandomID: 501,
SendAs: sendAs,
})
if err != nil {
t.Fatalf("send as current channel: %v", err)
}
sentUpdates := sent.(*tg.Updates)
sentMsg := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID {
t.Fatalf("send_as message from = %#v, want channel %d", sentMsg.FromID, channel.ID)
}
history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []tg.InputMessageClass{&tg.InputMessageID{ID: sentMsg.ID}},
})
if err != nil {
t.Fatalf("get send_as history: %v", err)
}
historyMsg := history.(*tg.MessagesMessages).Messages[0].(*tg.Message)
if from, ok := historyMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID {
t.Fatalf("history send_as from = %#v, want channel %d", historyMsg.FromID, channel.ID)
}
forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ID: []int{sentMsg.ID},
RandomID: []int64{502},
SendAs: sendAs,
})
if err != nil {
t.Fatalf("forward as current channel: %v", err)
}
forwardMsg := forwarded.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message)
if from, ok := forwardMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID {
t.Fatalf("forward send_as from = %#v, want channel %d", forwardMsg.FromID, channel.ID)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Message: "bad private send_as",
RandomID: 503,
SendAs: sendAs,
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("private send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Message: "bad member send_as",
RandomID: 504,
SendAs: sendAs,
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("member send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
ToPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
ID: []int{sentMsg.ID},
RandomID: []int64{505},
SendAs: sendAs,
}); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") {
t.Fatalf("private forward send_as err = %v, want SEND_AS_PEER_INVALID", err)
}
}
func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91001, Phone: "15550091001", FirstName: "Owner"})
viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91002, Phone: "15550091002", FirstName: "Viewer"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
verify := newFakeBotVerifications()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
BotVerifications: verify,
}, zaptest.NewLogger(t), clock.System)
public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Public Search",
Broadcast: true,
Date: 1700010000,
})
if err != nil {
t.Fatalf("create public channel: %v", err)
}
const searchPostsIcon = int64(8800021)
searchPostsPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: public.Channel.ID}
verify.marks[searchPostsPeer] = domain.CustomVerification{
VerifierBotID: 777000123,
Peer: searchPostsPeer,
IconDocumentID: searchPostsIcon,
Description: "Verified search result",
}
if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
UserID: owner.ID,
ChannelID: public.Channel.ID,
Username: "public_search_posts",
}); err != nil {
t.Fatalf("publish channel username: %v", err)
}
private, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Private Search",
Broadcast: true,
Date: 1700010001,
})
if err != nil {
t.Fatalf("create private channel: %v", err)
}
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: public.Channel.ID,
RandomID: 101,
Message: "launch alpha #ops",
Date: 1700010010,
}); err != nil {
t.Fatalf("send public first: %v", err)
}
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: public.Channel.ID,
RandomID: 102,
Message: "launch beta #ops",
Date: 1700010020,
}); err != nil {
t.Fatalf("send public second: %v", err)
}
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: private.Channel.ID,
RandomID: 103,
Message: "launch private #ops",
Date: 1700010030,
}); err != nil {
t.Fatalf("send private: %v", err)
}
req := &tg.ChannelsSearchPostsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 1,
}
req.SetQuery("launch")
got, err := r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), req)
if err != nil {
t.Fatalf("channels.searchPosts first page: %v", err)
}
slice, ok := got.(*tg.MessagesMessagesSlice)
if !ok {
t.Fatalf("first page = %T %+v, want messagesSlice", got, got)
}
if slice.Count <= len(slice.Messages) {
t.Fatalf("first page count = %d messages=%d, want more page", slice.Count, len(slice.Messages))
}
if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700010020 {
t.Fatalf("first page next_rate = %d ok %v, want newest message date", nextRate, ok)
}
if flood, ok := slice.GetSearchFlood(); !ok || !flood.QueryIsFree {
t.Fatalf("first page search_flood = %+v ok %v, want free flood state", flood, ok)
}
messages, chats, users := searchMessagesPayload(t, got)
if len(messages) != 1 || len(chats) != 1 || len(users) != 1 {
t.Fatalf("first page payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users))
}
first := messages[0].(*tg.Message)
if first.Message != "launch beta #ops" {
t.Fatalf("first result message = %q, want newest public hit", first.Message)
}
if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != public.Channel.ID {
t.Fatalf("first result peer = %#v, want public channel %d", first.PeerID, public.Channel.ID)
}
assertMessagesEnvelopeBotVerificationIcon(t, got, searchPostsPeer, searchPostsIcon)
page2 := &tg.ChannelsSearchPostsRequest{
OffsetRate: slice.NextRate,
OffsetPeer: &tg.InputPeerChannel{
ChannelID: public.Channel.ID,
AccessHash: public.Channel.AccessHash,
},
OffsetID: first.ID,
Limit: 10,
}
page2.SetQuery("launch")
got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), page2)
if err != nil {
t.Fatalf("channels.searchPosts second page: %v", err)
}
messages, chats, _ = searchMessagesPayload(t, got)
if len(messages) != 1 || len(chats) != 1 {
t.Fatalf("second page payload messages=%d chats=%d, want only older public hit", len(messages), len(chats))
}
if msg := messages[0].(*tg.Message); msg.Message != "launch alpha #ops" {
t.Fatalf("second result message = %q, want older public hit", msg.Message)
}
hashtagReq := &tg.ChannelsSearchPostsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 10,
}
hashtagReq.SetHashtag("ops")
got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), hashtagReq)
if err != nil {
t.Fatalf("channels.searchPosts hashtag: %v", err)
}
messages, _, _ = searchMessagesPayload(t, got)
if len(messages) != 2 {
t.Fatalf("hashtag results = %d, want two public hits only", len(messages))
}
for _, item := range messages {
if strings.Contains(item.(*tg.Message).Message, "private") {
t.Fatalf("hashtag leaked private message: %#v", item)
}
}
}
func TestChannelsSearchPostsValidatesStubBounds(t *testing.T) {
const userID = int64(1000000001)
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
valid := &tg.ChannelsSearchPostsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
Limit: 20,
}
valid.SetQuery("launch")
got, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), valid)
if err != nil {
t.Fatalf("channels.searchPosts valid stub: %v", err)
}
if page, ok := got.(*tg.MessagesMessages); !ok || len(page.Messages) != 0 || len(page.Chats) != 0 || len(page.Users) != 0 {
t.Fatalf("channels.searchPosts = %T %+v, want empty messages", got, got)
}
both := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}
both.SetQuery("launch")
both.SetHashtag("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), both); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") {
t.Fatalf("channels.searchPosts query+hashtag err = %v, want SEARCH_QUERY_EMPTY", err)
}
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") {
t.Fatalf("channels.searchPosts empty query err = %v, want SEARCH_QUERY_EMPTY", err)
}
huge := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}
huge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1))
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), huge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.searchPosts huge query err = %v, want LIMIT_INVALID", err)
}
badOffset := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, OffsetID: domain.MaxMessageBoxID + 1}
badOffset.SetHashtag("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badOffset); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("channels.searchPosts huge offset_id err = %v, want MESSAGE_ID_INVALID", err)
}
badStars := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, AllowPaidStars: -1}
badStars.SetQuery("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badStars); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.searchPosts negative stars err = %v, want LIMIT_INVALID", err)
}
badPeer := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerUser{UserID: 42}, Limit: 1}
badPeer.SetQuery("launch")
if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badPeer); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") {
t.Fatalf("channels.searchPosts user offset peer err = %v, want PEER_ID_INVALID", err)
}
floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{}
floodReq.SetQuery("launch")
flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodReq)
if err != nil {
t.Fatalf("channels.checkSearchPostsFlood: %v", err)
}
if !flood.QueryIsFree || flood.Remains <= 0 || flood.TotalDaily <= 0 {
t.Fatalf("channels.checkSearchPostsFlood = %+v, want free quota", flood)
}
if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), &tg.ChannelsCheckSearchPostsFloodRequest{}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") {
t.Fatalf("channels.checkSearchPostsFlood empty query err = %v, want SEARCH_QUERY_EMPTY", err)
}
floodHuge := &tg.ChannelsCheckSearchPostsFloodRequest{}
floodHuge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1))
if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodHuge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
t.Fatalf("channels.checkSearchPostsFlood huge query err = %v, want LIMIT_INVALID", err)
}
}