267 lines
11 KiB
Go
267 lines
11 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
// TestMessageStoreSavedDialogsLifecycle 覆盖收藏夹分会话 PG 主链路:
|
||
// 直发/转发的 saved_peer 写入、fwd saved_from 持久化(重读非内存 echo)、
|
||
// 聚合分页、置顶、按子会话历史过滤与删除(含置顶清理)。
|
||
func TestMessageStoreSavedDialogsLifecycle(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
alice, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1672" + suffix + "01", FirstName: "SavedAlice"})
|
||
if err != nil {
|
||
t.Fatalf("create alice: %v", err)
|
||
}
|
||
bob, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1672" + suffix + "02", FirstName: "SavedBob"})
|
||
if err != nil {
|
||
t.Fatalf("create bob: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID})
|
||
_, _ = pool.Exec(ctx, "DELETE FROM saved_dialog_pins WHERE user_id = ANY($1::bigint[])", []int64{alice.ID, bob.ID})
|
||
})
|
||
|
||
messages := NewMessageStore(pool)
|
||
selfPeer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}
|
||
bobPeer := domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}
|
||
|
||
// 直发笔记 → saved_peer = self。
|
||
note, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||
SenderUserID: alice.ID,
|
||
RecipientUserID: alice.ID,
|
||
RandomID: 841001,
|
||
Message: "note to self",
|
||
Date: 1700000700,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send note: %v", err)
|
||
}
|
||
|
||
// bob 私聊来信 → alice 转发进收藏夹 → saved_peer = bob 且 fwd saved_from 持久化。
|
||
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||
SenderUserID: bob.ID,
|
||
RecipientUserID: alice.ID,
|
||
RandomID: 841002,
|
||
Message: "from bob",
|
||
Date: 1700000701,
|
||
}); err != nil {
|
||
t.Fatalf("bob send: %v", err)
|
||
}
|
||
aliceInbox, err := messages.ListByUser(ctx, alice.ID, domain.MessageFilter{
|
||
HasPeer: true, Peer: bobPeer, Limit: 1,
|
||
})
|
||
if err != nil || len(aliceInbox.Messages) != 1 {
|
||
t.Fatalf("alice inbox = %+v err %v", aliceInbox.Messages, err)
|
||
}
|
||
fwdRes, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{
|
||
OwnerUserID: alice.ID,
|
||
ToUserID: alice.ID,
|
||
FromPeer: bobPeer,
|
||
MessageIDs: []int{aliceInbox.Messages[0].ID},
|
||
RandomIDs: []int64{841003},
|
||
Date: 1700000702,
|
||
})
|
||
if err != nil || len(fwdRes.SenderMessages) != 1 {
|
||
t.Fatalf("forward to saved: %+v err %v", fwdRes, err)
|
||
}
|
||
fwdID := fwdRes.SenderMessages[0].ID
|
||
|
||
// 重读 self-chat(非发送 echo)验证持久化:saved_peer 与 fwd saved_from 都在。
|
||
selfHistory, err := messages.ListByUser(ctx, alice.ID, domain.MessageFilter{
|
||
HasPeer: true, Peer: selfPeer, Limit: 10,
|
||
})
|
||
if err != nil || len(selfHistory.Messages) != 2 {
|
||
t.Fatalf("self history = %+v err %v, want note+forward", selfHistory.Messages, err)
|
||
}
|
||
for _, msg := range selfHistory.Messages {
|
||
switch msg.ID {
|
||
case note.SenderMessage.ID:
|
||
if msg.SavedPeer != selfPeer {
|
||
t.Fatalf("note saved_peer = %+v, want self", msg.SavedPeer)
|
||
}
|
||
case fwdID:
|
||
if msg.SavedPeer != bobPeer {
|
||
t.Fatalf("forward saved_peer = %+v, want bob", msg.SavedPeer)
|
||
}
|
||
if msg.Forward == nil || msg.Forward.SavedFrom != bobPeer || msg.Forward.SavedFromMsgID != aliceInbox.Messages[0].ID {
|
||
t.Fatalf("forward header = %+v, want persisted saved_from bob/%d", msg.Forward, aliceInbox.Messages[0].ID)
|
||
}
|
||
default:
|
||
t.Fatalf("unexpected self-chat message %d", msg.ID)
|
||
}
|
||
}
|
||
|
||
// 聚合:转发更晚在前;分页 limit=1 → slice,offset 翻页到尾页。
|
||
list, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 20})
|
||
if err != nil {
|
||
t.Fatalf("list saved dialogs: %v", err)
|
||
}
|
||
if !list.Full || list.Count != 2 || len(list.Dialogs) != 2 ||
|
||
list.Dialogs[0].Peer != bobPeer || list.Dialogs[0].TopMessage != fwdID ||
|
||
list.Dialogs[1].Peer != selfPeer || list.Dialogs[1].TopMessage != note.SenderMessage.ID {
|
||
t.Fatalf("saved dialogs = %+v count %d full %v, want bob then self", list.Dialogs, list.Count, list.Full)
|
||
}
|
||
page, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 1})
|
||
if err != nil || page.Full || len(page.Dialogs) != 1 || page.Dialogs[0].Peer != bobPeer || page.Count != 2 {
|
||
t.Fatalf("first page = %+v err %v, want slice bob count 2", page, err)
|
||
}
|
||
tail, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 1, OffsetID: fwdID})
|
||
if err != nil || !tail.Full || len(tail.Dialogs) != 1 || tail.Dialogs[0].Peer != selfPeer {
|
||
t.Fatalf("tail page = %+v err %v, want full self", tail, err)
|
||
}
|
||
|
||
// 置顶:self 置顶后首页在前、exclude 口径、ByPeers/pinned 一致。
|
||
changed, err := messages.ToggleSavedDialogPin(ctx, alice.ID, selfPeer, true)
|
||
if err != nil || !changed {
|
||
t.Fatalf("pin self = %v err %v", changed, err)
|
||
}
|
||
if changed, err = messages.ToggleSavedDialogPin(ctx, alice.ID, selfPeer, true); err != nil || changed {
|
||
t.Fatalf("re-pin self = %v err %v, want no-op", changed, err)
|
||
}
|
||
list, err = messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 20})
|
||
if err != nil || len(list.Dialogs) != 2 || list.Dialogs[0].Peer != selfPeer || !list.Dialogs[0].Pinned {
|
||
t.Fatalf("after pin = %+v err %v, want pinned self first", list.Dialogs, err)
|
||
}
|
||
excluded, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{ExcludePinned: true, Limit: 20})
|
||
if err != nil || excluded.Count != 1 || len(excluded.Dialogs) != 1 || excluded.Dialogs[0].Peer != bobPeer {
|
||
t.Fatalf("exclude pinned = %+v err %v, want only bob count 1", excluded, err)
|
||
}
|
||
pinned, err := messages.ListPinnedSavedDialogs(ctx, alice.ID)
|
||
if err != nil || len(pinned.Dialogs) != 1 || pinned.Dialogs[0].Peer != selfPeer {
|
||
t.Fatalf("pinned list = %+v err %v, want self", pinned.Dialogs, err)
|
||
}
|
||
byPeers, err := messages.ListSavedDialogsByPeers(ctx, alice.ID, []domain.Peer{bobPeer, {Type: domain.PeerTypeUser, ID: 424242}})
|
||
if err != nil || len(byPeers.Dialogs) != 1 || byPeers.Dialogs[0].Peer != bobPeer {
|
||
t.Fatalf("by peers = %+v err %v, want bob only", byPeers.Dialogs, err)
|
||
}
|
||
|
||
// reorder:bob 也置顶后强排为 bob→self。
|
||
if _, err := messages.ToggleSavedDialogPin(ctx, alice.ID, bobPeer, true); err != nil {
|
||
t.Fatalf("pin bob: %v", err)
|
||
}
|
||
if err := messages.ReorderPinnedSavedDialogs(ctx, alice.ID, []domain.Peer{bobPeer, selfPeer}, true); err != nil {
|
||
t.Fatalf("reorder: %v", err)
|
||
}
|
||
pinned, err = messages.ListPinnedSavedDialogs(ctx, alice.ID)
|
||
if err != nil || len(pinned.Dialogs) != 2 || pinned.Dialogs[0].Peer != bobPeer || pinned.Dialogs[1].Peer != selfPeer {
|
||
t.Fatalf("after reorder = %+v err %v, want bob then self", pinned.Dialogs, err)
|
||
}
|
||
|
||
// 子会话历史过滤。
|
||
bobHistory, err := messages.ListByUser(ctx, alice.ID, domain.MessageFilter{
|
||
HasPeer: true, Peer: selfPeer, SavedPeer: bobPeer, Limit: 10, NeedTotalCount: true,
|
||
})
|
||
if err != nil || len(bobHistory.Messages) != 1 || bobHistory.Messages[0].ID != fwdID || bobHistory.Count != 1 {
|
||
t.Fatalf("bob sublist history = %+v count %d err %v", bobHistory.Messages, bobHistory.Count, err)
|
||
}
|
||
|
||
// 删除 bob 子会话:消息消失、置顶清理、带 pts 的 delete 事件。
|
||
delRes, err := messages.DeleteSavedHistory(ctx, domain.DeleteSavedHistoryRequest{
|
||
OwnerUserID: alice.ID,
|
||
SavedPeer: bobPeer,
|
||
Date: 1700000710,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("delete saved history: %v", err)
|
||
}
|
||
if delRes.More || len(delRes.MessageIDs) != 1 || delRes.MessageIDs[0] != fwdID || delRes.Event.Pts == 0 || delRes.Event.PtsCount != 1 {
|
||
t.Fatalf("delete result = %+v, want fwd deleted with pts", delRes)
|
||
}
|
||
list, err = messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 20})
|
||
if err != nil || len(list.Dialogs) != 1 || list.Dialogs[0].Peer != selfPeer || list.Count != 1 {
|
||
t.Fatalf("after delete = %+v count %d err %v, want only self", list.Dialogs, list.Count, err)
|
||
}
|
||
pinned, err = messages.ListPinnedSavedDialogs(ctx, alice.ID)
|
||
if err != nil || len(pinned.Dialogs) != 1 || pinned.Dialogs[0].Peer != selfPeer {
|
||
t.Fatalf("pins after delete = %+v err %v, want bob pin cleared", pinned.Dialogs, err)
|
||
}
|
||
}
|
||
|
||
// TestSavedDialogsBackfillRule 验证 0087 回填 CASE 规则与 TDLib 语义对齐:
|
||
// fwd 作者可见或无 fwd → self;仅 from_name → hidden author 2666000。
|
||
// migration 已在建库时应用,这里手工插入老格式行(saved_peer_type=”)后
|
||
// 重放回填语句验证规则本身。
|
||
func TestSavedDialogsBackfillRule(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{AccessHash: 93, Phone: "+1673" + suffix + "01", FirstName: "BackfillOwner"})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||
})
|
||
|
||
insert := func(boxID int, fwdPeerID int64, fwdName string, fwdDate int) {
|
||
t.Helper()
|
||
var privateID int64
|
||
if err := pool.QueryRow(ctx, `
|
||
INSERT INTO private_messages (
|
||
sender_user_id, recipient_user_id, random_id, request_fingerprint, recipient_delivered,
|
||
sender_box_id, sender_pts, recipient_box_id, recipient_pts,
|
||
message_date, body, entities
|
||
)
|
||
VALUES (
|
||
$1, $1, $2::bigint, decode(repeat('00', 32), 'hex'), false,
|
||
$3::int, $3::int, 0, 0,
|
||
1700000800, 'legacy', '[]'::jsonb
|
||
)
|
||
RETURNING id`, owner.ID, 841100+boxID, boxID).Scan(&privateID); err != nil {
|
||
t.Fatalf("insert legacy private message %d: %v", boxID, err)
|
||
}
|
||
if _, err := pool.Exec(ctx, `
|
||
INSERT INTO message_boxes (
|
||
owner_user_id, box_id, private_message_id, message_sender_id,
|
||
peer_type, peer_id, from_user_id, message_date, outgoing, body, entities, pts,
|
||
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date
|
||
) VALUES ($1, $2::int, $3::bigint, $1, 'user', $1, $1, 1700000800, true, 'legacy', '[]'::jsonb, $2::int,
|
||
CASE WHEN $4::bigint <> 0 THEN 'user' ELSE '' END, $4::bigint, $5, $6::int)`,
|
||
owner.ID, boxID, privateID, fwdPeerID, fwdName, fwdDate); err != nil {
|
||
t.Fatalf("insert legacy row %d: %v", boxID, err)
|
||
}
|
||
}
|
||
insert(1, 0, "", 0) // 直发笔记 → self
|
||
insert(2, 4242, "", 1700000500) // fwd 作者可见、saved_from 丢失 → self
|
||
insert(3, 0, "Hidden", 1700000501) // 仅 from_name → 2666000
|
||
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE message_boxes
|
||
SET saved_peer_type = 'user',
|
||
saved_peer_id = CASE
|
||
WHEN fwd_date <> 0 AND fwd_from_peer_id = 0 AND fwd_from_name <> '' THEN 2666000
|
||
ELSE owner_user_id
|
||
END
|
||
WHERE peer_type = 'user'
|
||
AND peer_id = owner_user_id
|
||
AND saved_peer_type = ''`); err != nil {
|
||
t.Fatalf("replay backfill: %v", err)
|
||
}
|
||
|
||
messages := NewMessageStore(pool)
|
||
list, err := messages.ListSavedDialogs(ctx, owner.ID, domain.SavedDialogsFilter{Limit: 20})
|
||
if err != nil {
|
||
t.Fatalf("list after backfill: %v", err)
|
||
}
|
||
if len(list.Dialogs) != 2 {
|
||
t.Fatalf("backfill dialogs = %+v, want self + hidden author", list.Dialogs)
|
||
}
|
||
hidden := domain.Peer{Type: domain.PeerTypeUser, ID: domain.SavedHiddenAuthorUserID}
|
||
self := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||
if list.Dialogs[0].Peer != hidden || list.Dialogs[0].TopMessage != 3 {
|
||
t.Fatalf("hidden sublist = %+v, want top 3", list.Dialogs[0])
|
||
}
|
||
if list.Dialogs[1].Peer != self || list.Dialogs[1].TopMessage != 2 {
|
||
t.Fatalf("self sublist = %+v, want top 2 (visible-author legacy forward)", list.Dialogs[1])
|
||
}
|
||
}
|