chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,136 @@
package contacts
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
const (
contactAccountReadModel = "contact_account"
defaultContactListReadModelTTL = 24 * time.Hour
contactListReadModelMaxUsers = 4096
)
// contactListReadModelCache 是 contact list read-model 的 per-viewer 缓存,由统一缓存原语
// readmodelcache.Cache 承载(版本闸门 / epoch 守卫 / LRU 单条驱逐 / clone 内建)。
type contactListReadModelCache struct {
cache *readmodelcache.Cache[int64, domain.ContactList]
}
func newContactListReadModelCache(ttl time.Duration) *contactListReadModelCache {
if ttl <= 0 {
ttl = defaultContactListReadModelTTL
}
return &contactListReadModelCache{
cache: readmodelcache.New[int64, domain.ContactList](readmodelcache.Config[int64, domain.ContactList]{
MaxEntries: contactListReadModelMaxUsers,
TTL: ttl,
Clone: cloneContactList,
}),
}
}
// getOrLoad 命中即返回 clone,否则经 singleflight load。版本闸门:hasHash 且 currentHash!=0
// 时仅复用 storedHash==currentHash 的快照,否则重载(对齐 contact_account 版本脊)。
func (c *contactListReadModelCache) getOrLoad(ctx context.Context, userID int64, currentHash int64, hasHash bool, load func() (domain.ContactList, error)) (domain.ContactList, error) {
if c == nil {
return load()
}
effectiveHash := int64(0)
if hasHash {
effectiveHash = currentHash
}
return c.cache.GetOrLoadVersioned(ctx, userID, effectiveHash, load)
}
func (c *contactListReadModelCache) invalidate(ids ...int64) {
if c == nil {
return
}
c.cache.Invalidate(ids...)
}
func (c *contactListReadModelCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}
func (s *Service) contactAccountHash(ctx context.Context, userID int64) (int64, bool, error) {
if s == nil || s.versions == nil || userID == 0 {
return 0, false, nil
}
return s.versions.ReadModelHash(ctx, contactAccountReadModel, userID, domain.PeerTypeUser, userID)
}
func (s *Service) contactListReadModel(ctx context.Context, userID int64, currentHash int64, hasHash bool) (domain.ContactList, error) {
if s == nil {
return domain.ContactList{}, nil
}
if s.cache == nil {
return s.loadContactListReadModel(ctx, userID, currentHash, hasHash)
}
return s.cache.getOrLoad(ctx, userID, currentHash, hasHash, func() (domain.ContactList, error) {
return s.loadContactListReadModel(ctx, userID, currentHash, hasHash)
})
}
func (s *Service) loadContactListReadModel(ctx context.Context, userID int64, currentHash int64, hasHash bool) (domain.ContactList, error) {
list, err := s.contacts.ListByUser(ctx, userID)
if err != nil {
return domain.ContactList{}, err
}
if err := s.projectContactUsers(ctx, userID, &list); err != nil {
return domain.ContactList{}, err
}
if hasHash && currentHash != 0 {
list.Hash = currentHash
}
return list, nil
}
func (s *Service) InvalidateViewers(ids ...int64) {
if s == nil || s.cache == nil {
return
}
s.cache.invalidate(ids...)
}
func (s *Service) FlushReadModelCache() {
if s == nil || s.cache == nil {
return
}
s.cache.flush()
}
func cloneContactList(in domain.ContactList) domain.ContactList {
in.Contacts = cloneContacts(in.Contacts)
return in
}
func cloneContacts(in []domain.Contact) []domain.Contact {
out := make([]domain.Contact, len(in))
for i := range in {
out[i] = cloneContact(in[i])
}
return out
}
func cloneContact(in domain.Contact) domain.Contact {
in.User = cloneUser(in.User)
if in.NoteEntities != nil {
in.NoteEntities = append([]domain.MessageEntity(nil), in.NoteEntities...)
}
return in
}
func cloneUser(in domain.User) domain.User {
if in.PhotoStripped != nil {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
}
return in
}

View file

