package users import ( "context" "errors" "strings" "testing" privacyapp "telesrv/internal/app/privacy" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/store/memory" ) func TestServiceUsernameLifecycle(t *testing.T) { ctx := context.Background() store := memory.NewUserStore() owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } other, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"}) if err != nil { t.Fatalf("create other: %v", err) } svc := NewService(store) if ok, err := svc.CheckUsername(ctx, owner.ID, "123bad"); err == nil || ok || !errors.Is(err, domain.ErrUsernameInvalid) { t.Fatalf("CheckUsername invalid = ok %v err %v, want username invalid", ok, err) } if ok, err := svc.CheckUsername(ctx, owner.ID, "taken_name"); err != nil || ok { t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err) } if ok, err := svc.CheckUsername(ctx, owner.ID, "owner_name"); err != nil || !ok { t.Fatalf("CheckUsername available = ok %v err %v, want true/nil", ok, err) } updated, err := svc.UpdateUsername(ctx, owner.ID, "@Owner_Name") if err != nil { t.Fatalf("UpdateUsername: %v", err) } if updated.Username != "Owner_Name" { t.Fatalf("updated username = %q, want Owner_Name", updated.Username) } resolved, found, err := svc.ResolveUsername(ctx, other.ID, "owner_name") if err != nil || !found || resolved.ID != owner.ID { t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err) } registry := memory.NewCollectibleUsernameStore() store.AttachUsernameRegistry(registry) peer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} if _, err := registry.SetEditableUsername(ctx, peer, updated.Username); err != nil { t.Fatalf("seed editable username registry: %v", err) } if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{ Username: "nft4", Owner: peer, Currency: domain.CollectibleCurrencyStars, Amount: 1, Actor: "test", }); err != nil || !created { t.Fatalf("mint four-character collectible: created=%v err=%v", created, err) } resolved, found, err = svc.ResolveUsername(ctx, other.ID, "@NFT4") if err != nil || !found || resolved.ID != owner.ID { t.Fatalf("ResolveUsername collectible = user %+v found %v err %v, want owner", resolved, found, err) } if _, err := svc.UpdateUsername(ctx, owner.ID, "nft4"); !errors.Is(err, domain.ErrUsernameInvalid) { t.Fatalf("four-character editable username err = %v, want username invalid", err) } if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed { t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err) } if _, found, err := svc.ResolveUsername(ctx, other.ID, "nft4"); err != nil || found { t.Fatalf("inactive collectible found=%v err=%v, want hidden", found, err) } if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) { t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err) } phoneUser, found, err := svc.ResolvePhone(ctx, owner.ID, "+1 (555) 000-0002") if err != nil || !found || phoneUser.ID != other.ID { t.Fatalf("ResolvePhone = user %+v found %v err %v, want other", phoneUser, found, err) } cleared, err := svc.UpdateUsername(ctx, owner.ID, "") if err != nil { t.Fatalf("clear username: %v", err) } if cleared.Username != "" { t.Fatalf("cleared username = %q, want empty", cleared.Username) } } // TestServiceUsernameReservedBlocksNewClaimsButKeepsExisting locks in the // grandfather behavior config.ReservedUsernames needs: adding a word to the // list (or turning the feature on after channels/accounts already own a // matching username) must never break an account that already has it -- // only a genuinely new claim of a reserved word is refused. func TestServiceUsernameReservedBlocksNewClaimsButKeepsExisting(t *testing.T) { ctx := context.Background() store := memory.NewUserStore() grandfathered, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000003", FirstName: "Old", Username: "admin"}) if err != nil { t.Fatalf("create grandfathered: %v", err) } newcomer, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000004", FirstName: "New"}) if err != nil { t.Fatalf("create newcomer: %v", err) } svc := NewService(store, WithReservedUsernames([]string{"admin"})) // Re-submitting the exact same (grandfathered) reserved username -- the // shape a client resubmitting an unmodified field sends, "@" prefix and // all -- must be a no-op, not a rejection. if u, err := svc.UpdateUsername(ctx, grandfathered.ID, "@admin"); err != nil || u.Username != "admin" { t.Fatalf("re-submit grandfathered username = user %+v err %v, want no-op keeping %q", u, err, "admin") } // A different account claiming the same reserved word for the first time // must still be refused. if _, err := svc.UpdateUsername(ctx, newcomer.ID, "admin"); !errors.Is(err, domain.ErrUsernameInvalid) { t.Fatalf("new claim of reserved username err = %v, want username invalid", err) } // The grandfathered account moving to a *different* reserved word is a // genuine new claim too, and must be refused the same way. svc2 := NewService(store, WithReservedUsernames([]string{"admin", "support"})) if _, err := svc2.UpdateUsername(ctx, grandfathered.ID, "support"); !errors.Is(err, domain.ErrUsernameInvalid) { t.Fatalf("grandfathered account claiming a different reserved word err = %v, want username invalid", err) } } // marksbotOverrideStore wraps memory.UserStore to serve domain.VerifierBotUser() // for a fixed username lookup, since memory.UserStore.Create always assigns an // id from its own auto-increment sequence and can never produce the fixed // domain.VerifierBotUserID a real deployment seeds it under. type marksbotOverrideStore struct { *memory.UserStore } func (s *marksbotOverrideStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) { if strings.EqualFold(username, "marksbot") { return domain.VerifierBotUser(), true, nil } return s.UserStore.ByUsername(ctx, username) } func TestResolveUsernameHidesMarksbotWhenThirdPartyVerificationHidden(t *testing.T) { ctx := context.Background() store := &marksbotOverrideStore{UserStore: memory.NewUserStore()} viewer, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550002001", FirstName: "Viewer"}) if err != nil { t.Fatalf("create viewer: %v", err) } visible := NewService(store, WithHideThirdPartyVerification(false)) if u, found, err := visible.ResolveUsername(ctx, viewer.ID, "marksbot"); err != nil || !found || u.ID != domain.VerifierBotUserID { t.Fatalf("ResolveUsername (visible) = user %+v found %v err %v, want @marksbot", u, found, err) } hidden := NewService(store, WithHideThirdPartyVerification(true)) if _, found, err := hidden.ResolveUsername(ctx, viewer.ID, "marksbot"); err != nil || found { t.Fatalf("ResolveUsername (hidden) found=%v err=%v, want not found", found, err) } } 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() contacts := memory.NewContactStore() viewer, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001001", FirstName: "Viewer"}) if err != nil { t.Fatalf("create viewer: %v", err) } target, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550001002", FirstName: "Target"}) if err != nil { t.Fatalf("create target: %v", err) } privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts) if _, err := privacy.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil { t.Fatalf("set AddedByPhone: %v", err) } svc := NewService(users, WithContactStore(contacts), WithPrivacyEvaluator(privacy), ) if _, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone); !errors.Is(err, domain.ErrPhoneNotOccupied) || found { t.Fatalf("ResolvePhone stranger found=%v err=%v, want phone not occupied", found, err) } if _, err := contacts.Upsert(ctx, target.ID, domain.ContactInput{ ContactUserID: viewer.ID, FirstName: viewer.FirstName, }); err != nil { t.Fatalf("target add viewer: %v", err) } got, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone) if err != nil || !found || got.ID != target.ID { t.Fatalf("ResolvePhone contact = %+v found=%v err=%v, want target", got, found, err) } } func TestServiceUpdateProfile(t *testing.T) { ctx := context.Background() store := memory.NewUserStore() owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner", LastName: "Old"}) if err != nil { t.Fatalf("create owner: %v", err) } svc := NewService(store) updated, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{ FirstName: " New ", HasFirstName: true, LastName: "Name", HasLastName: true, About: "bio", HasAbout: true, }) if err != nil { t.Fatalf("UpdateProfile: %v", err) } if updated.FirstName != "New" || updated.LastName != "Name" || updated.About != "bio" { t.Fatalf("updated profile = %+v, want trimmed names and about", updated) } if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: " ", HasFirstName: true}); !errors.Is(err, domain.ErrFirstNameInvalid) { t.Fatalf("empty first name err = %v, want first name invalid", err) } if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{About: strings.Repeat("x", 71), HasAbout: true}); !errors.Is(err, domain.ErrAboutTooLong) { t.Fatalf("long about err = %v, want about too long", err) } } func TestServiceUpdateBirthday(t *testing.T) { ctx := context.Background() store := memory.NewUserStore() owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000010", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } svc := NewService(store) // 设置带年份的生日。 u, err := svc.UpdateBirthday(ctx, owner.ID, domain.Birthday{Day: 14, Month: 2, Year: 1990}) if err != nil { t.Fatalf("UpdateBirthday: %v", err) } if u.Birthday != (domain.Birthday{Day: 14, Month: 2, Year: 1990}) { t.Fatalf("birthday = %+v, want 14/2/1990", u.Birthday) } // 非法月份被拒。 if _, err := svc.UpdateBirthday(ctx, owner.ID, domain.Birthday{Day: 1, Month: 13}); !errors.Is(err, domain.ErrBirthdayInvalid) { t.Fatalf("invalid month err = %v, want birthday invalid", err) } // 清除(零值)后 IsSet=false。 u, err = svc.UpdateBirthday(ctx, owner.ID, domain.Birthday{}) if err != nil { t.Fatalf("clear birthday: %v", err) } if u.Birthday.IsSet() { t.Fatalf("birthday after clear = %+v, want unset", u.Birthday) } } // fakePhotoProvider always reports the same current photo for every owner, // regardless of owner id — enough to prove a mutation path preserves the // photo instead of returning the bare, un-projected store row. type fakePhotoProvider struct { ref domain.ProfilePhotoRef } func (f *fakePhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs)) for _, id := range ownerIDs { out[id] = f.ref } return out, nil } // TestServiceMutationsPreservePhoto guards against a class of bug where a // profile-mutating method returns the bare store row (users table has no // photo columns at all — the current avatar lives only in the profile_photos // association, attached via projectOne/Projector.One) instead of the // photo-enriched projection, causing the client to wipe its own avatar on // name/username/birthday/verified/color/emoji-status updates. func TestServiceMutationsPreservePhoto(t *testing.T) { ctx := context.Background() store := memory.NewUserStore() owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000099", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } photos := &fakePhotoProvider{ref: domain.ProfilePhotoRef{PhotoID: 555, DCID: 2, Stripped: []byte{1, 2, 3}}} svc := NewService(store, WithPhotoProvider(photos)) assertHasPhoto := func(t *testing.T, label string, u domain.User) { t.Helper() if u.PhotoID != 555 || u.PhotoDCID != 2 { t.Fatalf("%s: photo = {id:%d dc:%d}, want {id:555 dc:2}", label, u.PhotoID, u.PhotoDCID) } } u, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: "New", HasFirstName: true}) if err != nil { t.Fatalf("UpdateProfile: %v", err) } assertHasPhoto(t, "UpdateProfile", u) // No-op branch (nothing actually changed) must also preserve the photo. u, err = svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: "New", HasFirstName: true}) if err != nil { t.Fatalf("UpdateProfile no-op: %v", err) } assertHasPhoto(t, "UpdateProfile no-op", u) u, err = svc.UpdateUsername(ctx, owner.ID, "ownerhandle") if err != nil { t.Fatalf("UpdateUsername: %v", err) } assertHasPhoto(t, "UpdateUsername", u) u, err = svc.UpdateBirthday(ctx, owner.ID, domain.Birthday{Day: 1, Month: 1, Year: 2000}) if err != nil { t.Fatalf("UpdateBirthday: %v", err) } assertHasPhoto(t, "UpdateBirthday", u) u, err = svc.SetVerified(ctx, owner.ID, true) if err != nil { t.Fatalf("SetVerified: %v", err) } assertHasPhoto(t, "SetVerified", u) u, err = svc.UpdateColor(ctx, owner.ID, false, domain.PeerColor{HasColor: true, Color: 3}) if err != nil { t.Fatalf("UpdateColor: %v", err) } assertHasPhoto(t, "UpdateColor", u) } func TestServiceUpdatePersonalChannel(t *testing.T) { ctx := context.Background() store := memory.NewUserStore() owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000011", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } svc := NewService(store) u, err := svc.UpdatePersonalChannel(ctx, owner.ID, 4242) if err != nil { t.Fatalf("UpdatePersonalChannel: %v", err) } if u.PersonalChannelID != 4242 { t.Fatalf("personal channel = %d, want 4242", u.PersonalChannelID) } u, err = svc.UpdatePersonalChannel(ctx, owner.ID, 0) if err != nil { t.Fatalf("clear personal channel: %v", err) } if u.PersonalChannelID != 0 { t.Fatalf("personal channel after clear = %d, want 0", u.PersonalChannelID) } } func TestServiceByIDDoesNotReloadSelf(t *testing.T) { ctx := context.Background() base := memory.NewUserStore() owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Target"}) if err != nil { t.Fatalf("create target: %v", err) } store := &countingUserStore{UserStore: base} svc := NewService(store) got, found, err := svc.ByID(ctx, owner.ID, target.ID) if err != nil || !found || got.ID != target.ID { t.Fatalf("ByID = %+v found %v err %v, want target", got, found, err) } if store.byIDCalls != 0 { t.Fatalf("store ByID calls = %d, want 0 because service uses batch lookup", store.byIDCalls) } if store.byIDsCalls != 1 { t.Fatalf("store ByIDs calls = %d, want 1 target lookup only", store.byIDsCalls) } if len(store.lastByIDs) != 1 || store.lastByIDs[0] != target.ID { t.Fatalf("last ByIDs ids = %v, want target %d only", store.lastByIDs, target.ID) } } func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) { ctx := context.Background() base := memory.NewUserStore() contacts := memory.NewContactStore() owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Target"}) if err != nil { t.Fatalf("create target: %v", err) } store := &countingUserStore{UserStore: base} cache := newMemoryBaseUserCache() svc := NewService(store, WithBaseUserCache(cache), WithContactStore(contacts)) first, found, err := svc.ByID(ctx, owner.ID, target.ID) if err != nil || !found { t.Fatalf("first ByID found=%v err=%v", found, err) } if first.Contact || first.Phone != "" || first.FirstName != "Target" { t.Fatalf("first projected user = %+v, want non-contact base projection", first) } if store.byIDsCalls != 1 { t.Fatalf("store ByIDs calls after first read = %d, want 1", store.byIDsCalls) } if _, err := contacts.Upsert(ctx, owner.ID, domain.ContactInput{ ContactUserID: target.ID, Phone: "15550000002", FirstName: "Remark", LastName: "Friend", }); err != nil { t.Fatalf("upsert contact: %v", err) } second, found, err := svc.ByID(ctx, owner.ID, target.ID) if err != nil || !found { t.Fatalf("second ByID found=%v err=%v", found, err) } if !second.Contact || second.FirstName != "Remark" || second.LastName != "Friend" || second.Phone != "15550000002" { t.Fatalf("second projected user = %+v, want fresh contact overlay from cached base", second) } if store.byIDsCalls != 1 { t.Fatalf("store ByIDs calls after cached read = %d, want still 1", store.byIDsCalls) } } 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") } } // deletedOverrideUserStore reports one user id as an already-tombstoned // domain.User regardless of what the underlying memory store holds, so the // test doesn't need a memory.UserStore deletion helper to exercise the // base-user cache's handling of deleted users. type deletedOverrideUserStore struct { *memory.UserStore deletedID int64 byIDsCalls int } func (s *deletedOverrideUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) { s.byIDsCalls++ users, err := s.UserStore.ByIDs(ctx, ids) if err != nil { return nil, err } for i, u := range users { if u.ID == s.deletedID { users[i] = domain.User{ID: u.ID, Deleted: true, Status: domain.UserStatus{Kind: domain.UserStatusEmpty}} } } return users, nil } // TestServiceNeverCachesDeletedUser guards the redisstore.UserCache cache // schema gap: userBaseValue carries no Deleted/DeletedAt/Status field, so // caching a deleted user silently resets Deleted to false (and Status to the // zero UserStatusUnknown) on every round trip -- a bug that never self-heals, // since each subsequent cache miss reloads the correctly tombstoned row and // immediately re-corrupts it on write. It regressed a deleted account back to // looking live: blank name (still blank, that part survives), but "last seen // recently" instead of "Deleted Account". The service must keep deleted users // off the base cache entirely so every lookup hits the authoritative store. func TestServiceNeverCachesDeletedUser(t *testing.T) { ctx := context.Background() base := memory.NewUserStore() owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000031", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000032", FirstName: "Target"}) if err != nil { t.Fatalf("create target: %v", err) } store := &deletedOverrideUserStore{UserStore: base, deletedID: target.ID} cache := newMemoryBaseUserCache() svc := NewService(store, WithBaseUserCache(cache)) got, found, err := svc.ByID(ctx, owner.ID, target.ID) if err != nil || !found || !got.Deleted { t.Fatalf("ByID = %+v found=%v err=%v, want a deleted user", got, found, err) } if _, cached := cache.users[target.ID]; cached { t.Fatalf("cache holds deleted user %d, want it kept off the cache entirely", target.ID) } if _, found, err := svc.ByID(ctx, owner.ID, target.ID); err != nil || !found { t.Fatalf("second ByID found=%v err=%v", found, err) } if store.byIDsCalls != 2 { t.Fatalf("store ByIDs calls = %d, want 2 (deleted user must never be served from cache)", store.byIDsCalls) } } func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) { ctx := context.Background() base := memory.NewUserStore() owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Before"}) if err != nil { t.Fatalf("create target: %v", err) } store := &countingUserStore{UserStore: base} cache := newMemoryBaseUserCache() svc := NewService(store, WithBaseUserCache(cache)) if _, found, err := svc.ByID(ctx, owner.ID, target.ID); err != nil || !found { t.Fatalf("prime cache found=%v err=%v", found, err) } updated, err := svc.UpdateProfile(ctx, target.ID, domain.UserProfileUpdate{FirstName: "After", HasFirstName: true}) if err != nil { t.Fatalf("UpdateProfile: %v", err) } if updated.FirstName != "After" { t.Fatalf("updated first name = %q, want After", updated.FirstName) } got, found, err := svc.ByID(ctx, owner.ID, target.ID) if err != nil || !found { t.Fatalf("ByID after update found=%v err=%v", found, err) } if got.FirstName != "After" { t.Fatalf("cached user first name = %q, want After", got.FirstName) } } func TestServiceSetVerifiedRefreshesBaseCache(t *testing.T) { ctx := context.Background() base := memory.NewUserStore() owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000021", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000022", FirstName: "Target"}) if err != nil { t.Fatalf("create target: %v", err) } store := &countingUserStore{UserStore: base} cache := newMemoryBaseUserCache() svc := NewService(store, WithBaseUserCache(cache)) if _, found, err := svc.ByID(ctx, owner.ID, target.ID); err != nil || !found { t.Fatalf("prime cache found=%v err=%v", found, err) } updated, err := svc.SetVerified(ctx, target.ID, true) if err != nil { t.Fatalf("SetVerified: %v", err) } if !updated.Verified { t.Fatalf("updated verified = false, want true") } got, found, err := svc.ByID(ctx, owner.ID, target.ID) if err != nil || !found { t.Fatalf("ByID after verified found=%v err=%v", found, err) } if !got.Verified { t.Fatalf("cached verified = false, want true") } cleared, err := svc.SetVerified(ctx, target.ID, false) if err != nil { t.Fatalf("clear verified: %v", err) } 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) { ctx := context.Background() base := memory.NewUserStore() owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } store := &countingUserStore{UserStore: base} cache := newMemoryBaseUserCache() svc := NewService(store, WithBaseUserCache(cache)) if _, found, err := svc.ByID(ctx, owner.ID, owner.ID); err != nil || !found { t.Fatalf("prime cache found=%v err=%v", found, err) } if store.byIDsCalls != 1 { t.Fatalf("store ByIDs calls after prime = %d, want 1", store.byIDsCalls) } updated, err := svc.UpdateColor(ctx, owner.ID, true, domain.PeerColor{ HasColor: true, Color: 0, BackgroundEmojiID: 123456, }) if err != nil { t.Fatalf("UpdateColor: %v", err) } if !updated.ProfileColor.HasColor || updated.ProfileColor.Color != 0 || updated.ProfileColor.BackgroundEmojiID != 123456 { t.Fatalf("updated profile color = %+v, want explicit color=0 bg=123456", updated.ProfileColor) } got, found, err := svc.ByID(ctx, owner.ID, owner.ID) if err != nil || !found { t.Fatalf("ByID after color update found=%v err=%v", found, err) } if store.byIDsCalls != 1 { t.Fatalf("store ByIDs calls after cached color read = %d, want still 1", store.byIDsCalls) } if !got.ProfileColor.HasColor || got.ProfileColor.Color != 0 || got.ProfileColor.BackgroundEmojiID != 123456 { t.Fatalf("cached profile color = %+v, want explicit color=0 bg=123456", got.ProfileColor) } } func TestServiceProjectsUsersForViewerContacts(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() contacts := memory.NewContactStore() owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Public", LastName: "Name"}) if err != nil { t.Fatalf("create friend: %v", err) } stranger, err := userStore.Create(ctx, domain.User{AccessHash: 3, Phone: "15550000003", FirstName: "Stranger"}) if err != nil { t.Fatalf("create stranger: %v", err) } if _, err := contacts.Upsert(ctx, owner.ID, domain.ContactInput{ ContactUserID: friend.ID, Phone: "15550000002", FirstName: "Remark", LastName: "Friend", }); err != nil { t.Fatalf("upsert contact: %v", err) } svc := NewService(userStore, WithContactStore(contacts)) contactUser, found, err := svc.ByID(ctx, owner.ID, friend.ID) if err != nil || !found { t.Fatalf("ByID contact found=%v err=%v", found, err) } if !contactUser.Contact || contactUser.FirstName != "Remark" || contactUser.LastName != "Friend" || contactUser.Phone != "15550000002" { t.Fatalf("projected contact = %+v, want contact remark and phone", contactUser) } nonContact, found, err := svc.ByID(ctx, owner.ID, stranger.ID) if err != nil || !found { t.Fatalf("ByID non-contact found=%v err=%v", found, err) } if nonContact.Contact || nonContact.Phone != "" || nonContact.FirstName != "Stranger" { t.Fatalf("projected non-contact = %+v, want name with hidden phone", nonContact) } } type countingUserStore struct { *memory.UserStore byIDCalls int byIDsCalls int lastByID int64 lastByIDs []int64 } func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { s.byIDCalls++ s.lastByID = id return s.UserStore.ByID(ctx, id) } func (s *countingUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) { s.byIDsCalls++ s.lastByIDs = append([]int64(nil), ids...) return s.UserStore.ByIDs(ctx, ids) } type memoryBaseUserCache struct { users map[int64]domain.User deleteCalls int } func newMemoryBaseUserCache() *memoryBaseUserCache { return &memoryBaseUserCache{users: map[int64]domain.User{}} } func (c *memoryBaseUserCache) GetByIDs(_ context.Context, ids []int64) (map[int64]domain.User, error) { out := make(map[int64]domain.User, len(ids)) for _, id := range ids { if u, ok := c.users[id]; ok { out[id] = u } } return out, nil } func (c *memoryBaseUserCache) PutMany(_ context.Context, users []domain.User) error { for _, u := range users { if u.ID != 0 { c.users[u.ID] = u } } return nil } func (c *memoryBaseUserCache) Delete(_ context.Context, ids []int64) error { c.deleteCalls++ for _, id := range ids { delete(c.users, id) } return nil }