Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,714 @@
package memory
import (
"context"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
)
func TestChannelRealtimeRecipientsAreCapped(t *testing.T) {
store := NewChannelStore()
memberIDs := make([]int64, domain.MaxChannelRealtimeFanout+25)
for i := range memberIDs {
memberIDs[i] = int64(10_000 + i)
}
created, err := store.CreateChannel(context.Background(), domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "large realtime cap",
Megagroup: true,
MemberUserIDs: memberIDs,
Date: 1_700_000_100,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if got := len(created.Recipients); got != domain.MaxChannelRealtimeFanout {
t.Fatalf("create recipients = %d, want capped %d", got, domain.MaxChannelRealtimeFanout)
}
recipients, err := store.ListActiveChannelMemberIDs(context.Background(), 1, created.Channel.ID, 0)
if err != nil {
t.Fatalf("list active members: %v", err)
}
if got := len(recipients); got != domain.MaxChannelRealtimeFanout {
t.Fatalf("listed active members = %d, want capped %d", got, domain.MaxChannelRealtimeFanout)
}
}
func TestPendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "pending join requests",
Megagroup: true,
MemberUserIDs: []int64{2, 3, 4},
Date: 1_700_000_150,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: 1,
ChannelID: channelID,
MemberID: 2,
AdminRights: domain.ChannelAdminRights{
InviteUsers: true,
},
Date: 1_700_000_151,
}); err != nil {
t.Fatalf("promote invite admin: %v", err)
}
if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: 1,
ChannelID: channelID,
MemberID: 4,
AdminRights: domain.ChannelAdminRights{
ChangeInfo: true,
},
Date: 1_700_000_152,
}); err != nil {
t.Fatalf("promote change-info admin: %v", err)
}
invite, err := store.ExportInvite(ctx, domain.ExportChannelInviteRequest{
UserID: 1,
ChannelID: channelID,
Title: "approval",
RequestNeeded: true,
Date: 1_700_000_153,
})
if err != nil {
t.Fatalf("export invite: %v", err)
}
for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ {
_, err := store.ImportInvite(ctx, domain.ImportChannelInviteRequest{
UserID: int64(10 + i),
Hash: invite.Invite.Hash,
Date: 1_700_000_160 + i,
})
if !errors.Is(err, domain.ErrInviteRequestSent) {
t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err)
}
}
pending, err := store.PendingJoinRequests(ctx, channelID, 99)
if err != nil {
t.Fatalf("pending join requests: %v", err)
}
if pending.Count != domain.MaxChannelPendingJoinRecentRequesters+2 || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters {
t.Fatalf("pending summary = %+v, want bounded recent with full count", pending)
}
if pending.RecentRequesters[0] != 16 || pending.RecentRequesters[len(pending.RecentRequesters)-1] != 12 {
t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters)
}
admins, err := store.ListChannelInviteAdminMemberIDs(ctx, channelID, 0)
if err != nil {
t.Fatalf("invite admins: %v", err)
}
want := []int64{1, 2, 4}
if !reflect.DeepEqual(admins, want) {
t.Fatalf("invite admins = %+v, want %+v", admins, want)
}
}
func TestCommonChannelsOnlySharedMegagroups(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
first, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "common one",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_170,
})
if err != nil {
t.Fatalf("create first common channel: %v", err)
}
second, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "common two",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_171,
})
if err != nil {
t.Fatalf("create second common channel: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "broadcast excluded",
Broadcast: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_172,
}); err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
left, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "left excluded",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_173,
})
if err != nil {
t.Fatalf("create left channel: %v", err)
}
if _, err := store.LeaveChannel(ctx, left.Channel.ID, 2, 1_700_000_174); err != nil {
t.Fatalf("leave channel: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "not shared",
Megagroup: true,
MemberUserIDs: []int64{3},
Date: 1_700_000_175,
}); err != nil {
t.Fatalf("create non-shared channel: %v", err)
}
page, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{
UserID: 1,
TargetUserID: 2,
Limit: 10,
})
if err != nil {
t.Fatalf("list common channels: %v", err)
}
if page.Count != 2 || len(page.Channels) != 2 || page.Channels[0].ID != first.Channel.ID || page.Channels[1].ID != second.Channel.ID {
t.Fatalf("common channels = %+v, want two shared megagroups in id order", page)
}
next, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{
UserID: 1,
TargetUserID: 2,
MaxID: first.Channel.ID,
Limit: 1,
})
if err != nil {
t.Fatalf("list common channels after max id: %v", err)
}
if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].ID != second.Channel.ID {
t.Fatalf("paged common channels = %+v, want second channel with full count", next)
}
countOnly, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{
UserID: 1,
TargetUserID: 2,
CountOnly: true,
})
if err != nil {
t.Fatalf("count common channels: %v", err)
}
if countOnly.Count != 2 || len(countOnly.Channels) != 0 {
t.Fatalf("count-only common channels = %+v, want count without channels", countOnly)
}
}
func TestLeftChannelsReturnsPagedLeftMemberships(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
older, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "older left",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_180,
})
if err != nil {
t.Fatalf("create older channel: %v", err)
}
newer, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "newer left broadcast",
Broadcast: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_181,
})
if err != nil {
t.Fatalf("create newer channel: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "active excluded",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_182,
}); err != nil {
t.Fatalf("create active channel: %v", err)
}
if _, err := store.LeaveChannel(ctx, older.Channel.ID, 2, 1_700_000_183); err != nil {
t.Fatalf("leave older channel: %v", err)
}
if _, err := store.LeaveChannel(ctx, newer.Channel.ID, 2, 1_700_000_184); err != nil {
t.Fatalf("leave newer channel: %v", err)
}
page, err := store.ListLeftChannels(ctx, 2, 0, 1)
if err != nil {
t.Fatalf("list left channels: %v", err)
}
if page.Count != 2 || len(page.Channels) != 1 || page.Channels[0].Channel.ID != newer.Channel.ID || page.Channels[0].Self.Status != domain.ChannelMemberLeft {
t.Fatalf("first left page = %+v, want newest left channel and full count", page)
}
next, err := store.ListLeftChannels(ctx, 2, 1, 1)
if err != nil {
t.Fatalf("list next left channels: %v", err)
}
if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].Channel.ID != older.Channel.ID {
t.Fatalf("second left page = %+v, want older left channel", next)
}
empty, err := store.ListLeftChannels(ctx, 2, 2, 1)
if err != nil {
t.Fatalf("list empty left page: %v", err)
}
if empty.Count != 2 || len(empty.Channels) != 0 {
t.Fatalf("empty left page = %+v, want full count and no chats", empty)
}
if _, err := store.ListLeftChannels(ctx, 2, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) {
t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err)
}
}
func TestDiscussionGroupLinksAreBidirectionalAndReplaceOldLinks(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "broadcast",
Broadcast: true,
Date: 1_700_000_190,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
firstGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "first group",
Megagroup: true,
Date: 1_700_000_191,
})
if err != nil {
t.Fatalf("create first group: %v", err)
}
secondGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "second group",
Megagroup: true,
Date: 1_700_000_192,
})
if err != nil {
t.Fatalf("create second group: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "broadcast excluded",
Broadcast: true,
Date: 1_700_000_193,
}); err != nil {
t.Fatalf("create excluded broadcast: %v", err)
}
candidates, err := store.ListDiscussionGroups(ctx, 1, 10)
if err != nil {
t.Fatalf("list discussion groups: %v", err)
}
if len(candidates) != 2 || candidates[0].ID != secondGroup.Channel.ID || candidates[1].ID != firstGroup.Channel.ID {
t.Fatalf("discussion candidates = %+v, want creator megagroups newest id first", candidates)
}
linked, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID)
if err != nil {
t.Fatalf("link first group: %v", err)
}
if len(linked.Channels) != 2 {
t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels)
}
gotBroadcast, err := store.GetChannelByID(ctx, broadcast.Channel.ID)
if err != nil {
t.Fatalf("get linked broadcast: %v", err)
}
gotFirst, err := store.GetChannelByID(ctx, firstGroup.Channel.ID)
if err != nil {
t.Fatalf("get linked first group: %v", err)
}
if gotBroadcast.LinkedChatID != firstGroup.Channel.ID || gotFirst.LinkedChatID != broadcast.Channel.ID {
t.Fatalf("first link = broadcast %+v group %+v, want bidirectional ids", gotBroadcast, gotFirst)
}
replaced, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, secondGroup.Channel.ID)
if err != nil {
t.Fatalf("replace discussion group: %v", err)
}
if len(replaced.Channels) != 3 {
t.Fatalf("replace changed channels = %+v, want broadcast, old group, new group", replaced.Channels)
}
gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID)
gotFirst, _ = store.GetChannelByID(ctx, firstGroup.Channel.ID)
gotSecond, err := store.GetChannelByID(ctx, secondGroup.Channel.ID)
if err != nil {
t.Fatalf("get linked second group: %v", err)
}
if gotBroadcast.LinkedChatID != secondGroup.Channel.ID || gotSecond.LinkedChatID != broadcast.Channel.ID || gotFirst.LinkedChatID != 0 {
t.Fatalf("replace link = broadcast %d first %d second %d, want old cleared and new bidirectional",
gotBroadcast.LinkedChatID, gotFirst.LinkedChatID, gotSecond.LinkedChatID)
}
unlinked, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID)
if err != nil {
t.Fatalf("unlink from group side: %v", err)
}
if len(unlinked.Channels) != 2 {
t.Fatalf("unlink changed channels = %+v, want broadcast and group", unlinked.Channels)
}
gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID)
gotSecond, _ = store.GetChannelByID(ctx, secondGroup.Channel.ID)
if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 {
t.Fatalf("unlink = broadcast %d second %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID)
}
if _, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) {
t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err)
}
if _, err := store.SetPreHistoryHidden(ctx, 1, firstGroup.Channel.ID, true); err != nil {
t.Fatalf("hide first group prehistory: %v", err)
}
if _, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) {
t.Fatalf("hidden group link err = %v, want ErrMegagroupPrehistoryHidden", err)
}
}
func TestChannelDeleteHistoryCapsHugeMaxID(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "bounded delete history",
Megagroup: true,
Date: 1_700_000_200,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
totalMessages := domain.MaxDeleteHistoryBatch + 2
for i := 0; i < totalMessages; i++ {
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: int64(10_000 + i),
Message: "bulk",
Date: 1_700_000_201 + i,
}); err != nil {
t.Fatalf("send channel message %d: %v", i, err)
}
}
first, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: int(^uint(0) >> 1),
ForEveryone: true,
Date: 1_700_001_300,
})
if err != nil {
t.Fatalf("delete first batch: %v", err)
}
if first.Offset != 1 || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch || first.Event.PtsCount != domain.MaxDeleteHistoryBatch {
t.Fatalf("first batch = %+v, want capped page with offset", first)
}
second, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: int(^uint(0) >> 1),
ForEveryone: true,
Date: 1_700_001_301,
})
if err != nil {
t.Fatalf("delete second batch: %v", err)
}
if second.Offset != 0 || len(second.DeletedIDs) != 3 || second.Event.PtsCount != 3 {
t.Fatalf("second batch = %+v, want final bounded page", second)
}
}
func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "monotonic local clear",
Megagroup: true,
Date: 1_700_000_250,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
first, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 30_001,
Message: "first visible",
Date: 1_700_000_251,
})
if err != nil {
t.Fatalf("send first message: %v", err)
}
second, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 30_002,
Message: "second visible",
Date: 1_700_000_252,
})
if err != nil {
t.Fatalf("send second message: %v", err)
}
high, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: second.Message.ID,
Date: 1_700_000_253,
})
if err != nil {
t.Fatalf("clear high watermark: %v", err)
}
if high.AvailableMinID != second.Message.ID {
t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID)
}
stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: first.Message.ID,
Date: 1_700_000_254,
})
if err != nil {
t.Fatalf("clear stale low watermark: %v", err)
}
if stale.AvailableMinID != second.Message.ID {
t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID)
}
history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil {
t.Fatalf("list history: %v", err)
}
if len(history.Messages) != 0 {
t.Fatalf("history after stale clear = %+v, want no visible messages", history.Messages)
}
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get channel dialog: %v", err)
}
if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs)
}
if dialogs.Dialogs[0].TopMessage != 0 || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 {
t.Fatalf("dialog after stale clear = %+v, want top=0 read=%d unread=0", dialogs.Dialogs[0], second.Message.ID)
}
}
func TestChannelListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "single copy dialog top",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_300,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if _, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: 2,
ChannelID: created.Channel.ID,
MaxID: created.Message.ID,
Date: 1_700_000_301,
}); err != nil {
t.Fatalf("read initial service message: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 88,
Message: "visible without write fanout",
Date: 1_700_000_302,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
list, err := store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list recipient channel dialogs: %v", err)
}
if len(list.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one channel dialog", list.Dialogs)
}
dialog := list.Dialogs[0]
if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date || dialog.UnreadCount != 1 {
t.Fatalf("recipient dialog = %+v, want top sent message and unread=1", dialog)
}
if len(list.Messages) != 1 || list.Messages[0].ID != sent.Message.ID {
t.Fatalf("dialog messages = %+v, want sent top message", list.Messages)
}
}
func TestChannelUnreadExcludesOwnOutgoing(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "own outgoing unread",
Megagroup: true,
Date: 1_700_000_360,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 36_001,
Message: "own outgoing only",
Date: 1_700_000_361,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
store.mu.Lock()
member := store.members[created.Channel.ID][1]
member.ReadInboxMaxID = sent.Message.ID - 1
store.members[created.Channel.ID][1] = member
dialog := store.dialogs[1][created.Channel.ID]
dialog.ReadInboxMaxID = sent.Message.ID - 1
dialog.UnreadCount = 99
store.dialogs[1][created.Channel.ID] = dialog
store.mu.Unlock()
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get channel dialogs: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 0 {
t.Fatalf("dialogs = %+v, want own outgoing excluded from unread", dialogs.Dialogs)
}
read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: sent.Message.ID,
Date: 1_700_000_362,
})
if err != nil {
t.Fatalf("read channel history: %v", err)
}
if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 {
t.Fatalf("read result = %+v, want no own-outgoing unread", read)
}
}
func TestChannelReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "visible unread reaction",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_400,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 40_001,
Message: "react to this",
Date: 1_700_000_401,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: 2,
ChannelID: created.Channel.ID,
MessageID: sent.Message.ID,
Reactions: []domain.MessageReaction{{
Type: domain.MessageReactionEmoji,
Emoticon: "\U0001f525",
}},
Date: 1_700_000_402,
}); err != nil {
t.Fatalf("set channel reaction: %v", err)
}
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get owner channel dialogs: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 1 {
t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs)
}
unread, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{
ChannelID: created.Channel.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("list unread reactions: %v", err)
}
if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID {
t.Fatalf("unread reactions = %+v, want sent message", unread.Messages)
}
if unread.Messages[0].Reactions == nil || !hasUnreadChannelReaction(*unread.Messages[0].Reactions) {
t.Fatalf("unread message reactions = %+v, want unread recent reaction", unread.Messages[0].Reactions)
}
read, err := store.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{
UserID: 1,
ChannelID: created.Channel.ID,
IDs: []int{sent.Message.ID},
})
if err != nil {
t.Fatalf("read channel message contents: %v", err)
}
if !reflect.DeepEqual(read.ClearedUnreadReactionMessageIDs, []int{sent.Message.ID}) {
t.Fatalf("cleared reaction ids = %+v, want [%d]", read.ClearedUnreadReactionMessageIDs, sent.Message.ID)
}
if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || hasUnreadChannelReaction(*read.Messages[0].Reactions) {
t.Fatalf("read messages = %+v, want reaction returned as read", read.Messages)
}
unreadAfter, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{
ChannelID: created.Channel.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("list unread reactions after read contents: %v", err)
}
if len(unreadAfter.Messages) != 0 {
t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter.Messages)
}
dialogsAfter, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get dialogs after read contents: %v", err)
}
if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 {
t.Fatalf("dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs)
}
}
func hasUnreadChannelReaction(reactions domain.ChannelMessageReactions) bool {
for _, recent := range reactions.Recent {
if recent.Unread {
return true
}
}
return false
}

View file

@ -0,0 +1,229 @@
package memory
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestDialogStoreFiltersAndPaginates(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
list := domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000, Pinned: true},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800},
},
Messages: []domain.Message{
{ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "pinned"},
{ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "first"},
{ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "second"},
},
}
if err := store.SaveList(ctx, userID, list); err != nil {
t.Fatalf("SaveList: %v", err)
}
first, err := store.ListByUser(ctx, userID, domain.DialogFilter{ExcludePinned: true, Limit: 1})
if err != nil {
t.Fatalf("ListByUser first page: %v", err)
}
if first.Count != 2 || len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != 2 || len(first.Messages) != 1 || first.Messages[0].ID != 9 {
t.Fatalf("first page = %+v, want peer 2 with count 2 and top message", first)
}
next, err := store.ListByUser(ctx, userID, domain.DialogFilter{
ExcludePinned: true,
OffsetDate: first.Dialogs[0].TopMessageDate,
OffsetID: first.Dialogs[0].TopMessage,
HasOffsetPeer: true,
OffsetPeer: first.Dialogs[0].Peer,
Limit: 10,
})
if err != nil {
t.Fatalf("ListByUser next page: %v", err)
}
if next.Count != 2 || len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != 3 || len(next.Messages) != 1 || next.Messages[0].ID != 8 {
t.Fatalf("next page = %+v, want peer 3 with count 2 and top message", next)
}
}
func TestDialogStoreFoldersAndCustomFilters(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
contactPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1}
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2}
strangerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 3}
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: contactPeer, TopMessage: 10, TopMessageDate: 1000},
{Peer: archivedPeer, TopMessage: 9, TopMessageDate: 900, FolderID: domain.DialogArchiveFolderID},
{Peer: strangerPeer, TopMessage: 8, TopMessageDate: 800, UnreadCount: 1},
},
Users: []domain.User{
{ID: contactPeer.ID, Contact: true},
{ID: archivedPeer.ID, Contact: true},
{ID: strangerPeer.ID},
},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
main, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10})
if err != nil {
t.Fatalf("ListByUser main: %v", err)
}
if len(main.Dialogs) != 2 || main.Dialogs[0].Peer != contactPeer || main.Dialogs[1].Peer != strangerPeer {
t.Fatalf("main dialogs = %+v, want non-archived dialogs", main.Dialogs)
}
archive, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
if err != nil {
t.Fatalf("ListByUser archive: %v", err)
}
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer != archivedPeer {
t.Fatalf("archive dialogs = %+v, want archived peer", archive.Dialogs)
}
folder := domain.DialogFolder{
ID: 2,
Contacts: true,
ExcludeArchived: true,
IncludePeers: []domain.DialogFolderPeer{{Peer: strangerPeer}},
}
if err := store.UpsertFolder(ctx, userID, folder); err != nil {
t.Fatalf("UpsertFolder: %v", err)
}
custom, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10})
if err != nil {
t.Fatalf("ListByUser custom: %v", err)
}
if len(custom.Dialogs) != 2 || custom.Dialogs[0].Peer != contactPeer || custom.Dialogs[1].Peer != strangerPeer {
t.Fatalf("custom dialogs = %+v, want contact plus explicit stranger excluding archived", custom.Dialogs)
}
}
func TestUserStoreStartsAtTimestampBase(t *testing.T) {
ctx := context.Background()
store := NewUserStore()
u, err := store.Create(ctx, domain.User{
AccessHash: 1,
Phone: "15550000001",
FirstName: "Test",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if u.ID != domain.UserIDSequenceBase {
t.Fatalf("user id = %d, want base %d", u.ID, domain.UserIDSequenceBase)
}
}
func TestDialogStoreOffsetDateOnlyKeepsEnterpriseCountAndHash(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800},
},
Messages: []domain.Message{
{ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "first"},
{ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "second"},
{ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "third"},
},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser all: %v", err)
}
page, err := store.ListByUser(ctx, userID, domain.DialogFilter{OffsetDate: 900, Limit: 10})
if err != nil {
t.Fatalf("ListByUser offset date: %v", err)
}
if page.Count != 3 || page.Hash != all.Hash {
t.Fatalf("page summary = count %d hash %d, want full count/hash %d/%d", page.Count, page.Hash, all.Count, all.Hash)
}
if len(page.Dialogs) != 1 || page.Dialogs[0].Peer.ID != 3 || len(page.Messages) != 1 || page.Messages[0].ID != 8 {
t.Fatalf("page = %+v, want only dialog after offset date", page)
}
}
func TestDialogStoreEmptyPageKeepsEnterpriseCountAndHash(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900},
},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser all: %v", err)
}
empty, err := store.ListByUser(ctx, userID, domain.DialogFilter{
OffsetDate: 900,
OffsetID: 9,
HasOffsetPeer: true,
OffsetPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 2},
Limit: 10,
})
if err != nil {
t.Fatalf("ListByUser empty page: %v", err)
}
if empty.Count != 2 || empty.Hash != all.Hash || len(empty.Dialogs) != 0 {
t.Fatalf("empty page = %+v, want no page rows but full count/hash", empty)
}
}
func TestDialogStoreListByPeersReturnsExistingAndPlaceholders(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
official := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
missing := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: official, TopMessage: 10, TopMessageDate: 1000, UnreadCount: 1},
},
Messages: []domain.Message{
{ID: 10, Peer: official, From: official, Body: "login"},
},
Users: []domain.User{domain.OfficialSystemUser()},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
got, err := store.ListByPeers(ctx, userID, []domain.Peer{official, missing, official})
if err != nil {
t.Fatalf("ListByPeers: %v", err)
}
if got.Count != 2 || len(got.Dialogs) != 2 {
t.Fatalf("dialogs = %+v, want existing official and missing placeholder", got)
}
if got.Dialogs[0].Peer != official || got.Dialogs[0].TopMessage != 10 {
t.Fatalf("first dialog = %+v, want official top message", got.Dialogs[0])
}
if got.Dialogs[1].Peer != missing || got.Dialogs[1].TopMessage != 0 {
t.Fatalf("second dialog = %+v, want missing placeholder", got.Dialogs[1])
}
if len(got.Messages) != 1 || got.Messages[0].ID != 10 {
t.Fatalf("messages = %+v, want only official top message", got.Messages)
}
if len(got.Users) != 1 || got.Users[0].ID != domain.OfficialSystemUserID {
t.Fatalf("users = %+v, want official user", got.Users)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,531 @@
package memory
import (
"context"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
)
func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
req := domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 99,
Message: "hello",
Date: 1700000100,
}
got, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if got.SenderMessage.ID != 1 || got.SenderMessage.OwnerUserID != req.SenderUserID || !got.SenderMessage.Out || got.SenderMessage.Pts != 1 {
t.Fatalf("sender message = %+v, want first outgoing box with pts=1", got.SenderMessage)
}
if got.RecipientMessage.ID != 1 || got.RecipientMessage.OwnerUserID != req.RecipientUserID || got.RecipientMessage.Out || got.RecipientMessage.Pts != 1 {
t.Fatalf("recipient message = %+v, want first incoming box with pts=1", got.RecipientMessage)
}
if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID {
t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID)
}
second, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: 100,
Message: "again",
Date: 1700000110,
})
if err != nil {
t.Fatalf("SendPrivateText second: %v", err)
}
if second.SenderMessage.ID != 2 || second.SenderMessage.Pts != 2 || second.RecipientMessage.ID != 2 || second.RecipientMessage.Pts != 2 {
t.Fatalf("second send = %+v/%+v, want per-owner box_id and pts to advance", second.SenderMessage, second.RecipientMessage)
}
dup, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText duplicate: %v", err)
}
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
t.Fatalf("duplicate = %+v, want original message boxes", dup)
}
senderHistory, err := messages.ListByUser(ctx, req.SenderUserID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 2 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages))
}
}
func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
aliceID := int64(1000000001)
bobID := int64(1000000002)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 101,
Message: "react to me",
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}
res, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: bobID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
MessageID: sent.RecipientMessage.ID,
Reactions: []domain.MessageReaction{
reaction,
},
Big: true,
Date: 1700000200,
})
if err != nil {
t.Fatalf("SetMessageReactions: %v", err)
}
if len(res.Messages) != 2 {
t.Fatalf("reaction result messages = %d, want both owner boxes", len(res.Messages))
}
aliceReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{
OwnerUserID: aliceID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
IDs: []int{sent.SenderMessage.ID},
})
if err != nil {
t.Fatalf("alice GetMessageReactions: %v", err)
}
if len(aliceReactions.Messages) != 1 || aliceReactions.Messages[0].Reactions == nil {
t.Fatalf("alice reactions = %+v, want one enriched message", aliceReactions)
}
if got := aliceReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].Reaction != reaction || got[0].Count != 1 || got[0].ChosenOrder != 0 {
t.Fatalf("alice reaction counts = %+v, want one peer reaction without chosen order", got)
}
if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || !got[0].Big || got[0].My {
t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got)
}
bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{
OwnerUserID: bobID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
IDs: []int{sent.RecipientMessage.ID},
})
if err != nil {
t.Fatalf("bob GetMessageReactions: %v", err)
}
if got := bobReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].ChosenOrder != 1 {
t.Fatalf("bob reaction counts = %+v, want own chosen order", got)
}
if got := bobReactions.Messages[0].Reactions.Recent; len(got) != 1 || !got[0].My {
t.Fatalf("bob recent reactions = %+v, want my reaction", got)
}
}
func TestMessageStoreSendPrivateTextReplyAndForwardMetadata(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
aliceID := int64(1000000001)
bobID := int64(1000000002)
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 501,
Message: "first",
Date: 1700000100,
})
if err != nil {
t.Fatalf("seed SendPrivateText: %v", err)
}
reply, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 502,
Message: "reply",
Silent: true,
NoForwards: true,
ReplyTo: &domain.MessageReply{
MessageID: first.SenderMessage.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
QuoteText: "fir",
QuoteOffset: 0,
},
Date: 1700000110,
})
if err != nil {
t.Fatalf("reply SendPrivateText: %v", err)
}
if reply.SenderMessage.ReplyTo == nil || reply.SenderMessage.ReplyTo.MessageID != first.SenderMessage.ID {
t.Fatalf("sender reply = %+v, want sender-side message id", reply.SenderMessage.ReplyTo)
}
if reply.RecipientMessage.ReplyTo == nil || reply.RecipientMessage.ReplyTo.MessageID != first.RecipientMessage.ID {
t.Fatalf("recipient reply = %+v, want translated recipient-side message id", reply.RecipientMessage.ReplyTo)
}
if !reply.SenderMessage.Silent || !reply.SenderMessage.NoForwards {
t.Fatalf("reply flags = silent %v noforwards %v, want true/true", reply.SenderMessage.Silent, reply.SenderMessage.NoForwards)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 504,
Message: "bad quote offset",
ReplyTo: &domain.MessageReply{
MessageID: first.SenderMessage.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
QuoteText: "fir",
QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1,
},
Date: 1700000115,
}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err)
}
forwarded, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{
OwnerUserID: aliceID,
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
ToUserID: bobID,
MessageIDs: []int{first.SenderMessage.ID},
RandomIDs: []int64{503},
ReplyTo: &domain.MessageReply{
MessageID: first.SenderMessage.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
},
Date: 1700000120,
})
if err != nil {
t.Fatalf("ForwardPrivateMessages: %v", err)
}
if len(forwarded.SenderMessages) != 1 || forwarded.SenderMessages[0].Forward == nil || forwarded.SenderMessages[0].Forward.From.ID != aliceID {
t.Fatalf("forwarded messages = %+v, want original author header", forwarded.SenderMessages)
}
if forwarded.SenderMessages[0].ReplyTo == nil || forwarded.SenderMessages[0].ReplyTo.MessageID != first.SenderMessage.ID {
t.Fatalf("forward reply = %+v, want target dialog reply header", forwarded.SenderMessages[0].ReplyTo)
}
if _, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{
OwnerUserID: aliceID,
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
ToUserID: aliceID,
MessageIDs: []int{reply.SenderMessage.ID},
RandomIDs: []int64{504},
Date: 1700000130,
}); err != domain.ErrChatForwardsRestricted {
t.Fatalf("forward protected err=%v, want ErrChatForwardsRestricted", err)
}
}
func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
aliceID := int64(1000000001)
bobID := int64(1000000002)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}
for i := 1; i <= 6; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: int64(600 + i),
Message: "history",
Date: 1700000000 + i,
}); err != nil {
t.Fatalf("seed message %d: %v", i, err)
}
}
around, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 6,
})
if err != nil {
t.Fatalf("around history: %v", err)
}
if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) {
t.Fatalf("around ids = %v, want unread/newer side plus older context", got)
}
forward, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 3,
})
if err != nil {
t.Fatalf("forward history: %v", err)
}
if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("forward ids = %v, want messages newer than offset", got)
}
hugePositive, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
AddOffset: 1 << 30,
Limit: 3,
})
if err != nil {
t.Fatalf("huge positive add_offset history: %v", err)
}
if len(hugePositive.Messages) != 0 {
t.Fatalf("huge positive add_offset ids = %v, want bounded empty page", messageIDs(hugePositive.Messages))
}
hugeNegative, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -1 << 30,
Limit: 3,
})
if err != nil {
t.Fatalf("huge negative add_offset history: %v", err)
}
if got := messageIDs(hugeNegative.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("huge negative add_offset ids = %v, want clamped forward page", got)
}
}
func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000001)
recipientID := int64(1000000002)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 101,
Message: "hello",
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipientID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
Date: 1700000200,
})
if err != nil {
t.Fatalf("ReadHistory: %v", err)
}
if !read.Changed || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.Pts != 2 || read.InboxEvent.MaxID != sent.RecipientMessage.ID {
t.Fatalf("inbox read = %+v, want recipient inbox pts=2 max recipient id", read)
}
if !read.OutboxChanged || read.OutboxUserID != senderID || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID {
t.Fatalf("outbox read = %+v, want sender outbox receipt with sender message id", read)
}
date, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{
OwnerUserID: senderID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
ID: sent.SenderMessage.ID,
})
if err != nil || date != 1700000200 {
t.Fatalf("outbox read date = %d err=%v, want read date", date, err)
}
}
func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: 88,
Message: "voice placeholder",
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
got, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID},
})
if err != nil {
t.Fatalf("ReadMessageContents: %v", err)
}
if !reflect.DeepEqual(got.MessageIDs, []int{sent.RecipientMessage.ID}) {
t.Fatalf("MessageIDs = %v, want existing recipient id", got.MessageIDs)
}
if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{0},
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("invalid id error = %v, want ErrMessageIDInvalid", err)
}
}
func messageIDs(messages []domain.Message) []int {
out := make([]int, 0, len(messages))
for _, msg := range messages {
out = append(out, msg.ID)
}
return out
}
func sameInts(got, want []int) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
func TestMessageStoreEditMessageUpdatesBothBoxes(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000001)
recipientID := int64(1000000002)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 102,
Message: "before",
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: senderID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
ID: sent.SenderMessage.ID,
Message: "after",
EditDate: 1700000200,
})
if err != nil {
t.Fatalf("EditMessage: %v", err)
}
if len(edited.Edited) != 2 || edited.Self().Message.Body != "after" || edited.Self().Event.Type != domain.UpdateEventEditMessage {
t.Fatalf("edited = %+v, want both owner boxes and self edit event", edited)
}
recipientHistory, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after" || recipientHistory.Messages[0].EditDate != 1700000200 {
t.Fatalf("recipient history = %+v, want edited body/date", recipientHistory.Messages)
}
}
func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000001)
recipientID := int64(1000000002)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}
for i := 0; i < 2; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: int64(100 + i),
Message: "hello",
Date: 1700000200 + i,
}); err != nil {
t.Fatalf("seed send %d: %v", i, err)
}
}
deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: senderID,
Peer: peer,
Date: 1700000300,
})
if err != nil {
t.Fatalf("DeleteHistory: %v", err)
}
if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 {
t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2", self)
}
senderHistory, err := messages.ListByUser(ctx, senderID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages))
}
senderDialogs, err := dialogs.ListByUser(ctx, senderID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after delete: %v", err)
}
if len(senderDialogs.Dialogs) != 0 {
t.Fatalf("sender dialogs = %+v, want dialog deleted after full history delete", senderDialogs.Dialogs)
}
rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 200,
Message: "rebuilt",
Date: 1700000400,
})
if err != nil {
t.Fatalf("send after delete: %v", err)
}
senderDialogs, err = dialogs.ListByUser(ctx, senderID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after rebuild: %v", err)
}
if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID {
t.Fatalf("rebuilt dialogs = %+v, want one dialog with new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID)
}
preservedOwner := int64(1000000003)
preservedPeerID := int64(1000000004)
preservedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: preservedPeerID}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: 300,
Message: "clear but keep dialog",
Date: 1700000500,
}); err != nil {
t.Fatalf("seed preserved send: %v", err)
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000600,
}); err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
preservedDialogs, err := dialogs.ListByUser(ctx, preservedOwner, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("preserved dialogs: %v", err)
}
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer || preservedDialogs.Dialogs[0].TopMessage != 0 || len(preservedDialogs.Messages) != 0 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages)
}
}