@ -18,6 +18,7 @@ var (
)
const maxSearchLimit = 50
const maxCloseFriendsCount = 5000
type phonePrivacyService interface {
userprojection.PrivacyEvaluator
@ -31,6 +32,8 @@ type Service struct {
photos userprojection.ProfilePhotoProvider
privacy phonePrivacyService
projector *userprojection.Projector
versions store.ReadModelVersionStore
cache *contactListReadModelCache
}
// Option adjusts optional contacts service dependencies.
@ -46,9 +49,14 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option {
return func(s *Service) { s.privacy = p }
}
// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
return func(s *Service) { s.versions = v }
}
// NewService 创建 contacts 服务。
func NewService(contacts store.ContactStore, users ...store.UserStore) *Service {
s := &Service{contacts: contacts}
s := &Service{contacts: contacts, cache: newContactListReadModelCache(defaultContactListReadModelTTL)}
if len(users) > 0 {
s.users = users[0]
}
@ -84,16 +92,15 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do
if s == nil || s.contacts == nil || userID == 0 {
return domain.ContactList{}, false, nil
}
list, err := s.contacts.ListByUser(ctx, userID)
currentHash, hasHash, err := s.contactAccountHash(ctx, userID)
if err != nil {
return domain.ContactList{}, false, err
}
if s.users != nil && len(list.Contacts) > 0 {
if err := s.attachCurrentLastSeen(ctx, &list); err != nil {
return domain.ContactList{}, false, err
}
if hash != 0 && hasHash && hash == currentHash {
return domain.ContactList{Hash: currentHash}, true, nil
}
if err := s.projectContactUsers(ctx, userID, &list); err != nil {
list, err := s.contactListReadModel(ctx, userID, currentHash, hasHash)
if err != nil {
return domain.ContactList{}, false, err
}
if hash != 0 && hash == list.Hash {
@ -102,40 +109,6 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do
return list, false, nil
}
func (s *Service) attachCurrentLastSeen(ctx context.Context, list *domain.ContactList) error {
ids := make([]int64, 0, len(list.Contacts))
seen := make(map[int64]struct{}, len(list.Contacts))
for _, contact := range list.Contacts {
id := contact.User.ID
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return err
}
current := make(map[int64]domain.User, len(users))
for _, u := range users {
current[u.ID] = u
}
for i := range list.Contacts {
if u, ok := current[list.Contacts[i].User.ID]; ok {
list.Contacts[i].User.LastSeenAt = u.LastSeenAt
list.Contacts[i].User.Status = u.Status
}
}
return nil
}
func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID {
return domain.Contact{}, ErrContactIDInvalid
@ -143,6 +116,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
if input.FirstName == "" && input.LastName == "" {
return domain.Contact{}, ErrContactNameEmpty
}
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空),
// 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。
input.Phone = digitsOnly(input.Phone)
if s.users != nil {
target, found, err := s.users.ByID(ctx, input.ContactUserID)
if err != nil {
@ -159,6 +135,7 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
if err != nil {
return domain.Contact{}, err
}
s.InvalidateViewers(userID, input.ContactUserID)
if input.AddPhonePrivacyException && s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil {
return domain.Contact{}, err
@ -205,6 +182,7 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
if err != nil {
return domain.Contact{}, err
}
s.InvalidateViewers(userID, contactUserID)
if s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
return domain.Contact{}, err
@ -288,6 +266,12 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
if err != nil {
return domain.ImportContactsResult{}, err
}
changedIDs := make([]int64, 0, len(upserts)+1)
changedIDs = append(changedIDs, userID)
for _, input := range upserts {
changedIDs = append(changedIDs, input.ContactUserID)
}
s.InvalidateViewers(changedIDs...)
if s.privacy != nil {
for _, input := range upserts {
if !input.AddPhonePrivacyException || input.ContactUserID == 0 {
@ -331,7 +315,45 @@ func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserI
if s == nil || s.contacts == nil || userID == 0 {
return 0, nil
}
return s.contacts.Delete(ctx, userID, contactUserIDs)
count, err := s.contacts.Delete(ctx, userID, contactUserIDs)
if err == nil {
ids := make([]int64, 0, len(contactUserIDs)+1)
ids = append(ids, userID)
ids = append(ids, contactUserIDs...)
s.InvalidateViewers(ids...)
}
return count, err
}
func (s *Service) EditCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) {
if s == nil || s.contacts == nil || userID == 0 || len(contactUserIDs) > maxCloseFriendsCount {
return domain.CloseFriendsEditResult{}, ErrContactIDInvalid
}
ids := normalizeCloseFriendIDs(userID, contactUserIDs)
if s.users != nil && len(ids) > 0 {
users, err := s.users.ByIDs(ctx, ids)
if err != nil {
return domain.CloseFriendsEditResult{}, err
}
exists := make(map[int64]struct{}, len(users))
for _, user := range users {
if user.ID != 0 && !user.Bot {
exists[user.ID] = struct{}{}
}
}
filtered := ids[:0]
for _, id := range ids {
if _, ok := exists[id]; ok {
filtered = append(filtered, id)
}
}
ids = filtered
}
result, err := s.contacts.SetCloseFriends(ctx, userID, ids)
if err == nil {
s.InvalidateViewers(userID)
}
return result, err
}
func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) {
@ -345,6 +367,7 @@ func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID i
if !found {
return domain.Contact{}, ErrContactIDInvalid
}
s.InvalidateViewers(userID)
return contact, nil
}
@ -359,6 +382,7 @@ func (s *Service) SetPersonalPhoto(ctx context.Context, userID, contactUserID in
if !found {
return domain.Contact{}, ErrContactReqMissing
}
s.InvalidateViewers(userID)
return s.projectContact(ctx, userID, contact)
}
@ -373,6 +397,7 @@ func (s *Service) ClearPersonalPhoto(ctx context.Context, userID, contactUserID
if !found {
return domain.Contact{}, ErrContactReqMissing
}
s.InvalidateViewers(userID)
return s.projectContact(ctx, userID, contact)
}
@ -422,7 +447,11 @@ func (s *Service) BlockContact(ctx context.Context, userID, peerUserID int64, da
return false, ErrContactIDInvalid
}
}
return s.contacts.Block(ctx, userID, peerUserID, date)
changed, err := s.contacts.Block(ctx, userID, peerUserID, date)
if err == nil {
s.InvalidateViewers(userID, peerUserID)
}
return changed, err
}
// UnblockContact removes peer from the current user's blocklist.
@ -430,7 +459,11 @@ func (s *Service) UnblockContact(ctx context.Context, userID, peerUserID int64)
if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 || peerUserID == userID {
return false, ErrContactIDInvalid
}
return s.contacts.Unblock(ctx, userID, peerUserID)
changed, err := s.contacts.Unblock(ctx, userID, peerUserID)
if err == nil {
s.InvalidateViewers(userID, peerUserID)
}
return changed, err
}
// IsBlocked reports whether owner has blocked peer.
@ -509,10 +542,10 @@ func (s *Service) projectSearchResult(ctx context.Context, userID int64, res dom
return res, nil
}
func normalizePhone(phone string) string {
if !utf8.ValidString(phone) {
return ""
}
// digitsOnly 只保留数字字符。保存进 contacts.contact_phone 的号码必须与 users.phone
// 一样是不带 "+" 的纯数字:下发时 contact_phone 优先充当 TL user.phone,而客户端展示
// user.phone 时会自行补 "+",任何非数字前缀都会变成 "++<号码>" 这类坏显示。
func digitsOnly(phone string) string {
var b strings.Builder
b.Grow(len(phone))
for _, r := range phone {
@ -520,8 +553,31 @@ func normalizePhone(phone string) string {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return phone
}
return b.String()
}
func normalizePhone(phone string) string {
if !utf8.ValidString(phone) {
return ""
}
if digits := digitsOnly(phone); digits != "" {
return digits
}
return phone
}
func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 || id == userID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}

View file

@ -3,12 +3,130 @@ package contacts
import (
"context"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type serviceCountingContactStore struct {
store.ContactStore
listCalls int
}
func (s *serviceCountingContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
s.listCalls++
return s.ContactStore.ListByUser(ctx, userID)
}
type serviceCountingUserStore struct {
store.UserStore
byIDsCalls int
}
func (s *serviceCountingUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
s.byIDsCalls++
return s.UserStore.ByIDs(ctx, ids)
}
type fakeReadModelVersions struct {
hash int64
found bool
}
func (f *fakeReadModelVersions) ReadModelHash(_ context.Context, _ string, _ int64, _ domain.PeerType, _ int64) (int64, bool, error) {
return f.hash, f.found, nil
}
func (f *fakeReadModelVersions) ReadModelHashes(ctx context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
out := make(map[store.ReadModelKey]int64, len(keys))
for _, key := range keys {
hash, found, err := f.ReadModelHash(ctx, key.Model, key.OwnerUserID, key.PeerType, key.PeerID)
if err != nil {
return nil, err
}
if found {
out[key] = hash
}
}
return out, nil
}
func TestGetContactsReturnsNotModifiedFromReadModelHashWithoutLoadingList(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
counting := &serviceCountingContactStore{ContactStore: base}
versions := &fakeReadModelVersions{hash: 99101, found: true}
svc := NewService(counting).Configure(WithReadModelVersions(versions))
list, notModified, err := svc.GetContacts(ctx, 1, versions.hash)
if err != nil {
t.Fatalf("GetContacts: %v", err)
}
if !notModified {
t.Fatalf("notModified = false, want true")
}
if list.Hash != versions.hash {
t.Fatalf("notModified list hash = %d, want %d", list.Hash, versions.hash)
}
if counting.listCalls != 0 {
t.Fatalf("ListByUser calls = %d, want 0 on hash hit", counting.listCalls)
}
}
func TestGetContactsCachesProjectedReadModelAndRejectsStaleHash(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
target, err := users.Create(ctx, domain.User{Phone: "101", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: target.ID, FirstName: "Saved"}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
counting := &serviceCountingContactStore{ContactStore: base}
countingUsers := &serviceCountingUserStore{UserStore: users}
versions := &fakeReadModelVersions{hash: 12345, found: true}
svc := NewService(counting, countingUsers).Configure(WithReadModelVersions(versions))
first, notModified, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil || notModified {
t.Fatalf("first GetContacts notModified=%v err=%v", notModified, err)
}
if first.Hash != versions.hash || len(first.Contacts) != 1 {
t.Fatalf("first result hash=%d contacts=%d, want hash %d and one contact", first.Hash, len(first.Contacts), versions.hash)
}
second, notModified, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil || notModified {
t.Fatalf("second GetContacts notModified=%v err=%v", notModified, err)
}
if second.Hash != versions.hash || counting.listCalls != 1 {
t.Fatalf("second result hash=%d listCalls=%d, want cached hash %d and one load", second.Hash, counting.listCalls, versions.hash)
}
versions.hash = 67890
third, notModified, err := svc.GetContacts(ctx, owner.ID, 12345)
if err != nil || notModified {
t.Fatalf("third GetContacts notModified=%v err=%v", notModified, err)
}
if third.Hash != versions.hash {
t.Fatalf("third hash = %d, want new read-model hash %d", third.Hash, versions.hash)
}
if counting.listCalls != 2 {
t.Fatalf("ListByUser calls after hash change = %d, want 2", counting.listCalls)
}
if countingUsers.byIDsCalls != 0 {
t.Fatalf("Users.ByIDs calls = %d, want 0; presence must stay out of contact read model", countingUsers.byIDsCalls)
}
}
func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -89,6 +207,73 @@ func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) {
}
}
func TestEditCloseFriendsReplacesOwnerContactFlags(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
bob, err := users.Create(ctx, domain.User{Phone: "101", FirstName: "Bob"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
carol, err := users.Create(ctx, domain.User{Phone: "102", FirstName: "Carol"})
if err != nil {
t.Fatalf("create carol: %v", err)
}
bot, err := users.Create(ctx, domain.User{Phone: "103", FirstName: "Bot", Bot: true, BotInfoVersion: 1})
if err != nil {
t.Fatalf("create bot: %v", err)
}
for _, user := range []domain.User{bob, carol, bot} {
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: user.ID, FirstName: user.FirstName}); err != nil {
t.Fatalf("upsert contact %d: %v", user.ID, err)
}
}
svc := NewService(contactsStore, users)
result, err := svc.EditCloseFriends(ctx, owner.ID, []int64{bob.ID, bob.ID, 0, owner.ID, bot.ID, 999999})
if err != nil {
t.Fatalf("EditCloseFriends first: %v", err)
}
if got, want := result.AddedUserIDs, []int64{bob.ID}; !reflect.DeepEqual(got, want) {
t.Fatalf("first added = %v, want %v", got, want)
}
if len(result.RemovedUserIDs) != 0 {
t.Fatalf("first removed = %v, want empty", result.RemovedUserIDs)
}
list, notModified, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil || notModified {
t.Fatalf("GetContacts after first edit notModified=%v err=%v", notModified, err)
}
if !contactByID(t, list, bob.ID).CloseFriend || !contactByID(t, list, bob.ID).User.CloseFriend {
t.Fatalf("bob close friend projection = %+v, want true", contactByID(t, list, bob.ID))
}
if contactByID(t, list, carol.ID).CloseFriend || contactByID(t, list, bot.ID).CloseFriend {
t.Fatalf("carol/bot close friend flags = %+v / %+v, want false", contactByID(t, list, carol.ID), contactByID(t, list, bot.ID))
}
result, err = svc.EditCloseFriends(ctx, owner.ID, []int64{carol.ID})
if err != nil {
t.Fatalf("EditCloseFriends replace: %v", err)
}
if got, want := result.AddedUserIDs, []int64{carol.ID}; !reflect.DeepEqual(got, want) {
t.Fatalf("replace added = %v, want %v", got, want)
}
if got, want := result.RemovedUserIDs, []int64{bob.ID}; !reflect.DeepEqual(got, want) {
t.Fatalf("replace removed = %v, want %v", got, want)
}
replaced, _, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil {
t.Fatalf("GetContacts after replace: %v", err)
}
if contactByID(t, replaced, bob.ID).CloseFriend || !contactByID(t, replaced, carol.ID).CloseFriend {
t.Fatalf("replace flags bob=%+v carol=%+v, want bob false carol true", contactByID(t, replaced, bob.ID), contactByID(t, replaced, carol.ID))
}
}
func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -157,6 +342,55 @@ func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) {
}
}
func TestAddContactNormalizesPhoneToDigits(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
contactsStore := memory.NewContactStore()
alice, err := users.Create(ctx, domain.User{Phone: "15550060301", FirstName: "Alice", LastName: "A"})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := users.Create(ctx, domain.User{Phone: "15550060302", FirstName: "Bob", LastName: "B"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
svc := NewService(contactsStore, users)
contact, err := svc.AddContact(ctx, alice.ID, domain.ContactInput{
ContactUserID: bob.ID,
Phone: "+1 555-006-0302",
FirstName: "Bob B",
})
if err != nil {
t.Fatalf("AddContact: %v", err)
}
if contact.Phone != "15550060302" {
t.Fatalf("contact phone = %q, want digits-only 15550060302", contact.Phone)
}
if contact.User.Phone != "15550060302" {
t.Fatalf("projected user phone = %q, want digits-only 15550060302", contact.User.Phone)
}
stored, found, err := contactsStore.Get(ctx, alice.ID, bob.ID)
if err != nil || !found {
t.Fatalf("stored contact found=%v err=%v", found, err)
}
if stored.Phone != "15550060302" {
t.Fatalf("stored contact phone = %q, want digits-only 15550060302", stored.Phone)
}
emptied, err := svc.AddContact(ctx, alice.ID, domain.ContactInput{
ContactUserID: bob.ID,
Phone: "+",
FirstName: "Bob B",
})
if err != nil {
t.Fatalf("AddContact digitless phone: %v", err)
}
if emptied.Phone != bob.Phone {
t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone)
}
}
func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
@ -176,6 +410,17 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
}
}
func contactByID(t *testing.T, list domain.ContactList, id int64) domain.Contact {
t.Helper()
for _, contact := range list.Contacts {
if contact.User.ID == id {
return contact
}
}
t.Fatalf("contact %d not found in %+v", id, list.Contacts)
return domain.Contact{}
}
type contactProfilePhotos map[int64]domain.ProfilePhotoRef
func (p contactProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {