owpengram-server/internal/rpc/updates_rpc_test.go
A 2848ff0987 feat: sync visible product branding
Sync telesrv c779c48 (feat(branding): unify visible product naming).

Skipped private www files and preserved public README files; mapped orange appearance seed names to public default names.
2026-07-20 16:49:17 +08:00

858 lines
33 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"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
type bootstrapOrderUpdates struct {
*captureUpdates
sessions *captureSessions
publishedAfterReady bool
}
func (s *bootstrapOrderUpdates) PublishNewMessage(ctx context.Context, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) {
s.publishedAfterReady = s.sessions.snapshot().receives
return s.captureUpdates.PublishNewMessage(ctx, userID, msg)
}
func TestSignUpBootstrapLoginMessagePublishesNewMessageAfterReady(t *testing.T) {
bootstrap := memory.NewBootstrapUpdateJobStore()
updates := &captureUpdates{state: domain.UpdateState{Pts: 3, Date: 1700000000}}
messages := &captureMessages{
list: domain.MessageList{Messages: []domain.Message{{
ID: 99,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000100,
Body: "Login code: 12345",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 11}},
}}},
}
r := New(Config{}, Deps{BootstrapUpdates: bootstrap, Updates: updates, Messages: messages}, zaptest.NewLogger(t), clock.System)
msg := domain.Message{
ID: 99,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000100,
Body: "Login code: 12345",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 11}},
}
authKeyID := [8]byte{1, 2, 3}
sessionID := int64(55)
ctx := WithSessionID(WithAuthKeyID(context.Background(), authKeyID), sessionID)
r.enqueueLoginMessageBootstrap(ctx, msg)
claimed := r.publishReadyBootstrapUpdates(context.Background(), 10, time.Second, zaptest.NewLogger(t))
if claimed != 0 || len(updates.events) != 0 {
t.Fatalf("published before ready = claimed %d events %d, want none", claimed, len(updates.events))
}
ready, err := bootstrap.MarkReadyForSession(context.Background(), msg.OwnerUserID, authKeyID, sessionID)
if err != nil {
t.Fatalf("mark ready: %v", err)
}
if ready != 1 {
t.Fatalf("ready jobs = %d, want 1", ready)
}
claimed = r.publishReadyBootstrapUpdates(context.Background(), 10, time.Second, zaptest.NewLogger(t))
if claimed != 1 {
t.Fatalf("claimed jobs = %d, want 1", claimed)
}
if len(updates.events) != 1 {
t.Fatalf("published events = %d, want 1", len(updates.events))
}
event := updates.events[0]
if event.Type != domain.UpdateEventNewMessage || event.Message.ID != msg.ID || event.Pts != 4 {
t.Fatalf("event = %+v, want login message pts 4", event)
}
}
func TestSignUpBootstrapPendingJobFollowsSameAuthKeyReconnect(t *testing.T) {
bootstrap := memory.NewBootstrapUpdateJobStore()
updates := &captureUpdates{state: domain.UpdateState{Pts: 0, Date: 1700000000}}
msg := domain.Message{
ID: 7, OwnerUserID: 1780243777,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000100, Body: "Login code: 12345",
}
r := New(Config{}, Deps{
BootstrapUpdates: bootstrap,
Updates: updates,
Messages: &captureMessages{list: domain.MessageList{Messages: []domain.Message{msg}}},
}, zaptest.NewLogger(t), clock.System)
authKeyID := [8]byte{4, 5, 6}
oldSessionID := int64(1001)
newSessionID := int64(2002)
r.enqueueLoginMessageBootstrap(
WithSessionID(WithAuthKeyID(context.Background(), authKeyID), oldSessionID),
msg,
)
if ready, err := bootstrap.MarkReadyForSession(context.Background(), msg.OwnerUserID, [8]byte{9}, newSessionID); err != nil || ready != 0 {
t.Fatalf("different auth-key ready=%d err=%v, want 0/nil", ready, err)
}
ready, err := bootstrap.MarkReadyForSession(context.Background(), msg.OwnerUserID, authKeyID, newSessionID)
if err != nil || ready != 1 {
t.Fatalf("same auth-key reconnect ready=%d err=%v, want 1/nil", ready, err)
}
if claimed := r.publishReadyBootstrapUpdates(context.Background(), 1, time.Second, zaptest.NewLogger(t)); claimed != 1 {
t.Fatalf("published after same-auth reconnect = %d, want 1", claimed)
}
if len(updates.events) != 1 || updates.events[0].Message.ID != msg.ID {
t.Fatalf("events after reconnect = %+v", updates.events)
}
}
func TestUpdatesGetStatePublishesSignUpBootstrapAfterRPCResult(t *testing.T) {
bootstrap := memory.NewBootstrapUpdateJobStore()
sessions := &captureSessions{}
updates := &bootstrapOrderUpdates{
captureUpdates: &captureUpdates{state: domain.UpdateState{Pts: 0, Date: 1700000000}},
sessions: sessions,
}
msg := domain.Message{
ID: 1,
OwnerUserID: 1780243001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000100,
Body: "Login code: 12345",
}
messages := &captureMessages{list: domain.MessageList{Messages: []domain.Message{msg}}}
r := New(Config{}, Deps{BootstrapUpdates: bootstrap, Updates: updates, Messages: messages, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
authKeyID := [8]byte{9, 8, 7}
sessionID := int64(5723482677041206318)
ctx := postresponse.WithCallbacks(
WithClientInfo(
WithUserID(
WithSessionID(WithAuthKeyID(context.Background(), authKeyID), sessionID),
msg.OwnerUserID,
),
ClientInfo{Type: ClientTypeTDesktop},
),
)
r.enqueueLoginMessageBootstrap(ctx, msg)
var request bin.Buffer
if err := (&tg.UpdatesGetStateRequest{}).Encode(&request); err != nil {
t.Fatalf("encode updates.getState: %v", err)
}
encoded, err := r.Dispatch(ctx, authKeyID, sessionID, &request)
if err != nil {
t.Fatalf("getState: %v", err)
}
state, ok := encoded.(*tg.UpdatesState)
if !ok {
t.Fatalf("getState response = %T, want *tg.UpdatesState", encoded)
}
if state.Pts != 0 {
t.Fatalf("state pts = %d, want 0 before bootstrap publish", state.Pts)
}
if claimed := r.publishReadyBootstrapUpdates(context.Background(), 10, time.Second, zaptest.NewLogger(t)); claimed != 0 {
t.Fatalf("bootstrap published before post-response callback: %d", claimed)
}
if len(updates.events) != 0 {
t.Fatalf("events before post-response = %d, want 0", len(updates.events))
}
if got := sessions.snapshot(); got.receives || got.receivesCalls != 0 {
t.Fatalf("session readiness before post-response = receives:%v calls:%d, want false/0", got.receives, got.receivesCalls)
}
postresponse.Run(ctx)
if len(updates.events) != 1 {
t.Fatalf("events after post-response = %d, want 1", len(updates.events))
}
if got := updates.events[0]; got.Type != domain.UpdateEventNewMessage || got.Message.ID != msg.ID {
t.Fatalf("event after post-response = %+v, want login message", got)
}
if !updates.publishedAfterReady {
t.Fatal("bootstrap update published before session readiness/FIFO flush barrier")
}
if got := sessions.snapshot(); !got.receives || got.receivesCalls != 1 {
t.Fatalf("merged baseline callback readiness = receives:%v calls:%d, want true/1", got.receives, got.receivesCalls)
}
}
func TestSignInServiceNotificationMatchesEnterpriseShape(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 9
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithClientInfo(context.Background(), ClientInfo{
DeviceModel: "Telegram Desktop",
SystemVersion: "Windows",
AppVersion: "6.8.4",
})
got := r.tgSignInServiceNotification(ctx, domain.User{
ID: 1000000001,
FirstName: "Test",
LastName: "User",
}, authKeyID)
if len(got.Updates) != 1 {
t.Fatalf("updates = %+v, want one service notification", got.Updates)
}
update, ok := got.Updates[0].(*tg.UpdateServiceNotification)
if !ok {
t.Fatalf("update = %T, want *tg.UpdateServiceNotification", got.Updates[0])
}
if update.Popup || update.InboxDate == 0 || update.Media == nil {
t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media)
}
for _, want := range []string{"New login.", "Test User", "Telesrv Desktop", "Settings > Devices"} {
if !strings.Contains(update.Message, want) {
t.Fatalf("notification message %q missing %q", update.Message, want)
}
}
if len(update.Entities) < 3 {
t.Fatalf("entities = %+v, want bold entities for title/settings links", update.Entities)
}
}
func TestLogOutClearsSessionAndUpdateState(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 5
auth := &captureAuthService{}
updates := &captureUpdates{}
sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true}
r := New(Config{}, Deps{
Auth: auth,
Updates: updates,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
_, err := r.onAuthLogOut(WithAuthKeyID(context.Background(), authKeyID))
if err != nil {
t.Fatalf("auth.logOut: %v", err)
}
if auth.loggedOutAuthKeyID != authKeyID {
t.Fatalf("logged out auth key = %x, want %x", auth.loggedOutAuthKeyID, authKeyID)
}
if updates.clearedAuthKeyID != authKeyID || !updates.cleared {
t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID)
}
gotSession := sessions.snapshot()
if gotSession.userID != 0 || !gotSession.userResolved {
t.Fatalf("session user after logout = %d resolved=%v, want 0/true", gotSession.userID, gotSession.userResolved)
}
}
func TestSignInDifferentUserDoesNotClearFreshlyBoundUpdateState(t *testing.T) {
var authKeyID [8]byte
authKeyID[0] = 6
auth := &captureAuthService{signInUser: domain.User{ID: 1000000002, FirstName: "Two"}}
updates := &captureUpdates{}
sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true}
r := New(Config{}, Deps{
Auth: auth,
Updates: updates,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(WithAuthKeyID(WithSessionID(context.Background(), 77), authKeyID), 1000000001)
_, err := r.onAuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: "15550000002", PhoneCodeHash: "hash", PhoneCode: "12345"})
if err != nil {
t.Fatalf("auth.signIn: %v", err)
}
if updates.cleared {
t.Fatalf("router cleared auth key %x after Bind; this would delete the new user's retained-floor baseline", updates.clearedAuthKeyID)
}
gotSession := sessions.snapshot()
if gotSession.userID != 1000000002 {
t.Fatalf("session user = %d, want new user 1000000002", gotSession.userID)
}
}
func TestUpdatesGetStateMarksSessionReadyForPush(t *testing.T) {
sessions := &captureSessions{}
r := New(Config{}, Deps{
Sessions: sessions,
Updates: &captureUpdates{state: domain.UpdateState{Pts: 3, Date: 1700000000, Seq: 2}},
}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithClientInfo(WithUserID(WithSessionID(context.Background(), 77), 1000000001), ClientInfo{Type: ClientTypeTDesktop}))
got, err := r.onUpdatesGetState(ctx)
if err != nil {
t.Fatalf("updates.getState: %v", err)
}
if got.Pts != 3 || got.Seq != 2 {
t.Fatalf("state = %+v, want pts=3 seq=2", got)
}
if gotSession := sessions.snapshot(); gotSession.receives {
t.Fatal("session became ready before getState rpc_result delivery")
}
postresponse.Run(ctx)
gotSession := sessions.snapshot()
if gotSession.sessionID != 77 || !gotSession.receives {
t.Fatalf("session ready = id %d receives %v, want 77/true", gotSession.sessionID, gotSession.receives)
}
}
func TestUpdatesGetDifferenceMarksSessionReadyOnlyAfterRPCResult(t *testing.T) {
sessions := &captureSessions{}
r := New(Config{}, Deps{
Sessions: sessions,
Updates: &captureUpdates{state: domain.UpdateState{Pts: 7, Date: 1700000007}},
}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(
WithSessionID(
WithAuthKeyID(WithUserID(context.Background(), 1000000001), [8]byte{7}),
79,
),
)
diff, err := r.onUpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{Pts: 7, Date: 1700000007})
if err != nil {
t.Fatalf("updates.getDifference: %v", err)
}
if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok {
t.Fatalf("difference = %T, want UpdatesDifferenceEmpty", diff)
}
if sessions.snapshot().receives {
t.Fatal("session became ready before getDifference rpc_result delivery")
}
updates := r.deps.Updates.(*captureUpdates)
if updates.observedCalls != 1 || updates.observedRequest.Pts != 7 || updates.commitCalls != 0 {
t.Fatalf("pre-delivery cursors = observed_calls:%d observed:%+v commits:%d", updates.observedCalls, updates.observedRequest, updates.commitCalls)
}
postresponse.Run(ctx)
gotSession := sessions.snapshot()
if gotSession.sessionID != 79 || !gotSession.receives {
t.Fatalf("session ready = id %d receives %v, want 79/true", gotSession.sessionID, gotSession.receives)
}
if updates.commitCalls != 1 || updates.committedState.Pts != 7 || updates.commitMode != domain.UpdateStateCommitDeliveredOnly {
t.Fatalf("delivered difference commit = calls:%d state:%+v mode:%d", updates.commitCalls, updates.committedState, updates.commitMode)
}
}
func TestUpdatesDifferenceTooLongObservesRequestAndCommitsReturnedCursorAfterDelivery(t *testing.T) {
updates := &captureUpdates{currentState: &domain.UpdateState{Pts: 10, Date: 1700000010}}
r := New(Config{}, Deps{Updates: updates, Sessions: &captureSessions{}}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithAuthKeyID(WithUserID(context.Background(), 1000000001), [8]byte{10}))
req := &tg.UpdatesGetDifferenceRequest{Pts: 1, Qts: 3, Date: 1700000001}
req.SetPtsTotalLimit(2)
got, err := r.onUpdatesGetDifference(ctx, req)
if err != nil {
t.Fatalf("getDifference tooLong: %v", err)
}
tooLong, ok := got.(*tg.UpdatesDifferenceTooLong)
if !ok || tooLong.Pts != 10 {
t.Fatalf("tooLong result = %T %+v", got, got)
}
if updates.observedCalls != 1 || updates.observedRequest.Pts != 1 || updates.commitCalls != 0 {
t.Fatalf("pre-delivery cursors = observed:%+v calls:%d commits:%d", updates.observedRequest, updates.observedCalls, updates.commitCalls)
}
postresponse.Run(ctx)
if updates.commitCalls != 1 || updates.committedState.Pts != 10 || updates.committedState.Qts != req.Qts || updates.committedState.Date != req.Date || updates.commitMode != domain.UpdateStateCommitDeliveredOnly {
t.Fatalf("delivered tooLong commit = calls:%d state:%+v mode:%d", updates.commitCalls, updates.committedState, updates.commitMode)
}
}
func TestUpdatesDifferenceEmptyCommitsRequestCursorNotInternalCandidate(t *testing.T) {
updates := &captureUpdates{state: domain.UpdateState{Pts: 100, Date: 1700000100}}
r := New(Config{}, Deps{Updates: updates, Sessions: &captureSessions{}}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithAuthKeyID(WithUserID(context.Background(), 1000000002), [8]byte{11}))
got, err := r.onUpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{Pts: 5, Qts: 6, Date: 1700000005})
if err != nil {
t.Fatalf("getDifference empty: %v", err)
}
if _, ok := got.(*tg.UpdatesDifferenceEmpty); !ok {
t.Fatalf("difference = %T, want empty", got)
}
postresponse.Run(ctx)
if updates.committedState.Pts != 5 || updates.committedState.Qts != 6 {
t.Fatalf("empty delivered cursor = %+v, want request pts/qts 5/6 (wire has neither)", updates.committedState)
}
}
// TestUpdatesGetStateReturnsAccountCurrentState 复现 TDesktop 冷启动未读重复:
// getState 必须返回账号当前最新状态(而非设备旧确认水位),且不得再推
// updatesTooLong 诱导差分——TDesktop 不持久化 pts启动期离线数据由
// getDialogs 快照承载,旧水位差分会把快照里已计入的消息重放一遍
// (实测未读 2→3、dialog 预览被旧消息抢占)。
func TestUpdatesGetStateReturnsAccountCurrentState(t *testing.T) {
sessions := &captureSessions{}
current := domain.UpdateState{Pts: 4, Date: 1700000001}
updates := &captureUpdates{
state: domain.UpdateState{Pts: 3, Date: 1700000000},
currentState: &current,
}
r := New(Config{}, Deps{
Sessions: sessions,
Updates: updates,
}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithClientInfo(
WithUserID(WithSessionID(context.Background(), 77), 1000000001),
ClientInfo{Type: ClientTypeTDesktop},
))
got, err := r.onUpdatesGetState(ctx)
if err != nil {
t.Fatalf("updates.getState: %v", err)
}
if got.Pts != 4 {
t.Fatalf("state pts = %d, want account current 4 (per-key stale=3 会触发客户端重放)", got.Pts)
}
if updates.acknowledged {
t.Fatal("getState 在 rpc_result 物理交付前推进了设备确认水位")
}
postresponse.Run(ctx)
if !updates.acknowledged || updates.commitMode != domain.UpdateStateCommitDeliveredAndObservedBaseline || updates.committedState.Pts != 4 {
t.Fatalf("delivered getState commit = acknowledged:%v mode:%d state:%+v", updates.acknowledged, updates.commitMode, updates.committedState)
}
time.Sleep(500 * time.Millisecond)
if msg := sessions.snapshot().message; msg != nil {
if _, ok := msg.(*tg.UpdatesTooLong); ok {
t.Fatal("getState 后不应再推 updatesTooLong会诱导 TDesktop 重放快照前差分)")
}
}
}
func TestUpdatesGetStateUnknownClientDoesNotAdvanceObservedBaseline(t *testing.T) {
sessions := &captureSessions{}
current := domain.UpdateState{Pts: 9, Date: 1700000009}
updates := &captureUpdates{
state: domain.UpdateState{Pts: 3, Date: 1700000003},
currentState: &current,
}
r := New(Config{}, Deps{Sessions: sessions, Updates: updates}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithUserID(WithSessionID(context.Background(), 78), 1000000001))
got, err := r.onUpdatesGetState(ctx)
if err != nil {
t.Fatalf("updates.getState unknown client: %v", err)
}
if got.Pts != current.Pts {
t.Fatalf("state pts = %d, want current %d", got.Pts, current.Pts)
}
if updates.acknowledged {
t.Fatal("unknown client advanced the observed getState baseline")
}
if sessions.snapshot().receives {
t.Fatal("unknown client was enabled before getState rpc_result delivery")
}
postresponse.Run(ctx)
if !sessions.snapshot().receives {
t.Fatal("unknown client was not enabled for subsequent updates")
}
if updates.commitMode != domain.UpdateStateCommitDeliveredOnly || updates.committedState.Pts != current.Pts {
t.Fatalf("unknown client delivered commit = mode:%d state:%+v", updates.commitMode, updates.committedState)
}
}
func TestUpdatesGetStateDrKLOEstablishesObservedBaseline(t *testing.T) {
updates := &captureUpdates{currentState: &domain.UpdateState{Pts: 6, Date: 1700000006}}
r := New(Config{}, Deps{Sessions: &captureSessions{}, Updates: updates}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithClientInfo(WithUserID(context.Background(), 1000000001), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"}))
if _, err := r.onUpdatesGetState(ctx); err != nil {
t.Fatalf("updates.getState DrKLO: %v", err)
}
if updates.acknowledged {
t.Fatal("DrKLO getState established baseline before rpc_result delivery")
}
postresponse.Run(ctx)
if !updates.acknowledged || updates.commitMode != domain.UpdateStateCommitDeliveredAndObservedBaseline {
t.Fatalf("DrKLO delivered baseline = acknowledged:%v mode:%d", updates.acknowledged, updates.commitMode)
}
}
func TestUpdatesDifferenceIncludesLoginMessageAndOfficialUser(t *testing.T) {
msg := domain.Message{
ID: 88,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: 1700000100,
Body: "Login code: 12345",
}
got, ok := tgUpdatesDifference(0, domain.UpdateDifference{
State: domain.UpdateState{Pts: 5, Date: msg.Date, Seq: 4},
Events: []domain.UpdateEvent{{
Type: domain.UpdateEventNewMessage,
Pts: 5,
PtsCount: 1,
Date: msg.Date,
Message: msg,
}},
}).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", got)
}
if got.State.Pts != 5 || len(got.NewMessages) != 1 || len(got.Users) != 1 {
t.Fatalf("difference = %+v, want one message, one official user, pts=5", got)
}
user, ok := got.Users[0].(*tg.User)
if !ok || user.ID != domain.OfficialSystemUserID {
t.Fatalf("user = %#v, want official system user", got.Users[0])
}
}
func TestUpdatesDifferenceMarksViewerUserAsSelf(t *testing.T) {
viewerID := int64(1000000001)
msg := domain.Message{
ID: 90,
OwnerUserID: viewerID,
Out: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
From: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID},
Date: 1700000102,
Body: "self probe",
}
got, ok := tgUpdatesDifference(viewerID, domain.UpdateDifference{
State: domain.UpdateState{Pts: 7, Date: msg.Date},
Events: []domain.UpdateEvent{{
UserID: viewerID,
Type: domain.UpdateEventNewMessage,
Pts: 7,
PtsCount: 1,
Date: msg.Date,
Message: msg,
Users: []domain.User{
{ID: viewerID, FirstName: "Me"},
{ID: 1000000002, FirstName: "Peer"},
},
}},
}).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", got)
}
var sawSelf, sawPeer bool
for _, item := range got.Users {
user, ok := item.(*tg.User)
if !ok {
t.Fatalf("user = %#v, want *tg.User", item)
}
switch user.ID {
case viewerID:
sawSelf = true
if !user.Self {
t.Fatalf("viewer user = %#v, want self flag set: clients persist this object as the current account", user)
}
case 1000000002:
sawPeer = true
if user.Self {
t.Fatalf("peer user = %#v, must not carry self flag", user)
}
}
}
if !sawSelf || !sawPeer {
t.Fatalf("users = %+v, want viewer and peer entries", got.Users)
}
}
func TestUpdatesDifferenceIncludesForwardSourceChannelChat(t *testing.T) {
source := domain.Channel{
ID: 2000000001,
AccessHash: 9001,
Title: "Source Channel",
Broadcast: true,
Date: 1700000000,
}
msg := domain.Message{
ID: 89,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001},
Date: 1700000101,
Body: "forwarded",
Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000},
}
got, ok := tgUpdatesDifference(0, domain.UpdateDifference{
State: domain.UpdateState{Pts: 6, Date: msg.Date},
Events: []domain.UpdateEvent{{
UserID: msg.OwnerUserID,
Type: domain.UpdateEventNewMessage,
Pts: 6,
PtsCount: 1,
Date: msg.Date,
Message: msg,
Channels: []domain.Channel{source},
}},
}).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", got)
}
if len(got.Chats) != 1 {
t.Fatalf("chats = %+v, want source channel", got.Chats)
}
ch, ok := got.Chats[0].(*tg.Channel)
if !ok || ch.ID != source.ID || ch.Title != source.Title {
t.Fatalf("chat = %#v, want source channel", got.Chats[0])
}
}
func TestOutboxEventIncludesForwardSourceChannelChat(t *testing.T) {
source := domain.Channel{
ID: 2000000002,
AccessHash: 9002,
Title: "Forward Source",
Broadcast: true,
Date: 1700000000,
}
update := tgUpdateForOutboxEvent(domain.UpdateEvent{
UserID: 1000000001,
Type: domain.UpdateEventNewMessage,
Pts: 7,
PtsCount: 1,
Date: 1700000102,
Message: domain.Message{
ID: 90,
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
Date: 1700000102,
Body: "forwarded",
Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000},
},
Channels: []domain.Channel{source},
})
if update == nil || len(update.Chats) != 1 {
t.Fatalf("update = %+v, want source channel chat", update)
}
ch, ok := update.Chats[0].(*tg.Channel)
if !ok || ch.ID != source.ID {
t.Fatalf("chat = %#v, want source channel", update.Chats[0])
}
}
func TestOutboxEventChannelViewForumAsMessages(t *testing.T) {
update := tgUpdateForOutboxEvent(domain.UpdateEvent{
UserID: 1000000001,
Type: domain.UpdateEventChannelViewForum,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002},
Bool: true,
Date: 1700000200,
})
if update == nil || len(update.Updates) != 1 {
t.Fatalf("update = %+v, want one updateChannelViewForumAsMessages", update)
}
got, ok := update.Updates[0].(*tg.UpdateChannelViewForumAsMessages)
if !ok || got.ChannelID != 2000000002 || !got.Enabled {
t.Fatalf("update = %#v, want channel view-forum-as-messages enabled", update.Updates[0])
}
}
func TestUpdatesDifferenceIncludesChannelTooLongNudge(t *testing.T) {
const viewerID int64 = 1000000002
got, ok := tgUpdatesDifference(viewerID, domain.UpdateDifference{
State: domain.UpdateState{Pts: 8, Date: 1700000250, Seq: 0},
ChannelNudges: []domain.ChannelDifferenceNudge{{
ChannelID: 2000000001,
Pts: 12,
Channel: &domain.ChannelView{
Channel: domain.Channel{
ID: 2000000001,
AccessHash: 9100000001,
Title: "Dirty group",
Megagroup: true,
Date: 1700000200,
},
Self: domain.ChannelMember{
ChannelID: 2000000001,
UserID: viewerID,
Role: domain.ChannelRoleMember,
Status: domain.ChannelMemberActive,
},
},
}},
}).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", got)
}
if got.State.Pts != 8 || len(got.OtherUpdates) != 1 || len(got.Chats) != 1 {
t.Fatalf("difference = %+v, want one channel nudge, one chat and account pts unchanged", got)
}
update, ok := got.OtherUpdates[0].(*tg.UpdateChannelTooLong)
if !ok || update.ChannelID != 2000000001 {
t.Fatalf("update = %T %+v, want UpdateChannelTooLong", got.OtherUpdates[0], got.OtherUpdates[0])
}
if pts, ok := update.GetPts(); !ok || pts != 12 {
t.Fatalf("channel nudge pts = %d set=%v, want 12", pts, ok)
}
chat, ok := got.Chats[0].(*tg.Channel)
if !ok || chat.ID != 2000000001 || chat.Min {
t.Fatalf("chat = %T %+v, want full channel chat for channel nudge", got.Chats[0], got.Chats[0])
}
}
func TestUpdatesDifferenceChannelNudgeUpgradesExistingMinChat(t *testing.T) {
const viewerID int64 = 1000000001
channel := domain.Channel{
ID: 2000000003,
AccessHash: 9100000003,
CreatorUserID: viewerID,
Title: "Offline created group",
Megagroup: true,
Date: 1700000300,
}
got, ok := tgUpdatesDifference(viewerID, domain.UpdateDifference{
State: domain.UpdateState{Pts: 9, Date: 1700000310, Seq: 0},
Events: []domain.UpdateEvent{{
UserID: viewerID,
Type: domain.UpdateEventChannelState,
Pts: 8,
PtsCount: 1,
Date: 1700000300,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
Channels: []domain.Channel{channel},
}},
ChannelNudges: []domain.ChannelDifferenceNudge{{
ChannelID: channel.ID,
Pts: 3,
Channel: &domain.ChannelView{
Channel: channel,
Self: domain.ChannelMember{
ChannelID: channel.ID,
UserID: viewerID,
Role: domain.ChannelRoleCreator,
Status: domain.ChannelMemberActive,
},
},
}},
}).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", got)
}
if len(got.OtherUpdates) != 2 || len(got.Chats) != 1 {
t.Fatalf("difference = %+v, want channel state, nudge and one deduped chat", got)
}
chat, ok := got.Chats[0].(*tg.Channel)
if !ok || chat.ID != channel.ID || chat.Min || !chat.Creator {
t.Fatalf("chat = %T %+v, want upgraded full creator channel for Android unknown-channel recovery", got.Chats[0], got.Chats[0])
}
}
func TestUpdatesGetDifferenceChannelNudgeIncludesFullChat(t *testing.T) {
ctx := context.Background()
const (
ownerID int64 = 1000000101
memberID int64 = 1000000102
)
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{
Title: "Android visible group",
Date: 1700000400,
MemberUserIDs: []int64{memberID},
})
if err != nil {
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
}
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore())
r := New(Config{}, Deps{
Updates: updateSvc,
Channels: channelSvc,
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000410, 0)})
diff, err := r.onUpdatesGetDifference(WithUserID(ctx, memberID), &tg.UpdatesGetDifferenceRequest{
Date: 1700000399,
})
if err != nil {
t.Fatalf("updates.getDifference: %v", err)
}
got, ok := diff.(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff)
}
if len(got.OtherUpdates) != 1 || len(got.Chats) != 1 {
t.Fatalf("difference = %+v, want channel nudge with chat", got)
}
update, ok := got.OtherUpdates[0].(*tg.UpdateChannelTooLong)
if !ok || update.ChannelID != created.Channel.ID {
t.Fatalf("update = %T %+v, want updateChannelTooLong for channel %d", got.OtherUpdates[0], got.OtherUpdates[0], created.Channel.ID)
}
chat, ok := got.Chats[0].(*tg.Channel)
if !ok || chat.ID != created.Channel.ID || chat.Min {
t.Fatalf("chat = %T %+v, want full channel chat for Android unknown-channel recovery", got.Chats[0], got.Chats[0])
}
if chat.AccessHash == 0 {
t.Fatalf("chat access_hash = 0, want full channel access hash")
}
}
func TestUpdatesGetDifferenceEmptyRetentionCheckpointStaysDifferenceSlice(t *testing.T) {
updates := &captureUpdates{difference: &domain.UpdateDifference{
State: domain.UpdateState{Pts: 42, Date: 1700000442},
Partial: true,
}}
r := New(Config{}, Deps{Updates: updates}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000443, 0)})
authKeyID := [8]byte{42}
ctx := WithUserID(WithAuthKeyID(context.Background(), authKeyID), 1000000042)
diff, err := r.onUpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{Pts: 1, Date: 1700000400})
if err != nil {
t.Fatalf("updates.getDifference: %v", err)
}
slice, ok := diff.(*tg.UpdatesDifferenceSlice)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifferenceSlice (never Empty/TooLong)", diff)
}
if slice.IntermediateState.Pts != 42 || slice.IntermediateState.Date != 1700000442 || len(slice.NewMessages) != 0 || len(slice.OtherUpdates) != 0 {
t.Fatalf("checkpoint slice = %+v, want empty payload at pts/date 42/1700000442", slice)
}
}
func TestUpdatesDifferenceIncludesSettingsUpdates(t *testing.T) {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
got, ok := tgUpdatesDifference(0, domain.UpdateDifference{
State: domain.UpdateState{Pts: 6, Date: 1700000300, Seq: 0},
Events: []domain.UpdateEvent{
{Type: domain.UpdateEventContactsReset, Pts: 1, PtsCount: 1, Date: 1700000300},
{Type: domain.UpdateEventDialogPinned, Pts: 2, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: true},
{Type: domain.UpdateEventPinnedDialogs, Pts: 3, PtsCount: 1, Date: 1700000300, Peers: []domain.Peer{peer}},
{Type: domain.UpdateEventDialogUnreadMark, Pts: 4, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: false},
{Type: domain.UpdateEventPeerSettings, Pts: 5, PtsCount: 1, Date: 1700000300, Peer: peer, Settings: domain.PeerSettings{ShareContact: true}},
{Type: domain.UpdateEventPeerStoryBlocked, Pts: 6, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: true},
},
}).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", got)
}
if got.State.Pts != 6 || len(got.OtherUpdates) != 6 {
t.Fatalf("difference = %+v, want six settings updates and pts=6", got)
}
if _, ok := got.OtherUpdates[0].(*tg.UpdateContactsReset); !ok {
t.Fatalf("update[0] = %T, want contacts reset", got.OtherUpdates[0])
}
pinned, ok := got.OtherUpdates[1].(*tg.UpdateDialogPinned)
if !ok || !pinned.Pinned {
t.Fatalf("update[1] = %+v (%T), want pinned dialog", got.OtherUpdates[1], got.OtherUpdates[1])
}
pinnedDialogs, ok := got.OtherUpdates[2].(*tg.UpdatePinnedDialogs)
if !ok || len(pinnedDialogs.Order) != 1 {
t.Fatalf("update[2] = %T, want pinned dialogs", got.OtherUpdates[2])
}
unread, ok := got.OtherUpdates[3].(*tg.UpdateDialogUnreadMark)
if !ok || unread.Unread {
t.Fatalf("update[3] = %+v (%T), want unread=false mark", got.OtherUpdates[3], got.OtherUpdates[3])
}
peerSettings, ok := got.OtherUpdates[4].(*tg.UpdatePeerSettings)
if !ok || !peerSettings.Settings.ShareContact {
t.Fatalf("update[4] = %T, want peer settings", got.OtherUpdates[4])
}
peerBlocked, ok := got.OtherUpdates[5].(*tg.UpdatePeerBlocked)
if !ok || !peerBlocked.Blocked || !peerBlocked.BlockedMyStoriesFrom {
t.Fatalf("update[5] = %+v (%T), want story peer blocked", got.OtherUpdates[5], got.OtherUpdates[5])
}
if peerUser, ok := peerBlocked.PeerID.(*tg.PeerUser); !ok || peerUser.UserID != peer.ID {
t.Fatalf("update[5] peer = %#v, want user %d", peerBlocked.PeerID, peer.ID)
}
}