owpengram-server/internal/rpc/story_peer_projection_rpc_test.go
2026-09-01 12:06:31 +03:00

1678 lines
60 KiB
Go

package rpc
import (
"context"
"sync"
"testing"
"time"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appstories "telesrv/internal/app/stories"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type countingStoriesService struct {
StoriesService
maxIDCalls int
hiddenCalls int
projectionCalls int
activeCandidateCalls int
hiddenSnapshotCalls int
pinnedAvailCalls int
pinnedStoriesCalls int
}
type blockingProjectionStoriesService struct {
StoriesService
mu sync.Mutex
calls int
started chan struct{}
release chan struct{}
once sync.Once
}
func (s *blockingProjectionStoriesService) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) {
s.mu.Lock()
s.calls++
s.mu.Unlock()
s.once.Do(func() { close(s.started) })
select {
case <-s.release:
case <-ctx.Done():
return nil, ctx.Err()
}
return s.StoriesService.GetPeerStoryProjections(ctx, viewerUserID, peers, now)
}
func (s *blockingProjectionStoriesService) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func (s *countingStoriesService) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) {
s.maxIDCalls++
return s.StoriesService.GetPeerMaxIDs(ctx, viewerUserID, peers, now)
}
func (s *countingStoriesService) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) {
s.hiddenCalls++
return s.StoriesService.GetPeerHiddenStates(ctx, viewerUserID, peers)
}
func (s *countingStoriesService) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) {
s.projectionCalls++
return s.StoriesService.GetPeerStoryProjections(ctx, viewerUserID, peers, now)
}
func (s *countingStoriesService) ActiveStoryPeerExpirations(ctx context.Context, peers []domain.Peer, now int) (map[domain.Peer]int, error) {
s.activeCandidateCalls++
return s.StoriesService.(storySparseProjectionProvider).ActiveStoryPeerExpirations(ctx, peers, now)
}
func (s *countingStoriesService) ListHiddenStoryPeers(ctx context.Context, viewerUserID int64) ([]domain.Peer, error) {
s.hiddenSnapshotCalls++
return s.StoriesService.(storySparseProjectionProvider).ListHiddenStoryPeers(ctx, viewerUserID)
}
func (s *countingStoriesService) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) {
s.pinnedAvailCalls++
return s.StoriesService.HasPinnedStories(ctx, viewerUserID, peer, now)
}
func (s *countingStoriesService) GetPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
s.pinnedStoriesCalls++
return s.StoriesService.GetPinnedStories(ctx, viewerUserID, peer, offsetID, limit, now)
}
func TestUsersProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93001, Phone: "15550093001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93002, Phone: "15550093002", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 7,
Date: 1700000000,
ExpireDate: 1700003600,
Pinned: true,
Public: true,
}}); err != nil {
t.Fatalf("upsert story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
users, err := r.onUsersGetUsers(WithUserID(ctx, viewer.ID), []tg.InputUserClass{
&tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash},
})
if err != nil {
t.Fatalf("get users: %v", err)
}
if len(users) != 1 {
t.Fatalf("users = %d, want one", len(users))
}
assertUserStoryMaxID(t, users[0], 7)
full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash})
if err != nil {
t.Fatalf("get full user: %v", err)
}
if len(full.Users) != 1 {
t.Fatalf("full users = %d, want one", len(full.Users))
}
assertUserStoryMaxID(t, full.Users[0], 7)
if !full.FullUser.GetStoriesPinnedAvailable() {
t.Fatalf("user full stories_pinned_available = false, want true")
}
}
func TestStorySparseProjectionAvoidsViewerPeerNegativeMatrix(t *testing.T) {
ctx := context.Background()
const viewerID int64 = 94001
active := domain.Peer{Type: domain.PeerTypeUser, ID: 94002}
inactiveHidden := domain.Peer{Type: domain.PeerTypeChannel, ID: 94003}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: active, ID: 7, Date: 100, ExpireDate: 200, Public: true,
}}); err != nil {
t.Fatalf("upsert active story: %v", err)
}
if err := storyStore.SetPeerHidden(ctx, viewerID, inactiveHidden, true); err != nil {
t.Fatalf("hide inactive peer: %v", err)
}
service := &countingStoriesService{StoriesService: appstories.NewService(storyStore)}
versions := &fakeRPCReadModelVersions{hashes: map[store.ReadModelKey]int64{
storyPeerVersionKey(active): 401,
storyPeerVersionKey(inactiveHidden): 402,
storyHiddenListVersionKey(viewerID): 403,
}}
r := New(Config{}, Deps{Stories: service, ReadModelVersions: versions}, zaptest.NewLogger(t), fixedClock{now: time.Unix(150, 0)})
for i := 0; i < 2; i++ {
recent, hidden := r.storyProjectionMaps(ctx, viewerID, []domain.Peer{active, inactiveHidden})
if story, ok := recent[active]; !ok || story.MaxID != 7 {
t.Fatalf("recent(%d) = %+v, want active max id 7", i, recent)
}
if _, ok := recent[inactiveHidden]; ok || !hidden[inactiveHidden] {
t.Fatalf("inactive hidden projection(%d) recent=%+v hidden=%+v", i, recent, hidden)
}
}
if service.activeCandidateCalls != 1 || service.hiddenSnapshotCalls != 1 || service.projectionCalls != 1 {
t.Fatalf("sparse loads active=%d hidden=%d viewerProjection=%d, want 1/1/1",
service.activeCandidateCalls, service.hiddenSnapshotCalls, service.projectionCalls)
}
}
func TestUsersProjectStoriesHiddenWithoutActiveStory(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93101, Phone: "15550093101", FirstName: "HiddenOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93102, Phone: "15550093102", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
storyStore := memory.NewStoryStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
reqCtx := WithUserID(ctx, viewer.ID)
if ok, err := r.onStoriesTogglePeerStoriesHidden(reqCtx, &tg.StoriesTogglePeerStoriesHiddenRequest{
Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash},
Hidden: true,
}); err != nil || !ok {
t.Fatalf("toggle hidden = %v, %v", ok, err)
}
users, err := r.onUsersGetUsers(reqCtx, []tg.InputUserClass{
&tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash},
})
if err != nil {
t.Fatalf("get users after hide: %v", err)
}
if got := storiesHiddenForUser(t, users, owner.ID); !got {
t.Fatalf("users.getUsers stories_hidden = false, want true")
}
if ok, err := r.onStoriesTogglePeerStoriesHidden(reqCtx, &tg.StoriesTogglePeerStoriesHiddenRequest{
Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash},
Hidden: false,
}); err != nil || !ok {
t.Fatalf("toggle unhidden = %v, %v", ok, err)
}
users, err = r.onUsersGetUsers(reqCtx, []tg.InputUserClass{
&tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash},
})
if err != nil {
t.Fatalf("get users after unhide: %v", err)
}
if got := storiesHiddenForUser(t, users, owner.ID); got {
t.Fatalf("users.getUsers stories_hidden = true, want false")
}
}
func TestChannelsProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
const ownerID int64 = 1000000001
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "story channel",
Broadcast: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
ID: 11,
Date: 1700000000,
ExpireDate: 1700003600,
Pinned: true,
Public: true,
}}); err != nil {
t.Fatalf("upsert channel story: %v", err)
}
r := New(Config{}, Deps{
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
chats, err := r.onChannelsGetChannels(WithUserID(ctx, ownerID), []tg.InputChannelClass{input})
if err != nil {
t.Fatalf("get channels: %v", err)
}
list, ok := chats.(*tg.MessagesChats)
if !ok || len(list.Chats) != 1 {
t.Fatalf("channels = %T %+v, want one messages.chats", chats, chats)
}
assertChannelStoryMaxID(t, list.Chats[0], 11)
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), input)
if err != nil {
t.Fatalf("get full channel: %v", err)
}
if len(full.Chats) != 1 {
t.Fatalf("full chats = %d, want one", len(full.Chats))
}
assertChannelStoryMaxID(t, full.Chats[0], 11)
channelFull, ok := full.FullChat.(*tg.ChannelFull)
if !ok {
t.Fatalf("full chat = %T, want *tg.ChannelFull", full.FullChat)
}
if !channelFull.GetStoriesPinnedAvailable() {
t.Fatalf("channel full stories_pinned_available = false, want true")
}
}
func TestFullPeerPinnedAvailabilityUsesBooleanCache(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93401, Phone: "15550093401", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93402, Phone: "15550093402", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "pinned story profile cache",
Broadcast: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 7,
Date: 1700000000,
ExpireDate: 1700003600,
Pinned: true,
Public: true,
}}); err != nil {
t.Fatalf("upsert user story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
ID: 11,
Date: 1700000000,
ExpireDate: 1700003600,
Pinned: true,
Public: true,
}}); err != nil {
t.Fatalf("upsert channel story: %v", err)
}
stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: stories,
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
for i := 0; i < 2; i++ {
full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash})
if err != nil {
t.Fatalf("get full user #%d: %v", i+1, err)
}
if !full.FullUser.GetStoriesPinnedAvailable() {
t.Fatalf("user full #%d stories_pinned_available = false, want true", i+1)
}
}
input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
for i := 0; i < 2; i++ {
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input)
if err != nil {
t.Fatalf("get full channel #%d: %v", i+1, err)
}
channelFull, ok := full.FullChat.(*tg.ChannelFull)
if !ok {
t.Fatalf("full channel #%d = %T, want *tg.ChannelFull", i+1, full.FullChat)
}
if !channelFull.GetStoriesPinnedAvailable() {
t.Fatalf("channel full #%d stories_pinned_available = false, want true", i+1)
}
}
if stories.pinnedAvailCalls != 2 {
t.Fatalf("HasPinnedStories calls = %d, want 2", stories.pinnedAvailCalls)
}
if stories.pinnedStoriesCalls != 0 {
t.Fatalf("GetPinnedStories calls = %d, want 0", stories.pinnedStoriesCalls)
}
}
func TestGetPinnedStoriesCachesUntilStoryMutation(t *testing.T) {
ctx := context.Background()
ownerID := int64(93541)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: owner,
ID: 61,
Date: 1700000000,
ExpireDate: 1700003600,
Pinned: true,
Public: true,
}}); err != nil {
t.Fatalf("upsert pinned story: %v", err)
}
stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)}
r := New(Config{}, Deps{Stories: stories}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
reqCtx := WithUserID(ctx, ownerID)
req := &tg.StoriesGetPinnedStoriesRequest{Peer: &tg.InputPeerSelf{}, Limit: 10}
for i := 0; i < 2; i++ {
got, err := r.onStoriesGetPinnedStories(reqCtx, req)
if err != nil {
t.Fatalf("get pinned stories #%d: %v", i+1, err)
}
if got.Count != 1 || len(got.Stories) != 1 {
t.Fatalf("pinned stories #%d = count %d len %d, want one", i+1, got.Count, len(got.Stories))
}
}
if stories.pinnedStoriesCalls != 1 {
t.Fatalf("GetPinnedStories calls = %d, want 1 after cache hit", stories.pinnedStoriesCalls)
}
if _, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{
Peer: &tg.InputPeerSelf{},
ID: []int{61},
Pinned: false,
}); err != nil {
t.Fatalf("unpin story: %v", err)
}
got, err := r.onStoriesGetPinnedStories(reqCtx, req)
if err != nil {
t.Fatalf("get pinned stories after unpin: %v", err)
}
if got.Count != 0 || len(got.Stories) != 0 {
t.Fatalf("pinned stories after unpin = count %d len %d, want empty", got.Count, len(got.Stories))
}
if stories.pinnedStoriesCalls != 2 {
t.Fatalf("GetPinnedStories calls after mutation = %d, want 2", stories.pinnedStoriesCalls)
}
}
func TestChannelParticipantsProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93501, Phone: "15550093501", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 93502, Phone: "15550093502", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story participants",
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 29,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert inviter story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID},
ID: 31,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert member story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: input,
Filter: &tg.ChannelParticipantsRecent{},
Limit: 10,
})
if err != nil {
t.Fatalf("get participants: %v", err)
}
page, ok := participants.(*tg.ChannelsChannelParticipants)
if !ok {
t.Fatalf("participants = %T, want *tg.ChannelsChannelParticipants", participants)
}
assertUserStoryMaxID(t, findUserClass(page.Users, member.ID), 31)
assertUserStoryMaxID(t, findUserClass(page.Users, owner.ID), 29)
single, err := r.onChannelsGetParticipant(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantRequest{
Channel: input,
Participant: &tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("get participant: %v", err)
}
self, ok := single.Participant.(*tg.ChannelParticipantSelf)
if !ok {
t.Fatalf("participant = %T, want *tg.ChannelParticipantSelf", single.Participant)
}
if self.InviterID != owner.ID {
t.Fatalf("self inviter = %d, want owner %d", self.InviterID, owner.ID)
}
assertUserStoryMaxID(t, findUserClass(single.Users, member.ID), 31)
assertUserStoryMaxID(t, findUserClass(single.Users, owner.ID), 29)
}
func TestChannelParticipantsReuseStoryProjectionWithinBurst(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93511, Phone: "15550093511", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 93512, Phone: "15550093512", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story participants cache",
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 41,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert owner story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID},
ID: 43,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert member story: %v", err)
}
stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: stories,
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
for i := 0; i < 2; i++ {
participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: input,
Filter: &tg.ChannelParticipantsRecent{},
Limit: 10,
})
if err != nil {
t.Fatalf("get participants #%d: %v", i+1, err)
}
page, ok := participants.(*tg.ChannelsChannelParticipants)
if !ok {
t.Fatalf("participants #%d = %T, want *tg.ChannelsChannelParticipants", i+1, participants)
}
assertUserStoryMaxID(t, findUserClass(page.Users, member.ID), 43)
assertUserStoryMaxID(t, findUserClass(page.Users, owner.ID), 41)
}
if stories.projectionCalls != 1 || stories.maxIDCalls != 0 || stories.hiddenCalls != 0 {
t.Fatalf("story projection calls combined=%d max_id=%d hidden=%d, want 1/0/0", stories.projectionCalls, stories.maxIDCalls, stories.hiddenCalls)
}
}
func TestStoryProjectionSingleflightsConcurrentMiss(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93521, Phone: "15550093521", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 93522, Phone: "15550093522", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
storyStore := memory.NewStoryStore()
for _, item := range []struct {
owner domain.Peer
id int
}{
{owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, id: 51},
{owner: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, id: 53},
} {
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: item.owner,
ID: item.id,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert story %d: %v", item.id, err)
}
}
stories := &blockingProjectionStoriesService{
StoriesService: appstories.NewService(storyStore),
started: make(chan struct{}),
release: make(chan struct{}),
}
r := New(Config{}, Deps{Stories: stories}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
peers := []domain.Peer{
{Type: domain.PeerTypeUser, ID: owner.ID},
{Type: domain.PeerTypeUser, ID: member.ID},
}
const goroutines = 16
var wg sync.WaitGroup
errs := make(chan string, goroutines)
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
recent, hidden := r.storyProjectionMaps(ctx, owner.ID, peers)
if story, ok := recent[peers[0]]; !ok || story.MaxID != 51 {
errs <- "owner story max id missing"
return
}
if story, ok := recent[peers[1]]; !ok || story.MaxID != 53 {
errs <- "member story max id missing"
return
}
if got := hidden[peers[0]] || hidden[peers[1]]; got {
errs <- "hidden state should be false"
}
}()
}
<-stories.started
time.Sleep(20 * time.Millisecond)
close(stories.release)
wg.Wait()
close(errs)
for err := range errs {
if err != "" {
t.Fatal(err)
}
}
if got := stories.callCount(); got != 1 {
t.Fatalf("projection calls = %d, want 1", got)
}
}
func TestStoryProjectionCacheSurvivesFastDialogBurstWindow(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000100, 0)
viewerID := int64(93531)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 93532}
cache := newStoryProjectionCache(func() time.Time { return now })
loadCalls := 0
load := func(_ context.Context, _ []domain.Peer) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool) {
loadCalls++
return map[domain.Peer]tg.RecentStory{peer: {MaxID: 7}}, map[domain.Peer]bool{peer: true}
}
// 首次:miss → load → 缓存。
if recent, hidden := cache.getMany(ctx, viewerID, []domain.Peer{peer}, load); recent[peer].MaxID != 7 || !hidden[peer] {
t.Fatalf("first getMany recent=%+v hidden=%v", recent[peer], hidden[peer])
}
// 推进 11s(仍在 TTL 内):再次应命中缓存、不再 load。
now = now.Add(11 * time.Second)
recent, hidden := cache.getMany(ctx, viewerID, []domain.Peer{peer}, load)
if loadCalls != 1 {
t.Fatalf("burst window re-loaded: loadCalls=%d, want 1", loadCalls)
}
if story, ok := recent[peer]; !ok || story.MaxID != 7 {
t.Fatalf("recent story after burst window = %+v ok=%v, want max_id 7", story, ok)
}
if state, ok := hidden[peer]; !ok || !state {
t.Fatalf("hidden after burst window = %v ok=%v, want true", state, ok)
}
}
func TestChannelAdminLogProjectsStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93601, Phone: "15550093601", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story admin log",
Megagroup: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if _, err := channelService.EditTitle(ctx, owner.ID, domain.EditChannelTitleRequest{
ChannelID: created.Channel.ID,
Title: "story admin log renamed",
Date: 1700000010,
}); err != nil {
t.Fatalf("edit title: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 37,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert owner story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
ID: 41,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert channel story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{
Channel: input,
Limit: 10,
})
if err != nil {
t.Fatalf("get admin log: %v", err)
}
if len(adminLog.Events) == 0 {
t.Fatalf("admin log events = none, want title event")
}
assertUserStoryMaxID(t, findUserClass(adminLog.Users, owner.ID), 37)
assertChannelStoryMaxID(t, findChannelClass(adminLog.Chats, created.Channel.ID), 41)
}
func TestMessagesHistoryAndSearchProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93701, Phone: "15550093701", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93702, Phone: "15550093702", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 43,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert owner story: %v", err)
}
messages := &captureMessages{list: domain.MessageList{
Messages: []domain.Message{{
ID: 1,
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Date: 1700000100,
Body: "needle",
}},
Users: []domain.User{owner},
Count: 1,
}}
r := New(Config{}, Deps{
Messages: messages,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
peer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}
history := dispatchMessagesPayload(t, r, WithUserID(ctx, viewer.ID), &tg.MessagesGetHistoryRequest{
Peer: peer,
Limit: 20,
})
_, _, users := searchMessagesPayload(t, history)
assertUserStoryMaxID(t, findUserClass(users, owner.ID), 43)
search := dispatchMessagesPayload(t, r, WithUserID(ctx, viewer.ID), &tg.MessagesSearchRequest{
Peer: peer,
Q: "needle",
Filter: &tg.InputMessagesFilterEmpty{},
Limit: 20,
})
_, _, users = searchMessagesPayload(t, search)
assertUserStoryMaxID(t, findUserClass(users, owner.ID), 43)
}
func TestChannelHistoryAndSearchProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93801, Phone: "15550093801", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story history channel",
Megagroup: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 1,
Message: "channel needle",
Date: 1700000010,
}); err != nil {
t.Fatalf("send channel message: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 47,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert owner story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
ID: 53,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert channel story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
history := dispatchMessagesPayload(t, r, WithUserID(ctx, owner.ID), &tg.MessagesGetHistoryRequest{
Peer: peer,
Limit: 20,
})
_, chats, users := searchMessagesPayload(t, history)
assertUserStoryMaxID(t, findUserClass(users, owner.ID), 47)
assertChannelStoryMaxID(t, findChannelClass(chats, created.Channel.ID), 53)
search := dispatchMessagesPayload(t, r, WithUserID(ctx, owner.ID), &tg.MessagesSearchRequest{
Peer: peer,
Q: "needle",
Filter: &tg.InputMessagesFilterEmpty{},
Limit: 20,
})
_, chats, users = searchMessagesPayload(t, search)
assertUserStoryMaxID(t, findUserClass(users, owner.ID), 47)
assertChannelStoryMaxID(t, findChannelClass(chats, created.Channel.ID), 53)
}
func TestRepliesDiscussionAndForumTopicsProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93901, Phone: "15550093901", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 93902, Phone: "15550093902", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
broadcast, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story discussion source",
Broadcast: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
group, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story discussion group",
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1700000001,
})
if err != nil {
t.Fatalf("create group: %v", err)
}
forum, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story forum",
Megagroup: true,
Forum: true,
MemberUserIDs: []int64{member.ID},
Date: 1700000002,
})
if err != nil {
t.Fatalf("create forum: %v", err)
}
storyStore := memory.NewStoryStore()
upsertStory := func(peer domain.Peer, id int) {
t.Helper()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: peer,
ID: id,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert story %v/%d: %v", peer, id, err)
}
}
upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, 57)
upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, 59)
upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: broadcast.Channel.ID}, 61)
upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: group.Channel.ID}, 63)
upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: forum.Channel.ID}, 67)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
broadcastPeer := &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}
groupPeer := &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}
forumPeer := &tg.InputPeerChannel{ChannelID: forum.Channel.ID, AccessHash: forum.Channel.AccessHash}
if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{
Broadcast: &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash},
Group: &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash},
}); err != nil || !ok {
t.Fatalf("set discussion group = ok %v err %v, want true", ok, err)
}
postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: broadcastPeer,
Message: "story post",
RandomID: 939001,
})
if err != nil {
t.Fatalf("send broadcast post: %v", err)
}
post := findNewChannelTextMessage(t, postUpdates, "story post")
discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{
Peer: broadcastPeer,
MsgID: post.ID,
})
if err != nil {
t.Fatalf("get discussion message: %v", err)
}
if len(discussion.Messages) != 1 || len(discussion.Chats) != 2 {
t.Fatalf("discussion = %+v, want one root message and source/group chats", discussion)
}
assertChannelStoryMaxID(t, findChannelClass(discussion.Chats, broadcast.Channel.ID), 61)
assertChannelStoryMaxID(t, findChannelClass(discussion.Chats, group.Channel.ID), 63)
root := discussion.Messages[0].(*tg.Message)
replyTo := &tg.InputReplyToMessage{ReplyToMsgID: root.ID}
replyReq := &tg.MessagesSendMessageRequest{
Peer: groupPeer,
Message: "story discussion reply",
RandomID: 939002,
}
replyReq.SetReplyTo(replyTo)
replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), replyReq)
if err != nil {
t.Fatalf("send discussion reply: %v", err)
}
comment := findNewChannelTextMessage(t, replyUpdates, "story discussion reply")
replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{
Peer: broadcastPeer,
MsgID: post.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("get replies: %v", err)
}
replyMessages, replyChats, replyUsers := searchMessagesPayload(t, replies)
if len(replyMessages) != 1 || replyMessages[0].(*tg.Message).ID != comment.ID {
t.Fatalf("replies = %T %+v, want linked discussion reply %d", replies, replies, comment.ID)
}
assertUserStoryMaxID(t, findUserClass(replyUsers, member.ID), 59)
assertChannelStoryMaxID(t, findChannelClass(replyChats, broadcast.Channel.ID), 61)
assertChannelStoryMaxID(t, findChannelClass(replyChats, group.Channel.ID), 63)
forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{
Peer: forumPeer,
Limit: 10,
})
if err != nil {
t.Fatalf("get forum topics: %v", err)
}
if forumTopics.Count != 1 || len(forumTopics.Topics) != 1 || forumTopics.Pts == 0 {
t.Fatalf("forum topics = %+v, want general topic with pts", forumTopics)
}
assertUserStoryMaxID(t, findUserClass(forumTopics.Users, owner.ID), 57)
assertChannelStoryMaxID(t, findChannelClass(forumTopics.Chats, forum.Channel.ID), 67)
forumTopicsByID, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
Peer: forumPeer,
Topics: []int{forumGeneralTopicID},
})
if err != nil {
t.Fatalf("get forum topics by id: %v", err)
}
if forumTopicsByID.Count != 1 || len(forumTopicsByID.Topics) != 1 || forumTopicsByID.Pts != forumTopics.Pts {
t.Fatalf("forum topics by id = %+v, want same general topic pts %d", forumTopicsByID, forumTopics.Pts)
}
assertUserStoryMaxID(t, findUserClass(forumTopicsByID.Users, owner.ID), 57)
assertChannelStoryMaxID(t, findChannelClass(forumTopicsByID.Chats, forum.Channel.ID), 67)
}
func TestViewsUnreadMentionsAndReactionsProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93911, Phone: "15550093911", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 93912, Phone: "15550093912", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story view surfaces",
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
storyStore := memory.NewStoryStore()
upsertStory := func(peer domain.Peer, id int) {
t.Helper()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: peer,
ID: id,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert story %v/%d: %v", peer, id, err)
}
}
upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, 71)
upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, 73)
upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, 79)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
viewed, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 9391101,
Message: "story views",
Date: 1700000010,
})
if err != nil {
t.Fatalf("send viewed message: %v", err)
}
views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, member.ID), &tg.MessagesGetMessagesViewsRequest{
Peer: peer,
ID: []int{viewed.Message.ID},
})
if err != nil {
t.Fatalf("get message views: %v", err)
}
if len(views.Views) != 1 {
t.Fatalf("views = %+v, want one view", views)
}
assertUserStoryMaxID(t, findUserClass(views.Users, owner.ID), 71)
assertChannelStoryMaxID(t, findChannelClass(views.Chats, created.Channel.ID), 79)
mention, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 9391102,
Message: "story mention",
MentionUserIDs: []int64{member.ID},
Date: 1700000020,
})
if err != nil {
t.Fatalf("send mention: %v", err)
}
mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{
Peer: peer,
Limit: 10,
})
if err != nil {
t.Fatalf("get unread mentions: %v", err)
}
mentionMessages, mentionChats, mentionUsers := searchMessagesPayload(t, mentions)
if len(mentionMessages) != 1 || mentionMessages[0].(*tg.Message).ID != mention.Message.ID {
t.Fatalf("mentions = %T %+v, want mention message %d", mentions, mentions, mention.Message.ID)
}
assertUserStoryMaxID(t, findUserClass(mentionUsers, owner.ID), 71)
assertChannelStoryMaxID(t, findChannelClass(mentionChats, created.Channel.ID), 79)
reactionReq := &tg.MessagesSendReactionRequest{
Peer: peer,
MsgID: viewed.Message.ID,
Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}},
}
reactionReq.SetReaction(reactionReq.Reaction)
if _, err := r.onMessagesSendReaction(WithUserID(ctx, member.ID), reactionReq); err != nil {
t.Fatalf("send reaction: %v", err)
}
unreadReactions, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{
Peer: peer,
Limit: 10,
})
if err != nil {
t.Fatalf("get unread reactions: %v", err)
}
reactionMessages, reactionChats, reactionUsers := searchMessagesPayload(t, unreadReactions)
if len(reactionMessages) != 1 || reactionMessages[0].(*tg.Message).ID != viewed.Message.ID {
t.Fatalf("unread reactions = %T %+v, want viewed message %d", unreadReactions, unreadReactions, viewed.Message.ID)
}
assertUserStoryMaxID(t, findUserClass(reactionUsers, member.ID), 73)
assertChannelStoryMaxID(t, findChannelClass(reactionChats, created.Channel.ID), 79)
}
func TestMessageReactionsListProjectsStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 93921, Phone: "15550093921", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := userStore.Create(ctx, domain.User{AccessHash: 93922, Phone: "15550093922", FirstName: "Member"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story reaction list",
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
storyStore := memory.NewStoryStore()
upsertStory := func(peer domain.Peer, id int) {
t.Helper()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: peer,
ID: id,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert story %v/%d: %v", peer, id, err)
}
}
upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, 83)
upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, 89)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
sent, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: created.Channel.ID,
RandomID: 9392101,
Message: "story reaction list",
Date: 1700000010,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
reactionReq := &tg.MessagesSendReactionRequest{
Peer: peer,
MsgID: sent.Message.ID,
Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}},
}
reactionReq.SetReaction(reactionReq.Reaction)
if _, err := r.onMessagesSendReaction(WithUserID(ctx, member.ID), reactionReq); err != nil {
t.Fatalf("send reaction: %v", err)
}
list, err := r.onMessagesGetMessageReactionsList(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReactionsListRequest{
Peer: peer,
ID: sent.Message.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("get message reactions list: %v", err)
}
if list.Count != 1 || len(list.Reactions) != 1 {
t.Fatalf("reaction list = %+v, want one reaction", list)
}
if peerID, ok := list.Reactions[0].PeerID.(*tg.PeerUser); !ok || peerID.UserID != member.ID {
t.Fatalf("reaction peer = %+v, want member %d", list.Reactions[0].PeerID, member.ID)
}
assertUserStoryMaxID(t, findUserClass(list.Users, member.ID), 83)
assertChannelStoryMaxID(t, findChannelClass(list.Chats, created.Channel.ID), 89)
}
func TestStoriesAllStoriesHydratesCompanionPeersWithStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 94001, Phone: "15550094001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story strip channel",
Broadcast: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 7,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert user story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID},
ID: 11,
Date: 1700000010,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert channel story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
got, err := r.onStoriesGetAllStories(WithUserID(ctx, owner.ID), &tg.StoriesGetAllStoriesRequest{})
if err != nil {
t.Fatalf("get all stories: %v", err)
}
all, ok := got.(*tg.StoriesAllStories)
if !ok {
t.Fatalf("all stories = %T, want *tg.StoriesAllStories", got)
}
if len(all.PeerStories) != 2 {
t.Fatalf("peer stories = %d, want user and channel stories", len(all.PeerStories))
}
assertUserStoryMaxID(t, findUserClass(all.Users, owner.ID), 7)
assertChannelStoryMaxID(t, findChannelClass(all.Chats, created.Channel.ID), 11)
peerStories, err := r.onStoriesGetPeerStories(WithUserID(ctx, owner.ID), &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash})
if err != nil {
t.Fatalf("get peer stories: %v", err)
}
assertUserStoryMaxID(t, findUserClass(peerStories.Users, owner.ID), 7)
}
func TestBuildOutboxStoryUpdatesHydratesCompanionPeersWithStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 95001, Phone: "15550095001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 95002, Phone: "15550095002", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
storyStore := memory.NewStoryStore()
story := domain.Story{
Owner: ownerPeer,
ID: 13,
Date: 1700000200,
ExpireDate: 1700003800,
Public: true,
Caption: "outbox story",
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil {
t.Fatalf("upsert story: %v", err)
}
registry := newFakeUsernameRegistry()
registry.byPeer[ownerPeer] = []domain.Username{
{Username: "story_owner", Editable: true, Active: true, SortOrder: 0},
{Username: "story_collectible", Active: true, SortOrder: 1, CollectibleID: 51},
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Stories: appstories.NewService(storyStore),
Usernames: registry,
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000300, 0)})
updates, err := r.BuildOutboxUpdates(ctx, []OutboxUpdateRequest{{
TargetUserID: viewer.ID,
Event: domain.UpdateEvent{
UserID: viewer.ID,
Type: domain.UpdateEventStory,
Pts: 21,
PtsCount: 1,
Date: story.Date,
Peer: ownerPeer,
Story: story,
},
}})
if err != nil {
t.Fatalf("BuildOutboxUpdates: %v", err)
}
if len(updates) != 1 || updates[0] == nil {
t.Fatalf("updates = %+v, want one story update", updates)
}
if len(updates[0].Updates) != 2 {
t.Fatalf("update count = %d, want updateStory plus pts bookkeeping", len(updates[0].Updates))
}
if _, ok := updates[0].Updates[0].(*tg.UpdateStory); !ok {
t.Fatalf("first update = %T, want updateStory", updates[0].Updates[0])
}
ownerUser := findUserClass(updates[0].Users, owner.ID)
assertUserStoryMaxID(t, ownerUser, 13)
projectedOwner := ownerUser.(*tg.User)
assertVectorOnlyUsernames(t, "story owner", projectedOwner, []string{"story_owner", "story_collectible"})
if registry.peerCalls != 1 || registry.batchCalls != 0 {
t.Fatalf("username registry reads = peer %d / batch %d, want one claim-wide read", registry.peerCalls, registry.batchCalls)
}
}
func TestBuildOutboxNewStoryReactionHydratesReactorUser(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 95201, Phone: "15550095201", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
reactor, err := userStore.Create(ctx, domain.User{AccessHash: 95202, Phone: "15550095202", FirstName: "Reactor"})
if err != nil {
t.Fatalf("create reactor: %v", err)
}
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
storyStore := memory.NewStoryStore()
story := domain.Story{
Owner: ownerPeer,
ID: 14,
Date: 1700000210,
ExpireDate: 1700003810,
Public: true,
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil {
t.Fatalf("upsert story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000310, 0)})
updates, err := r.BuildOutboxUpdates(ctx, []OutboxUpdateRequest{{
TargetUserID: owner.ID,
Event: domain.UpdateEvent{
UserID: owner.ID,
Type: domain.UpdateEventNewStoryReaction,
Pts: 22,
PtsCount: 1,
Date: 1700000310,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reactor.ID},
Story: story,
MaxID: story.ID,
Reaction: &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "🔥"},
},
}})
if err != nil {
t.Fatalf("BuildOutboxUpdates: %v", err)
}
if len(updates) != 1 || updates[0] == nil {
t.Fatalf("updates = %+v, want one new story reaction update", updates)
}
if len(updates[0].Updates) != 2 {
t.Fatalf("update count = %d, want updateNewStoryReaction plus pts bookkeeping", len(updates[0].Updates))
}
got, ok := updates[0].Updates[0].(*tg.UpdateNewStoryReaction)
if !ok {
t.Fatalf("first update = %T, want updateNewStoryReaction", updates[0].Updates[0])
}
peer, ok := got.Peer.(*tg.PeerUser)
if !ok || peer.UserID != reactor.ID {
t.Fatalf("reaction peer = %T %+v, want reactor user peer", got.Peer, got.Peer)
}
if findUserClass(updates[0].Users, reactor.ID) == nil {
t.Fatalf("users = %+v, want reactor companion user", updates[0].Users)
}
assertUserStoryMaxID(t, findUserClass(updates[0].Users, owner.ID), story.ID)
}
func TestDialogContactAndSearchPayloadsProjectStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 96001, Phone: "15550096001", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 96002, Phone: "15550096002", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story payload channel",
Broadcast: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: ownerPeer,
ID: 17,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert user story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: channelPeer,
ID: 19,
Date: 1700000010,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert channel story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
dialogs, ok := r.tgMessagesDialogs(ctx, viewer.ID, domain.DialogList{
Dialogs: []domain.Dialog{{Peer: ownerPeer}, {Peer: channelPeer}},
Users: []domain.User{owner},
Channels: []domain.Channel{
created.Channel,
},
}).(*tg.MessagesDialogs)
if !ok {
t.Fatalf("dialogs type = %T, want *tg.MessagesDialogs", dialogs)
}
assertUserStoryMaxID(t, findUserClass(dialogs.Users, owner.ID), 17)
assertChannelStoryMaxID(t, findChannelClass(dialogs.Chats, created.Channel.ID), 19)
peerDialogs := r.tgPeerDialogs(ctx, viewer.ID, domain.DialogList{
Dialogs: []domain.Dialog{{Peer: ownerPeer}, {Peer: channelPeer}},
Users: []domain.User{owner},
Channels: []domain.Channel{
created.Channel,
},
}, domain.UpdateState{Pts: 7, Date: 1700000100})
assertUserStoryMaxID(t, findUserClass(peerDialogs.Users, owner.ID), 17)
assertChannelStoryMaxID(t, findChannelClass(peerDialogs.Chats, created.Channel.ID), 19)
contacts, ok := r.tgContacts(ctx, viewer.ID, domain.ContactList{
Contacts: []domain.Contact{{User: owner}},
}).(*tg.ContactsContacts)
if !ok {
t.Fatalf("contacts type = %T, want *tg.ContactsContacts", contacts)
}
assertUserStoryMaxID(t, findUserClass(contacts.Users, owner.ID), 17)
found := r.tgContactsFound(ctx, viewer.ID, domain.UserSearchResult{
Results: []domain.User{owner},
ChannelResults: []domain.Channel{created.Channel},
})
assertUserStoryMaxID(t, findUserClass(found.Users, owner.ID), 17)
assertChannelStoryMaxID(t, findChannelClass(found.Chats, created.Channel.ID), 19)
contactUpdate := r.contactPeerSettingsUpdates(ctx, viewer.ID, owner, domain.PeerSettings{}, false)
assertUserStoryMaxID(t, findUserClass(contactUpdate.Users, owner.ID), 17)
global, ok := r.tgGlobalSearchMessages(ctx, viewer.ID, 10, domain.MessageList{
Users: []domain.User{owner},
}, domain.ChannelHistory{
Channels: []domain.Channel{created.Channel},
}).(*tg.MessagesMessages)
if !ok {
t.Fatalf("global search type = %T, want *tg.MessagesMessages", global)
}
assertUserStoryMaxID(t, findUserClass(global.Users, owner.ID), 17)
assertChannelStoryMaxID(t, findChannelClass(global.Chats, created.Channel.ID), 19)
}
func TestContactsResolveUsernameProjectsStoriesMaxID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 97001, Phone: "15550097001", FirstName: "Owner", Username: "story_owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
viewer, err := userStore.Create(ctx, domain.User{AccessHash: 97002, Phone: "15550097002", FirstName: "Viewer"})
if err != nil {
t.Fatalf("create viewer: %v", err)
}
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "story resolve channel",
Broadcast: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
public, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{
ChannelID: created.Channel.ID,
Username: "story_resolve_channel",
})
if err != nil {
t.Fatalf("set channel username: %v", err)
}
storyStore := memory.NewStoryStore()
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
ID: 23,
Date: 1700000000,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert user story: %v", err)
}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: public.ID},
ID: 29,
Date: 1700000010,
ExpireDate: 1700003600,
Public: true,
}}); err != nil {
t.Fatalf("upsert channel story: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: channelService,
Stories: appstories.NewService(storyStore),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
userResolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@story_owner"})
if err != nil {
t.Fatalf("resolve user: %v", err)
}
assertUserStoryMaxID(t, findUserClass(userResolved.Users, owner.ID), 23)
channelResolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@story_resolve_channel"})
if err != nil {
t.Fatalf("resolve channel: %v", err)
}
assertChannelStoryMaxID(t, findChannelClass(channelResolved.Chats, public.ID), 29)
}
func dispatchMessagesPayload(t *testing.T, r *Router, ctx context.Context, req bin.Encoder) bin.Encoder {
t.Helper()
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode request: %v", err)
}
out, err := r.Dispatch(ctx, [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
return out
}
func findNewChannelTextMessage(t *testing.T, updates tg.UpdatesClass, body string) *tg.Message {
t.Helper()
box, ok := updates.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", updates)
}
for _, update := range box.Updates {
newMsg, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
msg, ok := newMsg.Message.(*tg.Message)
if ok && msg.Message == body {
return msg
}
}
t.Fatalf("updates = %+v, want new channel message %q", box.Updates, body)
return nil
}
func findUserClass(users []tg.UserClass, id int64) tg.UserClass {
for _, item := range users {
if user, ok := item.(*tg.User); ok && user.ID == id {
return item
}
}
return nil
}
func findChannelClass(chats []tg.ChatClass, id int64) tg.ChatClass {
for _, item := range chats {
if channel, ok := item.(*tg.Channel); ok && channel.ID == id {
return item
}
}
return nil
}
func assertUserStoryMaxID(t *testing.T, item tg.UserClass, want int) {
t.Helper()
user, ok := item.(*tg.User)
if !ok {
t.Fatalf("user = %T, want *tg.User", item)
}
recent, ok := user.GetStoriesMaxID()
if !ok {
t.Fatalf("user stories_max_id missing")
}
assertRecentStoryMaxID(t, recent, want)
}
func assertChannelStoryMaxID(t *testing.T, item tg.ChatClass, want int) {
t.Helper()
channel, ok := item.(*tg.Channel)
if !ok {
t.Fatalf("channel = %T, want *tg.Channel", item)
}
recent, ok := channel.GetStoriesMaxID()
if !ok {
t.Fatalf("channel stories_max_id missing")
}
assertRecentStoryMaxID(t, recent, want)
}
func assertRecentStoryMaxID(t *testing.T, recent tg.RecentStory, want int) {
t.Helper()
got, ok := recent.GetMaxID()
if !ok || got != want {
t.Fatalf("recent story max_id = %d/%v, want %d", got, ok, want)
}
}