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:f0bd6c9699Origin-Merge:7713847b2aCanonical: f1ba406015a2d5e37802b963757a5cac8ee3eb0e Co-authored-by: epilepticseizureee <business.egor.sg@gmail.com>
1046 lines
45 KiB
Go
1046 lines
45 KiB
Go
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)
|
||
}
|
||
}
|