merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -3,6 +3,7 @@ package users
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
|
@ -13,7 +14,14 @@ import (
|
|||
)
|
||||
|
||||
// ErrNotAuthorized 表示当前 auth_key 尚未登录。
|
||||
var ErrNotAuthorized = errors.New("not authorized")
|
||||
var (
|
||||
ErrNotAuthorized = errors.New("not authorized")
|
||||
ErrSystemUserImmutable = errors.New("system user identity is immutable")
|
||||
ErrBatchUsersLimit = errors.New("batch users limit exceeded")
|
||||
ErrBatchViewerCells = errors.New("batch viewer projection cell limit exceeded")
|
||||
ErrBatchUserMissing = errors.New("batch user projection source is incomplete")
|
||||
ErrLastSeenBatchUnsupported = errors.New("last seen batch store unsupported")
|
||||
)
|
||||
|
||||
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。
|
||||
type ProfilePhotoProvider = userprojection.ProfilePhotoProvider
|
||||
|
|
@ -93,6 +101,9 @@ const (
|
|||
maxProfileAboutRunes = 70
|
||||
maxProfileAboutRunesPremium = 140
|
||||
maxBatchUsers = 1000
|
||||
// A dense fan-out materializes one complete domain.User per viewer/owner
|
||||
// cell in both the result and the batch cache. Bound the retained graph.
|
||||
maxBatchViewerProjectionCells = 131072
|
||||
)
|
||||
|
||||
// NewService 创建用户服务。
|
||||
|
|
@ -161,6 +172,19 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
|
|||
return s.loadBaseUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// BotStatus returns only the immutable viewer-independent bot fact. Presence
|
||||
// classification must not pay for contact/privacy/photo projection.
|
||||
func (s *Service) BotStatus(ctx context.Context, userID int64) (bool, bool, error) {
|
||||
if userID == 0 {
|
||||
return false, false, nil
|
||||
}
|
||||
u, found, err := s.loadBaseUserByID(ctx, userID)
|
||||
if err != nil || !found {
|
||||
return false, found, err
|
||||
}
|
||||
return u.Bot, true, nil
|
||||
}
|
||||
|
||||
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
|
||||
// shared base-user read model. Privacy uses this as a batched cold loader behind
|
||||
// its bounded process cache; no viewer projection is performed, avoiding a
|
||||
|
|
@ -186,11 +210,11 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
|
|||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
if len(ids) >= maxBatchUsers {
|
||||
return nil, fmt.Errorf("%w: more than %d unique owners", ErrBatchUsersLimit, maxBatchUsers)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) >= maxBatchUsers {
|
||||
break
|
||||
}
|
||||
}
|
||||
users, err := s.loadBaseUsersByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
|
|
@ -200,22 +224,68 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
|
|||
}
|
||||
|
||||
// ByIDsForViewers 跨多个 viewer 批量投影同一组 user(fan-out 模板化):base user 只加载一次,
|
||||
// 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User,
|
||||
// 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**(ForViewers v1
|
||||
// 跳过,客户端下次 getChannelDifference/getHistory 自愈)。供 channel fan-out 预热每 viewer 投影,
|
||||
// 隐私/改名/头像投影经 userprojection.ForViewers 收敛成批量查询。返回 map[viewerID][]User,
|
||||
// 每个切片与 ByIDs(viewer, ids) 字节等价,包含 viewer-specific personal photo overlay。
|
||||
// 供 channel fan-out 预热每 viewer 投影,
|
||||
// 把 per-recipient 的 ByIDs(=ForViewer) 折叠成一次跨 viewer 投影。不做 ByIDs 的单 caller 鉴权
|
||||
// (viewer 是 fan-out 收件人集合,非 RPC 调用方)。
|
||||
func (s *Service) ByIDsForViewers(ctx context.Context, viewerUserIDs []int64, userIDs []int64) (map[int64][]domain.User, error) {
|
||||
if len(viewerUserIDs) == 0 || len(userIDs) == 0 {
|
||||
return map[int64][]domain.User{}, nil
|
||||
}
|
||||
ids := uniqueUserIDs(userIDs, maxBatchUsers)
|
||||
ids := uniqueUserIDs(userIDs, 0)
|
||||
if len(ids) > maxBatchUsers {
|
||||
return nil, fmt.Errorf("%w: got %d unique owners, maximum %d", ErrBatchUsersLimit, len(ids), maxBatchUsers)
|
||||
}
|
||||
viewers := uniqueUserIDs(viewerUserIDs, 0)
|
||||
if !batchViewerProjectionCellsAllowed(len(viewers), len(ids)) {
|
||||
return nil, fmt.Errorf("%w: got %d viewers x %d owners, maximum %d cells", ErrBatchViewerCells, len(viewers), len(ids), maxBatchViewerProjectionCells)
|
||||
}
|
||||
base, err := s.loadBaseUsersByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err = requireBatchBaseUsers(ids, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// projector 为 nil 时 ForViewers 返回各 viewer 的原始 base 副本(与 projectUsers 的 nil 分支一致)。
|
||||
return s.projector.ForViewers(ctx, viewerUserIDs, base)
|
||||
return s.projector.ForViewers(ctx, viewers, base)
|
||||
}
|
||||
|
||||
func batchViewerProjectionCellsAllowed(viewers, owners int) bool {
|
||||
if viewers <= 0 || owners <= 0 {
|
||||
return true
|
||||
}
|
||||
// Division avoids overflow from viewers*owners on hostile inputs.
|
||||
return viewers <= maxBatchViewerProjectionCells/owners
|
||||
}
|
||||
|
||||
// requireBatchBaseUsers turns the fan-out projection API into a complete
|
||||
// envelope contract. Deleted users remain durable tombstones and therefore
|
||||
// still appear in base; a truly missing referenced user must fail closed rather
|
||||
// than produce a message whose sender cannot be resolved. System users are
|
||||
// protocol-local constants and do not require a backing users row.
|
||||
func requireBatchBaseUsers(ids []int64, base []domain.User) ([]domain.User, error) {
|
||||
byID := make(map[int64]domain.User, len(base))
|
||||
for _, user := range base {
|
||||
if user.ID != 0 {
|
||||
byID[user.ID] = user
|
||||
}
|
||||
}
|
||||
out := make([]domain.User, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if user, ok := byID[id]; ok {
|
||||
out = append(out, user)
|
||||
continue
|
||||
}
|
||||
if system, ok := domain.SystemUserByID(id); ok {
|
||||
out = append(out, system)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("%w: user_id=%d", ErrBatchUserMissing, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckUsername 校验当前用户是否可以占用 username。
|
||||
|
|
@ -277,35 +347,6 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
|
|||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// SetPhone force-sets a user's phone number (admin use -- no code
|
||||
// verification, unlike the user-facing verified change-phone flow in
|
||||
// internal/app/account). Pre-checks availability via ByPhone before writing,
|
||||
// on top of the store's own unique-constraint backstop.
|
||||
func (s *Service) SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
phone = domain.NormalizePhone(strings.TrimSpace(phone))
|
||||
if !domain.ValidPhone(phone) {
|
||||
return domain.User{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
if phone == self.Phone {
|
||||
return s.projectOne(ctx, self.ID, self)
|
||||
}
|
||||
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
|
||||
return domain.User{}, err
|
||||
} else if found && existing.ID != self.ID {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
u, err := s.users.UpdatePhone(ctx, self.ID, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, u)
|
||||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
|
|
@ -345,6 +386,37 @@ func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain
|
|||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// SetPhone force-sets the authoritative phone for the trusted admin path. It
|
||||
// remains a non-PTS profile mutation because updateUserPhone and updateUser
|
||||
// carry no pts/pts_count in every admitted exact layer.
|
||||
func (s *Service) SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if self.Bot || domain.IsSystemUserID(self.ID) {
|
||||
return domain.User{}, domain.ErrPhoneChangeForbidden
|
||||
}
|
||||
phone = domain.NormalizePhone(strings.TrimSpace(phone))
|
||||
if !domain.ValidPhone(phone) {
|
||||
return domain.User{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
if phone == self.Phone {
|
||||
return s.projectOne(ctx, self.ID, self)
|
||||
}
|
||||
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
|
||||
return domain.User{}, err
|
||||
} else if found && existing.ID != self.ID {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
u, err := s.users.UpdatePhone(ctx, self.ID, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, u)
|
||||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// UpdateLastSeen records the latest visible account activity time.
|
||||
func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error {
|
||||
if userID == 0 {
|
||||
|
|
@ -360,6 +432,45 @@ func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt i
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateLastSeenBatch is the production lifecycle-presence write boundary. It
|
||||
// requires a real batch-capable store: silently looping over UpdateLastSeen
|
||||
// would recreate the exact per-account transaction fan-out this API exists to
|
||||
// remove. The cache delete is part of batch completion; callers may retry the
|
||||
// whole idempotent batch when Redis is temporarily unavailable.
|
||||
func (s *Service) UpdateLastSeenBatch(ctx context.Context, updates []store.UserLastSeenUpdate) error {
|
||||
batch, ok := s.users.(store.UserLastSeenBatchStore)
|
||||
if !ok {
|
||||
return ErrLastSeenBatchUnsupported
|
||||
}
|
||||
latest := make(map[int64]int, len(updates))
|
||||
for _, update := range updates {
|
||||
if update.UserID == 0 || update.LastSeenAt <= 0 {
|
||||
continue
|
||||
}
|
||||
if current := latest[update.UserID]; update.LastSeenAt > current {
|
||||
latest[update.UserID] = update.LastSeenAt
|
||||
}
|
||||
}
|
||||
if len(latest) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make([]store.UserLastSeenUpdate, 0, len(latest))
|
||||
userIDs := make([]int64, 0, len(latest))
|
||||
for userID, lastSeenAt := range latest {
|
||||
merged = append(merged, store.UserLastSeenUpdate{UserID: userID, LastSeenAt: lastSeenAt})
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
if err := batch.UpdateLastSeenBatch(ctx, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Delete(ctx, userIDs); err != nil {
|
||||
return fmt.Errorf("invalidate last seen batch user cache: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PremiumActive 报告用户当前是否有效会员。走基础用户缓存路径、不做 viewer
|
||||
// 投影,供限额双档判断(pin 上限、reaction 上限、bio 长度等)低成本调用。
|
||||
func (s *Service) PremiumActive(ctx context.Context, userID int64) bool {
|
||||
|
|
@ -412,6 +523,9 @@ func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool)
|
|||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
if domain.IsSystemUserID(userID) && !verified {
|
||||
return domain.User{}, ErrSystemUserImmutable
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
|
|
@ -630,6 +744,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
return domain.User{}, false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
_, reservedSystemUsername := domain.SystemUserByUsername(username)
|
||||
// Resolution covers both the editable username slot (5..32) and
|
||||
// Fragment-style collectible usernames (4..32). Keep the stricter
|
||||
// validUsername check on create/update paths; only lookup accepts the
|
||||
|
|
@ -638,8 +753,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
// server-controlled handles, not user input -- but still resolve through
|
||||
// the normal DB-backed path below, so caching/projection/hidden-bot
|
||||
// handling stay exactly as for any other account.
|
||||
_, isSystemUsername := domain.SystemUserByUsername(username)
|
||||
if !isSystemUsername && !domain.ValidCollectibleUsername(username) {
|
||||
if !reservedSystemUsername && !domain.ValidCollectibleUsername(username) {
|
||||
return domain.User{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
|
|
@ -664,7 +778,7 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
|||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
phone = normalizePhone(phone)
|
||||
phone = domain.NormalizePhone(phone)
|
||||
if phone == "" {
|
||||
return domain.User{}, false, domain.ErrPhoneNotOccupied
|
||||
}
|
||||
|
|
@ -721,7 +835,10 @@ func (s *Service) loadBaseUserByID(ctx context.Context, userID int64) (domain.Us
|
|||
}
|
||||
|
||||
func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
ids := uniqueUserIDs(userIDs, maxBatchUsers)
|
||||
ids := uniqueUserIDs(userIDs, maxBatchUsers+1)
|
||||
if len(ids) > maxBatchUsers {
|
||||
return nil, fmt.Errorf("%w: more than %d unique owners", ErrBatchUsersLimit, maxBatchUsers)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -799,6 +916,15 @@ func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) {
|
|||
_ = s.cache.Delete(ctx, userIDs)
|
||||
}
|
||||
|
||||
// InvalidateUsers drops viewer-independent user snapshots after an aggregate
|
||||
// transaction updates users without passing through this service.
|
||||
func (s *Service) InvalidateUsers(ctx context.Context, userIDs ...int64) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.dropCachedUsers(ctx, userIDs...)
|
||||
}
|
||||
|
||||
func uniqueUserIDs(ids []int64, limit int) []int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
|
|
@ -857,18 +983,3 @@ func validUsername(username string) bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if phone == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
58
internal/app/users/service_sparse.go
Normal file
58
internal/app/users/service_sparse.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ByIDsForViewerUserIDs projects an actual sparse viewer->owner graph. Base
|
||||
// users are loaded once for the union; unlike ByIDsForViewers, owners belonging
|
||||
// to one viewer are never implicitly projected for every other viewer.
|
||||
func (s *Service) ByIDsForViewerUserIDs(ctx context.Context, userIDsByViewer map[int64][]int64) (map[int64][]domain.User, error) {
|
||||
requested := make(map[int64][]int64, len(userIDsByViewer))
|
||||
union := make([]int64, 0)
|
||||
seenUnion := make(map[int64]struct{})
|
||||
pairs := 0
|
||||
for viewerID, userIDs := range userIDsByViewer {
|
||||
if viewerID == 0 {
|
||||
continue
|
||||
}
|
||||
ids := uniqueUserIDs(userIDs, 0)
|
||||
if !sparseViewerProjectionPairsAllowed(pairs, len(ids)) {
|
||||
return nil, fmt.Errorf("%w: got more than %d sparse pairs", ErrBatchViewerCells, maxBatchViewerProjectionCells)
|
||||
}
|
||||
pairs += len(ids)
|
||||
requested[viewerID] = ids
|
||||
for _, id := range ids {
|
||||
if _, ok := seenUnion[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenUnion[id] = struct{}{}
|
||||
union = append(union, id)
|
||||
if len(union) > maxBatchUsers {
|
||||
return nil, ErrBatchUsersLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(requested) == 0 || len(union) == 0 {
|
||||
return map[int64][]domain.User{}, nil
|
||||
}
|
||||
base, err := s.loadBaseUsersByIDs(ctx, union)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err = requireBatchBaseUsers(union, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.projector.ForViewerUserIDs(ctx, requested, base)
|
||||
}
|
||||
|
||||
func sparseViewerProjectionPairsAllowed(current, additional int) bool {
|
||||
if current < 0 || additional < 0 || current > maxBatchViewerProjectionCells {
|
||||
return false
|
||||
}
|
||||
return additional <= maxBatchViewerProjectionCells-current
|
||||
}
|
||||
138
internal/app/users/service_sparse_test.go
Normal file
138
internal/app/users/service_sparse_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type countingSparseBaseUserStore struct {
|
||||
store.UserStore
|
||||
byIDsCalls int
|
||||
byIDs []int64
|
||||
}
|
||||
|
||||
func (s *countingSparseBaseUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
|
||||
s.byIDsCalls++
|
||||
s.byIDs = append([]int64(nil), ids...)
|
||||
return s.UserStore.ByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
type countingSparsePhotoProvider struct {
|
||||
profile map[int64]domain.ProfilePhotoRef
|
||||
profileCalls int
|
||||
fallbackCalls int
|
||||
}
|
||||
|
||||
func (p *countingSparsePhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(ctx, ownerType, ids, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
func (p *countingSparsePhotoProvider) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
if kind == domain.ProfilePhotoKindFallback {
|
||||
p.fallbackCalls++
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
}
|
||||
p.profileCalls++
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
|
||||
for _, id := range ids {
|
||||
if ref, ok := p.profile[id]; ok {
|
||||
out[id] = ref
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestByIDsForViewerUserIDsLoadsUnionOnceAndPreservesViewerSemantics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
viewerA, _ := base.Create(ctx, domain.User{Phone: "15550001", FirstName: "Viewer A"})
|
||||
viewerB, _ := base.Create(ctx, domain.User{Phone: "15550002", FirstName: "Viewer B"})
|
||||
ownerA, _ := base.Create(ctx, domain.User{Phone: "15550101", FirstName: "Owner A"})
|
||||
ownerB, _ := base.Create(ctx, domain.User{Phone: "15550102", FirstName: "Owner B"})
|
||||
contacts := memory.NewContactStore()
|
||||
if _, err := contacts.Upsert(ctx, viewerA.ID, domain.ContactInput{ContactUserID: ownerA.ID, FirstName: "Alias A", Phone: "local-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := contacts.Upsert(ctx, viewerB.ID, domain.ContactInput{ContactUserID: ownerB.ID, FirstName: "Alias B"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := contacts.SetPersonalPhoto(ctx, viewerA.ID, ownerA.ID, 9901, 1); err != nil || !found {
|
||||
t.Fatalf("SetPersonalPhoto: found=%v err=%v", found, err)
|
||||
}
|
||||
rules := memory.NewPrivacyStore()
|
||||
privacy := privacyapp.NewService(rules, contacts)
|
||||
if _, err := privacy.SetRules(ctx, ownerA.ID, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := privacy.SetRules(ctx, ownerB.ID, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
countingUsers := &countingSparseBaseUserStore{UserStore: base}
|
||||
photos := &countingSparsePhotoProvider{profile: map[int64]domain.ProfilePhotoRef{
|
||||
viewerA.ID: {PhotoID: 9801, DCID: 2}, viewerB.ID: {PhotoID: 9802, DCID: 2},
|
||||
ownerA.ID: {PhotoID: 9811, DCID: 2}, ownerB.ID: {PhotoID: 9812, DCID: 2},
|
||||
}}
|
||||
svc := NewService(countingUsers, WithContactStore(contacts), WithPrivacyEvaluator(privacy), WithPhotoProvider(photos))
|
||||
got, err := svc.ByIDsForViewerUserIDs(ctx, map[int64][]int64{
|
||||
viewerA.ID: {ownerA.ID, viewerA.ID},
|
||||
viewerB.ID: {ownerB.ID, viewerB.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ByIDsForViewerUserIDs: %v", err)
|
||||
}
|
||||
if countingUsers.byIDsCalls != 1 {
|
||||
t.Fatalf("base ByIDs calls = %d, want one union load", countingUsers.byIDsCalls)
|
||||
}
|
||||
sort.Slice(countingUsers.byIDs, func(i, j int) bool { return countingUsers.byIDs[i] < countingUsers.byIDs[j] })
|
||||
wantIDs := []int64{viewerA.ID, viewerB.ID, ownerA.ID, ownerB.ID}
|
||||
sort.Slice(wantIDs, func(i, j int) bool { return wantIDs[i] < wantIDs[j] })
|
||||
if len(countingUsers.byIDs) != len(wantIDs) {
|
||||
t.Fatalf("base ids = %v, want %v", countingUsers.byIDs, wantIDs)
|
||||
}
|
||||
for i := range wantIDs {
|
||||
if countingUsers.byIDs[i] != wantIDs[i] {
|
||||
t.Fatalf("base ids = %v, want %v", countingUsers.byIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
if photos.profileCalls != 1 || photos.fallbackCalls != 1 {
|
||||
t.Fatalf("photo reads = profile %d fallback %d, want one each", photos.profileCalls, photos.fallbackCalls)
|
||||
}
|
||||
a := got[viewerA.ID][0]
|
||||
if a.ID != ownerA.ID || a.FirstName != "Alias A" || a.Phone != "local-a" || a.PhotoID != 9901 || !a.PhotoPersonal {
|
||||
t.Fatalf("viewer A owner projection = %+v", a)
|
||||
}
|
||||
selfA := got[viewerA.ID][1]
|
||||
if selfA.ID != viewerA.ID || selfA.FirstName != "Viewer A" || selfA.Phone != "15550001" || selfA.PhotoID != 9801 {
|
||||
t.Fatalf("viewer A self projection = %+v", selfA)
|
||||
}
|
||||
b := got[viewerB.ID][0]
|
||||
if b.ID != ownerB.ID || b.FirstName != "Alias B" || b.Phone != "15550102" || b.PhotoID != 9812 || b.PhotoPersonal {
|
||||
t.Fatalf("viewer B owner projection = %+v", b)
|
||||
}
|
||||
selfB := got[viewerB.ID][1]
|
||||
if selfB.ID != viewerB.ID || selfB.FirstName != "Viewer B" || selfB.Phone != "15550002" || selfB.PhotoID != 9802 {
|
||||
t.Fatalf("viewer B self projection = %+v", selfB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewerUserIDsRejectsMissingReferencedUserAndPairOverflow(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
if _, err := svc.ByIDsForViewerUserIDs(context.Background(), map[int64][]int64{
|
||||
1001: {2001},
|
||||
}); !errors.Is(err, ErrBatchUserMissing) {
|
||||
t.Fatalf("missing referenced user err = %v, want ErrBatchUserMissing", err)
|
||||
}
|
||||
if !sparseViewerProjectionPairsAllowed(maxBatchViewerProjectionCells-1, 1) {
|
||||
t.Fatal("sparse pair admission rejected the exact boundary")
|
||||
}
|
||||
if sparseViewerProjectionPairsAllowed(maxBatchViewerProjectionCells, 1) {
|
||||
t.Fatal("sparse pair admission accepted a batch above the boundary")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -160,6 +161,84 @@ func TestResolveUsernameHidesMarksbotWhenThirdPartyVerificationHidden(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewersRejectsOwnerSetAboveBound(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
ids := make([]int64, maxBatchUsers+1)
|
||||
for i := range ids {
|
||||
ids[i] = int64(i + 1)
|
||||
}
|
||||
if _, err := svc.ByIDsForViewers(context.Background(), []int64{1}, ids); !errors.Is(err, ErrBatchUsersLimit) {
|
||||
t.Fatalf("ByIDsForViewers err = %v, want ErrBatchUsersLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsRejectsOwnerSetAboveBoundInsteadOfTruncating(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
ids := make([]int64, maxBatchUsers+1)
|
||||
for i := range ids {
|
||||
ids[i] = int64(i + 1)
|
||||
}
|
||||
if _, err := svc.ByIDs(context.Background(), 1, ids); !errors.Is(err, ErrBatchUsersLimit) {
|
||||
t.Fatalf("ByIDs err = %v, want ErrBatchUsersLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotStatusReadsViewerIndependentBaseFact(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
bot, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000077", FirstName: "Bot", Bot: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(base)
|
||||
got, found, err := svc.BotStatus(ctx, bot.ID)
|
||||
if err != nil || !found || !got {
|
||||
t.Fatalf("BotStatus = %v, found=%v, err=%v", got, found, err)
|
||||
}
|
||||
if got, found, err := svc.BotStatus(ctx, bot.ID+1000); err != nil || found || got {
|
||||
t.Fatalf("missing BotStatus = %v, found=%v, err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivacyBaseUsersRejectsViewerSetAboveBoundInsteadOfNegativeCachingTruncation(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
ids := make([]int64, maxBatchUsers+1)
|
||||
for i := range ids {
|
||||
ids[i] = int64(i + 1)
|
||||
}
|
||||
if _, err := svc.PrivacyBaseUsers(context.Background(), ids); !errors.Is(err, ErrBatchUsersLimit) {
|
||||
t.Fatalf("PrivacyBaseUsers err = %v, want ErrBatchUsersLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewersRejectsDenseCellSetAboveBound(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
owners := make([]int64, maxBatchUsers)
|
||||
for i := range owners {
|
||||
owners[i] = int64(i + 1)
|
||||
}
|
||||
viewers := make([]int64, maxBatchViewerProjectionCells/maxBatchUsers+1)
|
||||
for i := range viewers {
|
||||
viewers[i] = int64(10_000 + i)
|
||||
}
|
||||
if _, err := svc.ByIDsForViewers(context.Background(), viewers, owners); !errors.Is(err, ErrBatchViewerCells) {
|
||||
t.Fatalf("ByIDsForViewers err = %v, want ErrBatchViewerCells", err)
|
||||
}
|
||||
if !batchViewerProjectionCellsAllowed(1, maxBatchViewerProjectionCells) {
|
||||
t.Fatal("cell limit rejected exact boundary")
|
||||
}
|
||||
if batchViewerProjectionCellsAllowed(2, maxBatchViewerProjectionCells) {
|
||||
t.Fatal("cell limit accepted overflow boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewersRejectsMissingReferencedUser(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
if _, err := svc.ByIDsForViewers(context.Background(), []int64{1001}, []int64{2001}); !errors.Is(err, ErrBatchUserMissing) {
|
||||
t.Fatalf("ByIDsForViewers err = %v, want ErrBatchUserMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePhoneHonorsAddedByPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -434,6 +513,48 @@ func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateLastSeenBatchIsMonotonicAndInvalidatesOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
first, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000881", FirstName: "First"})
|
||||
if err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
second, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000882", FirstName: "Second"})
|
||||
if err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
cache := newMemoryBaseUserCache()
|
||||
if err := cache.PutMany(ctx, []domain.User{first, second}); err != nil {
|
||||
t.Fatalf("prime cache: %v", err)
|
||||
}
|
||||
svc := NewService(base, WithBaseUserCache(cache))
|
||||
if err := svc.UpdateLastSeenBatch(ctx, []store.UserLastSeenUpdate{
|
||||
{UserID: second.ID, LastSeenAt: 20},
|
||||
{UserID: first.ID, LastSeenAt: 9},
|
||||
{UserID: first.ID, LastSeenAt: 17},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateLastSeenBatch: %v", err)
|
||||
}
|
||||
loadedFirst, found, err := base.ByID(ctx, first.ID)
|
||||
if err != nil || !found || loadedFirst.LastSeenAt != 17 {
|
||||
t.Fatalf("first last seen = %d found=%v err=%v, want 17", loadedFirst.LastSeenAt, found, err)
|
||||
}
|
||||
loadedSecond, found, err := base.ByID(ctx, second.ID)
|
||||
if err != nil || !found || loadedSecond.LastSeenAt != 20 {
|
||||
t.Fatalf("second last seen = %d found=%v err=%v, want 20", loadedSecond.LastSeenAt, found, err)
|
||||
}
|
||||
if cache.deleteCalls != 1 {
|
||||
t.Fatalf("cache delete calls = %d, want one batch invalidation", cache.deleteCalls)
|
||||
}
|
||||
if _, ok := cache.users[first.ID]; ok {
|
||||
t.Fatal("first user remained cached")
|
||||
}
|
||||
if _, ok := cache.users[second.ID]; ok {
|
||||
t.Fatal("second user remained cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
|
|
@ -507,6 +628,9 @@ func TestServiceSetVerifiedRefreshesBaseCache(t *testing.T) {
|
|||
if cleared.Verified {
|
||||
t.Fatalf("cleared verified = true, want false")
|
||||
}
|
||||
if _, err := svc.SetVerified(ctx, domain.OfficialSystemUserID, false); !errors.Is(err, ErrSystemUserImmutable) {
|
||||
t.Fatalf("clear system user verified err=%v, want ErrSystemUserImmutable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRefreshesBaseCacheAfterColorUpdate(t *testing.T) {
|
||||
|
|
@ -612,7 +736,8 @@ func (s *countingUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.Us
|
|||
}
|
||||
|
||||
type memoryBaseUserCache struct {
|
||||
users map[int64]domain.User
|
||||
users map[int64]domain.User
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func newMemoryBaseUserCache() *memoryBaseUserCache {
|
||||
|
|
@ -639,6 +764,7 @@ func (c *memoryBaseUserCache) PutMany(_ context.Context, users []domain.User) er
|
|||
}
|
||||
|
||||
func (c *memoryBaseUserCache) Delete(_ context.Context, ids []int64) error {
|
||||
c.deleteCalls++
|
||||
for _, id := range ids {
|
||||
delete(c.users, id)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue