redisstore.userBaseValue has no Deleted/DeletedAt/Status field, so caching a deleted user silently reset Deleted back to false (and Status to the zero UserStatusUnknown) on every round trip. That never self-healed: each later cache miss reloaded the correctly tombstoned DB row and immediately re-corrupted it on write, so once anyone looked a deleted account up, it kept showing a blank name with "last seen recently" instead of "Deleted Account". Keep deleted users off the base cache entirely so lookups always hit the authoritative store, and stop presence overlay from touching a Deleted user's Status at all as defense in depth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
835 lines
31 KiB
Go
835 lines
31 KiB
Go
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
|
|
}
|