chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
136
internal/app/contacts/read_model_cache.go
Normal file
136
internal/app/contacts/read_model_cache.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue