fix: sync Android cursor tolerance fixes
This commit is contained in:
parent
ca79562fd8
commit
2d5a6236c2
5 changed files with 198 additions and 8 deletions
|
|
@ -21,9 +21,13 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages
|
||||||
if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil {
|
if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if hasParent {
|
} else if hasParent {
|
||||||
if req.Limit < 0 || req.OffsetID < 0 {
|
if req.Limit < 0 {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
offsetID := req.OffsetID
|
||||||
|
if offsetID < 0 {
|
||||||
|
offsetID = 0
|
||||||
|
}
|
||||||
mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent)
|
mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -38,15 +42,23 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
// parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。
|
// parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。
|
||||||
return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, req.OffsetID)
|
return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, offsetID)
|
||||||
}
|
}
|
||||||
if req.Limit < 0 || req.OffsetID < 0 || req.OffsetDate < 0 {
|
if req.Limit < 0 {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
offsetID := req.OffsetID
|
||||||
|
if offsetID < 0 {
|
||||||
|
offsetID = 0
|
||||||
|
}
|
||||||
|
offsetDate := req.OffsetDate
|
||||||
|
if offsetDate < 0 {
|
||||||
|
offsetDate = 0
|
||||||
|
}
|
||||||
filter := domain.SavedDialogsFilter{
|
filter := domain.SavedDialogsFilter{
|
||||||
ExcludePinned: req.ExcludePinned,
|
ExcludePinned: req.ExcludePinned,
|
||||||
OffsetID: req.OffsetID,
|
OffsetID: offsetID,
|
||||||
OffsetDate: req.OffsetDate,
|
OffsetDate: offsetDate,
|
||||||
Limit: req.Limit,
|
Limit: req.Limit,
|
||||||
}
|
}
|
||||||
if req.OffsetPeer != nil {
|
if req.OffsetPeer != nil {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/gotd/td/bin"
|
"github.com/gotd/td/bin"
|
||||||
"github.com/gotd/td/clock"
|
"github.com/gotd/td/clock"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/gotd/td/tgerr"
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
appcontacts "telesrv/internal/app/contacts"
|
appcontacts "telesrv/internal/app/contacts"
|
||||||
|
|
@ -252,6 +253,34 @@ func TestMessagesGetSavedDialogsPagination(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMessagesGetSavedDialogsNegativePendingOffset 验证 DrKLO Android self Saved
|
||||||
|
// Messages 分组里出现本地 pending 负消息 id 时,下一页请求不会被误判为
|
||||||
|
// LIMIT_INVALID。服务端持久化 top_message 仍保持正数;负 offset 仅是客户端本地
|
||||||
|
// 游标噪声,按首页/无 offset 处理。
|
||||||
|
func TestMessagesGetSavedDialogsNegativePendingOffset(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, _, alice, _, _, _ := savedDialogsFixture(t)
|
||||||
|
|
||||||
|
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{
|
||||||
|
Limit: 20,
|
||||||
|
OffsetID: -1001,
|
||||||
|
OffsetDate: -1,
|
||||||
|
OffsetPeer: &tg.InputPeerSelf{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("negative pending offset: %v", err)
|
||||||
|
}
|
||||||
|
dialogs, _, _, _, full := savedDialogPage(t, res)
|
||||||
|
if !full || len(dialogs) != 2 {
|
||||||
|
t.Fatalf("negative pending offset page = %d full %v, want first page 2 full", len(dialogs), full)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: -1})
|
||||||
|
if !tgerr.Is(err, "LIMIT_INVALID") {
|
||||||
|
t.Fatalf("negative limit err = %v, want LIMIT_INVALID", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中
|
// TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中
|
||||||
// 返回 savedDialogsNotModified{count}。
|
// 返回 savedDialogsNotModified{count}。
|
||||||
func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) {
|
func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,13 @@ func (r *Router) onStoriesGetAllStories(ctx context.Context, req *tg.StoriesGetA
|
||||||
now := int(r.clock.Now().Unix())
|
now := int(r.clock.Now().Unix())
|
||||||
var cursor domain.StoryListCursor
|
var cursor domain.StoryListCursor
|
||||||
if next {
|
if next {
|
||||||
|
if digest, ok := storyAllStoriesDigestFromCompleteState(requestState, hidden); ok {
|
||||||
|
list := domain.StoryList{Hidden: hidden, Count: digest.Count, State: requestState}
|
||||||
|
if r.deps.Stories == nil || userID == 0 {
|
||||||
|
return tgStoriesAllStories(userID, list), nil
|
||||||
|
}
|
||||||
|
return r.tgStoriesAllStories(ctx, userID, list), nil
|
||||||
|
}
|
||||||
cursor, err = storyAllStoriesCursorFromState(requestState, hidden)
|
cursor, err = storyAllStoriesCursorFromState(requestState, hidden)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, offsetInvalidErr()
|
return nil, offsetInvalidErr()
|
||||||
|
|
@ -135,7 +142,16 @@ func validateStoriesGetAllStoriesRequest(req *tg.StoriesGetAllStoriesRequest) er
|
||||||
next := storyAllStoriesNext(req)
|
next := storyAllStoriesNext(req)
|
||||||
hidden := storyAllStoriesHidden(req)
|
hidden := storyAllStoriesHidden(req)
|
||||||
if next {
|
if next {
|
||||||
if !hasState || state == "" || !storyAllStoriesCursorStateToken(state) {
|
if !hasState || state == "" {
|
||||||
|
return offsetInvalidErr()
|
||||||
|
}
|
||||||
|
if storyAllStoriesCompleteState(state) {
|
||||||
|
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
|
||||||
|
return offsetInvalidErr()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !storyAllStoriesCursorStateToken(state) {
|
||||||
return offsetInvalidErr()
|
return offsetInvalidErr()
|
||||||
}
|
}
|
||||||
if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil {
|
if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil {
|
||||||
|
|
@ -172,6 +188,22 @@ func storyAllStoriesCompleteState(state string) bool {
|
||||||
return strings.HasPrefix(state, "ts1:")
|
return strings.HasPrefix(state, "ts1:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func storyAllStoriesDigestFromCompleteState(state string, hidden bool) (domain.StoryListDigest, bool) {
|
||||||
|
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
|
||||||
|
return domain.StoryListDigest{}, false
|
||||||
|
}
|
||||||
|
parts := strings.Split(state, ":")
|
||||||
|
count, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil || count < 0 {
|
||||||
|
return domain.StoryListDigest{}, false
|
||||||
|
}
|
||||||
|
hash, err := strconv.ParseUint(parts[3], 16, 64)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StoryListDigest{}, false
|
||||||
|
}
|
||||||
|
return domain.StoryListDigest{Count: count, Hash: hash}, true
|
||||||
|
}
|
||||||
|
|
||||||
func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool {
|
func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool {
|
||||||
parts := strings.Split(state, ":")
|
parts := strings.Split(state, ":")
|
||||||
if len(parts) != 4 || parts[0] != "ts1" {
|
if len(parts) != 4 || parts[0] != "ts1" {
|
||||||
|
|
|
||||||
|
|
@ -298,8 +298,16 @@ func TestStoriesGetAllStoriesPaginatesByPeerState(t *testing.T) {
|
||||||
finalAsCursor := &tg.StoriesGetAllStoriesRequest{}
|
finalAsCursor := &tg.StoriesGetAllStoriesRequest{}
|
||||||
finalAsCursor.SetState(next.State)
|
finalAsCursor.SetState(next.State)
|
||||||
finalAsCursor.SetNext(true)
|
finalAsCursor.SetNext(true)
|
||||||
if _, err := r.onStoriesGetAllStories(ctx, finalAsCursor); err == nil || !tgerr.Is(err, "OFFSET_INVALID") {
|
finalAsCursorClass, err := r.onStoriesGetAllStories(reqCtx, finalAsCursor)
|
||||||
t.Fatalf("final state as cursor err = %v, want OFFSET_INVALID", err)
|
if err != nil {
|
||||||
|
t.Fatalf("final state as cursor: %v", err)
|
||||||
|
}
|
||||||
|
finalAsCursorPage, ok := finalAsCursorClass.(*tg.StoriesAllStories)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("final state as cursor response = %T, want stories.allStories terminal page", finalAsCursorClass)
|
||||||
|
}
|
||||||
|
if finalAsCursorPage.HasMore || len(finalAsCursorPage.PeerStories) != 0 || finalAsCursorPage.Count != domain.MaxStoryListLimit+1 || finalAsCursorPage.State != next.State {
|
||||||
|
t.Fatalf("final state as cursor page = %+v, want empty terminal page preserving request count/state", finalAsCursorPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,115 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
||||||
assertWebViewData("recipient event", events[0].Message)
|
assertWebViewData("recipient event", events[0].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
suffix := randomSuffix(t)
|
||||||
|
|
||||||
|
users := NewUserStore(pool)
|
||||||
|
caller := createTestUser(t, ctx, users, "+1666"+suffix+"41", "CallFirstSender", "")
|
||||||
|
callee := createTestUser(t, ctx, users, "+1666"+suffix+"42", "CallFirstRecipient", "")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{caller.ID, callee.ID})
|
||||||
|
})
|
||||||
|
|
||||||
|
const callID int64 = 0x1020304050607080
|
||||||
|
messages := NewMessageStore(pool)
|
||||||
|
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||||
|
SenderUserID: caller.ID,
|
||||||
|
RecipientUserID: callee.ID,
|
||||||
|
RandomID: 4041001,
|
||||||
|
Date: 1700000410,
|
||||||
|
Media: &domain.MessageMedia{
|
||||||
|
Kind: domain.MessageMediaKindService,
|
||||||
|
ServiceAction: &domain.MessageServiceAction{
|
||||||
|
Kind: domain.MessageServiceActionPhoneCall,
|
||||||
|
Call: &domain.MessagePhoneCallAction{
|
||||||
|
CallID: callID,
|
||||||
|
Reason: string(domain.PhoneCallDiscardReasonMissed),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendPrivateText phone call service: %v", err)
|
||||||
|
}
|
||||||
|
if sent.SenderMessage.ID != 1 || sent.RecipientMessage.ID != 1 {
|
||||||
|
t.Fatalf("first phone call boxes = sender %d recipient %d, want both first message", sent.SenderMessage.ID, sent.RecipientMessage.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPhoneCallMessage := func(name string, msg domain.Message, ownerID, peerID, fromID int64, out bool) {
|
||||||
|
t.Helper()
|
||||||
|
if msg.OwnerUserID != ownerID || msg.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: peerID}) ||
|
||||||
|
msg.From != (domain.Peer{Type: domain.PeerTypeUser, ID: fromID}) || msg.Out != out {
|
||||||
|
t.Fatalf("%s identity = owner %d peer %+v from %+v out %v", name, msg.OwnerUserID, msg.Peer, msg.From, msg.Out)
|
||||||
|
}
|
||||||
|
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindService ||
|
||||||
|
msg.Media.ServiceAction == nil || msg.Media.ServiceAction.Kind != domain.MessageServiceActionPhoneCall ||
|
||||||
|
msg.Media.ServiceAction.Call == nil {
|
||||||
|
t.Fatalf("%s media = %+v, want phone_call service action", name, msg.Media)
|
||||||
|
}
|
||||||
|
call := msg.Media.ServiceAction.Call
|
||||||
|
if call.CallID != callID || call.Reason != string(domain.PhoneCallDiscardReasonMissed) || call.Duration != 0 {
|
||||||
|
t.Fatalf("%s phone call action = %+v", name, call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertPhoneCallMessage("sender box", sent.SenderMessage, caller.ID, callee.ID, caller.ID, true)
|
||||||
|
assertPhoneCallMessage("recipient box", sent.RecipientMessage, callee.ID, caller.ID, caller.ID, false)
|
||||||
|
|
||||||
|
dialogs := NewDialogStore(pool)
|
||||||
|
calleeDialogs, err := dialogs.ListByUser(ctx, callee.ID, domain.DialogFilter{Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("callee dialogs: %v", err)
|
||||||
|
}
|
||||||
|
if len(calleeDialogs.Dialogs) != 1 || calleeDialogs.Dialogs[0].TopMessage != sent.RecipientMessage.ID ||
|
||||||
|
calleeDialogs.Dialogs[0].UnreadCount != 1 || calleeDialogs.Dialogs[0].ReadInboxMaxID != 0 {
|
||||||
|
t.Fatalf("callee dialogs = %+v, want unread first call service as top", calleeDialogs.Dialogs)
|
||||||
|
}
|
||||||
|
if len(calleeDialogs.Messages) != 1 {
|
||||||
|
t.Fatalf("callee dialog messages = %+v, want top message payload", calleeDialogs.Messages)
|
||||||
|
}
|
||||||
|
assertPhoneCallMessage("callee dialog top", calleeDialogs.Messages[0], callee.ID, caller.ID, caller.ID, false)
|
||||||
|
if _, ok := findDialogUserByID(calleeDialogs.Users, caller.ID); !ok {
|
||||||
|
t.Fatalf("callee dialog users = %+v, want caller snapshot", calleeDialogs.Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
calleeHistory, err := messages.ListByUser(ctx, callee.ID, domain.MessageFilter{
|
||||||
|
HasPeer: true,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: caller.ID},
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil || len(calleeHistory.Messages) != 1 {
|
||||||
|
t.Fatalf("callee history = %+v err=%v, want one first call service", calleeHistory, err)
|
||||||
|
}
|
||||||
|
assertPhoneCallMessage("callee history", calleeHistory.Messages[0], callee.ID, caller.ID, caller.ID, false)
|
||||||
|
if _, ok := findDialogUserByID(calleeHistory.Users, caller.ID); !ok {
|
||||||
|
t.Fatalf("callee history users = %+v, want caller snapshot", calleeHistory.Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := NewUpdateEventStore(pool)
|
||||||
|
calleeEvents, err := updates.ListAfter(ctx, callee.ID, 0, 10)
|
||||||
|
if err != nil || len(calleeEvents) != 1 {
|
||||||
|
t.Fatalf("callee events = %+v err=%v, want one new_message", calleeEvents, err)
|
||||||
|
}
|
||||||
|
if calleeEvents[0].Type != domain.UpdateEventNewMessage || calleeEvents[0].Pts != sent.RecipientMessage.Pts {
|
||||||
|
t.Fatalf("callee event = %+v, want new_message pts %d", calleeEvents[0], sent.RecipientMessage.Pts)
|
||||||
|
}
|
||||||
|
assertPhoneCallMessage("callee difference event", calleeEvents[0].Message, callee.ID, caller.ID, caller.ID, false)
|
||||||
|
if _, ok := findDialogUserByID(calleeEvents[0].Users, caller.ID); !ok {
|
||||||
|
t.Fatalf("callee event users = %+v, want caller snapshot", calleeEvents[0].Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch, err := updates.BatchByCursor(ctx, []store.EventCursor{{UserID: callee.ID, Pts: calleeEvents[0].Pts}})
|
||||||
|
if err != nil || len(batch) != 1 {
|
||||||
|
t.Fatalf("callee batch events = %+v err=%v, want one dispatch event", batch, err)
|
||||||
|
}
|
||||||
|
assertPhoneCallMessage("callee dispatch event", batch[0].Message, callee.ID, caller.ID, caller.ID, false)
|
||||||
|
if _, ok := findDialogUserByID(batch[0].Users, caller.ID); !ok {
|
||||||
|
t.Fatalf("callee batch users = %+v, want caller snapshot", batch[0].Users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateEventStorePreservesChannelForwardRefsWithoutChannelSnapshot(t *testing.T) {
|
func TestUpdateEventStorePreservesChannelForwardRefsWithoutChannelSnapshot(t *testing.T) {
|
||||||
pool := testPool(t)
|
pool := testPool(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue