1727 lines
69 KiB
Go
1727 lines
69 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iamxvbaba/td/bin"
|
|
"github.com/iamxvbaba/td/clock"
|
|
"github.com/iamxvbaba/td/tg"
|
|
"github.com/iamxvbaba/td/tlprofile"
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
appchannels "telesrv/internal/app/channels"
|
|
appcontacts "telesrv/internal/app/contacts"
|
|
appprivacy "telesrv/internal/app/privacy"
|
|
appstories "telesrv/internal/app/stories"
|
|
appupdates "telesrv/internal/app/updates"
|
|
usernamesapp "telesrv/internal/app/usernames"
|
|
"telesrv/internal/app/userprojection"
|
|
appusers "telesrv/internal/app/users"
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store/memory"
|
|
)
|
|
|
|
func TestContactsSearchFindsUsers(t *testing.T) {
|
|
ctx := context.Background()
|
|
users := memory.NewUserStore()
|
|
registry := memory.NewCollectibleUsernameStore()
|
|
users.AttachUsernameRegistry(registry)
|
|
owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
friend, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Searchable", LastName: "Friend", Username: "search_friend"})
|
|
if err != nil {
|
|
t.Fatalf("create friend: %v", err)
|
|
}
|
|
friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID}
|
|
if _, err := registry.SetEditableUsername(ctx, friendPeer, friend.Username); err != nil {
|
|
t.Fatalf("seed editable username: %v", err)
|
|
}
|
|
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
|
Username: "nft4",
|
|
Owner: friendPeer,
|
|
Currency: domain.CollectibleCurrencyStars,
|
|
Amount: 1,
|
|
Actor: "test",
|
|
}); err != nil || !created {
|
|
t.Fatalf("mint collectible: created=%v err=%v", created, err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(memory.NewContactStore(), users),
|
|
Usernames: usernamesapp.NewService(
|
|
usernamesapp.WithRegistryStore(registry),
|
|
usernamesapp.WithCollectibleStore(registry),
|
|
),
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
|
|
var in bin.Buffer
|
|
if err := (&tg.ContactsSearchRequest{Q: "@NFT4", Limit: 20}).Encode(&in); err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in)
|
|
if err != nil {
|
|
t.Fatalf("dispatch: %v", err)
|
|
}
|
|
box, ok := enc.(*tg.ContactsFound)
|
|
if !ok {
|
|
t.Fatalf("result type = %T, want *tg.ContactsFound", enc)
|
|
}
|
|
if len(box.Results) != 1 || len(box.Users) != 1 {
|
|
t.Fatalf("search result sizes = results %d users %d, want 1/1", len(box.Results), len(box.Users))
|
|
}
|
|
peer, ok := box.Results[0].(*tg.PeerUser)
|
|
if !ok || peer.UserID != friend.ID {
|
|
t.Fatalf("peer = %T %+v, want friend", box.Results[0], box.Results[0])
|
|
}
|
|
user := box.Users[0].(*tg.User)
|
|
vector := assertVectorOnlyUsernames(t, "contacts.search result", user, []string{"search_friend", "nft4"})
|
|
if !vector[1].Active {
|
|
t.Fatalf("search result username vector = %+v, want active nft4 alias", vector)
|
|
}
|
|
}
|
|
|
|
func TestContactsEditCloseFriendsProjectsUserFlag(t *testing.T) {
|
|
ctx := context.Background()
|
|
users := memory.NewUserStore()
|
|
contactsStore := memory.NewContactStore()
|
|
owner, err := users.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
friend, err := users.Create(ctx, domain.User{AccessHash: 12, Phone: "15550001002", FirstName: "Friend"})
|
|
if err != nil {
|
|
t.Fatalf("create friend: %v", err)
|
|
}
|
|
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "Saved"}); err != nil {
|
|
t.Fatalf("upsert contact: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contactsStore, users),
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
|
|
var edit bin.Buffer
|
|
if err := (&tg.ContactsEditCloseFriendsRequest{ID: []int64{friend.ID, friend.ID, owner.ID, 0}}).Encode(&edit); err != nil {
|
|
t.Fatalf("encode edit close friends: %v", err)
|
|
}
|
|
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &edit)
|
|
if err != nil {
|
|
t.Fatalf("dispatch edit close friends: %v", err)
|
|
}
|
|
if value, ok := dispatchCanonicalValue(enc).(bool); !ok || !value {
|
|
t.Fatalf("edit close friends result = %#v (%T), want true", dispatchCanonicalValue(enc), enc)
|
|
}
|
|
|
|
var get bin.Buffer
|
|
if err := (&tg.ContactsGetContactsRequest{}).Encode(&get); err != nil {
|
|
t.Fatalf("encode get contacts: %v", err)
|
|
}
|
|
got, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &get)
|
|
if err != nil {
|
|
t.Fatalf("dispatch get contacts: %v", err)
|
|
}
|
|
list, ok := got.(*tg.ContactsContacts)
|
|
if !ok {
|
|
t.Fatalf("contacts result = %T %+v, want *tg.ContactsContacts", got, got)
|
|
}
|
|
if len(list.Users) != 1 {
|
|
t.Fatalf("contacts result = %T %+v, want one contact user", got, got)
|
|
}
|
|
user, ok := list.Users[0].(*tg.User)
|
|
if !ok || !user.CloseFriend {
|
|
t.Fatalf("contact user = %T %+v, want close_friend", list.Users[0], list.Users[0])
|
|
}
|
|
}
|
|
|
|
func TestContactsEditCloseFriendsFanoutsCloseFriendStories(t *testing.T) {
|
|
ctx := context.Background()
|
|
users := memory.NewUserStore()
|
|
contactsStore := memory.NewContactStore()
|
|
storyStore := memory.NewStoryStore()
|
|
stateStore := memory.NewUpdateStateStore()
|
|
updateStore := memory.NewUpdateEventStore()
|
|
owner, err := users.Create(ctx, domain.User{AccessHash: 21, Phone: "15550002001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
friend, err := users.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend"})
|
|
if err != nil {
|
|
t.Fatalf("create friend: %v", err)
|
|
}
|
|
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "Friend"}); err != nil {
|
|
t.Fatalf("upsert contact: %v", err)
|
|
}
|
|
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
|
for _, story := range []domain.Story{
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 1,
|
|
Date: 1700000000,
|
|
ExpireDate: 1700003600,
|
|
CloseFriends: true,
|
|
Out: true,
|
|
Views: domain.StoryViews{ViewsCount: 1, HasViewers: true, RecentViewers: []int64{friend.ID}},
|
|
},
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 2,
|
|
Date: 1700000001,
|
|
ExpireDate: 1700003600,
|
|
CloseFriends: true,
|
|
AllowUserIDs: []int64{friend.ID},
|
|
},
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 3,
|
|
Date: 1700000002,
|
|
ExpireDate: 1700000005,
|
|
CloseFriends: true,
|
|
},
|
|
} {
|
|
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil {
|
|
t.Fatalf("upsert story %d: %v", story.ID, err)
|
|
}
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contactsStore, users),
|
|
Stories: appstories.NewService(storyStore),
|
|
Updates: appupdates.NewService(stateStore, updateStore),
|
|
Users: appusers.NewService(users),
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
|
|
|
|
ownerAuth := [8]byte{1, 2, 3}
|
|
var add bin.Buffer
|
|
if err := (&tg.ContactsEditCloseFriendsRequest{ID: []int64{friend.ID}}).Encode(&add); err != nil {
|
|
t.Fatalf("encode add close friend: %v", err)
|
|
}
|
|
ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuth), 44)
|
|
if _, err := r.Dispatch(ownerCtx, ownerAuth, 44, &add); err != nil {
|
|
t.Fatalf("dispatch add close friend: %v", err)
|
|
}
|
|
if _, found, err := stateStore.Get(ctx, ownerAuth, friend.ID); err != nil || found {
|
|
t.Fatalf("owner auth state for friend found=%v err=%v, want absent", found, err)
|
|
}
|
|
|
|
var clear bin.Buffer
|
|
if err := (&tg.ContactsEditCloseFriendsRequest{ID: []int64{}}).Encode(&clear); err != nil {
|
|
t.Fatalf("encode clear close friends: %v", err)
|
|
}
|
|
if _, err := r.Dispatch(ownerCtx, ownerAuth, 44, &clear); err != nil {
|
|
t.Fatalf("dispatch clear close friends: %v", err)
|
|
}
|
|
|
|
diff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, friend.ID), [8]byte{9}), &tg.UpdatesGetDifferenceRequest{Pts: 0})
|
|
if err != nil {
|
|
t.Fatalf("get friend difference: %v", err)
|
|
}
|
|
got, ok := diff.(*tg.UpdatesDifference)
|
|
if !ok {
|
|
t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff)
|
|
}
|
|
if got.State.Pts != 3 || len(got.OtherUpdates) != 3 {
|
|
t.Fatalf("difference pts/updates = %d/%d, want 3/3", got.State.Pts, len(got.OtherUpdates))
|
|
}
|
|
for i, update := range got.OtherUpdates[:2] {
|
|
storyUpdate, ok := update.(*tg.UpdateStory)
|
|
if !ok {
|
|
t.Fatalf("add update %d = %T, want *tg.UpdateStory", i, update)
|
|
}
|
|
item, ok := storyUpdate.Story.(*tg.StoryItem)
|
|
if !ok {
|
|
t.Fatalf("add story %d = %T, want *tg.StoryItem", i, storyUpdate.Story)
|
|
}
|
|
if _, ok := item.GetViews(); item.Out || ok {
|
|
t.Fatalf("add story %d = %+v, want viewer fanout without out/views", i, item)
|
|
}
|
|
}
|
|
last, ok := got.OtherUpdates[2].(*tg.UpdateStory)
|
|
if !ok {
|
|
t.Fatalf("remove update = %T, want *tg.UpdateStory", got.OtherUpdates[2])
|
|
}
|
|
deleted, ok := last.Story.(*tg.StoryItemDeleted)
|
|
if !ok {
|
|
t.Fatalf("remove story = %T, want *tg.StoryItemDeleted", last.Story)
|
|
}
|
|
if deleted.ID != 1 {
|
|
t.Fatalf("deleted story id = %d, want only story 1 removed", deleted.ID)
|
|
}
|
|
}
|
|
|
|
func TestContactsBlockUnblockFanoutsStoryVisibilityChanges(t *testing.T) {
|
|
ctx := context.Background()
|
|
users := memory.NewUserStore()
|
|
contactsStore := memory.NewContactStore()
|
|
storyStore := memory.NewStoryStore()
|
|
updateStore := memory.NewUpdateEventStore()
|
|
owner, err := users.Create(ctx, domain.User{AccessHash: 31, Phone: "15550003001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
viewer, err := users.Create(ctx, domain.User{AccessHash: 32, Phone: "15550003002", FirstName: "Viewer"})
|
|
if err != nil {
|
|
t.Fatalf("create viewer: %v", err)
|
|
}
|
|
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: viewer.ID, FirstName: "Viewer"}); err != nil {
|
|
t.Fatalf("upsert contact: %v", err)
|
|
}
|
|
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
|
for _, story := range []domain.Story{
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 1,
|
|
Date: 1700000000,
|
|
ExpireDate: 1700003600,
|
|
Public: true,
|
|
Out: true,
|
|
Views: domain.StoryViews{ViewsCount: 3, HasViewers: true, RecentViewers: []int64{viewer.ID}},
|
|
},
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 2,
|
|
Date: 1700000001,
|
|
ExpireDate: 1700003600,
|
|
SelectedContacts: true,
|
|
AllowUserIDs: []int64{viewer.ID},
|
|
},
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 3,
|
|
Date: 1700000002,
|
|
ExpireDate: 1700003600,
|
|
Contacts: true,
|
|
},
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 4,
|
|
Date: 1700000003,
|
|
ExpireDate: 1700003600,
|
|
CloseFriends: true,
|
|
},
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 5,
|
|
Date: 1700000004,
|
|
ExpireDate: 1700003600,
|
|
Public: true,
|
|
DisallowUserIDs: []int64{viewer.ID},
|
|
},
|
|
{
|
|
Owner: ownerPeer,
|
|
ID: 6,
|
|
Date: 1700000005,
|
|
ExpireDate: 1700000006,
|
|
Public: true,
|
|
},
|
|
} {
|
|
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil {
|
|
t.Fatalf("upsert story %d: %v", story.ID, err)
|
|
}
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contactsStore, users),
|
|
Stories: appstories.NewService(storyStore),
|
|
Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore),
|
|
Users: appusers.NewService(users),
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
|
|
|
|
ownerAuth := [8]byte{7, 7, 1}
|
|
ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuth), 51)
|
|
if ok, err := r.onContactsBlock(ownerCtx, &tg.ContactsBlockRequest{
|
|
MyStoriesFrom: true,
|
|
ID: &tg.InputPeerUser{UserID: viewer.ID, AccessHash: viewer.AccessHash},
|
|
}); err != nil || !ok {
|
|
t.Fatalf("contacts.block = %v, %v", ok, err)
|
|
}
|
|
ownerBlockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, owner.ID), [8]byte{7, 7, 2}), &tg.UpdatesGetDifferenceRequest{Pts: 0})
|
|
if err != nil {
|
|
t.Fatalf("get owner block difference: %v", err)
|
|
}
|
|
ownerBlockUpdates, ok := ownerBlockDiff.(*tg.UpdatesDifference)
|
|
if !ok {
|
|
t.Fatalf("owner block difference = %T, want *tg.UpdatesDifference", ownerBlockDiff)
|
|
}
|
|
if ownerBlockUpdates.State.Pts != 2 || len(ownerBlockUpdates.OtherUpdates) != 2 {
|
|
t.Fatalf("owner block difference pts/updates = %d/%d, want 2/2", ownerBlockUpdates.State.Pts, len(ownerBlockUpdates.OtherUpdates))
|
|
}
|
|
ownerBlocked, ok := ownerBlockUpdates.OtherUpdates[0].(*tg.UpdatePeerBlocked)
|
|
if !ok || !ownerBlocked.Blocked || !ownerBlocked.BlockedMyStoriesFrom {
|
|
t.Fatalf("owner block update[0] = %+v (%T), want story block", ownerBlockUpdates.OtherUpdates[0], ownerBlockUpdates.OtherUpdates[0])
|
|
}
|
|
if peer, ok := ownerBlocked.PeerID.(*tg.PeerUser); !ok || peer.UserID != viewer.ID {
|
|
t.Fatalf("owner block peer = %#v, want viewer", ownerBlocked.PeerID)
|
|
}
|
|
if _, ok := ownerBlockUpdates.OtherUpdates[1].(*tg.UpdatePeerSettings); !ok {
|
|
t.Fatalf("owner block update[1] = %T, want peer settings", ownerBlockUpdates.OtherUpdates[1])
|
|
}
|
|
blockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{3, 1}), &tg.UpdatesGetDifferenceRequest{Pts: 0})
|
|
if err != nil {
|
|
t.Fatalf("get viewer block difference: %v", err)
|
|
}
|
|
blockUpdates, ok := blockDiff.(*tg.UpdatesDifference)
|
|
if !ok {
|
|
t.Fatalf("block difference = %T, want *tg.UpdatesDifference", blockDiff)
|
|
}
|
|
if blockUpdates.State.Pts != 3 || len(blockUpdates.OtherUpdates) != 3 {
|
|
t.Fatalf("block difference pts/updates = %d/%d, want 3/3", blockUpdates.State.Pts, len(blockUpdates.OtherUpdates))
|
|
}
|
|
for i, update := range blockUpdates.OtherUpdates {
|
|
storyUpdate, ok := update.(*tg.UpdateStory)
|
|
if !ok {
|
|
t.Fatalf("block update %d = %T, want *tg.UpdateStory", i, update)
|
|
}
|
|
if peer, ok := storyUpdate.Peer.(*tg.PeerUser); !ok || peer.UserID != owner.ID {
|
|
t.Fatalf("block update %d peer = %#v, want owner peer", i, storyUpdate.Peer)
|
|
}
|
|
deleted, ok := storyUpdate.Story.(*tg.StoryItemDeleted)
|
|
if !ok {
|
|
t.Fatalf("block story %d = %T, want *tg.StoryItemDeleted", i, storyUpdate.Story)
|
|
}
|
|
if deleted.ID != i+1 {
|
|
t.Fatalf("block deleted story id = %d, want %d", deleted.ID, i+1)
|
|
}
|
|
}
|
|
|
|
if ok, err := r.onContactsUnblock(ownerCtx, &tg.ContactsUnblockRequest{
|
|
MyStoriesFrom: true,
|
|
ID: &tg.InputPeerUser{UserID: viewer.ID, AccessHash: viewer.AccessHash},
|
|
}); err != nil || !ok {
|
|
t.Fatalf("contacts.unblock = %v, %v", ok, err)
|
|
}
|
|
ownerUnblockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, owner.ID), [8]byte{7, 7, 3}), &tg.UpdatesGetDifferenceRequest{Pts: ownerBlockUpdates.State.Pts})
|
|
if err != nil {
|
|
t.Fatalf("get owner unblock difference: %v", err)
|
|
}
|
|
ownerUnblockUpdates, ok := ownerUnblockDiff.(*tg.UpdatesDifference)
|
|
if !ok {
|
|
t.Fatalf("owner unblock difference = %T, want *tg.UpdatesDifference", ownerUnblockDiff)
|
|
}
|
|
if ownerUnblockUpdates.State.Pts != 4 || len(ownerUnblockUpdates.OtherUpdates) != 2 {
|
|
t.Fatalf("owner unblock difference pts/updates = %d/%d, want 4/2", ownerUnblockUpdates.State.Pts, len(ownerUnblockUpdates.OtherUpdates))
|
|
}
|
|
ownerUnblocked, ok := ownerUnblockUpdates.OtherUpdates[0].(*tg.UpdatePeerBlocked)
|
|
if !ok || ownerUnblocked.Blocked || !ownerUnblocked.BlockedMyStoriesFrom {
|
|
t.Fatalf("owner unblock update[0] = %+v (%T), want story unblock", ownerUnblockUpdates.OtherUpdates[0], ownerUnblockUpdates.OtherUpdates[0])
|
|
}
|
|
if peer, ok := ownerUnblocked.PeerID.(*tg.PeerUser); !ok || peer.UserID != viewer.ID {
|
|
t.Fatalf("owner unblock peer = %#v, want viewer", ownerUnblocked.PeerID)
|
|
}
|
|
unblockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{3, 2}), &tg.UpdatesGetDifferenceRequest{Pts: blockUpdates.State.Pts})
|
|
if err != nil {
|
|
t.Fatalf("get viewer unblock difference: %v", err)
|
|
}
|
|
unblockUpdates, ok := unblockDiff.(*tg.UpdatesDifference)
|
|
if !ok {
|
|
t.Fatalf("unblock difference = %T, want *tg.UpdatesDifference", unblockDiff)
|
|
}
|
|
if unblockUpdates.State.Pts != 6 || len(unblockUpdates.OtherUpdates) != 3 {
|
|
t.Fatalf("unblock difference pts/updates = %d/%d, want 6/3", unblockUpdates.State.Pts, len(unblockUpdates.OtherUpdates))
|
|
}
|
|
for i, update := range unblockUpdates.OtherUpdates {
|
|
storyUpdate, ok := update.(*tg.UpdateStory)
|
|
if !ok {
|
|
t.Fatalf("unblock update %d = %T, want *tg.UpdateStory", i, update)
|
|
}
|
|
item, ok := storyUpdate.Story.(*tg.StoryItem)
|
|
if !ok {
|
|
t.Fatalf("unblock story %d = %T, want *tg.StoryItem", i, storyUpdate.Story)
|
|
}
|
|
if item.ID != i+1 {
|
|
t.Fatalf("unblock story id = %d, want %d", item.ID, i+1)
|
|
}
|
|
if _, ok := item.GetViews(); item.Out || ok {
|
|
t.Fatalf("unblock story %d = %+v, want viewer fanout without out/views", i, item)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContactsSetBlockedReplacesStoryBlocklistFanouts(t *testing.T) {
|
|
ctx := context.Background()
|
|
users := memory.NewUserStore()
|
|
contactsStore := memory.NewContactStore()
|
|
storyStore := memory.NewStoryStore()
|
|
updateStore := memory.NewUpdateEventStore()
|
|
owner, err := users.Create(ctx, domain.User{AccessHash: 41, Phone: "15550004001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
oldViewer, err := users.Create(ctx, domain.User{AccessHash: 42, Phone: "15550004002", FirstName: "Old"})
|
|
if err != nil {
|
|
t.Fatalf("create old viewer: %v", err)
|
|
}
|
|
newViewer, err := users.Create(ctx, domain.User{AccessHash: 43, Phone: "15550004003", FirstName: "New"})
|
|
if err != nil {
|
|
t.Fatalf("create new viewer: %v", err)
|
|
}
|
|
for _, viewer := range []domain.User{oldViewer, newViewer} {
|
|
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: viewer.ID, FirstName: viewer.FirstName}); err != nil {
|
|
t.Fatalf("upsert contact %d: %v", viewer.ID, err)
|
|
}
|
|
}
|
|
if _, err := contactsStore.Block(ctx, owner.ID, oldViewer.ID, 1700000000); err != nil {
|
|
t.Fatalf("pre-block old viewer: %v", err)
|
|
}
|
|
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
|
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
|
|
Owner: ownerPeer,
|
|
ID: 1,
|
|
Date: 1700000000,
|
|
ExpireDate: 1700003600,
|
|
Public: true,
|
|
Out: true,
|
|
Views: domain.StoryViews{ViewsCount: 2, HasViewers: true, RecentViewers: []int64{oldViewer.ID, newViewer.ID}},
|
|
}}); err != nil {
|
|
t.Fatalf("upsert story: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contactsStore, users),
|
|
Stories: appstories.NewService(storyStore),
|
|
Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore),
|
|
Users: appusers.NewService(users),
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
|
|
|
|
ownerAuth := [8]byte{8, 8, 1}
|
|
ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuth), 61)
|
|
ok, err := r.onContactsSetBlocked(ownerCtx, &tg.ContactsSetBlockedRequest{
|
|
MyStoriesFrom: true,
|
|
ID: []tg.InputPeerClass{
|
|
&tg.InputPeerUser{UserID: newViewer.ID, AccessHash: newViewer.AccessHash},
|
|
&tg.InputPeerUser{UserID: newViewer.ID, AccessHash: newViewer.AccessHash},
|
|
},
|
|
Limit: 2,
|
|
})
|
|
if err != nil || !ok {
|
|
t.Fatalf("contacts.setBlocked = %v, %v", ok, err)
|
|
}
|
|
|
|
blockedList, err := r.onContactsGetBlocked(WithUserID(ctx, owner.ID), &tg.ContactsGetBlockedRequest{MyStoriesFrom: true, Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("contacts.getBlocked: %v", err)
|
|
}
|
|
blocked, ok := blockedList.(*tg.ContactsBlocked)
|
|
if !ok || len(blocked.Blocked) != 1 {
|
|
t.Fatalf("blocked list = %T %+v, want one blocked peer", blockedList, blockedList)
|
|
}
|
|
if peer, ok := blocked.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != newViewer.ID {
|
|
t.Fatalf("blocked peer = %#v, want new viewer", blocked.Blocked[0].PeerID)
|
|
}
|
|
|
|
ownerDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, owner.ID), [8]byte{8, 8, 2}), &tg.UpdatesGetDifferenceRequest{Pts: 0})
|
|
if err != nil {
|
|
t.Fatalf("owner difference: %v", err)
|
|
}
|
|
ownerUpdates, ok := ownerDiff.(*tg.UpdatesDifference)
|
|
if !ok {
|
|
t.Fatalf("owner difference = %T, want *tg.UpdatesDifference", ownerDiff)
|
|
}
|
|
if ownerUpdates.State.Pts != 4 || len(ownerUpdates.OtherUpdates) != 4 {
|
|
t.Fatalf("owner difference pts/updates = %d/%d, want 4/4", ownerUpdates.State.Pts, len(ownerUpdates.OtherUpdates))
|
|
}
|
|
blockedUpdate, ok := ownerUpdates.OtherUpdates[0].(*tg.UpdatePeerBlocked)
|
|
if !ok || !blockedUpdate.Blocked || !blockedUpdate.BlockedMyStoriesFrom {
|
|
t.Fatalf("owner update[0] = %+v (%T), want new viewer story block", ownerUpdates.OtherUpdates[0], ownerUpdates.OtherUpdates[0])
|
|
}
|
|
if peer, ok := blockedUpdate.PeerID.(*tg.PeerUser); !ok || peer.UserID != newViewer.ID {
|
|
t.Fatalf("owner block peer = %#v, want new viewer", blockedUpdate.PeerID)
|
|
}
|
|
unblockedUpdate, ok := ownerUpdates.OtherUpdates[2].(*tg.UpdatePeerBlocked)
|
|
if !ok || unblockedUpdate.Blocked || !unblockedUpdate.BlockedMyStoriesFrom {
|
|
t.Fatalf("owner update[2] = %+v (%T), want old viewer story unblock", ownerUpdates.OtherUpdates[2], ownerUpdates.OtherUpdates[2])
|
|
}
|
|
if peer, ok := unblockedUpdate.PeerID.(*tg.PeerUser); !ok || peer.UserID != oldViewer.ID {
|
|
t.Fatalf("owner unblock peer = %#v, want old viewer", unblockedUpdate.PeerID)
|
|
}
|
|
|
|
oldDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, oldViewer.ID), [8]byte{8, 8, 3}), &tg.UpdatesGetDifferenceRequest{Pts: 0})
|
|
if err != nil {
|
|
t.Fatalf("old viewer difference: %v", err)
|
|
}
|
|
oldUpdates, ok := oldDiff.(*tg.UpdatesDifference)
|
|
if !ok || oldUpdates.State.Pts != 1 || len(oldUpdates.OtherUpdates) != 1 {
|
|
t.Fatalf("old viewer difference = %T %+v, want one restored story", oldDiff, oldDiff)
|
|
}
|
|
oldStory, ok := oldUpdates.OtherUpdates[0].(*tg.UpdateStory)
|
|
if !ok {
|
|
t.Fatalf("old viewer update = %T, want updateStory", oldUpdates.OtherUpdates[0])
|
|
}
|
|
if item, ok := oldStory.Story.(*tg.StoryItem); !ok || item.ID != 1 || item.Out {
|
|
t.Fatalf("old viewer story = %+v (%T), want visible non-owner story", oldStory.Story, oldStory.Story)
|
|
}
|
|
|
|
newDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, newViewer.ID), [8]byte{8, 8, 4}), &tg.UpdatesGetDifferenceRequest{Pts: 0})
|
|
if err != nil {
|
|
t.Fatalf("new viewer difference: %v", err)
|
|
}
|
|
newUpdates, ok := newDiff.(*tg.UpdatesDifference)
|
|
if !ok || newUpdates.State.Pts != 1 || len(newUpdates.OtherUpdates) != 1 {
|
|
t.Fatalf("new viewer difference = %T %+v, want one deleted story", newDiff, newDiff)
|
|
}
|
|
newStory, ok := newUpdates.OtherUpdates[0].(*tg.UpdateStory)
|
|
if !ok {
|
|
t.Fatalf("new viewer update = %T, want updateStory", newUpdates.OtherUpdates[0])
|
|
}
|
|
if deleted, ok := newStory.Story.(*tg.StoryItemDeleted); !ok || deleted.ID != 1 {
|
|
t.Fatalf("new viewer story = %+v (%T), want deleted story 1", newStory.Story, newStory.Story)
|
|
}
|
|
}
|
|
|
|
func TestContactsSearchFindsPublicChannels(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000011", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550000012", FirstName: "Viewer"})
|
|
if err != nil {
|
|
t.Fatalf("create viewer: %v", err)
|
|
}
|
|
stranger, err := userStore.Create(ctx, domain.User{AccessHash: 13, Phone: "15550000013", FirstName: "Stranger"})
|
|
if err != nil {
|
|
t.Fatalf("create stranger: %v", err)
|
|
}
|
|
channelStore := memory.NewChannelStore()
|
|
channelSvc := appchannels.NewService(channelStore)
|
|
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
|
Title: "CU Public RPC",
|
|
Megagroup: true,
|
|
MemberUserIDs: []int64{viewer.ID},
|
|
Date: 1700000012,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create channel: %v", err)
|
|
}
|
|
public, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
|
|
ChannelID: created.Channel.ID,
|
|
Username: "cu_public_rpc",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("set channel username: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Users: appusers.NewService(userStore),
|
|
Contacts: appcontacts.NewService(memory.NewContactStore(), userStore),
|
|
Channels: channelSvc,
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
|
|
var in bin.Buffer
|
|
if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&in); err != nil {
|
|
t.Fatalf("encode request: %v", err)
|
|
}
|
|
enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in)
|
|
if err != nil {
|
|
t.Fatalf("dispatch: %v", err)
|
|
}
|
|
box, ok := enc.(*tg.ContactsFound)
|
|
if !ok {
|
|
t.Fatalf("result type = %T, want *tg.ContactsFound", enc)
|
|
}
|
|
if len(box.MyResults) != 0 || len(box.Results) != 0 || len(box.Chats) != 0 {
|
|
t.Fatalf("member public search = my %d results %d chats %d, want no discovery channel for active member", len(box.MyResults), len(box.Results), len(box.Chats))
|
|
}
|
|
|
|
var strangerIn bin.Buffer
|
|
if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&strangerIn); err != nil {
|
|
t.Fatalf("encode stranger request: %v", err)
|
|
}
|
|
strangerEnc, err := r.Dispatch(WithUserID(ctx, stranger.ID), [8]byte{}, 0, &strangerIn)
|
|
if err != nil {
|
|
t.Fatalf("dispatch stranger: %v", err)
|
|
}
|
|
strangerBox, ok := strangerEnc.(*tg.ContactsFound)
|
|
if !ok {
|
|
t.Fatalf("stranger result type = %T, want *tg.ContactsFound", strangerEnc)
|
|
}
|
|
if len(strangerBox.Results) != 1 || len(strangerBox.Chats) != 1 {
|
|
t.Fatalf("stranger search sizes = results %d chats %d, want 1/1", len(strangerBox.Results), len(strangerBox.Chats))
|
|
}
|
|
strangerPeer, ok := strangerBox.Results[0].(*tg.PeerChannel)
|
|
if !ok || strangerPeer.ChannelID != public.ID {
|
|
t.Fatalf("stranger peer = %T %+v, want public channel", strangerBox.Results[0], strangerBox.Results[0])
|
|
}
|
|
strangerChat, ok := strangerBox.Chats[0].(*tg.Channel)
|
|
if !ok || !strangerChat.Left || strangerChat.ID != public.ID {
|
|
t.Fatalf("stranger chat = %T %+v, want left public channel", strangerBox.Chats[0], strangerBox.Chats[0])
|
|
}
|
|
|
|
resolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@CU_PUBLIC_RPC"})
|
|
if err != nil {
|
|
t.Fatalf("resolve username: %v", err)
|
|
}
|
|
resolvedPeer, ok := resolved.Peer.(*tg.PeerChannel)
|
|
if !ok || resolvedPeer.ChannelID != public.ID || len(resolved.Chats) != 1 {
|
|
t.Fatalf("resolved channel = %+v, want peer + chat", resolved)
|
|
}
|
|
resolvedChat, ok := resolved.Chats[0].(*tg.Channel)
|
|
if !ok || resolvedChat.ID != public.ID || resolvedChat.Username != "cu_public_rpc" || resolvedChat.AccessHash != public.AccessHash {
|
|
t.Fatalf("resolved chat = %T %+v, want full public channel projection", resolved.Chats[0], resolved.Chats[0])
|
|
}
|
|
if resolvedChat.Left {
|
|
t.Fatalf("member resolved chat left = true, want active member channel")
|
|
}
|
|
|
|
strangerResolved, err := r.onContactsResolveUsername(WithUserID(ctx, stranger.ID), &tg.ContactsResolveUsernameRequest{Username: "@CU_PUBLIC_RPC"})
|
|
if err != nil {
|
|
t.Fatalf("resolve username stranger: %v", err)
|
|
}
|
|
strangerResolvedPeer, ok := strangerResolved.Peer.(*tg.PeerChannel)
|
|
if !ok || strangerResolvedPeer.ChannelID != public.ID || len(strangerResolved.Chats) != 1 {
|
|
t.Fatalf("stranger resolved channel = %+v, want peer + chat", strangerResolved)
|
|
}
|
|
strangerResolvedChat, ok := strangerResolved.Chats[0].(*tg.Channel)
|
|
if !ok || strangerResolvedChat.ID != public.ID || strangerResolvedChat.Username != "cu_public_rpc" || strangerResolvedChat.AccessHash != public.AccessHash {
|
|
t.Fatalf("stranger resolved chat = %T %+v, want full public channel projection", strangerResolved.Chats[0], strangerResolved.Chats[0])
|
|
}
|
|
if !strangerResolvedChat.Left {
|
|
t.Fatalf("stranger resolved chat left = false, want public preview channel")
|
|
}
|
|
}
|
|
|
|
func TestUsernameRPCLifecycle(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"})
|
|
if err != nil {
|
|
t.Fatalf("create other: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System)
|
|
reqCtx := WithUserID(ctx, owner.ID)
|
|
|
|
ok, err := r.onAccountCheckUsername(reqCtx, "taken_name")
|
|
if err != nil || ok {
|
|
t.Fatalf("check occupied = ok %v err %v, want false/nil", ok, err)
|
|
}
|
|
user, err := r.onAccountUpdateUsername(reqCtx, "owner_name")
|
|
if err != nil {
|
|
t.Fatalf("update username: %v", err)
|
|
}
|
|
self, ok := user.(*tg.User)
|
|
if !ok || self.Username != "owner_name" || len(self.Usernames) != 0 {
|
|
t.Fatalf("updated user = %T %+v, want self with scalar username only", user, user)
|
|
}
|
|
|
|
resolved, err := r.onContactsResolveUsername(reqCtx, &tg.ContactsResolveUsernameRequest{Username: "@OWNER_NAME"})
|
|
if err != nil {
|
|
t.Fatalf("resolve username: %v", err)
|
|
}
|
|
peer, ok := resolved.Peer.(*tg.PeerUser)
|
|
if !ok || peer.UserID != owner.ID || len(resolved.Users) != 1 {
|
|
t.Fatalf("resolved username = %+v, want owner peer", resolved)
|
|
}
|
|
|
|
resolvedPhone, err := r.onContactsResolvePhone(reqCtx, "+15550000002")
|
|
if err != nil {
|
|
t.Fatalf("resolve phone: %v", err)
|
|
}
|
|
phonePeer, ok := resolvedPhone.Peer.(*tg.PeerUser)
|
|
if !ok || phonePeer.UserID != other.ID {
|
|
t.Fatalf("resolved phone peer = %+v, want other", resolvedPhone.Peer)
|
|
}
|
|
}
|
|
|
|
func TestAccountUpdateProfileRPC(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System)
|
|
req := &tg.AccountUpdateProfileRequest{}
|
|
req.SetFirstName("Updated")
|
|
req.SetLastName("User")
|
|
req.SetAbout("profile bio")
|
|
|
|
user, err := r.onAccountUpdateProfile(WithUserID(ctx, owner.ID), req)
|
|
if err != nil {
|
|
t.Fatalf("update profile: %v", err)
|
|
}
|
|
self, ok := user.(*tg.User)
|
|
if !ok || self.FirstName != "Updated" || self.LastName != "User" {
|
|
t.Fatalf("updated user = %T %+v, want updated self user", user, user)
|
|
}
|
|
full, err := r.onUsersGetFullUser(WithUserID(ctx, owner.ID), &tg.InputUserSelf{})
|
|
if err != nil {
|
|
t.Fatalf("get full user: %v", err)
|
|
}
|
|
if full.FullUser.About != "profile bio" {
|
|
t.Fatalf("full about = %q, want profile bio", full.FullUser.About)
|
|
}
|
|
}
|
|
|
|
func TestUsersGetFullUserProjectsOwnerScopedContactNoteAcrossCacheUpdates(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
rawContacts := memory.NewContactStore()
|
|
cachedContacts := userprojection.NewCachedContactStore(rawContacts, time.Hour)
|
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
altOwner, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Alt"})
|
|
if err != nil {
|
|
t.Fatalf("create alternate owner: %v", err)
|
|
}
|
|
friend, err := userStore.Create(ctx, domain.User{AccessHash: 3, Phone: "15550000003", FirstName: "Friend"})
|
|
if err != nil {
|
|
t.Fatalf("create friend: %v", err)
|
|
}
|
|
contactsService := appcontacts.NewService(cachedContacts, userStore)
|
|
usersService := appusers.NewService(userStore, appusers.WithContactStore(cachedContacts))
|
|
sessions := &captureSessions{}
|
|
r := New(Config{}, Deps{Users: usersService, Contacts: contactsService, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
|
|
hasUserRefresh := func(updates *tg.Updates, userID int64) bool {
|
|
t.Helper()
|
|
if updates == nil {
|
|
return false
|
|
}
|
|
for _, update := range updates.Updates {
|
|
if changed, ok := update.(*tg.UpdateUser); ok && changed.UserID == userID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
add := &tg.ContactsAddContactRequest{
|
|
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
|
FirstName: "Friend",
|
|
}
|
|
add.SetNote(tg.TextWithEntities{
|
|
Text: "owner note",
|
|
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 5}},
|
|
})
|
|
addedClass, err := r.onContactsAddContact(WithUserID(ctx, owner.ID), add)
|
|
if err != nil {
|
|
t.Fatalf("add owner contact through RPC: %v", err)
|
|
}
|
|
added, ok := addedClass.(*tg.Updates)
|
|
if !ok || !hasUserRefresh(added, friend.ID) {
|
|
t.Fatalf("add contact updates = %T %+v, want updateUser refresh for note", addedClass, addedClass)
|
|
}
|
|
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
|
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
|
t.Fatalf("add contact push = %T %+v, want other-session updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
|
}
|
|
if _, err := contactsService.AddContact(ctx, altOwner.ID, domain.ContactInput{
|
|
ContactUserID: friend.ID,
|
|
FirstName: "Friend",
|
|
Note: "alternate note",
|
|
}); err != nil {
|
|
t.Fatalf("add alternate owner contact: %v", err)
|
|
}
|
|
getNote := func(viewer domain.User) (tg.TextWithEntities, bool) {
|
|
t.Helper()
|
|
full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash})
|
|
if err != nil {
|
|
t.Fatalf("get full user for viewer %d: %v", viewer.ID, err)
|
|
}
|
|
return full.FullUser.GetNote()
|
|
}
|
|
|
|
note, ok := getNote(owner)
|
|
if !ok || note.Text != "owner note" || len(note.Entities) != 1 {
|
|
t.Fatalf("owner note = %+v present=%v, want owner note with entity", note, ok)
|
|
}
|
|
bold, ok := note.Entities[0].(*tg.MessageEntityBold)
|
|
if !ok || bold.Offset != 0 || bold.Length != 5 {
|
|
t.Fatalf("owner note entity = %T %+v, want bold 0/5", note.Entities[0], note.Entities[0])
|
|
}
|
|
// The large UserFull LRU intentionally excludes private notes; every response
|
|
// overlays one from the already-loaded viewer contact projection.
|
|
cachedFull, ok := r.userFullProjectionCache.Lookup(owner.ID, friend.ID)
|
|
if !ok {
|
|
t.Fatal("user full projection was not cached")
|
|
}
|
|
if cachedNote, present := cachedFull.GetNote(); present {
|
|
t.Fatalf("cached user full leaked private note: %+v", cachedNote)
|
|
}
|
|
// Mutating one response must not leak through the cache or contact snapshot.
|
|
bold.Length = 99
|
|
note, ok = getNote(owner)
|
|
if !ok || note.Entities[0].(*tg.MessageEntityBold).Length != 5 {
|
|
t.Fatalf("owner note after response mutation = %+v present=%v", note, ok)
|
|
}
|
|
|
|
altNote, ok := getNote(altOwner)
|
|
if !ok || altNote.Text != "alternate note" {
|
|
t.Fatalf("alternate owner note = %+v present=%v, want isolated value", altNote, ok)
|
|
}
|
|
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
|
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
|
Note: tg.TextWithEntities{
|
|
Text: "bad",
|
|
Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 3, Length: 1}},
|
|
},
|
|
}); err == nil || ok || !strings.Contains(err.Error(), "ENTITY_BOUNDS_INVALID") {
|
|
t.Fatalf("invalid contact note ok=%v err=%v, want ENTITY_BOUNDS_INVALID", ok, err)
|
|
}
|
|
note, ok = getNote(owner)
|
|
if !ok || note.Text != "owner note" {
|
|
t.Fatalf("invalid update mutated owner note: %+v present=%v", note, ok)
|
|
}
|
|
|
|
updated := &tg.ContactsUpdateContactNoteRequest{
|
|
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
|
Note: tg.TextWithEntities{
|
|
Text: "fresh note",
|
|
Entities: []tg.MessageEntityClass{&tg.MessageEntityItalic{Offset: 0, Length: 5}},
|
|
},
|
|
}
|
|
sessions.clearMessages()
|
|
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), updated); err != nil || !ok {
|
|
t.Fatalf("update contact note ok=%v err=%v", ok, err)
|
|
}
|
|
pushed, ok = sessions.lastUserPush().(*tg.Updates)
|
|
if !ok || !hasUserRefresh(pushed, friend.ID) {
|
|
t.Fatalf("update contact note push = %T %+v, want updateUser refresh", sessions.lastUserPush(), sessions.lastUserPush())
|
|
}
|
|
hasReset := false
|
|
for _, update := range pushed.Updates {
|
|
if _, ok := update.(*tg.UpdateContactsReset); ok {
|
|
hasReset = true
|
|
}
|
|
}
|
|
if !hasReset {
|
|
t.Fatalf("update contact note push = %+v, want contactsReset for non-reliable dispatch", pushed)
|
|
}
|
|
note, ok = getNote(owner)
|
|
if !ok || note.Text != "fresh note" || len(note.Entities) != 1 {
|
|
t.Fatalf("fresh owner note = %+v present=%v", note, ok)
|
|
}
|
|
if _, ok := note.Entities[0].(*tg.MessageEntityItalic); !ok {
|
|
t.Fatalf("fresh owner note entity = %T, want italic", note.Entities[0])
|
|
}
|
|
altNote, ok = getNote(altOwner)
|
|
if !ok || altNote.Text != "alternate note" {
|
|
t.Fatalf("alternate note changed with owner update: %+v present=%v", altNote, ok)
|
|
}
|
|
|
|
if ok, err := r.onContactsUpdateContactNote(WithUserID(ctx, owner.ID), &tg.ContactsUpdateContactNoteRequest{
|
|
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
|
Note: tg.TextWithEntities{},
|
|
}); err != nil || !ok {
|
|
t.Fatalf("clear contact note ok=%v err=%v", ok, err)
|
|
}
|
|
if note, present := getNote(owner); present {
|
|
t.Fatalf("cleared contact note still present: %+v", note)
|
|
}
|
|
|
|
// Simulate a write committed by another instance: both the shared contact
|
|
// snapshot and RPC projection receive the existing contact_account NOTIFY.
|
|
if _, found, err := rawContacts.UpdateNote(ctx, owner.ID, friend.ID, "remote note", nil); err != nil || !found {
|
|
t.Fatalf("remote update found=%v err=%v", found, err)
|
|
}
|
|
cachedContacts.InvalidateViewers(owner.ID)
|
|
r.InvalidateRPCProjectionReadModelForViewer(owner.ID)
|
|
note, ok = getNote(owner)
|
|
if !ok || note.Text != "remote note" {
|
|
t.Fatalf("note after cross-instance invalidation = %+v present=%v", note, ok)
|
|
}
|
|
}
|
|
|
|
func TestContactNoteReliableDispatchPushesOnlyTransientUserRefresh(t *testing.T) {
|
|
sessions := &captureSessions{}
|
|
r := New(Config{}, Deps{
|
|
Sessions: sessions,
|
|
Updates: &captureUpdates{reliableDispatch: true},
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
peer := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Friend", Contact: true}
|
|
|
|
r.pushContactNoteRefreshIfReliableDispatch(WithUserID(context.Background(), 1000000001), 1000000001, peer)
|
|
|
|
pushed, ok := sessions.lastUserPush().(*tg.Updates)
|
|
if !ok {
|
|
t.Fatalf("contact note refresh = %T, want *tg.Updates", sessions.lastUserPush())
|
|
}
|
|
if len(pushed.Updates) != 1 {
|
|
t.Fatalf("contact note refresh updates = %+v, want one updateUser without duplicate contactsReset", pushed.Updates)
|
|
}
|
|
changed, ok := pushed.Updates[0].(*tg.UpdateUser)
|
|
if !ok || changed.UserID != peer.ID {
|
|
t.Fatalf("contact note refresh update = %T %+v, want updateUser(%d)", pushed.Updates[0], pushed.Updates[0], peer.ID)
|
|
}
|
|
if len(pushed.Users) != 1 || pushed.Users[0].GetID() != peer.ID {
|
|
t.Fatalf("contact note refresh users = %+v, want peer companion", pushed.Users)
|
|
}
|
|
}
|
|
|
|
func TestUsersSavedMusicStubsValidateInput(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550000002", FirstName: "Friend"})
|
|
if err != nil {
|
|
t.Fatalf("create friend: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System)
|
|
reqCtx := WithUserID(ctx, owner.ID)
|
|
|
|
got, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{
|
|
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
|
Offset: 0,
|
|
Limit: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("users.getSavedMusic: %v", err)
|
|
}
|
|
music, ok := got.(*tg.UsersSavedMusic)
|
|
if !ok || music.Count != 0 || len(music.Documents) != 0 {
|
|
t.Fatalf("saved music = %T %+v, want empty users.savedMusic", got, got)
|
|
}
|
|
if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{
|
|
ID: &tg.InputUserSelf{},
|
|
Limit: maxSavedMusicLimit + 1,
|
|
}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") {
|
|
t.Fatalf("large saved music limit err = %v, want LIMIT_INVALID", err)
|
|
}
|
|
if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{
|
|
ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1},
|
|
Limit: 20,
|
|
}); err == nil || !strings.Contains(err.Error(), "USER_ID_INVALID") {
|
|
t.Fatalf("bad saved music user err = %v, want USER_ID_INVALID", err)
|
|
}
|
|
}
|
|
|
|
func TestUsersGetRequirementsToContactReturnsEmptyRequirements(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000001", FirstName: "Owner"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550000002", FirstName: "Friend"})
|
|
if err != nil {
|
|
t.Fatalf("create friend: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System)
|
|
reqCtx := WithUserID(ctx, owner.ID)
|
|
|
|
got, err := r.onUsersGetRequirementsToContact(reqCtx, []tg.InputUserClass{
|
|
&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash},
|
|
&tg.InputUserSelf{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("users.getRequirementsToContact: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("requirements len = %d, want 2", len(got))
|
|
}
|
|
for i, req := range got {
|
|
if _, ok := req.(*tg.RequirementToContactEmpty); !ok {
|
|
t.Fatalf("requirement %d = %T, want requirementToContactEmpty", i, req)
|
|
}
|
|
}
|
|
got, err = r.onUsersGetRequirementsToContact(reqCtx, []tg.InputUserClass{
|
|
&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("bad requirements user should degrade to empty requirement: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("bad requirements len = %d, want 1", len(got))
|
|
}
|
|
if _, ok := got[0].(*tg.RequirementToContactEmpty); !ok {
|
|
t.Fatalf("bad requirements result = %T, want requirementToContactEmpty", got[0])
|
|
}
|
|
}
|
|
|
|
func TestTGUserIncludesRecentlyStatusForTypingEligibility(t *testing.T) {
|
|
got := tgUser(domain.User{ID: 1000000002, FirstName: "Bob"})
|
|
if _, ok := got.Status.(*tg.UserStatusRecently); !ok {
|
|
t.Fatalf("status = %T, want *tg.UserStatusRecently", got.Status)
|
|
}
|
|
|
|
online := tgUser(domain.User{
|
|
ID: 1000000002,
|
|
FirstName: "Bob",
|
|
Status: domain.UserStatus{Kind: domain.UserStatusOnline, Expires: 1700000300},
|
|
})
|
|
if status, ok := online.Status.(*tg.UserStatusOnline); !ok || status.Expires != 1700000300 {
|
|
t.Fatalf("online status = %#v, want userStatusOnline expires=1700000300", online.Status)
|
|
}
|
|
|
|
offline := tgUser(domain.User{
|
|
ID: 1000000002,
|
|
FirstName: "Bob",
|
|
Status: domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: 1700000000},
|
|
})
|
|
if status, ok := offline.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 {
|
|
t.Fatalf("offline status = %#v, want userStatusOffline was_online=1700000000", offline.Status)
|
|
}
|
|
}
|
|
|
|
func TestAccountUpdateStatusPushesPresenceToOnlineContacts(t *testing.T) {
|
|
ctx := context.Background()
|
|
alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
|
|
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true}
|
|
contacts := memory.NewContactStore()
|
|
if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{
|
|
Contacts: []domain.Contact{{User: bob}},
|
|
}); err != nil {
|
|
t.Fatalf("save contacts: %v", err)
|
|
}
|
|
sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contacts),
|
|
Sessions: sessions,
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
callCtx := WithSessionID(WithUserID(ctx, alice.ID), 77)
|
|
|
|
ok, err := r.onAccountUpdateStatus(callCtx, false)
|
|
if err != nil || !ok {
|
|
t.Fatalf("account.updateStatus online = %v, %v", ok, err)
|
|
}
|
|
gotPushes := sessions.pushedUserIDs()
|
|
if !reflect.DeepEqual(gotPushes, []int64{alice.ID, bob.ID}) {
|
|
t.Fatalf("pushed users = %+v, want self and online contact", gotPushes)
|
|
}
|
|
update := pushedUserStatus(t, sessions.snapshot().message)
|
|
if update.UserID != alice.ID {
|
|
t.Fatalf("status user = %d, want alice", update.UserID)
|
|
}
|
|
if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) {
|
|
t.Fatalf("status = %#v, want online with future expires", update.Status)
|
|
}
|
|
|
|
ok, err = r.onAccountUpdateStatus(callCtx, true)
|
|
if err != nil || !ok {
|
|
t.Fatalf("account.updateStatus offline = %v, %v", ok, err)
|
|
}
|
|
update = pushedUserStatus(t, sessions.snapshot().message)
|
|
if update.UserID != alice.ID {
|
|
t.Fatalf("offline status user = %d, want alice", update.UserID)
|
|
}
|
|
if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 {
|
|
t.Fatalf("status = %#v, want offline with was_online", update.Status)
|
|
}
|
|
}
|
|
|
|
func TestAccountUpdateStatusPersistsLastSeen(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"})
|
|
if err != nil {
|
|
t.Fatalf("create alice: %v", err)
|
|
}
|
|
const now = 1700000200
|
|
r := New(Config{}, Deps{
|
|
Users: appusers.NewService(userStore),
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)})
|
|
|
|
ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, alice.ID), 77), false)
|
|
if err != nil || !ok {
|
|
t.Fatalf("account.updateStatus online = %v, %v", ok, err)
|
|
}
|
|
stored, found, err := userStore.ByID(ctx, alice.ID)
|
|
if err != nil || !found {
|
|
t.Fatalf("load alice found=%v err=%v", found, err)
|
|
}
|
|
if stored.LastSeenAt != now {
|
|
t.Fatalf("last_seen_at after online = %d, want %d", stored.LastSeenAt, now)
|
|
}
|
|
}
|
|
|
|
func TestContactsStatusesUsesPersistedLastSeen(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"})
|
|
if err != nil {
|
|
t.Fatalf("create alice: %v", err)
|
|
}
|
|
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"})
|
|
if err != nil {
|
|
t.Fatalf("create bob: %v", err)
|
|
}
|
|
const lastSeen = 1700000000
|
|
if err := userStore.UpdateLastSeen(ctx, bob.ID, lastSeen); err != nil {
|
|
t.Fatalf("update last seen: %v", err)
|
|
}
|
|
contacts := memory.NewContactStore()
|
|
if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{
|
|
Contacts: []domain.Contact{{User: domain.User{ID: bob.ID, FirstName: "Old Bob", Contact: true}}},
|
|
}); err != nil {
|
|
t.Fatalf("save contacts: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contacts, userStore),
|
|
Users: appusers.NewService(userStore),
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
|
|
statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID))
|
|
if err != nil {
|
|
t.Fatalf("contacts.getStatuses: %v", err)
|
|
}
|
|
if len(statuses) != 1 || statuses[0].UserID != bob.ID {
|
|
t.Fatalf("statuses = %+v, want bob only", statuses)
|
|
}
|
|
if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline != lastSeen {
|
|
t.Fatalf("status = %#v, want userStatusOffline was_online=%d", statuses[0].Status, lastSeen)
|
|
}
|
|
}
|
|
|
|
func TestContactsStatusesUsePresenceAndContactsKeepStableProjection(t *testing.T) {
|
|
ctx := context.Background()
|
|
alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
|
|
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true}
|
|
contacts := memory.NewContactStore()
|
|
if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{
|
|
Contacts: []domain.Contact{{User: bob}},
|
|
}); err != nil {
|
|
t.Fatalf("save contacts: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contacts),
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
|
|
if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false); err != nil || !ok {
|
|
t.Fatalf("bob account.updateStatus = %v, %v", ok, err)
|
|
}
|
|
statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID))
|
|
if err != nil {
|
|
t.Fatalf("contacts.getStatuses: %v", err)
|
|
}
|
|
if len(statuses) != 1 || statuses[0].UserID != bob.ID {
|
|
t.Fatalf("statuses = %+v, want bob only", statuses)
|
|
}
|
|
if _, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok {
|
|
t.Fatalf("contacts.getStatuses status = %T, want online", statuses[0].Status)
|
|
}
|
|
|
|
contactsRes, err := r.onContactsGetContacts(WithUserID(ctx, alice.ID), 0)
|
|
if err != nil {
|
|
t.Fatalf("contacts.getContacts: %v", err)
|
|
}
|
|
full, ok := contactsRes.(*tg.ContactsContacts)
|
|
if !ok || len(full.Users) != 1 {
|
|
t.Fatalf("contacts result = %T %+v, want one full contacts response", contactsRes, contactsRes)
|
|
}
|
|
user, ok := full.Users[0].(*tg.User)
|
|
if !ok || user.ID != bob.ID {
|
|
t.Fatalf("contacts user = %T %+v, want bob", full.Users[0], full.Users[0])
|
|
}
|
|
if _, ok := user.Status.(*tg.UserStatusRecently); !ok {
|
|
t.Fatalf("contacts user status = %T, want stable non-presence projection", user.Status)
|
|
}
|
|
}
|
|
|
|
func TestContactsAcceptContactReturnsSettingsAndReset(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
contactsStore := memory.NewContactStore()
|
|
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice", LastName: "A"})
|
|
if err != nil {
|
|
t.Fatalf("create alice: %v", err)
|
|
}
|
|
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob", LastName: "B"})
|
|
if err != nil {
|
|
t.Fatalf("create bob: %v", err)
|
|
}
|
|
contactsSvc := appcontacts.NewService(contactsStore, userStore)
|
|
if _, err := contactsSvc.AddContact(ctx, alice.ID, domain.ContactInput{
|
|
ContactUserID: bob.ID,
|
|
Phone: bob.Phone,
|
|
FirstName: "Bobby",
|
|
LastName: "Remark",
|
|
}); err != nil {
|
|
t.Fatalf("alice add bob: %v", err)
|
|
}
|
|
updatesSvc := &captureUpdates{state: domain.UpdateState{Pts: 10, Date: 1700000400}}
|
|
r := New(Config{}, Deps{
|
|
Contacts: contactsSvc,
|
|
Users: appusers.NewService(userStore, appusers.WithContactStore(contactsStore)),
|
|
Updates: updatesSvc,
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000400, 0)})
|
|
|
|
out, err := r.onContactsAcceptContact(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash})
|
|
if err != nil {
|
|
t.Fatalf("contacts.acceptContact: %v", err)
|
|
}
|
|
got, ok := out.(*tg.Updates)
|
|
if !ok {
|
|
t.Fatalf("updates = %T, want *tg.Updates", out)
|
|
}
|
|
if len(got.Updates) != 2 {
|
|
t.Fatalf("updates = %+v, want peer settings + contacts reset", got.Updates)
|
|
}
|
|
settings, ok := got.Updates[0].(*tg.UpdatePeerSettings)
|
|
if !ok {
|
|
t.Fatalf("update[0] = %T, want UpdatePeerSettings", got.Updates[0])
|
|
}
|
|
if settings.Settings.ShareContact || settings.Settings.AddContact {
|
|
t.Fatalf("peer settings = %+v, want share/add false", settings.Settings)
|
|
}
|
|
if _, ok := got.Updates[1].(*tg.UpdateContactsReset); !ok {
|
|
t.Fatalf("update[1] = %T, want UpdateContactsReset", got.Updates[1])
|
|
}
|
|
if len(updatesSvc.events) != 4 {
|
|
t.Fatalf("recorded events = %+v, want current peer/reset and target peer/reset", updatesSvc.events)
|
|
}
|
|
if updatesSvc.events[0].UserID != alice.ID || updatesSvc.events[0].Settings.ShareContact {
|
|
t.Fatalf("current peer settings event = %+v, want alice share=false", updatesSvc.events[0])
|
|
}
|
|
if updatesSvc.events[2].UserID != bob.ID || updatesSvc.events[2].Settings.ShareContact {
|
|
t.Fatalf("target peer settings event = %+v, want bob share=false", updatesSvc.events[2])
|
|
}
|
|
reverse, found, err := contactsStore.Get(ctx, bob.ID, alice.ID)
|
|
if err != nil || !found {
|
|
t.Fatalf("bob contact alice found=%v err=%v", found, err)
|
|
}
|
|
if reverse.Phone != alice.Phone || !reverse.Mutual {
|
|
t.Fatalf("bob contact alice = %+v, want shared phone and mutual", reverse)
|
|
}
|
|
}
|
|
|
|
func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
contactsStore := memory.NewContactStore()
|
|
privacySvc := appprivacy.NewService(memory.NewPrivacyStore(), contactsStore)
|
|
alice, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "2001", FirstName: "Alice", LastName: "A"})
|
|
if err != nil {
|
|
t.Fatalf("create alice: %v", err)
|
|
}
|
|
bob, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "2002", FirstName: "Bob", LastName: "B"})
|
|
if err != nil {
|
|
t.Fatalf("create bob: %v", err)
|
|
}
|
|
carol, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "2003", FirstName: "Carol", LastName: "C"})
|
|
if err != nil {
|
|
t.Fatalf("create carol: %v", err)
|
|
}
|
|
contactsSvc := appcontacts.NewService(contactsStore, userStore).Configure(appcontacts.WithPrivacyEvaluator(privacySvc))
|
|
usersSvc := appusers.NewService(userStore, appusers.WithContactStore(contactsStore), appusers.WithPrivacyEvaluator(privacySvc))
|
|
r := New(Config{}, Deps{
|
|
Contacts: contactsSvc,
|
|
Users: usersSvc,
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000410, 0)})
|
|
|
|
withoutException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
|
|
ID: &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash},
|
|
Phone: "",
|
|
FirstName: "Bobby",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("contacts.addContact without exception: %v", err)
|
|
}
|
|
bobSettings := firstPeerSettingsUpdate(t, withoutException)
|
|
if bobSettings.Settings.AddContact || !bobSettings.Settings.ShareContact || !bobSettings.Settings.NeedContactsException {
|
|
t.Fatalf("bob peer settings = %+v, want share + need exception", bobSettings.Settings)
|
|
}
|
|
if allowed, err := privacySvc.CanSee(ctx, alice.ID, bob.ID, domain.PrivacyKeyPhoneNumber); err != nil {
|
|
t.Fatalf("bob can see alice phone: %v", err)
|
|
} else if allowed {
|
|
t.Fatalf("bob can see alice phone = true, want false before exception")
|
|
}
|
|
withoutExceptionUpdates := withoutException.(*tg.Updates)
|
|
for _, item := range withoutExceptionUpdates.Users {
|
|
if user, ok := item.(*tg.User); ok && user.ID == bob.ID && user.Phone != "" {
|
|
t.Fatalf("contacts.addContact(phone=\"\") leaked bob phone %q in updates", user.Phone)
|
|
}
|
|
}
|
|
storedBob, found, err := contactsStore.Get(ctx, alice.ID, bob.ID)
|
|
if err != nil || !found {
|
|
t.Fatalf("stored bob contact found=%v err=%v", found, err)
|
|
}
|
|
if storedBob.Phone != "" || storedBob.User.Phone != "" {
|
|
t.Fatalf("stored bob contact phone = local %q user %q, want empty", storedBob.Phone, storedBob.User.Phone)
|
|
}
|
|
|
|
withException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
|
|
AddPhonePrivacyException: true,
|
|
ID: &tg.InputUser{UserID: carol.ID, AccessHash: carol.AccessHash},
|
|
Phone: carol.Phone,
|
|
FirstName: "Carol",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("contacts.addContact with exception: %v", err)
|
|
}
|
|
carolSettings := firstPeerSettingsUpdate(t, withException)
|
|
if carolSettings.Settings.AddContact || carolSettings.Settings.ShareContact || carolSettings.Settings.NeedContactsException {
|
|
t.Fatalf("carol peer settings = %+v, want no add/share/need exception", carolSettings.Settings)
|
|
}
|
|
if allowed, err := privacySvc.CanSee(ctx, alice.ID, carol.ID, domain.PrivacyKeyPhoneNumber); err != nil {
|
|
t.Fatalf("carol can see alice phone: %v", err)
|
|
} else if !allowed {
|
|
t.Fatalf("carol can see alice phone = false, want true after exception")
|
|
}
|
|
}
|
|
|
|
func firstPeerSettingsUpdate(t *testing.T, updates tg.UpdatesClass) *tg.UpdatePeerSettings {
|
|
t.Helper()
|
|
got, ok := updates.(*tg.Updates)
|
|
if !ok {
|
|
t.Fatalf("updates = %T, want *tg.Updates", updates)
|
|
}
|
|
if len(got.Updates) == 0 {
|
|
t.Fatalf("updates = %+v, want UpdatePeerSettings", got.Updates)
|
|
}
|
|
settings, ok := got.Updates[0].(*tg.UpdatePeerSettings)
|
|
if !ok {
|
|
t.Fatalf("update[0] = %T, want UpdatePeerSettings", got.Updates[0])
|
|
}
|
|
return settings
|
|
}
|
|
|
|
func TestContactsStatusesUsesOnlineSessionFallback(t *testing.T) {
|
|
ctx := context.Background()
|
|
alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
|
|
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true}
|
|
contacts := memory.NewContactStore()
|
|
if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{
|
|
Contacts: []domain.Contact{{User: bob}},
|
|
}); err != nil {
|
|
t.Fatalf("save contacts: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contacts),
|
|
Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}},
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
|
|
statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID))
|
|
if err != nil {
|
|
t.Fatalf("contacts.getStatuses: %v", err)
|
|
}
|
|
if len(statuses) != 1 || statuses[0].UserID != bob.ID {
|
|
t.Fatalf("statuses = %+v, want bob only", statuses)
|
|
}
|
|
if status, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) {
|
|
t.Fatalf("fallback status = %#v, want online from active session", statuses[0].Status)
|
|
}
|
|
}
|
|
|
|
func TestContactsStatusesHonorsExplicitOffline(t *testing.T) {
|
|
ctx := context.Background()
|
|
alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"}
|
|
bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true}
|
|
contacts := memory.NewContactStore()
|
|
if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{
|
|
Contacts: []domain.Contact{{User: bob}},
|
|
}); err != nil {
|
|
t.Fatalf("save contacts: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contacts),
|
|
Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}},
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), true); err != nil || !ok {
|
|
t.Fatalf("bob account.updateStatus offline = %v, %v", ok, err)
|
|
}
|
|
|
|
statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID))
|
|
if err != nil {
|
|
t.Fatalf("contacts.getStatuses: %v", err)
|
|
}
|
|
if len(statuses) != 1 || statuses[0].UserID != bob.ID {
|
|
t.Fatalf("statuses = %+v, want bob only", statuses)
|
|
}
|
|
if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 {
|
|
t.Fatalf("status = %#v, want explicit offline", statuses[0].Status)
|
|
}
|
|
}
|
|
|
|
func TestSessionOfflinePersistsLastSeenAndPushes(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"})
|
|
if err != nil {
|
|
t.Fatalf("create alice: %v", err)
|
|
}
|
|
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"})
|
|
if err != nil {
|
|
t.Fatalf("create bob: %v", err)
|
|
}
|
|
contacts := memory.NewContactStore()
|
|
if err := contacts.SaveList(ctx, bob.ID, domain.ContactList{Contacts: []domain.Contact{{User: alice}}}); err != nil {
|
|
t.Fatalf("save bob contacts: %v", err)
|
|
}
|
|
const now = 1700000300
|
|
sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}}
|
|
r := New(Config{}, Deps{
|
|
Contacts: appcontacts.NewService(contacts, userStore),
|
|
Users: appusers.NewService(userStore),
|
|
Sessions: sessions,
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)})
|
|
|
|
// offline 广播经去抖宽限后异步执行;缩短宽限并轮询等待。
|
|
grace := offlineAnnounceGrace
|
|
offlineAnnounceGrace = 10 * time.Millisecond
|
|
defer func() { offlineAnnounceGrace = grace }()
|
|
|
|
r.SessionOffline([8]byte{1, 2, 3}, 22, bob.ID, true)
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for len(sessions.pushedUserIDs()) < 2 {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("offline announce never fired; pushed users = %+v", sessions.pushedUserIDs())
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
stored, found, err := userStore.ByID(ctx, bob.ID)
|
|
if err != nil || !found {
|
|
t.Fatalf("load bob found=%v err=%v", found, err)
|
|
}
|
|
if stored.LastSeenAt != now {
|
|
t.Fatalf("last_seen_at = %d, want %d", stored.LastSeenAt, now)
|
|
}
|
|
if got := sessions.pushedUserIDs(); !reflect.DeepEqual(got, []int64{bob.ID, alice.ID}) {
|
|
t.Fatalf("pushed users = %+v, want bob self and online contact alice", got)
|
|
}
|
|
update := pushedUserStatus(t, sessions.snapshot().message)
|
|
if update.UserID != bob.ID {
|
|
t.Fatalf("status user = %d, want bob", update.UserID)
|
|
}
|
|
if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != now {
|
|
t.Fatalf("status = %#v, want offline was_online=%d", update.Status, now)
|
|
}
|
|
}
|
|
|
|
// TestSessionOfflineSkipsAnnounceWhenUserReconnects 验证去抖宽限内用户重新上线时
|
|
// 跳过 offline 广播——移动端重连/换 session 是「先断旧再建新」,没有去抖会对
|
|
// 全部好友广播一轮 offline→online 抖动。
|
|
func TestSessionOfflineSkipsAnnounceWhenUserReconnects(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"})
|
|
if err != nil {
|
|
t.Fatalf("create bob: %v", err)
|
|
}
|
|
const now = 1700000300
|
|
// bob 在 IsUserOnline 名单里:模拟宽限期内已重新上线。
|
|
sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}}
|
|
r := New(Config{}, Deps{
|
|
Users: appusers.NewService(userStore),
|
|
Sessions: sessions,
|
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)})
|
|
|
|
grace := offlineAnnounceGrace
|
|
offlineAnnounceGrace = 10 * time.Millisecond
|
|
defer func() { offlineAnnounceGrace = grace }()
|
|
|
|
r.SessionOffline([8]byte{1, 2, 3}, 22, bob.ID, true)
|
|
time.Sleep(20 * offlineAnnounceGrace)
|
|
|
|
if got := sessions.pushedUserIDs(); len(got) != 0 {
|
|
t.Fatalf("pushed users = %+v, want none (user reconnected within grace)", got)
|
|
}
|
|
stored, found, err := userStore.ByID(ctx, bob.ID)
|
|
if err != nil || !found {
|
|
t.Fatalf("load bob found=%v err=%v", found, err)
|
|
}
|
|
if stored.LastSeenAt != 0 {
|
|
t.Fatalf("last_seen_at = %d, want untouched 0", stored.LastSeenAt)
|
|
}
|
|
}
|
|
|
|
func TestAccountUpdateStatusKeepsUserOnlineUntilAllSessionsOffline(t *testing.T) {
|
|
const userID = int64(1000000001)
|
|
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
|
session1 := WithSessionID(WithUserID(context.Background(), userID), 1)
|
|
session2 := WithSessionID(WithUserID(context.Background(), userID), 2)
|
|
|
|
if ok, err := r.onAccountUpdateStatus(session1, false); err != nil || !ok {
|
|
t.Fatalf("session1 online = %v, %v", ok, err)
|
|
}
|
|
if ok, err := r.onAccountUpdateStatus(session2, false); err != nil || !ok {
|
|
t.Fatalf("session2 online = %v, %v", ok, err)
|
|
}
|
|
if ok, err := r.onAccountUpdateStatus(session1, true); err != nil || !ok {
|
|
t.Fatalf("session1 offline = %v, %v", ok, err)
|
|
}
|
|
if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOnline {
|
|
t.Fatalf("aggregate after one offline = %+v, want online", status)
|
|
}
|
|
if ok, err := r.onAccountUpdateStatus(session2, true); err != nil || !ok {
|
|
t.Fatalf("session2 offline = %v, %v", ok, err)
|
|
}
|
|
if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOffline || status.WasOnline == 0 {
|
|
t.Fatalf("aggregate after all offline = %+v, want offline", status)
|
|
}
|
|
}
|
|
|
|
func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Alice"})
|
|
if err != nil {
|
|
t.Fatalf("create alice: %v", err)
|
|
}
|
|
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009002", FirstName: "Bob"})
|
|
if err != nil {
|
|
t.Fatalf("create bob: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Users: appusers.NewService(userStore),
|
|
Contacts: appcontacts.NewService(memory.NewContactStore(), userStore),
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
bobCtx := WithUserID(ctx, bob.ID)
|
|
userFull, err := r.onUsersGetFullUser(bobCtx, &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash})
|
|
if err != nil {
|
|
t.Fatalf("users.getFullUser before block: %v", err)
|
|
}
|
|
if userFull.FullUser.Blocked || userFull.FullUser.BlockedMyStoriesFrom || !userFull.FullUser.Settings.BlockContact {
|
|
t.Fatalf("full user before block = %+v, want unblocked flags and block action", userFull.FullUser)
|
|
}
|
|
|
|
ok, err := r.onContactsBlock(bobCtx, &tg.ContactsBlockRequest{
|
|
ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
|
|
})
|
|
if err != nil || !ok {
|
|
t.Fatalf("contacts.block = %v, %v", ok, err)
|
|
}
|
|
blocked, err := r.onContactsGetBlocked(bobCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("contacts.getBlocked: %v", err)
|
|
}
|
|
full, ok := blocked.(*tg.ContactsBlocked)
|
|
if !ok || len(full.Blocked) != 1 || len(full.Users) != 1 {
|
|
t.Fatalf("blocked = %T %+v, want one blocked user", blocked, blocked)
|
|
}
|
|
if peer, ok := full.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != alice.ID {
|
|
t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID)
|
|
}
|
|
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID || user.Phone != "" {
|
|
t.Fatalf("blocked user = %#v, want alice with hidden phone", full.Users[0])
|
|
}
|
|
userFull, err = r.onUsersGetFullUser(bobCtx, &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash})
|
|
if err != nil {
|
|
t.Fatalf("users.getFullUser after block: %v", err)
|
|
}
|
|
if !userFull.FullUser.Blocked || !userFull.FullUser.BlockedMyStoriesFrom || userFull.FullUser.Settings.BlockContact {
|
|
t.Fatalf("full user after block = %+v, want blocked flags and no block action", userFull.FullUser)
|
|
}
|
|
aliceView, err := r.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash})
|
|
if err != nil {
|
|
t.Fatalf("users.getFullUser for opposite owner: %v", err)
|
|
}
|
|
if aliceView.FullUser.Blocked || aliceView.FullUser.BlockedMyStoriesFrom || !aliceView.FullUser.Settings.BlockContact {
|
|
t.Fatalf("opposite owner full user = %+v, want unblocked owner-scoped state", aliceView.FullUser)
|
|
}
|
|
|
|
ok, err = r.onContactsUnblock(bobCtx, &tg.ContactsUnblockRequest{
|
|
ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
|
|
})
|
|
if err != nil || !ok {
|
|
t.Fatalf("contacts.unblock = %v, %v", ok, err)
|
|
}
|
|
blocked, err = r.onContactsGetBlocked(bobCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("contacts.getBlocked after unblock: %v", err)
|
|
}
|
|
if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 0 {
|
|
t.Fatalf("blocked after unblock = %T %+v, want empty contacts.blocked", blocked, blocked)
|
|
}
|
|
userFull, err = r.onUsersGetFullUser(bobCtx, &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash})
|
|
if err != nil {
|
|
t.Fatalf("users.getFullUser after unblock: %v", err)
|
|
}
|
|
if userFull.FullUser.Blocked || userFull.FullUser.BlockedMyStoriesFrom || !userFull.FullUser.Settings.BlockContact {
|
|
t.Fatalf("full user after unblock = %+v, want unblocked flags and block action", userFull.FullUser)
|
|
}
|
|
}
|
|
|
|
func TestContactsBlockGetBlockedAndUnblockAcrossExactProfiles(t *testing.T) {
|
|
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
|
|
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
|
|
ctx := context.Background()
|
|
userStore := memory.NewUserStore()
|
|
alice, err := userStore.Create(ctx, domain.User{
|
|
AccessHash: 11,
|
|
Phone: "15550009101",
|
|
FirstName: "Alice",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create alice: %v", err)
|
|
}
|
|
bob, err := userStore.Create(ctx, domain.User{
|
|
AccessHash: 22,
|
|
Phone: "15550009102",
|
|
FirstName: "Bob",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create bob: %v", err)
|
|
}
|
|
r := New(Config{}, Deps{
|
|
Users: appusers.NewService(userStore),
|
|
Contacts: appcontacts.NewService(memory.NewContactStore(), userStore),
|
|
}, zaptest.NewLogger(t), clock.System)
|
|
ownerCtx := WithUserID(ctx, bob.ID)
|
|
peer := &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}
|
|
|
|
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsBlockRequest{ID: peer}); method != "contacts.block" {
|
|
t.Fatalf("block method = %q", method)
|
|
}
|
|
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsGetBlockedRequest{Limit: 10}); method != "contacts.getBlocked" {
|
|
t.Fatalf("getBlocked method = %q", method)
|
|
}
|
|
blocked, err := r.onContactsGetBlocked(ownerCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("read blocked after exact block: %v", err)
|
|
}
|
|
if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 1 {
|
|
t.Fatalf("blocked after exact block = %T %+v", blocked, blocked)
|
|
}
|
|
result, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.UsersGetFullUserRequest{
|
|
ID: &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash},
|
|
})
|
|
if method != "users.getFullUser" {
|
|
t.Fatalf("getFullUser method = %q", method)
|
|
}
|
|
var responseWire bin.Buffer
|
|
if err := result.Encode(&responseWire); err != nil {
|
|
t.Fatalf("encode exact blocked full user: %v", err)
|
|
}
|
|
decoded, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: responseWire.Copy()}, tlprofile.Limits{})
|
|
if err != nil {
|
|
t.Fatalf("decode exact blocked full user: %v", err)
|
|
}
|
|
blockedFull, ok := decoded.(*tg.UsersUserFull)
|
|
if !ok || !blockedFull.FullUser.Blocked || !blockedFull.FullUser.BlockedMyStoriesFrom || blockedFull.FullUser.Settings.BlockContact {
|
|
t.Fatalf("Layer %d blocked full user = %T %+v", profile, decoded, decoded)
|
|
}
|
|
|
|
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsUnblockRequest{ID: peer}); method != "contacts.unblock" {
|
|
t.Fatalf("unblock method = %q", method)
|
|
}
|
|
if _, method := dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.ContactsGetBlockedRequest{Limit: 10}); method != "contacts.getBlocked" {
|
|
t.Fatalf("getBlocked after unblock method = %q", method)
|
|
}
|
|
blocked, err = r.onContactsGetBlocked(ownerCtx, &tg.ContactsGetBlockedRequest{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("read blocked after exact unblock: %v", err)
|
|
}
|
|
if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 0 {
|
|
t.Fatalf("blocked after exact unblock = %T %+v", blocked, blocked)
|
|
}
|
|
result, method = dispatchExactLayerRPCTest(t, r, ownerCtx, profile, &tg.UsersGetFullUserRequest{
|
|
ID: &tg.InputUser{UserID: alice.ID, AccessHash: alice.AccessHash},
|
|
})
|
|
if method != "users.getFullUser" {
|
|
t.Fatalf("getFullUser after unblock method = %q", method)
|
|
}
|
|
responseWire.Reset()
|
|
if err := result.Encode(&responseWire); err != nil {
|
|
t.Fatalf("encode exact unblocked full user: %v", err)
|
|
}
|
|
decoded, err = tlprofile.DecodeObject(profile, &bin.Buffer{Buf: responseWire.Copy()}, tlprofile.Limits{})
|
|
if err != nil {
|
|
t.Fatalf("decode exact unblocked full user: %v", err)
|
|
}
|
|
unblockedFull, ok := decoded.(*tg.UsersUserFull)
|
|
if !ok || unblockedFull.FullUser.Blocked || unblockedFull.FullUser.BlockedMyStoriesFrom || !unblockedFull.FullUser.Settings.BlockContact {
|
|
t.Fatalf("Layer %d unblocked full user = %T %+v", profile, decoded, decoded)
|
|
}
|
|
})
|
|
}
|
|
}
|