1188 lines
39 KiB
Go
1188 lines
39 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"reflect"
|
||
"telesrv/internal/domain"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestChannelStoreReadOutboxDoesNotRegressSenderDialogUnread(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 35,
|
||
Phone: "+1777" + suffix + "17",
|
||
FirstName: "ReadOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := users.Create(ctx, domain.User{
|
||
AccessHash: 36,
|
||
Phone: "+1777" + suffix + "18",
|
||
FirstName: "ReadMember",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Read Outbox " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{member.ID},
|
||
Date: 1700000340,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create channel: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
ownerMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9341,
|
||
Message: "owner message before member reply",
|
||
Date: 1700000341,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send owner message: %v", err)
|
||
}
|
||
if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: ownerMsg.Message.ID,
|
||
Date: 1700000342,
|
||
}); err != nil {
|
||
t.Fatalf("member read owner message: %v", err)
|
||
}
|
||
memberMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9342,
|
||
Message: "member reply should stay read for sender",
|
||
Date: 1700000343,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send member message: %v", err)
|
||
}
|
||
|
||
var storedReadInbox, storedUnread int
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT read_inbox_max_id, unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil {
|
||
t.Fatalf("read member dialog after self send: %v", err)
|
||
}
|
||
if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 {
|
||
t.Fatalf("member dialog after self send read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID)
|
||
}
|
||
|
||
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
MaxID: memberMsg.Message.ID,
|
||
Date: 1700000344,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("owner read member message: %v", err)
|
||
}
|
||
if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != member.ID || read.OutboxUpdates[0].MaxID != memberMsg.Message.ID {
|
||
t.Fatalf("read outbox updates = %+v, want member max id %d", read.OutboxUpdates, memberMsg.Message.ID)
|
||
}
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT read_inbox_max_id, unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil {
|
||
t.Fatalf("read member dialog after owner read: %v", err)
|
||
}
|
||
if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 {
|
||
t.Fatalf("member dialog after owner read read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID)
|
||
}
|
||
}
|
||
|
||
func TestChannelStoreReadHistoryNoopDoesNotRewriteDialogState(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 351,
|
||
Phone: "+1777" + suffix + "41",
|
||
FirstName: "ReadNoopOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := users.Create(ctx, domain.User{
|
||
AccessHash: 352,
|
||
Phone: "+1777" + suffix + "42",
|
||
FirstName: "ReadNoopMember",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Read Noop " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{member.ID},
|
||
Date: 1700000440,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create channel: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9441,
|
||
Message: "read once then repeat",
|
||
Date: 1700000441,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send channel message: %v", err)
|
||
}
|
||
first, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: sent.Message.ID,
|
||
Date: 1700000442,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("first read history: %v", err)
|
||
}
|
||
if !first.Changed {
|
||
t.Fatalf("first read changed = false, want true")
|
||
}
|
||
|
||
stableUpdatedAt := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE channel_members
|
||
SET updated_at = $3, unread_mark = false
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID, stableUpdatedAt); err != nil {
|
||
t.Fatalf("stabilize member updated_at: %v", err)
|
||
}
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE channel_dialogs
|
||
SET updated_at = $3, unread_mark = false
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID, stableUpdatedAt); err != nil {
|
||
t.Fatalf("stabilize dialog updated_at: %v", err)
|
||
}
|
||
|
||
repeat, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: sent.Message.ID,
|
||
Date: 1700000443,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("repeat read history: %v", err)
|
||
}
|
||
if repeat.Changed {
|
||
t.Fatalf("repeat read changed = true, want false")
|
||
}
|
||
var memberUpdatedAt, dialogUpdatedAt time.Time
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT updated_at
|
||
FROM channel_members
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&memberUpdatedAt); err != nil {
|
||
t.Fatalf("read member updated_at: %v", err)
|
||
}
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT updated_at
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&dialogUpdatedAt); err != nil {
|
||
t.Fatalf("read dialog updated_at: %v", err)
|
||
}
|
||
if !memberUpdatedAt.Equal(stableUpdatedAt) {
|
||
t.Fatalf("member updated_at = %v, want no-op timestamp %v", memberUpdatedAt, stableUpdatedAt)
|
||
}
|
||
if !dialogUpdatedAt.Equal(stableUpdatedAt) {
|
||
t.Fatalf("dialog updated_at = %v, want no-op timestamp %v", dialogUpdatedAt, stableUpdatedAt)
|
||
}
|
||
}
|
||
|
||
func TestChannelStoreChannelUnreadExcludesOwnOutgoing(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 39,
|
||
Phone: "+1777" + suffix + "19",
|
||
FirstName: "OwnUnreadOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Own Unread " + suffix,
|
||
Megagroup: true,
|
||
Date: 1700000350,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create channel: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9351,
|
||
Message: "own outgoing should not be unread",
|
||
Date: 1700000351,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send owner message: %v", err)
|
||
}
|
||
readBeforeOwnMessage := sent.Message.ID - 1
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE channel_members
|
||
SET read_inbox_max_id = $3, unread_mark = false
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil {
|
||
t.Fatalf("regress owner member read watermark: %v", err)
|
||
}
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE channel_dialogs
|
||
SET read_inbox_max_id = $3, unread_count = 0, unread_mark = false
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil {
|
||
t.Fatalf("regress owner dialog unread: %v", err)
|
||
}
|
||
|
||
dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
|
||
if err != nil {
|
||
t.Fatalf("get owner channel dialogs: %v", err)
|
||
}
|
||
if len(dialogs.Dialogs) != 1 {
|
||
t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs)
|
||
}
|
||
if dialogs.Dialogs[0].UnreadCount != 0 {
|
||
t.Fatalf("owner dialog unread = %d, want own outgoing excluded", dialogs.Dialogs[0].UnreadCount)
|
||
}
|
||
unreadOnly, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{
|
||
Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true},
|
||
Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list unread-only channel dialogs: %v", err)
|
||
}
|
||
for _, dialog := range unreadOnly.Dialogs {
|
||
if dialog.Peer.ID == channelID {
|
||
t.Fatalf("unread-only dialogs include own-outgoing-only channel: %+v", unreadOnly.Dialogs)
|
||
}
|
||
}
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE channel_dialogs
|
||
SET unread_count = 99
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID); err != nil {
|
||
t.Fatalf("corrupt owner dialog unread before read repair: %v", err)
|
||
}
|
||
|
||
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
MaxID: sent.Message.ID,
|
||
Date: 1700000352,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("read owner channel history: %v", err)
|
||
}
|
||
if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 {
|
||
t.Fatalf("read result = %+v, want no unread own outgoing messages", read)
|
||
}
|
||
var storedUnread int
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&storedUnread); err != nil {
|
||
t.Fatalf("read stored owner unread: %v", err)
|
||
}
|
||
if storedUnread != 0 {
|
||
t.Fatalf("stored owner unread = %d, want repaired to 0", storedUnread)
|
||
}
|
||
}
|
||
|
||
func TestChannelStoreBroadcastUnreadDerivesDespiteStaleCache(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 421,
|
||
Phone: "+1777" + suffix + "33",
|
||
FirstName: "BroadcastUnreadOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := users.Create(ctx, domain.User{
|
||
AccessHash: 422,
|
||
Phone: "+1777" + suffix + "34",
|
||
FirstName: "BroadcastUnreadMember",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Broadcast Unread " + suffix,
|
||
Broadcast: true,
|
||
MemberUserIDs: []int64{member.ID},
|
||
Date: 1700000333,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create broadcast channel: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: created.Message.ID,
|
||
Date: 1700000334,
|
||
}); err != nil {
|
||
t.Fatalf("read initial broadcast service message: %v", err)
|
||
}
|
||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9333,
|
||
Message: "broadcast unread derives despite stale cache",
|
||
Date: 1700000335,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send broadcast message: %v", err)
|
||
}
|
||
|
||
var storedUnread int
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedUnread); err != nil {
|
||
t.Fatalf("read stale broadcast dialog cache: %v", err)
|
||
}
|
||
if storedUnread != 0 {
|
||
t.Fatalf("stored broadcast unread cache = %d, want no send fanout", storedUnread)
|
||
}
|
||
|
||
list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("list broadcast channel dialogs: %v", err)
|
||
}
|
||
if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 {
|
||
t.Fatalf("broadcast dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs)
|
||
}
|
||
|
||
unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{
|
||
Folder: &domain.DialogFolder{ExcludeRead: true, Broadcasts: true},
|
||
Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list unread-only broadcast dialogs: %v", err)
|
||
}
|
||
if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID {
|
||
t.Fatalf("unread-only broadcast dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs)
|
||
}
|
||
|
||
view, err := channels.GetChannel(ctx, member.ID, channelID)
|
||
if err != nil {
|
||
t.Fatalf("get broadcast channel: %v", err)
|
||
}
|
||
if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID {
|
||
t.Fatalf("broadcast view dialog = %+v, want dynamic unread=1", view.Dialog)
|
||
}
|
||
dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID})
|
||
if err != nil {
|
||
t.Fatalf("get broadcast channel dialogs: %v", err)
|
||
}
|
||
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 {
|
||
t.Fatalf("get broadcast dialogs = %+v, want dynamic unread=1", dialogs.Dialogs)
|
||
}
|
||
}
|
||
|
||
func TestChannelStoreLargeMegagroupUnreadDerivesDespiteStaleCache(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 431,
|
||
Phone: "+1777" + suffix + "35",
|
||
FirstName: "LargeUnreadOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := users.Create(ctx, domain.User{
|
||
AccessHash: 432,
|
||
Phone: "+1777" + suffix + "36",
|
||
FirstName: "LargeUnreadMember",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Large Unread " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{member.ID},
|
||
Date: 1700000336,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create large megagroup: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: created.Message.ID,
|
||
Date: 1700000337,
|
||
}); err != nil {
|
||
t.Fatalf("read initial large service message: %v", err)
|
||
}
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE channels
|
||
SET participants_count = $2
|
||
WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil {
|
||
t.Fatalf("mark megagroup as over synchronous fanout threshold: %v", err)
|
||
}
|
||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9336,
|
||
Message: "large megagroup unread derives despite stale cache",
|
||
Date: 1700000338,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send large megagroup message: %v", err)
|
||
}
|
||
|
||
var storedTop, storedUnread int
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT top_message_id, unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop, &storedUnread); err != nil {
|
||
t.Fatalf("read stale large dialog cache: %v", err)
|
||
}
|
||
if storedTop == sent.Message.ID || storedUnread != 0 {
|
||
t.Fatalf("stored large dialog cache top=%d unread=%d, want stale top and unread=0", storedTop, storedUnread)
|
||
}
|
||
|
||
list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("list large channel dialogs: %v", err)
|
||
}
|
||
if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 {
|
||
t.Fatalf("large dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs)
|
||
}
|
||
ownerView, err := channels.GetChannel(ctx, owner.ID, channelID)
|
||
if err != nil {
|
||
t.Fatalf("get large channel for sender: %v", err)
|
||
}
|
||
if ownerView.Dialog.UnreadCount != 0 {
|
||
t.Fatalf("large sender dialog = %+v, want own outgoing excluded from dynamic unread", ownerView.Dialog)
|
||
}
|
||
|
||
unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{
|
||
Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true},
|
||
Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list unread-only large dialogs: %v", err)
|
||
}
|
||
if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID {
|
||
t.Fatalf("unread-only large dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs)
|
||
}
|
||
|
||
view, err := channels.GetChannel(ctx, member.ID, channelID)
|
||
if err != nil {
|
||
t.Fatalf("get large channel: %v", err)
|
||
}
|
||
if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID {
|
||
t.Fatalf("large view dialog = %+v, want dynamic unread=1", view.Dialog)
|
||
}
|
||
dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID})
|
||
if err != nil {
|
||
t.Fatalf("get large channel dialogs: %v", err)
|
||
}
|
||
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 {
|
||
t.Fatalf("get large dialogs = %+v, want dynamic unread=1", dialogs.Dialogs)
|
||
}
|
||
|
||
cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: sent.Message.ID,
|
||
Date: 1700000339,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("local clear large history: %v", err)
|
||
}
|
||
if cleared.AvailableMinID != sent.Message.ID {
|
||
t.Fatalf("large local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID)
|
||
}
|
||
afterClear, err := channels.GetChannel(ctx, member.ID, channelID)
|
||
if err != nil {
|
||
t.Fatalf("get large channel after local clear: %v", err)
|
||
}
|
||
if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 {
|
||
t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog)
|
||
}
|
||
}
|
||
|
||
func TestChannelStoreLargeMegagroupUnreadSkipsDeletedHole(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 441,
|
||
Phone: "+1777" + suffix + "37",
|
||
FirstName: "DeletedHoleOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := users.Create(ctx, domain.User{
|
||
AccessHash: 442,
|
||
Phone: "+1777" + suffix + "38",
|
||
FirstName: "DeletedHoleMember",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Deleted Hole " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{member.ID},
|
||
Date: 1700000339,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create deleted-hole megagroup: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: created.Message.ID,
|
||
Date: 1700000340,
|
||
}); err != nil {
|
||
t.Fatalf("read initial deleted-hole service message: %v", err)
|
||
}
|
||
if _, err := pool.Exec(ctx, `
|
||
UPDATE channels
|
||
SET participants_count = $2
|
||
WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil {
|
||
t.Fatalf("mark deleted-hole megagroup over threshold: %v", err)
|
||
}
|
||
first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9339,
|
||
Message: "deleted unread hole",
|
||
Date: 1700000341,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send first large message: %v", err)
|
||
}
|
||
second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9340,
|
||
Message: "remaining unread message",
|
||
Date: 1700000342,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send second large message: %v", err)
|
||
}
|
||
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
IDs: []int{first.Message.ID},
|
||
Date: 1700000343,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("delete non-top unread message: %v", err)
|
||
}
|
||
if len(deleted.DeletedIDs) != 1 || deleted.DeletedIDs[0] != first.Message.ID {
|
||
t.Fatalf("deleted ids = %+v, want first message only", deleted.DeletedIDs)
|
||
}
|
||
|
||
list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("list deleted-hole dialogs: %v", err)
|
||
}
|
||
if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != second.Message.ID || list.Dialogs[0].UnreadCount != 1 {
|
||
t.Fatalf("deleted-hole dialogs = %+v, want only non-deleted unread top counted", list.Dialogs)
|
||
}
|
||
}
|
||
|
||
func TestChannelStoreSmallMegagroupDeleteRefreshesDialogUnreadCache(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 451,
|
||
Phone: "+1777" + suffix + "39",
|
||
FirstName: "SmallDeleteOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := users.Create(ctx, domain.User{
|
||
AccessHash: 452,
|
||
Phone: "+1777" + suffix + "40",
|
||
FirstName: "SmallDeleteMember",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Small Delete Cache " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{member.ID},
|
||
Date: 1700000344,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create small megagroup: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: member.ID,
|
||
ChannelID: channelID,
|
||
MaxID: created.Message.ID,
|
||
Date: 1700000345,
|
||
}); err != nil {
|
||
t.Fatalf("read initial service message: %v", err)
|
||
}
|
||
first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9341,
|
||
Message: "remaining first unread",
|
||
Date: 1700000346,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send first message: %v", err)
|
||
}
|
||
second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9342,
|
||
Message: "deleted middle unread",
|
||
Date: 1700000347,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send second message: %v", err)
|
||
}
|
||
third, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9343,
|
||
Message: "deleted top unread",
|
||
Date: 1700000348,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send third message: %v", err)
|
||
}
|
||
|
||
var cachedTop, cachedUnread int
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT top_message_id, unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil {
|
||
t.Fatalf("read cached dialog before delete: %v", err)
|
||
}
|
||
if cachedTop != third.Message.ID || cachedUnread != 3 {
|
||
t.Fatalf("cached dialog before delete top=%d unread=%d, want top %d unread 3", cachedTop, cachedUnread, third.Message.ID)
|
||
}
|
||
|
||
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
IDs: []int{second.Message.ID},
|
||
Date: 1700000349,
|
||
}); err != nil {
|
||
t.Fatalf("delete middle unread message: %v", err)
|
||
}
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT top_message_id, unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil {
|
||
t.Fatalf("read cached dialog after middle delete: %v", err)
|
||
}
|
||
if cachedTop != third.Message.ID || cachedUnread != 2 {
|
||
t.Fatalf("cached dialog after middle delete top=%d unread=%d, want top %d unread 2", cachedTop, cachedUnread, third.Message.ID)
|
||
}
|
||
list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("list dialogs after middle delete: %v", err)
|
||
}
|
||
if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != third.Message.ID || list.Dialogs[0].UnreadCount != 2 {
|
||
t.Fatalf("dialogs after middle delete = %+v, want top third unread 2", list.Dialogs)
|
||
}
|
||
|
||
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
IDs: []int{third.Message.ID},
|
||
Date: 1700000350,
|
||
}); err != nil {
|
||
t.Fatalf("delete top unread message: %v", err)
|
||
}
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT top_message_id, unread_count
|
||
FROM channel_dialogs
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil {
|
||
t.Fatalf("read cached dialog after top delete: %v", err)
|
||
}
|
||
if cachedTop != first.Message.ID || cachedUnread != 1 {
|
||
t.Fatalf("cached dialog after top delete top=%d unread=%d, want top %d unread 1", cachedTop, cachedUnread, first.Message.ID)
|
||
}
|
||
list, err = channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10})
|
||
if err != nil {
|
||
t.Fatalf("list dialogs after top delete: %v", err)
|
||
}
|
||
if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != first.Message.ID || list.Dialogs[0].UnreadCount != 1 {
|
||
t.Fatalf("dialogs after top delete = %+v, want top first unread 1", list.Dialogs)
|
||
}
|
||
if len(list.Messages) != 1 || list.Messages[0].ID != first.Message.ID {
|
||
t.Fatalf("dialog messages after top delete = %+v, want first message", list.Messages)
|
||
}
|
||
}
|
||
|
||
func TestChannelStoreDeleteMessagesMaintainsUnreadMentionIndex(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 453,
|
||
Phone: "+1777" + suffix + "41",
|
||
FirstName: "MentionDeleteOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
member, err := users.Create(ctx, domain.User{
|
||
AccessHash: 454,
|
||
Phone: "+1777" + suffix + "42",
|
||
FirstName: "MentionDeleteMember",
|
||
Username: "mention_delete_" + suffix,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create member: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Mention Delete " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{member.ID},
|
||
Date: 1700000351,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create mention delete channel: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
|
||
first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9352,
|
||
Message: "first mention",
|
||
MentionUserIDs: []int64{member.ID},
|
||
Date: 1700000352,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send first mention: %v", err)
|
||
}
|
||
second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9353,
|
||
Message: "second mention",
|
||
MentionUserIDs: []int64{member.ID},
|
||
Date: 1700000353,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send second mention: %v", err)
|
||
}
|
||
plain, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 9354,
|
||
Message: "plain message with stale mention index only",
|
||
Date: 1700000354,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send plain message: %v", err)
|
||
}
|
||
requireUnreadMentionRows := func(messageID int, wantMentionRows, wantIndexRows int) {
|
||
t.Helper()
|
||
var mentionRows, indexRows int
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int
|
||
FROM channel_unread_mentions
|
||
WHERE user_id = $1 AND channel_id = $2 AND message_id = $3`, member.ID, channelID, messageID).Scan(&mentionRows); err != nil {
|
||
t.Fatalf("count mention rows for %d: %v", messageID, err)
|
||
}
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int
|
||
FROM channel_unread_mention_index
|
||
WHERE channel_id = $1 AND message_id = $2 AND user_id = $3`, channelID, messageID, member.ID).Scan(&indexRows); err != nil {
|
||
t.Fatalf("count mention index rows for %d: %v", messageID, err)
|
||
}
|
||
if mentionRows != wantMentionRows || indexRows != wantIndexRows {
|
||
t.Fatalf("mention rows for msg %d = mention:%d index:%d, want mention:%d index:%d", messageID, mentionRows, indexRows, wantMentionRows, wantIndexRows)
|
||
}
|
||
}
|
||
requireDialogMentions := func(want int) {
|
||
t.Helper()
|
||
var got int
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT unread_mentions_count
|
||
FROM channel_dialogs
|
||
WHERE user_id = $1 AND channel_id = $2`, member.ID, channelID).Scan(&got); err != nil {
|
||
t.Fatalf("read dialog mention count: %v", err)
|
||
}
|
||
if got != want {
|
||
t.Fatalf("dialog unread_mentions_count = %d, want %d", got, want)
|
||
}
|
||
}
|
||
|
||
requireUnreadMentionRows(first.Message.ID, 1, 1)
|
||
requireUnreadMentionRows(second.Message.ID, 1, 1)
|
||
requireDialogMentions(2)
|
||
|
||
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
IDs: []int{first.Message.ID},
|
||
Date: 1700000355,
|
||
}); err != nil {
|
||
t.Fatalf("delete first mention: %v", err)
|
||
}
|
||
requireUnreadMentionRows(first.Message.ID, 0, 0)
|
||
requireUnreadMentionRows(second.Message.ID, 1, 1)
|
||
requireDialogMentions(1)
|
||
mentions, err := channels.ListChannelUnreadMentions(ctx, member.ID, domain.ChannelUnreadMentionsFilter{
|
||
ChannelID: channelID,
|
||
Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list unread mentions after first delete: %v", err)
|
||
}
|
||
if len(mentions.Messages) != 1 || mentions.Messages[0].ID != second.Message.ID || !mentions.Messages[0].Mentioned {
|
||
t.Fatalf("unread mentions after first delete = %+v, want only second mentioned message", mentions.Messages)
|
||
}
|
||
|
||
if _, err := pool.Exec(ctx, `
|
||
INSERT INTO channel_unread_mention_index (channel_id, message_id, user_id)
|
||
VALUES ($1, $2, $3)
|
||
ON CONFLICT DO NOTHING`, channelID, plain.Message.ID, member.ID); err != nil {
|
||
t.Fatalf("insert stale mention index: %v", err)
|
||
}
|
||
requireUnreadMentionRows(plain.Message.ID, 0, 1)
|
||
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
IDs: []int{plain.Message.ID},
|
||
Date: 1700000356,
|
||
}); err != nil {
|
||
t.Fatalf("delete stale-index plain message: %v", err)
|
||
}
|
||
requireUnreadMentionRows(plain.Message.ID, 0, 0)
|
||
requireDialogMentions(1)
|
||
|
||
if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
IDs: []int{second.Message.ID},
|
||
Date: 1700000357,
|
||
}); err != nil {
|
||
t.Fatalf("delete second mention: %v", err)
|
||
}
|
||
requireUnreadMentionRows(second.Message.ID, 0, 0)
|
||
requireDialogMentions(0)
|
||
}
|
||
|
||
func TestChannelStoreReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{
|
||
AccessHash: 81,
|
||
Phone: "+1891" + suffix + "01",
|
||
FirstName: "ReactionOwner",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
friend, err := users.Create(ctx, domain.User{
|
||
AccessHash: 82,
|
||
Phone: "+1891" + suffix + "02",
|
||
FirstName: "ReactionFriend",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create friend: %v", err)
|
||
}
|
||
var channelID int64
|
||
t.Cleanup(func() {
|
||
if channelID != 0 {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||
}
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
|
||
})
|
||
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Visible Reaction " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{friend.ID},
|
||
Date: 1700000900,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create channel: %v", err)
|
||
}
|
||
channelID = created.Channel.ID
|
||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
RandomID: 90_001,
|
||
Message: "react to this",
|
||
Date: 1700000901,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send channel message: %v", err)
|
||
}
|
||
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
|
||
UserID: friend.ID,
|
||
ChannelID: channelID,
|
||
MessageID: sent.Message.ID,
|
||
Reactions: []domain.MessageReaction{{
|
||
Type: domain.MessageReactionEmoji,
|
||
Emoticon: "\U0001f525",
|
||
}},
|
||
Date: 1700000902,
|
||
}); err != nil {
|
||
t.Fatalf("set channel reaction: %v", err)
|
||
}
|
||
dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
|
||
if err != nil {
|
||
t.Fatalf("get owner 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 := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{
|
||
ChannelID: channelID,
|
||
Limit: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("list unread reactions: %v", err)
|
||
}
|
||
if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID || unread.Messages[0].Reactions == nil || !hasUnreadChannelReactionPG(*unread.Messages[0].Reactions) {
|
||
t.Fatalf("unread reactions = %+v, want unread sent message", unread.Messages)
|
||
}
|
||
|
||
read, err := channels.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: channelID,
|
||
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 || hasUnreadChannelReactionPG(*read.Messages[0].Reactions) {
|
||
t.Fatalf("read messages = %+v, want returned reaction marked read", read.Messages)
|
||
}
|
||
unreadAfter, err := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{
|
||
ChannelID: channelID,
|
||
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 := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
|
||
if err != nil {
|
||
t.Fatalf("get owner dialogs after read contents: %v", err)
|
||
}
|
||
if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 {
|
||
t.Fatalf("owner dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs)
|
||
}
|
||
var stillUnread bool
|
||
if err := pool.QueryRow(ctx, `
|
||
SELECT unread
|
||
FROM channel_message_reactions
|
||
WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`,
|
||
channelID, sent.Message.ID, friend.ID).Scan(&stillUnread); err != nil {
|
||
t.Fatalf("read reaction row: %v", err)
|
||
}
|
||
if stillUnread {
|
||
t.Fatal("reaction row still unread after read contents")
|
||
}
|
||
}
|
||
|
||
func TestChannelReadOutboxDerivesFromPublicWatermark(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
suffix := randomSuffix(t)
|
||
|
||
users := NewUserStore(pool)
|
||
owner, err := users.Create(ctx, domain.User{AccessHash: 45, Phone: "+1777" + suffix + "45", FirstName: "WmOwner"})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
sender, err := users.Create(ctx, domain.User{AccessHash: 46, Phone: "+1777" + suffix + "46", FirstName: "WmSender"})
|
||
if err != nil {
|
||
t.Fatalf("create sender: %v", err)
|
||
}
|
||
channels := NewChannelStore(pool)
|
||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||
CreatorUserID: owner.ID,
|
||
Title: "Watermark " + suffix,
|
||
Megagroup: true,
|
||
MemberUserIDs: []int64{sender.ID},
|
||
Date: 1700000400,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("create channel: %v", err)
|
||
}
|
||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||
UserID: sender.ID,
|
||
ChannelID: created.Channel.ID,
|
||
RandomID: 4601,
|
||
Message: "read me",
|
||
Date: 1700000401,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("send channel message: %v", err)
|
||
}
|
||
if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||
UserID: owner.ID,
|
||
ChannelID: created.Channel.ID,
|
||
MaxID: sent.Message.ID,
|
||
Date: 1700000402,
|
||
}); err != nil {
|
||
t.Fatalf("owner read history: %v", err)
|
||
}
|
||
|
||
// 模拟实时 fanout 截断:抹掉 sender 的 per-member outbox 水位与 dialog
|
||
// 缓存,read_outbox 必须仍能从 channel 级公共水位派生出来。
|
||
if _, err := pool.Exec(ctx, `UPDATE channel_members SET read_outbox_max_id = 0 WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, sender.ID); err != nil {
|
||
t.Fatalf("reset sender member outbox: %v", err)
|
||
}
|
||
if _, err := pool.Exec(ctx, `DELETE FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, sender.ID); err != nil {
|
||
t.Fatalf("reset sender dialog cache: %v", err)
|
||
}
|
||
|
||
view, err := channels.GetChannel(ctx, sender.ID, created.Channel.ID)
|
||
if err != nil {
|
||
t.Fatalf("sender view: %v", err)
|
||
}
|
||
if view.Dialog.ReadOutboxMaxID != sent.Message.ID {
|
||
t.Fatalf("sender read_outbox = %d, want %d derived from public watermark even when fanout state is missing", view.Dialog.ReadOutboxMaxID, sent.Message.ID)
|
||
}
|
||
// top1 持有者(owner)自己派生用 top2:sender 未读过任何消息时回退 0,
|
||
// 不能把自己的已读水位当成对方的回执。
|
||
ownerView, err := channels.GetChannel(ctx, owner.ID, created.Channel.ID)
|
||
if err != nil {
|
||
t.Fatalf("owner view: %v", err)
|
||
}
|
||
if ownerView.Dialog.ReadOutboxMaxID > ownerView.Dialog.ReadInboxMaxID {
|
||
t.Fatalf("owner read_outbox = %+v, must not exceed peers' actual reads", ownerView.Dialog)
|
||
}
|
||
}
|