package rpc import ( "context" "errors" "strings" "testing" "time" "github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" "go.uber.org/zap/zaptest" appchannels "telesrv/internal/app/channels" appusers "telesrv/internal/app/users" "telesrv/internal/domain" "telesrv/internal/store/memory" ) // fakeUsernameRegistry is an in-memory UsernameRegistryService. It enforces the // same domain rules the real registry does, so the RPC tests exercise the actual // error mapping rather than hand-rolled sentinels. type fakeUsernameRegistry struct { byPeer map[domain.Peer][]domain.Username collectibles map[string]domain.CollectibleUsername // err, when set, fails every read. Used for the degradation tests. err error // batchCalls / peerCalls count read fan-out so the tests can assert no N+1. batchCalls int peerCalls int } func newFakeUsernameRegistry() *fakeUsernameRegistry { return &fakeUsernameRegistry{ byPeer: make(map[domain.Peer][]domain.Username), collectibles: make(map[string]domain.CollectibleUsername), } } func (f *fakeUsernameRegistry) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) { f.peerCalls++ if f.err != nil { return nil, f.err } return f.byPeer[peer], nil } func (f *fakeUsernameRegistry) UsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) { f.batchCalls++ if f.err != nil { return nil, f.err } out := make(map[domain.Peer][]domain.Username, len(peers)) for _, peer := range peers { if list, ok := f.byPeer[peer]; ok { out[peer] = list } } return out, nil } func (f *fakeUsernameRegistry) ToggleUsername(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) { if f.err != nil { return false, f.err } current := f.byPeer[peer] if err := domain.ValidateUsernameToggle(current, username, active); err != nil { return false, err } changed := false next := append([]domain.Username(nil), current...) for i := range next { if next[i].Username != domain.NormalizeUsername(username) { continue } if next[i].Active != active { next[i].Active = active changed = true } } f.byPeer[peer] = next return changed, nil } func (f *fakeUsernameRegistry) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) { if f.err != nil { return false, f.err } current := f.byPeer[peer] next, err := domain.ApplyUsernameReorder(current, order) if err != nil { return false, err } // Same contract as both real backends: the renumbering is always persisted, // and "changed" is only about what a client can see. changed := !domain.SameUsernameOrder(current, next) f.byPeer[peer] = next return changed, nil } func (f *fakeUsernameRegistry) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) { if f.err != nil { return false, f.err } next := append([]domain.Username(nil), f.byPeer[peer]...) changed := false for i := range next { if next[i].Collectible() && next[i].Active { next[i].Active = false changed = true } } f.byPeer[peer] = next return changed, nil } func (f *fakeUsernameRegistry) Collectible(_ context.Context, username string) (domain.CollectibleUsername, error) { if f.err != nil { return domain.CollectibleUsername{}, f.err } // Usernames are case-insensitive in the registry, as they are in the store. asset, ok := f.collectibles[strings.ToLower(domain.NormalizeUsername(username))] if !ok { return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound } return asset, nil } var _ UsernameRegistryService = (*fakeUsernameRegistry)(nil) func TestTgUsernamesFromRegistryFollowsStoredOrder(t *testing.T) { // Legacy numbering, which is what every peer that never reordered carries: // the editable slot and the first collectible share sort_order 0 and the // editable slot wins the tie, so it still projects first. assertUsernameVector(t, []domain.Username{ {Username: "second", Active: false, SortOrder: 1, CollectibleID: 22}, {Username: "editable_slot", Active: true, Editable: true, SortOrder: 0}, {Username: "first", Active: true, SortOrder: 0, CollectibleID: 11}, }, []tg.Username{ {Editable: true, Active: true, Username: "editable_slot"}, {Editable: false, Active: true, Username: "first"}, {Editable: false, Active: false, Username: "second"}, }) // After a reorder made a collectible primary, stored order wins: clients read // usernames[0] as the peer's primary username // (core.telegram.org/api/fragment), so the editable slot must be able to // leave that position. assertUsernameVector(t, []domain.Username{ {Username: "second", Active: false, SortOrder: 2, CollectibleID: 22}, {Username: "editable_slot", Active: true, Editable: true, SortOrder: 1}, {Username: "first", Active: true, SortOrder: 0, CollectibleID: 11}, }, []tg.Username{ {Editable: false, Active: true, Username: "first"}, {Editable: true, Active: true, Username: "editable_slot"}, {Editable: false, Active: false, Username: "second"}, }) } func assertUsernameVector(t *testing.T, list []domain.Username, want []tg.Username) { t.Helper() got := tgUsernamesFromRegistry(list, "editable_slot") if len(got) != len(want) { t.Fatalf("usernames = %+v, want %+v", got, want) } for i := range want { if got[i].Username != want[i].Username || got[i].Editable != want[i].Editable || got[i].Active != want[i].Active { t.Fatalf("usernames[%d] = %+v, want %+v", i, got[i], want[i]) } } } func TestTgUsernamesFromRegistryDegradesToScalar(t *testing.T) { // Empty registry contribution must be byte-identical to the legacy vector. if got, want := tgUsernamesFromRegistry(nil, "legacy"), tgUsernames("legacy"); len(got) != 1 || got[0] != want[0] { t.Fatalf("empty registry = %+v, want %+v", got, want) } if got := tgUsernamesFromRegistry(nil, ""); got != nil { t.Fatalf("empty registry with empty scalar = %+v, want nil", got) } // A registry list that normalizes away entirely also degrades rather than // emitting an empty vector. if got, want := tgUsernamesFromRegistry([]domain.Username{{Username: " "}}, "legacy"), tgUsernames("legacy"); len(got) != 1 || got[0] != want[0] { t.Fatalf("blank registry rows = %+v, want %+v", got, want) } } type usernameProjectionFixture struct { router *Router registry *fakeUsernameRegistry owner domain.User friend domain.User } func newUsernameProjectionFixture(t *testing.T, registry UsernameRegistryService) usernameProjectionFixture { t.Helper() ctx := context.Background() userStore := memory.NewUserStore() owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner", Username: "owner_slot"}) if err != nil { t.Fatalf("create owner: %v", err) } friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend", Username: "friend_slot"}) if err != nil { t.Fatalf("create friend: %v", err) } fake, _ := registry.(*fakeUsernameRegistry) return usernameProjectionFixture{ router: New(Config{}, Deps{ Users: appusers.NewService(userStore), Usernames: registry, }, zaptest.NewLogger(t), clock.System), registry: fake, owner: owner, friend: friend, } } func usernameStrings(list []tg.Username) []string { out := make([]string, 0, len(list)) for _, item := range list { out = append(out, item.Username) } return out } func TestUsersGetUsersProjectsCollectibleUsernamesInOneBatch(t *testing.T) { registry := newFakeUsernameRegistry() f := newUsernameProjectionFixture(t, registry) registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}] = []domain.Username{ {Username: "owner_slot", Editable: true, Active: true, SortOrder: 1}, {Username: "nft4", Active: true, SortOrder: 0, CollectibleID: 7}, } registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.friend.ID}] = []domain.Username{ {Username: "friend_slot", Editable: true, Active: true}, {Username: "gem4", Active: false, SortOrder: 1, CollectibleID: 8}, } ctx := WithUserID(context.Background(), f.owner.ID) out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{ &tg.InputUserSelf{}, &tg.InputUser{UserID: f.friend.ID, AccessHash: f.friend.AccessHash}, }) if err != nil { t.Fatalf("get users: %v", err) } if len(out) != 2 { t.Fatalf("users = %d, want 2", len(out)) } self := out[0].(*tg.User) if scalar, ok := self.GetUsername(); !ok || scalar != "nft4" { t.Fatalf("self scalar username = %q (set %v), want primary collectible nft4", scalar, ok) } vector, ok := self.GetUsernames() if !ok { t.Fatalf("self usernames unset, want registry vector") } if got := usernameStrings(vector); len(got) != 2 || got[0] != "nft4" || got[1] != "owner_slot" { t.Fatalf("self usernames = %v, want [nft4 owner_slot]", got) } if vector[0].Editable || !vector[0].Active { t.Fatalf("primary collectible flags = %+v, want non-editable+active", vector[0]) } if !vector[1].Editable || !vector[1].Active { t.Fatalf("editable slot flags = %+v, want editable+active", vector[1]) } friend := out[1].(*tg.User) if scalar, ok := friend.GetUsername(); !ok || scalar != "friend_slot" { t.Fatalf("friend scalar username = %q (set %v), want friend_slot", scalar, ok) } friendVector, _ := friend.GetUsernames() if got := usernameStrings(friendVector); len(got) != 2 || got[1] != "gem4" { t.Fatalf("friend usernames = %v, want [friend_slot gem4]", got) } if friendVector[1].Active { t.Fatalf("inactive collectible projected active: %+v", friendVector[1]) } // Two users, one batch read: no N+1. if registry.batchCalls != 1 || registry.peerCalls != 0 { t.Fatalf("registry reads = batch %d / peer %d, want batch 1 / peer 0", registry.batchCalls, registry.peerCalls) } } func TestUsersGetUsersDegradesWithoutRegistry(t *testing.T) { f := newUsernameProjectionFixture(t, nil) ctx := WithUserID(context.Background(), f.owner.ID) out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}}) if err != nil { t.Fatalf("get users: %v", err) } self := out[0].(*tg.User) // Without a collectible registry the official legacy shape is scalar-only. self.SetFlags() vector, ok := self.GetUsernames() if ok || len(vector) != 0 { t.Fatalf("usernames = %+v (set %v), want vector absent", vector, ok) } if scalar, ok := self.GetUsername(); !ok || scalar != "owner_slot" { t.Fatalf("scalar username = %q (set %v), want owner_slot", scalar, ok) } } func TestUsersGetUsersDegradesWhenRegistryFails(t *testing.T) { registry := newFakeUsernameRegistry() registry.err = errors.New("registry unavailable") f := newUsernameProjectionFixture(t, registry) ctx := WithUserID(context.Background(), f.owner.ID) out, err := f.router.onUsersGetUsers(ctx, []tg.InputUserClass{ &tg.InputUserSelf{}, &tg.InputUser{UserID: f.friend.ID, AccessHash: f.friend.AccessHash}, }) if err != nil { t.Fatalf("get users must not fail when the registry does: %v", err) } if len(out) != 2 { t.Fatalf("users = %d, want 2", len(out)) } for i, want := range []string{"owner_slot", "friend_slot"} { user := out[i].(*tg.User) user.SetFlags() vector, ok := user.GetUsernames() if ok || len(vector) != 0 { t.Fatalf("users[%d] usernames = %+v, want vector absent", i, vector) } if scalar, ok := user.GetUsername(); !ok || scalar != want { t.Fatalf("users[%d] scalar username = %q (set %v), want %q", i, scalar, ok, want) } } } func TestFragmentGetCollectibleInfoRPC(t *testing.T) { registry := newFakeUsernameRegistry() f := newUsernameProjectionFixture(t, registry) peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID} registry.collectibles["nfts"] = domain.CollectibleUsername{ ID: 7, Username: "nfts", Status: domain.CollectibleUsernameStatusOwned, Owner: peer, PurchaseDate: time.Unix(1700000000, 0), Currency: domain.CollectibleCurrencyUSD, Amount: 550000, CryptoCurrency: domain.CollectibleCryptoCurrencyTON, CryptoAmount: 1200000000, URL: "https://fragment.example/username/nfts", } registry.byPeer[peer] = []domain.Username{{Username: "nfts", Active: false, CollectibleID: 7}} ctx := WithUserID(context.Background(), f.owner.ID) info, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{ Collectible: &tg.InputCollectibleUsername{Username: "@NFTS"}, }) if err != nil { t.Fatalf("get collectible info: %v", err) } if info.PurchaseDate != 1700000000 || info.Currency != domain.CollectibleCurrencyUSD || info.Amount != 550000 { t.Fatalf("collectible info = %+v, want the stored purchase record", info) } if info.CryptoCurrency != domain.CollectibleCryptoCurrencyTON || info.CryptoAmount != 1200000000 { t.Fatalf("collectible crypto = %s/%d, want TON/1200000000", info.CryptoCurrency, info.CryptoAmount) } if info.URL != "https://fragment.example/username/nfts" { t.Fatalf("collectible url = %q", info.URL) } // Inactive collectibles are private to their current owner. friendCtx := WithUserID(context.Background(), f.friend.ID) if _, err := f.router.onFragmentGetCollectibleInfo(friendCtx, &tg.FragmentGetCollectibleInfoRequest{ Collectible: &tg.InputCollectibleUsername{Username: "nfts"}, }); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") { t.Fatalf("inactive collectible seen by another user: %v, want COLLECTIBLE_NOT_FOUND", err) } registry.byPeer[peer][0].Active = true if _, err := f.router.onFragmentGetCollectibleInfo(friendCtx, &tg.FragmentGetCollectibleInfoRequest{ Collectible: &tg.InputCollectibleUsername{Username: "nfts"}, }); err != nil { t.Fatalf("active collectible hidden from another user: %v", err) } // A name with no collectible asset behind it is not occupied as a collectible. if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{ Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"}, }); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") { t.Fatalf("non-collectible username err = %v, want COLLECTIBLE_NOT_FOUND", err) } // Syntactically impossible name is rejected before the registry is consulted. if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{ Collectible: &tg.InputCollectibleUsername{Username: "a"}, }); !tgerr.Is(err, "COLLECTIBLE_INVALID") { t.Fatalf("malformed username err = %v, want COLLECTIBLE_INVALID", err) } // Collectible phones do not exist in this server. if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{ Collectible: &tg.InputCollectiblePhone{Phone: "15550002001"}, }); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") { t.Fatalf("collectible phone err = %v, want COLLECTIBLE_NOT_FOUND", err) } } func TestFragmentGetCollectibleInfoWithoutRegistry(t *testing.T) { f := newUsernameProjectionFixture(t, nil) ctx := WithUserID(context.Background(), f.owner.ID) if _, err := f.router.onFragmentGetCollectibleInfo(ctx, &tg.FragmentGetCollectibleInfoRequest{ Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"}, }); !tgerr.Is(err, "COLLECTIBLE_NOT_FOUND") { t.Fatalf("no registry err = %v, want COLLECTIBLE_NOT_FOUND", err) } } func TestAccountToggleAndReorderUsernamesRPC(t *testing.T) { registry := newFakeUsernameRegistry() f := newUsernameProjectionFixture(t, registry) peer := domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID} registry.byPeer[peer] = []domain.Username{ {Username: "owner_slot", Editable: true, Active: true}, {Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 1}, {Username: "bravo", Active: true, SortOrder: 1, CollectibleID: 2}, } ctx := WithUserID(context.Background(), f.owner.ID) if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "alpha", Active: false}); !ok || err != nil { t.Fatalf("toggle collectible = %v,%v, want true,nil", ok, err) } if list := registry.byPeer[peer]; list[1].Active { t.Fatalf("alpha still active after toggle: %+v", list) } // Toggling the same value again changes nothing. if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "alpha", Active: false}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { t.Fatalf("idempotent toggle = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) } // The editable slot is not a collectible: account.updateUsername owns it. if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: false}); ok || !tgerr.Is(err, "USERNAME_INVALID") { t.Fatalf("toggle editable slot = %v,%v, want false,USERNAME_INVALID", ok, err) } // An unknown name is not occupied. if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "ghost", Active: true}); ok || !tgerr.Is(err, "USERNAME_NOT_OCCUPIED") { t.Fatalf("toggle unknown = %v,%v, want false,USERNAME_NOT_OCCUPIED", ok, err) } // alpha is inactive at this point, so the order does not have to mention it. if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot", "bravo"}}); !ok || err != nil { t.Fatalf("reorder = %v,%v, want true,nil", ok, err) } list := domain.SortUsernames(registry.byPeer[peer]) if len(list) != 3 || !list[0].Editable || list[1].Username != "bravo" || list[2].Username != "alpha" { t.Fatalf("order after reorder = %+v, want editable, bravo, alpha", list) } // The editable slot may lead the order or follow a collectible. if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"bravo", "owner_slot"}}); !ok || err != nil { t.Fatalf("reorder with a collectible first = %v,%v, want true,nil", ok, err) } if list := domain.SortUsernames(registry.byPeer[peer]); list[0].Username != "bravo" || !list[1].Editable { t.Fatalf("order after promoting a collectible = %+v, want bravo, editable, alpha", list) } // An order that omits an active username is still rejected. if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"bravo"}}); ok || !tgerr.Is(err, "ORDER_INVALID") { t.Fatalf("partial reorder = %v,%v, want false,ORDER_INVALID", ok, err) } // So is one naming something the peer does not own. if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot", "bravo", "ghost"}}); ok || !tgerr.Is(err, "ORDER_INVALID") { t.Fatalf("reorder with an unknown name = %v,%v, want false,ORDER_INVALID", ok, err) } if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{ Order: make([]string, domain.MaxPeerCollectibleUsernames+2), }); ok || !tgerr.Is(err, "LIMIT_INVALID") { t.Fatalf("oversized reorder = %v,%v, want false,LIMIT_INVALID", ok, err) } } func TestAccountUsernameManagementWithoutRegistry(t *testing.T) { f := newUsernameProjectionFixture(t, nil) ctx := WithUserID(context.Background(), f.owner.ID) if ok, err := f.router.onAccountToggleUsername(ctx, &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: true}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { t.Fatalf("toggle without registry = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) } if ok, err := f.router.onAccountReorderUsernames(ctx, &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { t.Fatalf("reorder without registry = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) } } // TestCollectibleUsernameRPCsAreRegistered proves the three previously missing // methods reach a handler through the real dispatcher rather than the unregistered // fallback: an ordinary client calls them by wire constructor, not by Go method. func TestCollectibleUsernameRPCsAreRegistered(t *testing.T) { f := newUsernameProjectionFixture(t, nil) ctx := WithUserID(context.Background(), f.owner.ID) cases := []struct { name string req bin.Encoder want string }{ {name: "account.reorderUsernames", req: &tg.AccountReorderUsernamesRequest{Order: []string{"owner_slot"}}, want: "USERNAME_NOT_MODIFIED"}, {name: "account.toggleUsername", req: &tg.AccountToggleUsernameRequest{Username: "owner_slot", Active: true}, want: "USERNAME_NOT_MODIFIED"}, {name: "fragment.getCollectibleInfo", req: &tg.FragmentGetCollectibleInfoRequest{Collectible: &tg.InputCollectibleUsername{Username: "owner_slot"}}, want: "COLLECTIBLE_NOT_FOUND"}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { var in bin.Buffer if err := tt.req.Encode(&in); err != nil { t.Fatalf("encode request: %v", err) } if _, err := f.router.Dispatch(ctx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) { t.Fatalf("dispatch err = %v, want %s", err, tt.want) } }) } } func newCollectibleChannelFixture(t *testing.T, registry UsernameRegistryService) (*Router, domain.User, *tg.Channel) { t.Helper() ctx := context.Background() userStore := memory.NewUserStore() owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003001", FirstName: "Owner"}) if err != nil { t.Fatalf("create owner: %v", err) } channelStore := memory.NewChannelStore() r := New(Config{}, Deps{ Users: appusers.NewService(userStore), Channels: appchannels.NewService(channelStore), Usernames: registry, }, zaptest.NewLogger(t), clock.System) ownerCtx := WithUserID(ctx, owner.ID) created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{ Broadcast: true, Title: "Collectible Channel", About: "about", }) if err != nil { t.Fatalf("create channel: %v", err) } channel := created.(*tg.Updates).Chats[0].(*tg.Channel) if _, err := r.onChannelsUpdateUsername(ownerCtx, &tg.ChannelsUpdateUsernameRequest{ Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, Username: "chan_slot", }); err != nil { t.Fatalf("update channel username: %v", err) } return r, owner, channel } func TestChannelsUsernameManagementUsesRegistry(t *testing.T) { registry := newFakeUsernameRegistry() r, owner, channel := newCollectibleChannelFixture(t, registry) ctx := WithUserID(context.Background(), owner.ID) input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID} registry.byPeer[peer] = []domain.Username{ {Username: "chan_slot", Editable: true, Active: true}, {Username: "alpha", Active: true, SortOrder: 0, CollectibleID: 31}, {Username: "bravo", Active: true, SortOrder: 1, CollectibleID: 32}, } if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: false}); !ok || err != nil { t.Fatalf("toggle channel collectible = %v,%v, want true,nil", ok, err) } if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: false}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { t.Fatalf("idempotent channel toggle = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) } if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "chan_slot", Active: false}); ok || !tgerr.Is(err, "USERNAME_INVALID") { t.Fatalf("toggle channel editable slot = %v,%v, want false,USERNAME_INVALID", ok, err) } // alpha was just deactivated, so the order carries only the active names. if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot", "bravo"}}); !ok || err != nil { t.Fatalf("reorder channel usernames = %v,%v, want true,nil", ok, err) } if list := domain.SortUsernames(registry.byPeer[peer]); list[1].Username != "bravo" { t.Fatalf("channel order = %+v, want bravo first collectible", list) } if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil { t.Fatalf("deactivate all = %v,%v, want true,nil", ok, err) } for _, item := range registry.byPeer[peer] { if item.Collectible() && item.Active { t.Fatalf("collectible still active after deactivateAll: %+v", item) } } // Deactivate-all is idempotent, while reorder follows the documented // USERNAME_NOT_MODIFIED result for an unchanged order. if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil { t.Fatalf("repeat deactivate all = %v,%v, want true,nil", ok, err) } if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { t.Fatalf("repeat reorder after deactivateAll = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) } // A non-admin must not reach the registry at all. other := WithUserID(context.Background(), owner.ID+9999) if _, err := r.onChannelsToggleUsername(other, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "alpha", Active: true}); err == nil { t.Fatalf("toggle as stranger err = nil, want permission failure") } } func TestChannelsUsernameManagementWithoutRegistryKeepsLegacyAnswers(t *testing.T) { r, owner, channel := newCollectibleChannelFixture(t, nil) ctx := WithUserID(context.Background(), owner.ID) input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} if ok, err := r.onChannelsToggleUsername(ctx, &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "chan_slot", Active: true}); !ok || err != nil { t.Fatalf("toggle without registry = %v,%v, want true,nil", ok, err) } if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); !ok || err != nil { t.Fatalf("reorder without registry = %v,%v, want true,nil", ok, err) } if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil { t.Fatalf("deactivate without registry = %v,%v, want true,nil", ok, err) } } // TestChannelsDeactivateAllUsernamesIsIdempotent covers the report "I created a // channel without a username and later could not set one": Telegram Desktop // drives its channel-username editor by calling channels.deactivateAllUsernames // first and only continuing when it answers true. A channel that owns no // collectible username at all -- every freshly created channel -- has nothing to // deactivate, and answering USERNAME_NOT_MODIFIED there aborted the whole flow // client-side. Deactivating an empty set is success. func TestChannelsDeactivateAllUsernamesIsIdempotent(t *testing.T) { registry := newFakeUsernameRegistry() r, owner, channel := newCollectibleChannelFixture(t, registry) ctx := WithUserID(context.Background(), owner.ID) input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID} // A channel with only its editable slot: no collectible username exists. registry.byPeer[peer] = []domain.Username{{Username: "chan_slot", Editable: true, Active: true}} for attempt := 0; attempt < 2; attempt++ { if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil { t.Fatalf("deactivate all with nothing collectible (attempt %d) = %v,%v, want true,nil", attempt, ok, err) } } // The whole visible list is just the editable slot, which is exactly what // Telegram Desktop sends here. if ok, err := r.onChannelsReorderUsernames(ctx, &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"chan_slot"}}); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { t.Fatalf("reorder with nothing collectible = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) } // The editable slot is untouched by either call: only collectibles are hidden. if len(registry.byPeer[peer]) != 1 || !registry.byPeer[peer][0].Active { t.Fatalf("editable slot after bulk calls = %+v, want it left active", registry.byPeer[peer]) } // A channel with no rows at all behaves the same way. delete(registry.byPeer, peer) if ok, err := r.onChannelsDeactivateAllUsernames(ctx, input); !ok || err != nil { t.Fatalf("deactivate all on an empty registry = %v,%v, want true,nil", ok, err) } // Permission is still checked before the registry is consulted. stranger := WithUserID(context.Background(), owner.ID+4242) if ok, err := r.onChannelsDeactivateAllUsernames(stranger, input); ok || err == nil { t.Fatalf("deactivate all as stranger = %v,%v, want false and a permission failure", ok, err) } } func TestChannelsGetChannelsProjectsCollectibleUsernames(t *testing.T) { registry := newFakeUsernameRegistry() r, owner, channel := newCollectibleChannelFixture(t, registry) ctx := WithUserID(context.Background(), owner.ID) registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}] = []domain.Username{ {Username: "chan_slot", Editable: true, Active: true, SortOrder: 1}, {Username: "chan_nft", Active: true, SortOrder: 0, CollectibleID: 44}, } chats, err := r.onChannelsGetChannels(ctx, []tg.InputChannelClass{ &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, }) if err != nil { t.Fatalf("get channels: %v", err) } out := chats.(*tg.MessagesChats).Chats if len(out) != 1 { t.Fatalf("chats = %d, want 1", len(out)) } vector, ok := out[0].(*tg.Channel).GetUsernames() if !ok { t.Fatalf("channel usernames unset, want registry vector") } if got := usernameStrings(vector); len(got) != 2 || got[0] != "chan_nft" || got[1] != "chan_slot" { t.Fatalf("channel usernames = %v, want [chan_nft chan_slot]", got) } if scalar, ok := out[0].(*tg.Channel).GetUsername(); !ok || scalar != "chan_nft" { t.Fatalf("scalar channel username = %q (set %v), want primary collectible chan_nft", scalar, ok) } } func TestChannelsGetChannelsDegradesWithoutRegistry(t *testing.T) { r, owner, channel := newCollectibleChannelFixture(t, nil) ctx := WithUserID(context.Background(), owner.ID) chats, err := r.onChannelsGetChannels(ctx, []tg.InputChannelClass{ &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, }) if err != nil { t.Fatalf("get channels: %v", err) } vector, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel).GetUsernames() if ok || len(vector) != 0 { t.Fatalf("legacy channel usernames = %+v (set %v), want vector absent", vector, ok) } if scalar, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel).GetUsername(); !ok || scalar != "chan_slot" { t.Fatalf("legacy scalar username = %q (set %v), want chan_slot", scalar, ok) } }