owpengram-server/internal/store/memory/message_test.go
A bf965f610c feat: sync bot keyboards and callbacks
Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
2026-07-19 20:38:48 +08:00

1427 lines
51 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"context"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
)
var validRichMessageBlocks = []byte{
0x15, 0xc4, 0xb5, 0x1c, // vector
0x01, 0x00, 0x00, 0x00, // count
0x8a, 0x7e, 0x56, 0x13, // pageBlockUnsupported
}
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)
}
afterDup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: 101,
Message: "after duplicate",
Date: 1700000111,
})
if err != nil {
t.Fatalf("SendPrivateText after duplicate: %v", err)
}
if afterDup.SenderMessage.ID != 3 || afterDup.SenderMessage.Pts != 3 || afterDup.RecipientMessage.ID != 3 || afterDup.RecipientMessage.Pts != 3 {
t.Fatalf("send after duplicate = %+v/%+v, want next contiguous box_id and pts", afterDup.SenderMessage, afterDup.RecipientMessage)
}
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) != 3 || len(recipientHistory.Messages) != 3 {
t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages))
}
}
func TestMessageStoreSendPrivateTextContentInvariant(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
richOnly, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 191,
Date: 1700000191,
RichMessage: &domain.MessageRichMessage{Blocks: validRichMessageBlocks},
})
if err != nil {
t.Fatalf("SendPrivateText rich-only: %v", err)
}
if richOnly.SenderMessage.Body != "" || richOnly.SenderMessage.RichMessage.IsZero() {
t.Fatalf("rich-only sender message = %+v, want empty body with rich payload", richOnly.SenderMessage)
}
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 192,
Date: 1700000192,
})
if !errors.Is(err, domain.ErrMessageEmpty) {
t.Fatalf("SendPrivateText empty err = %v, want ErrMessageEmpty", err)
}
}
func TestMessageStoreEditRichOnlyMessageUsesFinalContentState(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 201,
Date: 1700000200,
RichMessage: &domain.MessageRichMessage{Blocks: validRichMessageBlocks},
})
if err != nil {
t.Fatalf("SendPrivateText rich-only: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
ID: sent.SenderMessage.ID,
Message: "",
HideEdited: true,
EditDate: 1700000210,
})
if err != nil {
t.Fatalf("EditMessage rich-only hide-edit: %v", err)
}
if len(edited.Edited) != 2 {
t.Fatalf("edited boxes = %d, want 2", len(edited.Edited))
}
for _, item := range edited.Edited {
if item.Message.RichMessage.IsZero() {
t.Fatalf("edited rich-only box for user %d lost rich message: %+v", item.UserID, item.Message)
}
}
}
func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
req := domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 199,
Date: 1700000120,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionWebViewDataSent,
WebViewData: &domain.MessageWebViewDataAction{
ButtonText: "Open",
Data: `{"ok":true}`,
},
},
},
}
got, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
assertWebViewData := func(name string, msg domain.Message) {
if msg.Media == nil || msg.Media.ServiceAction == nil ||
msg.Media.ServiceAction.Kind != domain.MessageServiceActionWebViewDataSent ||
msg.Media.ServiceAction.WebViewData == nil {
t.Fatalf("%s media = %+v, want webview data service action", name, msg.Media)
}
if data := msg.Media.ServiceAction.WebViewData; data.ButtonText != "Open" || data.Data != `{"ok":true}` {
t.Fatalf("%s webview data = %+v, want Open/data", name, data)
}
}
assertWebViewData("sender", got.SenderMessage)
assertWebViewData("recipient", got.RecipientMessage)
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: req.RandomID,
Date: 1700000121,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionWebViewDataSent,
WebViewData: &domain.MessageWebViewDataAction{
ButtonText: "Changed",
Data: `{"ok":false}`,
},
},
},
})
if !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed-media duplicate err = %v, want ErrMessageRandomIDDuplicate", err)
}
recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
Limit: 10,
})
if err != nil || len(recipientHistory.Messages) != 1 {
t.Fatalf("recipient history = %+v err=%v, want one message", recipientHistory, err)
}
assertWebViewData("recipient history", recipientHistory.Messages[0])
}
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
photo := domain.Photo{ID: 8101, Sizes: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
}}}
requestedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003}
req := domain.SendPrivateTextRequest{
SenderUserID: 1000000001, RecipientUserID: 1000000002, RandomID: 200, Date: 1700000121,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 77, Peers: []domain.Peer{requestedPeer},
Details: []domain.MessageRequestedPeerDetails{{
Peer: requestedPeer, FirstName: "Shared", Username: "shared_user", Photo: &photo,
}},
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
},
}},
}
got, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
assertSnapshot := func(name string, msg domain.Message) {
t.Helper()
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
}
action := msg.Media.ServiceAction.RequestedPeer
if action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
len(action.Details) != 1 || action.Details[0].FirstName != "Shared" ||
action.Details[0].Username != "shared_user" || action.Details[0].Photo == nil ||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
t.Fatalf("%s requested-peer=%+v", name, action)
}
}
assertSnapshot("sender", got.SenderMessage)
assertSnapshot("recipient", got.RecipientMessage)
// Mutating either the request or one returned box must not alter the other
// box or the immutable store snapshot.
req.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-request"
req.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 1
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-result"
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 2
assertSnapshot("isolated recipient result", got.RecipientMessage)
for _, owner := range []int64{req.SenderUserID, req.RecipientUserID} {
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{Limit: 10})
if err != nil || len(history.Messages) != 1 {
t.Fatalf("owner %d history=%+v err=%v", owner, history, err)
}
assertSnapshot("stored history", history.Messages[0])
}
}
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].SenderUserID != aliceID || !got[0].Unread || !got[0].Big || got[0].My {
t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got)
}
aliceBox, err := messages.GetByIDs(ctx, aliceID, []int{sent.SenderMessage.ID})
if err != nil {
t.Fatalf("alice GetByIDs after reaction: %v", err)
}
if len(aliceBox.Messages) != 1 || !aliceBox.Messages[0].ReactionUnread {
t.Fatalf("alice box after reaction = %+v, want reaction_unread", aliceBox.Messages)
}
read, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: aliceID,
IDs: []int{sent.SenderMessage.ID},
Date: 1700000210,
})
if err != nil {
t.Fatalf("ReadMessageContents reaction: %v", err)
}
if !reflect.DeepEqual(read.MessageIDs, []int{sent.SenderMessage.ID}) || read.Event.Type != domain.UpdateEventReadMessageContents || read.Event.Pts == 0 {
t.Fatalf("read reaction contents = %+v, want one read_message_contents event", read)
}
aliceBox, err = messages.GetByIDs(ctx, aliceID, []int{sent.SenderMessage.ID})
if err != nil {
t.Fatalf("alice GetByIDs after read reaction: %v", err)
}
if len(aliceBox.Messages) != 1 || aliceBox.Messages[0].ReactionUnread {
t.Fatalf("alice box after read reaction = %+v, want reaction_unread cleared", aliceBox.Messages)
}
if got := aliceBox.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread || got[0].SenderUserID != aliceID {
t.Fatalf("alice recent reactions after read = %+v, want unread flag cleared", 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].UserID != bobID || got[0].SenderUserID != aliceID || got[0].Unread || !got[0].My {
t.Fatalf("bob recent reactions = %+v, want my reaction", got)
}
}
func TestMessageStorePrivateReactionEnrichesDialogTopMessages(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
aliceID := int64(1000000001)
bobID := int64(1000000002)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 111,
Message: "latest from alice",
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\u2764"}
if _, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: bobID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
MessageID: sent.RecipientMessage.ID,
Reactions: []domain.MessageReaction{
reaction,
},
Date: 1700000310,
}); err != nil {
t.Fatalf("SetMessageReactions: %v", err)
}
aliceDialogs, err := dialogs.ListByUser(ctx, aliceID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("alice ListByUser: %v", err)
}
if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].TopMessage != sent.SenderMessage.ID || aliceDialogs.Dialogs[0].UnreadReactions != 1 {
t.Fatalf("alice dialog = %+v, want top message with one unread reaction", aliceDialogs.Dialogs)
}
if len(aliceDialogs.Messages) != 1 || aliceDialogs.Messages[0].ID != sent.SenderMessage.ID || aliceDialogs.Messages[0].Reactions == nil {
t.Fatalf("alice dialog messages = %+v, want enriched top message", aliceDialogs.Messages)
}
if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || !got[0].Unread || got[0].My {
t.Fatalf("alice dialog recent reactions = %+v, want bob unread non-my reaction", got)
}
bobDialogs, err := dialogs.ListByPeers(ctx, bobID, []domain.Peer{{Type: domain.PeerTypeUser, ID: aliceID}})
if err != nil {
t.Fatalf("bob ListByPeers: %v", err)
}
if len(bobDialogs.Messages) != 1 || bobDialogs.Messages[0].Reactions == nil {
t.Fatalf("bob peer dialog messages = %+v, want enriched top message", bobDialogs.Messages)
}
if got := bobDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || got[0].Unread || !got[0].My {
t.Fatalf("bob peer dialog recent reactions = %+v, want my read reaction", got)
}
if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: aliceID,
IDs: []int{sent.SenderMessage.ID},
Date: 1700000320,
}); err != nil {
t.Fatalf("ReadMessageContents: %v", err)
}
aliceDialogs, err = dialogs.ListByUser(ctx, aliceID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("alice ListByUser after read: %v", err)
}
if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].UnreadReactions != 0 {
t.Fatalf("alice dialog after read = %+v, want no unread reactions", aliceDialogs.Dialogs)
}
if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread {
t.Fatalf("alice dialog recent reactions after read = %+v, want unread cleared", 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 TestMessageStoreReadHistoryStaleUnreadRepairDoesNotAdvancePts(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000101)
recipientID := int64(1000000102)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 201,
Message: "hello",
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipientID,
Peer: peer,
MaxID: sent.RecipientMessage.ID,
Date: 1700000200,
}); err != nil {
t.Fatalf("first ReadHistory: %v", err)
}
dialogs.mu.Lock()
list := dialogs.m[recipientID]
for i := range list.Dialogs {
if list.Dialogs[i].Peer != peer {
continue
}
list.Dialogs[i].UnreadCount = 1
list.Dialogs[i].UnreadMentions = 1
list.Dialogs[i].UnreadReactions = 1
list.Dialogs[i].UnreadMark = true
}
dialogs.m[recipientID] = list
dialogs.mu.Unlock()
read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipientID,
Peer: peer,
MaxID: sent.RecipientMessage.ID,
Date: 1700000300,
})
if err != nil {
t.Fatalf("second ReadHistory: %v", err)
}
if read.Changed || read.InboxEvent.Pts != 0 || read.OutboxChanged {
t.Fatalf("stale unread repair = %+v, want no read pts/outbox event", read)
}
dialogs.mu.RLock()
repaired := dialogs.m[recipientID].Dialogs[0]
dialogs.mu.RUnlock()
// readHistory 重算 UnreadCount、清 mentions/mark但不清 reaction 角标(与 PG
// 对齐reaction 未读由 readReactions/readMessageContents 单独清),故 UnreadReactions
// 保留为 1。
if repaired.UnreadCount != 0 || repaired.UnreadMentions != 0 || repaired.UnreadReactions != 1 || repaired.UnreadMark {
t.Fatalf("dialog after repair = %+v, want unread/mentions/mark cleared and reactions preserved", repaired)
}
next, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 202,
Message: "next",
Date: 1700000400,
})
if err != nil {
t.Fatalf("next SendPrivateText: %v", err)
}
if next.RecipientMessage.Pts != 3 {
t.Fatalf("next recipient pts = %d, want 3 after no-op read repair", next.RecipientMessage.Pts)
}
}
func TestMessageStoreSendPrivateTextRecomputesInboxUnreadFromReadMax(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000201)
recipientID := int64(1000000202)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 301,
Message: "already read",
Date: 1700000500,
})
if err != nil {
t.Fatalf("SendPrivateText first: %v", err)
}
if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipientID,
Peer: peer,
MaxID: first.RecipientMessage.ID,
Date: 1700000510,
}); err != nil {
t.Fatalf("ReadHistory: %v", err)
}
dialogs.mu.Lock()
list := dialogs.m[recipientID]
for i := range list.Dialogs {
if list.Dialogs[i].Peer == peer {
list.Dialogs[i].UnreadCount = 2
}
}
dialogs.m[recipientID] = list
dialogs.mu.Unlock()
bodies := []string{"one", "two", "three"}
var last domain.SendPrivateTextResult
for i, body := range bodies {
last, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 310 + int64(i),
Message: body,
Date: 1700000520 + i,
})
if err != nil {
t.Fatalf("SendPrivateText %q: %v", body, err)
}
}
dialogs.mu.RLock()
var got domain.Dialog
for _, dialog := range dialogs.m[recipientID].Dialogs {
if dialog.Peer == peer {
got = dialog
break
}
}
dialogs.mu.RUnlock()
if got.UnreadCount != len(bodies) || got.TopMessage != last.RecipientMessage.ID || got.ReadInboxMaxID != first.RecipientMessage.ID {
t.Fatalf("dialog = %+v, want unread=3 top=%d read=%d", got, last.RecipientMessage.ID, first.RecipientMessage.ID)
}
}
func TestMessageStoreDeleteMessagesRecomputesRecipientUnreadAndTop(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000301)
recipientID := int64(1000000302)
recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
bodies := []string{"one", "two", "three"}
sent := make([]domain.SendPrivateTextResult, 0, len(bodies))
for i, body := range bodies {
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 330 + int64(i),
Message: body,
Date: 1700000600 + i,
})
if err != nil {
t.Fatalf("SendPrivateText %q: %v", body, err)
}
sent = append(sent, got)
}
deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: senderID,
IDs: []int{sent[2].SenderMessage.ID},
Revoke: true,
Date: 1700000610,
})
if err != nil {
t.Fatalf("DeleteMessages revoke latest: %v", err)
}
if len(deleted.Deleted) != 2 || !deleted.Changed() {
t.Fatalf("deleted = %+v, want both owner boxes revoked", deleted)
}
dialogList, err := dialogs.ListByUser(ctx, recipientID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("recipient dialogs: %v", err)
}
var got domain.Dialog
for _, dialog := range dialogList.Dialogs {
if dialog.Peer == recipientPeer {
got = dialog
break
}
}
if got.UnreadCount != 2 || got.TopMessage != sent[1].RecipientMessage.ID {
t.Fatalf("recipient dialog after revoke = %+v, want unread=2 top=%d", got, sent[1].RecipientMessage.ID)
}
}
func TestMessageStoreDeleteMiddleUnreadKeepsRecipientTop(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000401)
recipientID := int64(1000000402)
recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID}
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 429,
Message: "already read",
Date: 1700000690,
})
if err != nil {
t.Fatalf("SendPrivateText first: %v", err)
}
if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipientID,
Peer: recipientPeer,
MaxID: first.RecipientMessage.ID,
Date: 1700000695,
}); err != nil {
t.Fatalf("ReadHistory first: %v", err)
}
bodies := []string{"one", "two", "three"}
sent := make([]domain.SendPrivateTextResult, 0, len(bodies))
for i, body := range bodies {
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 430 + int64(i),
Message: body,
Date: 1700000700 + i,
})
if err != nil {
t.Fatalf("SendPrivateText %q: %v", body, err)
}
sent = append(sent, got)
}
if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: senderID,
IDs: []int{sent[1].SenderMessage.ID},
Revoke: true,
Date: 1700000710,
}); err != nil {
t.Fatalf("DeleteMessages revoke middle: %v", err)
}
dialogList, err := dialogs.ListByUser(ctx, recipientID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("recipient dialogs: %v", err)
}
var got domain.Dialog
for _, dialog := range dialogList.Dialogs {
if dialog.Peer == recipientPeer {
got = dialog
break
}
}
if got.UnreadCount != 2 || got.TopMessage != sent[2].RecipientMessage.ID || got.ReadInboxMaxID != first.RecipientMessage.ID {
t.Fatalf("recipient dialog after middle revoke = %+v, want unread=2 top=%d read=%d", got, sent[2].RecipientMessage.ID, first.RecipientMessage.ID)
}
}
func TestMessageStoreReadMessageContentsClearsUnreadContentOnce(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: 88,
Message: "voice placeholder",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Voice: true},
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if !sent.RecipientMessage.MediaUnread {
t.Fatalf("recipient MediaUnread = false, want true for incoming media")
}
got, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID},
Date: 1700000400,
})
if err != nil {
t.Fatalf("ReadMessageContents: %v", err)
}
if !reflect.DeepEqual(got.MessageIDs, []int{sent.RecipientMessage.ID}) {
t.Fatalf("MessageIDs = %v, want unread recipient id", got.MessageIDs)
}
if got.Event.Type != domain.UpdateEventReadMessageContents || got.Event.Pts == 0 || got.Event.PtsCount != 1 {
t.Fatalf("Event = %+v, want read_message_contents pts update", got.Event)
}
repeated, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID},
Date: 1700000500,
})
if err != nil {
t.Fatalf("ReadMessageContents repeat: %v", err)
}
if len(repeated.MessageIDs) != 0 || repeated.Event.Pts != 0 {
t.Fatalf("repeat = %+v, want no affected messages and no pts", repeated)
}
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 TestMessageStoreReadMessageContentsNotifiesVoiceSender(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: 99,
Message: "",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Voice: true},
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if !sent.SenderMessage.MediaUnread {
t.Fatalf("sender voice MediaUnread = false, want true until the peer listens")
}
photo, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: 100,
Message: "",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 7}},
Date: 1700000301,
})
if err != nil {
t.Fatalf("SendPrivateText photo: %v", err)
}
if photo.SenderMessage.MediaUnread || photo.RecipientMessage.MediaUnread {
t.Fatalf("photo media_unread sender=%v recipient=%v, want false: only voice/round carry unread payload",
photo.SenderMessage.MediaUnread, photo.RecipientMessage.MediaUnread)
}
read, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID},
Date: 1700000400,
})
if err != nil {
t.Fatalf("ReadMessageContents: %v", err)
}
if len(read.SenderEvents) != 1 {
t.Fatalf("SenderEvents = %+v, want one sender receipt", read.SenderEvents)
}
receipt := read.SenderEvents[0]
if receipt.UserID != 1001 || receipt.Type != domain.UpdateEventReadMessageContents || receipt.Pts == 0 {
t.Fatalf("receipt = %+v, want sender-side read_message_contents", receipt)
}
if !reflect.DeepEqual(receipt.MessageIDs, []int{sent.SenderMessage.ID}) {
t.Fatalf("receipt ids = %v, want sender box id %d", receipt.MessageIDs, sent.SenderMessage.ID)
}
if receipt.Date != 1700000400 {
t.Fatalf("receipt date = %d, want read time 1700000400", receipt.Date)
}
repeat, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID},
Date: 1700000500,
})
if err != nil {
t.Fatalf("ReadMessageContents repeat: %v", err)
}
if len(repeat.SenderEvents) != 0 {
t.Fatalf("repeat SenderEvents = %+v, want none", repeat.SenderEvents)
}
}
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 TestMessageStoreEditViaBotMessageUpdatesBothBoxes(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000001)
recipientID := int64(1000000002)
viaBotID := int64(1000000900)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 103,
Message: "via before",
ViaBotID: viaBotID,
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: recipientID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
ID: sent.RecipientMessage.ID,
Message: "bad bot",
EditDate: 1700000190,
ViaBotEditBotID: viaBotID + 1,
}); err != domain.ErrMessageAuthorRequired {
t.Fatalf("EditMessage wrong via bot err = %v, want ErrMessageAuthorRequired", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: recipientID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
ID: sent.RecipientMessage.ID,
Message: "via after",
EditDate: 1700000200,
ViaBotEditBotID: viaBotID,
})
if err != nil {
t.Fatalf("EditMessage via bot: %v", err)
}
if len(edited.Edited) != 2 {
t.Fatalf("edited boxes = %d, want 2", len(edited.Edited))
}
senderHistory, err := messages.ListByUser(ctx, senderID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
if len(senderHistory.Messages) != 1 || senderHistory.Messages[0].Body != "via after" || senderHistory.Messages[0].ViaBotID != viaBotID {
t.Fatalf("sender history = %+v, want via after with via bot", senderHistory.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)
}
}
func TestMessageStoreRevokeHistorySweepsPeerSideAfterLocalClear(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
for i := 0; i < 3; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: int64(700 + i),
Message: "history",
Date: 1700000600 + i,
}); err != nil {
t.Fatalf("SendPrivateText %d: %v", i, err)
}
}
// 先单向清空自己侧。
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
Date: 1700000700,
}); err != nil {
t.Fatalf("local clear: %v", err)
}
// 再双向清史:反查模型对"我方已无 box"的消息失效,必须直扫对端。
res, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
Revoke: true,
Date: 1700000800,
})
if err != nil {
t.Fatalf("revoke clear: %v", err)
}
var peerEvent bool
for _, d := range res.Deleted {
if d.UserID == 1002 && len(d.MessageIDs) == 3 {
peerEvent = true
}
}
if !peerEvent {
t.Fatalf("revoke deleted = %+v, want peer-side sweep of all 3 messages", res.Deleted)
}
peerHistory, err := messages.ListByUser(ctx, 1002, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Limit: 10,
})
if err != nil {
t.Fatalf("peer history: %v", err)
}
if len(peerHistory.Messages) != 0 {
t.Fatalf("peer history after revoke = %+v, want empty", peerHistory.Messages)
}
}
func TestReorderPinnedForceScopedToFolder(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
mainPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2001}
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
if err := dialogs.Upsert(ctx, 1001, domain.Dialog{Peer: mainPeer, TopMessage: 1, TopMessageDate: 10}); err != nil {
t.Fatalf("upsert main: %v", err)
}
if err := dialogs.Upsert(ctx, 1001, domain.Dialog{Peer: archivedPeer, FolderID: domain.DialogArchiveFolderID, TopMessage: 2, TopMessageDate: 20}); err != nil {
t.Fatalf("upsert archived: %v", err)
}
if _, _, err := dialogs.SetPinned(ctx, 1001, mainPeer, true); err != nil {
t.Fatalf("pin main: %v", err)
}
if _, folderID, err := dialogs.SetPinned(ctx, 1001, archivedPeer, true); err != nil || folderID != domain.DialogArchiveFolderID {
t.Fatalf("pin archived folder = %d err %v, want archive", folderID, err)
}
// 归档列表内 force 重排:绝不允许清掉主列表的置顶。
if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogArchiveFolderID, []domain.Peer{archivedPeer}, true); err != nil || changed {
t.Fatalf("reorder archive = changed %v err %v, want no-op", changed, err)
}
list, err := dialogs.ListByUser(ctx, 1001, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list main: %v", err)
}
for _, dialog := range list.Dialogs {
if dialog.Peer == mainPeer && !dialog.Pinned {
t.Fatalf("main pin cleared by archive force reorder: %+v", dialog)
}
}
// 主列表 force 重排同样不得波及归档置顶。
if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogMainFolderID, []domain.Peer{mainPeer}, true); err != nil || changed {
t.Fatalf("reorder main = changed %v err %v, want no-op", changed, err)
}
archived, err := dialogs.ListByUser(ctx, 1001, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
if err != nil {
t.Fatalf("list archive: %v", err)
}
for _, dialog := range archived.Dialogs {
if dialog.Peer == archivedPeer && !dialog.Pinned {
t.Fatalf("archive pin cleared by main force reorder: %+v", dialog)
}
}
}
func TestSendPrivateTextClearsSenderUnreadMark(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 1,
Message: "hi",
Date: 1700000100,
})
if err != nil {
t.Fatalf("seed send: %v", err)
}
peer := first.SenderMessage.Peer
if _, err := dialogs.SetUnreadMark(ctx, 1000000001, peer, true); err != nil {
t.Fatalf("mark unread: %v", err)
}
// 发送方向发出消息即清手动未读标记(对齐 postgres UpsertOutboxDialog
// 与 channel 发送路径)。
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 2,
Message: "again",
Date: 1700000200,
}); err != nil {
t.Fatalf("send after mark: %v", err)
}
marks, err := dialogs.ListUnreadMarked(ctx, 1000000001)
if err != nil {
t.Fatalf("list unread marks: %v", err)
}
if len(marks) != 0 {
t.Fatalf("unread marks after send = %+v, want cleared", marks)
}
}
func TestSendPrivateTextPreservesPinnedDialog(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 1,
Message: "hi",
Date: 1700000100,
})
if err != nil {
t.Fatalf("seed send: %v", err)
}
peer := first.SenderMessage.Peer
if _, _, err := dialogs.SetPinned(ctx, 1000000001, peer, true); err != nil {
t.Fatalf("pin: %v", err)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 2,
Message: "again",
Date: 1700000200,
}); err != nil {
t.Fatalf("send after pin: %v", err)
}
list, err := dialogs.ListByUser(ctx, 1000000001, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list: %v", err)
}
found := false
for _, dialog := range list.Dialogs {
if dialog.Peer == peer {
found = true
if !dialog.Pinned || dialog.PinnedOrder == 0 {
t.Fatalf("dialog after send = %+v, want pinned preserved", dialog)
}
}
}
if !found {
t.Fatalf("dialog not found after send")
}
}