fix: sync harden channel reads and suggested approvals
This commit is contained in:
parent
037ce017d4
commit
209a0d279b
14 changed files with 467 additions and 26 deletions
|
|
@ -1613,6 +1613,23 @@ const (
|
||||||
SuggestedPostStateRefunded SuggestedPostLifecycleState = "refunded"
|
SuggestedPostStateRefunded SuggestedPostLifecycleState = "refunded"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const MaxSuggestedPostScheduleDelay = 31 * 24 * 60 * 60
|
||||||
|
|
||||||
|
// EffectiveSuggestedPostPublishDate validates an approval schedule against the
|
||||||
|
// server's bounded future window and converts a missing or already-due absolute
|
||||||
|
// date into an immediate publication at now. Client date pickers can legally
|
||||||
|
// submit after their selected time has crossed a relative minimum; that delay
|
||||||
|
// must not turn a valid approval into an error or persist a past accepted date.
|
||||||
|
func EffectiveSuggestedPostPublishDate(scheduleDate, now int) (int, error) {
|
||||||
|
if now <= 0 || scheduleDate > now+MaxSuggestedPostScheduleDelay {
|
||||||
|
return 0, ErrSuggestedPostInvalid
|
||||||
|
}
|
||||||
|
if scheduleDate <= now {
|
||||||
|
return now, nil
|
||||||
|
}
|
||||||
|
return scheduleDate, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ToggleSuggestedPostApprovalResult contains every durable update produced by
|
// ToggleSuggestedPostApprovalResult contains every durable update produced by
|
||||||
// one command or lifecycle transition. OriginalEvent is an edit in the
|
// one command or lifecycle transition. OriginalEvent is an edit in the
|
||||||
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
// monoforum; ServiceEvent is the approval/success/refund service message; an
|
||||||
|
|
@ -2332,7 +2349,11 @@ type ReadChannelHistoryResult struct {
|
||||||
MaxID int
|
MaxID int
|
||||||
StillUnreadCount int
|
StillUnreadCount int
|
||||||
Changed bool
|
Changed bool
|
||||||
Pts int
|
// ReadOnly marks a synthetic viewer (public preview, linked guest or
|
||||||
|
// monoforum shell). The read is acknowledged without creating member,
|
||||||
|
// dialog, watermark, receipt or update state.
|
||||||
|
ReadOnly bool
|
||||||
|
Pts int
|
||||||
// Forum 标记该频道是否为话题群。RPC 层据此在频道级 readHistory 后顺带推进
|
// Forum 标记该频道是否为话题群。RPC 层据此在频道级 readHistory 后顺带推进
|
||||||
// General(topic 1) 的话题级已读水位(General 消息即频道根历史,被频道级已读覆盖)。
|
// General(topic 1) 的话题级已读水位(General 消息即频道根历史,被频道级已读覆盖)。
|
||||||
Forum bool
|
Forum bool
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,13 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
|
||||||
if !ok || !historyChat.Left || historyChat.ID != public.Channel.ID {
|
if !ok || !historyChat.Left || historyChat.ID != public.Channel.ID {
|
||||||
t.Fatalf("history chat = %T %+v, want left public channel", history.Chats[0], history.Chats[0])
|
t.Fatalf("history chat = %T %+v, want left public channel", history.Chats[0], history.Chats[0])
|
||||||
}
|
}
|
||||||
|
readOK, err := r.onChannelsReadHistory(WithUserID(ctx, viewer.ID), &tg.ChannelsReadHistoryRequest{
|
||||||
|
Channel: input,
|
||||||
|
MaxID: sent.Message.ID,
|
||||||
|
})
|
||||||
|
if err != nil || !readOK {
|
||||||
|
t.Fatalf("non-member readHistory public preview = %v err=%v, want successful no-op", readOK, err)
|
||||||
|
}
|
||||||
|
|
||||||
viewerCtx := WithSessionID(WithRawAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{9, 2}), 9202)
|
viewerCtx := WithSessionID(WithRawAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{9, 2}), 9202)
|
||||||
diff, err := r.onUpdatesGetChannelDifference(viewerCtx, &tg.UpdatesGetChannelDifferenceRequest{
|
diff, err := r.onUpdatesGetChannelDifference(viewerCtx, &tg.UpdatesGetChannelDifferenceRequest{
|
||||||
|
|
@ -266,3 +273,44 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) {
|
||||||
t.Fatalf("joined peer dialog chat = %T %+v, want active channel with left=false", joinedPeerDialogs.Chats[0], joinedPeerDialogs.Chats[0])
|
t.Fatalf("joined peer dialog chat = %T %+v, want active channel with left=false", joinedPeerDialogs.Chats[0], joinedPeerDialogs.Chats[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChannelsReadHistoryAllowsSyntheticMonoforumViewers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
userStore := memory.NewUserStore()
|
||||||
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 92101, Phone: "15550092101", FirstName: "Owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subscriber, err := userStore.Create(ctx, domain.User{AccessHash: 92102, Phone: "15550092102", FirstName: "Subscriber"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
channelStore := memory.NewChannelStore()
|
||||||
|
channels := appchannels.NewService(channelStore)
|
||||||
|
parent, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||||
|
Title: "Monoforum Read RPC", Broadcast: true, Date: 1_700_011_000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, parent.Channel.ID, 0, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mono, err := channelStore.GetChannelByID(ctx, enabled.Channel.LinkedMonoforumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Users: appusers.NewService(userStore), Channels: channels,
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
for _, userID := range []int64{owner.ID, subscriber.ID} {
|
||||||
|
ok, err := r.onChannelsReadHistory(WithUserID(ctx, userID), &tg.ChannelsReadHistoryRequest{
|
||||||
|
Channel: &tg.InputChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash},
|
||||||
|
MaxID: mono.TopMessageID,
|
||||||
|
})
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("synthetic monoforum read for %d = %v err=%v, want successful no-op", userID, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead
|
||||||
if r.deps.Channels == nil {
|
if r.deps.Channels == nil {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
if req == nil {
|
||||||
|
return false, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, internalErr()
|
return false, internalErr()
|
||||||
|
|
@ -75,6 +78,9 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, channelInvalidErr(err)
|
return false, channelInvalidErr(err)
|
||||||
}
|
}
|
||||||
|
if read.ReadOnly {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil {
|
if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
minSuggestedPostScheduleDelay = 5 * 60
|
|
||||||
maxSuggestedPostScheduleDelay = 31 * 24 * 60 * 60
|
|
||||||
maxSuggestedPostRejectComment = 1024
|
maxSuggestedPostRejectComment = 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -54,7 +52,7 @@ func (r *Router) onMessagesToggleSuggestedPostApproval(ctx context.Context, req
|
||||||
}
|
}
|
||||||
now := int(r.clock.Now().Unix())
|
now := int(r.clock.Now().Unix())
|
||||||
scheduleDate, hasScheduleDate := req.GetScheduleDate()
|
scheduleDate, hasScheduleDate := req.GetScheduleDate()
|
||||||
if hasScheduleDate && (req.Reject || scheduleDate < now+minSuggestedPostScheduleDelay || scheduleDate > now+maxSuggestedPostScheduleDelay) {
|
if hasScheduleDate && (req.Reject || scheduleDate <= 0) {
|
||||||
return nil, scheduleDateInvalidErr()
|
return nil, scheduleDateInvalidErr()
|
||||||
}
|
}
|
||||||
result, err := service.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
result, err := service.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/iamxvbaba/td/bin"
|
"github.com/iamxvbaba/td/bin"
|
||||||
"github.com/iamxvbaba/td/clock"
|
"github.com/iamxvbaba/td/clock"
|
||||||
|
|
@ -110,6 +111,107 @@ func TestMessagesToggleSuggestedPostApprovalRegisteredAndProjectsLifecycle(t *te
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessagesToggleSuggestedPostApprovalAcceptsDelayedAbsoluteDate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
users := memory.NewUserStore()
|
||||||
|
owner, err := users.Create(ctx, domain.User{AccessHash: 111, Phone: "15551110111", FirstName: "Owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subscriber, err := users.Create(ctx, domain.User{AccessHash: 112, Phone: "15551110112", FirstName: "Subscriber"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
channelsStore := memory.NewChannelStore()
|
||||||
|
channels := appchannels.NewService(channelsStore)
|
||||||
|
created, err := channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||||
|
Title: "Delayed Suggested", Broadcast: true, Date: 1_700_030_000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enabled, err := channelsStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mono, err := channelsStore.GetChannelByID(ctx, enabled.Channel.LinkedMonoforumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||||
|
suggestion, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||||
|
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: saved,
|
||||||
|
RandomID: 101, Message: "delayed RPC suggestion", SuggestedPost: &domain.SuggestedPost{}, Date: 1_700_030_010,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = 1_700_030_100
|
||||||
|
req := &tg.MessagesToggleSuggestedPostApprovalRequest{
|
||||||
|
Peer: &tg.InputPeerChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash},
|
||||||
|
MsgID: suggestion.Message.ID,
|
||||||
|
}
|
||||||
|
req.SetScheduleDate(now + 2*60)
|
||||||
|
dispatch := func(t *testing.T, router *Router, layer int) *tg.Updates {
|
||||||
|
t.Helper()
|
||||||
|
var raw bin.Buffer
|
||||||
|
if err := req.Encode(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response, err := router.Dispatch(WithLayer(WithUserID(ctx, owner.ID), layer), [8]byte{}, 0, &raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch delayed approval at Layer %d: %v", layer, err)
|
||||||
|
}
|
||||||
|
updates, ok := response.(*tg.Updates)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("delayed approval response=%T", response)
|
||||||
|
}
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
first := dispatch(t, New(Config{}, Deps{
|
||||||
|
Users: appusers.NewService(users), Channels: channels,
|
||||||
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}), 228)
|
||||||
|
if len(first.Updates) != 2 {
|
||||||
|
t.Fatalf("near-future approval updates=%d, want edit + approval service", len(first.Updates))
|
||||||
|
}
|
||||||
|
replay := dispatch(t, New(Config{}, Deps{
|
||||||
|
Users: appusers.NewService(users), Channels: channels,
|
||||||
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now+10*60, 0)}), 227)
|
||||||
|
if len(replay.Updates) != 2 {
|
||||||
|
t.Fatalf("late duplicate updates=%d, want persisted edit + approval service", len(replay.Updates))
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||||
|
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: saved,
|
||||||
|
RandomID: 102, Message: "due RPC suggestion", SuggestedPost: &domain.SuggestedPost{}, Date: now + 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dueReq := &tg.MessagesToggleSuggestedPostApprovalRequest{
|
||||||
|
Peer: &tg.InputPeerChannel{ChannelID: mono.ID, AccessHash: mono.AccessHash},
|
||||||
|
MsgID: due.Message.ID,
|
||||||
|
}
|
||||||
|
dueReq.SetScheduleDate(now - 1)
|
||||||
|
var dueRaw bin.Buffer
|
||||||
|
if err := dueReq.Encode(&dueRaw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response, err := New(Config{}, Deps{
|
||||||
|
Users: appusers.NewService(users), Channels: channels,
|
||||||
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now+2, 0)}).Dispatch(
|
||||||
|
WithLayer(WithUserID(ctx, owner.ID), 228), [8]byte{}, 0, &dueRaw,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dispatch already-due approval: %v", err)
|
||||||
|
}
|
||||||
|
dueUpdates, ok := response.(*tg.Updates)
|
||||||
|
if !ok || len(dueUpdates.Updates) != 3 {
|
||||||
|
t.Fatalf("already-due response=%T %#v, want edit + approval + published", response, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSuggestedPostTLProjectionSeparatesSuggestionAndPublishedPaymentFlags(t *testing.T) {
|
func TestSuggestedPostTLProjectionSeparatesSuggestionAndPublishedPaymentFlags(t *testing.T) {
|
||||||
original := domain.ChannelMessage{ChannelID: 10, ID: 1, SenderUserID: 20, From: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, Date: 100, Body: "proposal", SuggestedPost: &domain.SuggestedPost{Accepted: true, Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}}
|
original := domain.ChannelMessage{ChannelID: 10, ID: 1, SenderUserID: 20, From: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 20}, Date: 100, Body: "proposal", SuggestedPost: &domain.SuggestedPost{Accepted: true, Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}}}
|
||||||
proposal := tgChannelMessage(20, original).(*tg.Message)
|
proposal := tgChannelMessage(20, original).(*tg.Message)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,23 @@ func TestSetPaidMessagesPriceCreatesAndReusesMonoforum(t *testing.T) {
|
||||||
if mono.TopMessageID == 0 || mono.Pts == 0 {
|
if mono.TopMessageID == 0 || mono.Pts == 0 {
|
||||||
t.Fatalf("monoforum top/pts = %d/%d, want service top message", mono.TopMessageID, mono.Pts)
|
t.Fatalf("monoforum top/pts = %d/%d, want service top message", mono.TopMessageID, mono.Pts)
|
||||||
}
|
}
|
||||||
|
for _, userID := range []int64{1, 42} {
|
||||||
|
read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||||||
|
UserID: userID, ChannelID: monoID, MaxID: mono.TopMessageID, Date: 1_700_000_901,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("synthetic monoforum read for %d: %v", userID, err)
|
||||||
|
}
|
||||||
|
if !read.ReadOnly || read.Changed || read.MaxID != mono.TopMessageID {
|
||||||
|
t.Fatalf("synthetic monoforum read for %d = %+v, want read-only no-op at %d", userID, read, mono.TopMessageID)
|
||||||
|
}
|
||||||
|
if _, exists := store.members[monoID][userID]; exists {
|
||||||
|
t.Fatalf("synthetic monoforum read persisted member for %d", userID)
|
||||||
|
}
|
||||||
|
if _, exists := store.dialogs[userID][monoID]; exists {
|
||||||
|
t.Fatalf("synthetic monoforum read persisted dialog for %d", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100})
|
dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list dialogs after enable: %v", err)
|
t.Fatalf("list dialogs after enable: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadCha
|
||||||
func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
channel, _, readOnly, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ReadChannelHistoryResult{}, err
|
return domain.ReadChannelHistoryResult{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +260,15 @@ func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChan
|
||||||
if maxID <= 0 || maxID > channel.TopMessageID {
|
if maxID <= 0 || maxID > channel.TopMessageID {
|
||||||
maxID = channel.TopMessageID
|
maxID = channel.TopMessageID
|
||||||
}
|
}
|
||||||
|
if readOnly {
|
||||||
|
return domain.ReadChannelHistoryResult{
|
||||||
|
ChannelID: req.ChannelID,
|
||||||
|
MaxID: maxID,
|
||||||
|
ReadOnly: true,
|
||||||
|
Pts: channel.Pts,
|
||||||
|
Forum: channel.Forum,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
member := s.members[req.ChannelID][req.UserID]
|
member := s.members[req.ChannelID][req.UserID]
|
||||||
previous := member.ReadInboxMaxID
|
previous := member.ReadInboxMaxID
|
||||||
changed := maxID > member.ReadInboxMaxID
|
changed := maxID > member.ReadInboxMaxID
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,12 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
|
||||||
if req.ScheduleDate > 0 {
|
if req.ScheduleDate > 0 {
|
||||||
scheduleDate = req.ScheduleDate
|
scheduleDate = req.ScheduleDate
|
||||||
}
|
}
|
||||||
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
|
if !req.Reject {
|
||||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
effectiveDate, scheduleErr := domain.EffectiveSuggestedPostPublishDate(scheduleDate, req.Date)
|
||||||
|
if scheduleErr != nil {
|
||||||
|
return domain.ToggleSuggestedPostApprovalResult{}, scheduleErr
|
||||||
|
}
|
||||||
|
scheduleDate = effectiveDate
|
||||||
}
|
}
|
||||||
recipients := s.monoforumRecipientsLocked(parent.ID, original.SavedPeer.ID)
|
recipients := s.monoforumRecipientsLocked(parent.ID, original.SavedPeer.ID)
|
||||||
base := domain.ToggleSuggestedPostApprovalResult{
|
base := domain.ToggleSuggestedPostApprovalResult{
|
||||||
|
|
@ -136,13 +140,6 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
|
||||||
original.SuggestedPost.Accepted = true
|
original.SuggestedPost.Accepted = true
|
||||||
original.SuggestedPost.Rejected = false
|
original.SuggestedPost.Rejected = false
|
||||||
effectivePublishDate := scheduleDate
|
effectivePublishDate := scheduleDate
|
||||||
if effectivePublishDate == 0 {
|
|
||||||
// TDesktop deliberately omits schedule_date for "Publish Now", but
|
|
||||||
// renders the approval service action as an absolute date. Persist one
|
|
||||||
// effective publication timestamp across the edited suggestion, action
|
|
||||||
// and approval record instead of leaking an accepted zero date.
|
|
||||||
effectivePublishDate = req.Date
|
|
||||||
}
|
|
||||||
original.SuggestedPost.ScheduleDate = effectivePublishDate
|
original.SuggestedPost.ScheduleDate = effectivePublishDate
|
||||||
original.Pts = s.nextChannelPtsLocked(mono.ID)
|
original.Pts = s.nextChannelPtsLocked(mono.ID)
|
||||||
s.messages[mono.ID][idx] = cloneChannelMessage(original)
|
s.messages[mono.ID][idx] = cloneChannelMessage(original)
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,89 @@ func TestSuggestedPostLowBalanceRetryScheduleAndRoleMatrix(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSuggestedPostApprovalAcceptsDelayedScheduleAndKeepsPTSIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||||
|
|
||||||
|
const now = 1_700_010_000
|
||||||
|
near, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||||
|
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
|
||||||
|
RandomID: 71, Message: "near schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now - 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nearDate := now + 2*60
|
||||||
|
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
UserID: 1, MonoforumID: mono.ID, MessageID: near.Message.ID,
|
||||||
|
ScheduleDate: nearDate, Date: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("accept schedule below former five-minute gate: %v", err)
|
||||||
|
}
|
||||||
|
if accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil ||
|
||||||
|
accepted.OriginalMessage.SuggestedPost.ScheduleDate != nearDate ||
|
||||||
|
accepted.ServiceMessage.Action == nil ||
|
||||||
|
accepted.ServiceMessage.Action.SuggestedPostScheduleDate != nearDate {
|
||||||
|
t.Fatalf("near schedule approval = %+v, want scheduled at %d", accepted, nearDate)
|
||||||
|
}
|
||||||
|
monoPts, parentPts := store.channels[mono.ID].Pts, store.channels[parent.ID].Pts
|
||||||
|
monoEvents, parentEvents := len(store.events[mono.ID]), len(store.events[parent.ID])
|
||||||
|
replay, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
UserID: 1, MonoforumID: mono.ID, MessageID: near.Message.ID,
|
||||||
|
ScheduleDate: nearDate, Date: now + 10*60,
|
||||||
|
})
|
||||||
|
if err != nil || !replay.Duplicate {
|
||||||
|
t.Fatalf("late duplicate approval = %+v err=%v", replay, err)
|
||||||
|
}
|
||||||
|
if store.channels[mono.ID].Pts != monoPts || store.channels[parent.ID].Pts != parentPts ||
|
||||||
|
len(store.events[mono.ID]) != monoEvents || len(store.events[parent.ID]) != parentEvents {
|
||||||
|
t.Fatal("late duplicate approval advanced PTS or appended an event")
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||||
|
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
|
||||||
|
RandomID: 72, Message: "already due", SuggestedPost: &domain.SuggestedPost{}, Date: now + 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
approvedAt := now + 30
|
||||||
|
dueResult, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
UserID: 1, MonoforumID: mono.ID, MessageID: due.Message.ID,
|
||||||
|
ScheduleDate: now - 1, Date: approvedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("approve already-due schedule: %v", err)
|
||||||
|
}
|
||||||
|
if dueResult.State != domain.SuggestedPostStateCompleted || dueResult.Published == nil ||
|
||||||
|
dueResult.OriginalMessage.SuggestedPost.ScheduleDate != approvedAt ||
|
||||||
|
dueResult.ServiceMessage.Action == nil ||
|
||||||
|
dueResult.ServiceMessage.Action.SuggestedPostScheduleDate != approvedAt {
|
||||||
|
t.Fatalf("due schedule approval = %+v, want immediate publish at %d", dueResult, approvedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
far, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||||
|
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
|
||||||
|
RandomID: 73, Message: "too far", SuggestedPost: &domain.SuggestedPost{}, Date: now + 40,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
monoPts, parentPts = store.channels[mono.ID].Pts, store.channels[parent.ID].Pts
|
||||||
|
monoEvents, parentEvents = len(store.events[mono.ID]), len(store.events[parent.ID])
|
||||||
|
if _, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
UserID: 1, MonoforumID: mono.ID, MessageID: far.Message.ID,
|
||||||
|
ScheduleDate: approvedAt + domain.MaxSuggestedPostScheduleDelay + 1, Date: approvedAt,
|
||||||
|
}); !errors.Is(err, domain.ErrSuggestedPostInvalid) {
|
||||||
|
t.Fatalf("far schedule err=%v, want suggested post invalid", err)
|
||||||
|
}
|
||||||
|
if store.channels[mono.ID].Pts != monoPts || store.channels[parent.ID].Pts != parentPts ||
|
||||||
|
len(store.events[mono.ID]) != monoEvents || len(store.events[parent.ID]) != parentEvents {
|
||||||
|
t.Fatal("far schedule rejection advanced PTS or appended an event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChannelAuthoredSuggestedPostAcceptedBySubscriber(t *testing.T) {
|
func TestChannelAuthoredSuggestedPostAcceptedBySubscriber(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)
|
||||||
|
|
|
||||||
|
|
@ -756,7 +756,7 @@ WHERE (
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
||||||
channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
|
channel, _, readOnly, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ReadChannelHistoryResult{}, err
|
return domain.ReadChannelHistoryResult{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -764,6 +764,15 @@ func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.Re
|
||||||
if maxID <= 0 || maxID > channel.TopMessageID {
|
if maxID <= 0 || maxID > channel.TopMessageID {
|
||||||
maxID = channel.TopMessageID
|
maxID = channel.TopMessageID
|
||||||
}
|
}
|
||||||
|
if readOnly {
|
||||||
|
return domain.ReadChannelHistoryResult{
|
||||||
|
ChannelID: req.ChannelID,
|
||||||
|
MaxID: maxID,
|
||||||
|
ReadOnly: true,
|
||||||
|
Pts: channel.Pts,
|
||||||
|
Forum: channel.Forum,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
previous, unreadMark, err := s.channelReadHistoryState(ctx, req.ChannelID, req.UserID)
|
previous, unreadMark, err := s.channelReadHistoryState(ctx, req.ChannelID, req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err)
|
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,28 @@ func TestChannelStoreEnablingDirectMessagesCreatesMonoforum(t *testing.T) {
|
||||||
if mfTop == 0 || mfPts == 0 {
|
if mfTop == 0 || mfPts == 0 {
|
||||||
t.Fatalf("monoforum top/pts = %d/%d, want paid-messages service top", mfTop, mfPts)
|
t.Fatalf("monoforum top/pts = %d/%d, want paid-messages service top", mfTop, mfPts)
|
||||||
}
|
}
|
||||||
|
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||||||
|
UserID: owner.ID, ChannelID: monoID, MaxID: mfTop, Date: 1700000901,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read synthetic monoforum history: %v", err)
|
||||||
|
}
|
||||||
|
if !read.ReadOnly || read.Changed || read.MaxID != mfTop {
|
||||||
|
t.Fatalf("synthetic monoforum read = %+v, want read-only no-op at %d", read, mfTop)
|
||||||
|
}
|
||||||
|
var memberExists, dialogExists bool
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2
|
||||||
|
),
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM channel_dialogs WHERE channel_id=$1 AND user_id=$2
|
||||||
|
)`, monoID, owner.ID).Scan(&memberExists, &dialogExists); err != nil {
|
||||||
|
t.Fatalf("check synthetic monoforum read footprint: %v", err)
|
||||||
|
}
|
||||||
|
if memberExists || dialogExists {
|
||||||
|
t.Fatalf("synthetic monoforum read persisted member/dialog = %v/%v", memberExists, dialogExists)
|
||||||
|
}
|
||||||
// 同批下发母广播频道(TDesktop 据此 resolve linked_monoforum_id 并派生 MonoforumAdmin
|
// 同批下发母广播频道(TDesktop 据此 resolve linked_monoforum_id 并派生 MonoforumAdmin
|
||||||
// 渲染 Direct-Messages 容器):GetChannelDialogs([mono]) 的 chats[] 必须同时带 mono 与母频道。
|
// 渲染 Direct-Messages 容器):GetChannelDialogs([mono]) 的 chats[] 必须同时带 mono 与母频道。
|
||||||
coDelivery, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{monoID})
|
coDelivery, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{monoID})
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,15 @@ func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) {
|
||||||
if !found || history.Self.Status != domain.ChannelMemberLeft {
|
if !found || history.Self.Status != domain.ChannelMemberLeft {
|
||||||
t.Fatalf("preview history = %+v self=%+v", history.Messages, history.Self)
|
t.Fatalf("preview history = %+v self=%+v", history.Messages, history.Self)
|
||||||
}
|
}
|
||||||
|
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||||||
|
UserID: viewer.ID, ChannelID: public.ID, MaxID: sent.Message.ID, Date: 1700009411 + i,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read public preview history: %v", err)
|
||||||
|
}
|
||||||
|
if !read.ReadOnly || read.Changed || read.MaxID != sent.Message.ID {
|
||||||
|
t.Fatalf("public preview read = %+v, want read-only no-op at %d", read, sent.Message.ID)
|
||||||
|
}
|
||||||
audience, err := channels.FilterChannelMessageAudienceIDs(ctx, public.ID, []int64{viewer.ID, owner.ID, viewer.ID})
|
audience, err := channels.FilterChannelMessageAudienceIDs(ctx, public.ID, []int64{viewer.ID, owner.ID, viewer.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("filter public message audience: %v", err)
|
t.Fatalf("filter public message audience: %v", err)
|
||||||
|
|
@ -111,14 +120,16 @@ func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) {
|
||||||
previewDialog.Pts != sent.Event.Pts {
|
previewDialog.Pts != sent.Event.Pts {
|
||||||
t.Fatalf("public preview bootstrap dialog = %+v", previewDialog)
|
t.Fatalf("public preview bootstrap dialog = %+v", previewDialog)
|
||||||
}
|
}
|
||||||
var memberExists bool
|
var memberExists, dialogExists bool
|
||||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (
|
if err := pool.QueryRow(ctx, `SELECT EXISTS (
|
||||||
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
|
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
|
||||||
)`, public.ID, viewer.ID).Scan(&memberExists); err != nil {
|
), EXISTS (
|
||||||
|
SELECT 1 FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2
|
||||||
|
)`, public.ID, viewer.ID).Scan(&memberExists, &dialogExists); err != nil {
|
||||||
t.Fatalf("check preview member row: %v", err)
|
t.Fatalf("check preview member row: %v", err)
|
||||||
}
|
}
|
||||||
if memberExists {
|
if memberExists || dialogExists {
|
||||||
t.Fatal("public preview persisted a channel member row")
|
t.Fatalf("public preview persisted member/dialog = %v/%v", memberExists, dialogExists)
|
||||||
}
|
}
|
||||||
if _, err := channels.JoinChannel(ctx, public.ID, viewer.ID, 1700009420+i); err != nil {
|
if _, err := channels.JoinChannel(ctx, public.ID, viewer.ID, 1700009420+i); err != nil {
|
||||||
t.Fatalf("join public peer: %v", err)
|
t.Fatalf("join public peer: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -118,8 +118,12 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
|
||||||
if req.ScheduleDate > 0 {
|
if req.ScheduleDate > 0 {
|
||||||
scheduleDate = req.ScheduleDate
|
scheduleDate = req.ScheduleDate
|
||||||
}
|
}
|
||||||
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
|
if !req.Reject {
|
||||||
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
|
effectiveDate, scheduleErr := domain.EffectiveSuggestedPostPublishDate(scheduleDate, req.Date)
|
||||||
|
if scheduleErr != nil {
|
||||||
|
return domain.ToggleSuggestedPostApprovalResult{}, scheduleErr
|
||||||
|
}
|
||||||
|
scheduleDate = effectiveDate
|
||||||
}
|
}
|
||||||
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
|
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -173,11 +177,6 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
effectivePublishDate := scheduleDate
|
effectivePublishDate := scheduleDate
|
||||||
if effectivePublishDate == 0 {
|
|
||||||
// TDesktop's "Publish Now" request has no schedule_date flag, but
|
|
||||||
// its approval service renderer always expects an absolute date.
|
|
||||||
effectivePublishDate = req.Date
|
|
||||||
}
|
|
||||||
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
|
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
|
||||||
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
|
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -133,3 +133,122 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
|
||||||
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
|
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSuggestedPostApprovalAcceptsDelayedSchedulePostgres(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
suffix := randomSuffix(t)
|
||||||
|
users := NewUserStore(pool)
|
||||||
|
owner, err := users.Create(ctx, domain.User{AccessHash: 211, Phone: "+1889" + suffix + "01", FirstName: "DelayedOwner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
subscriber, err := users.Create(ctx, domain.User{AccessHash: 212, Phone: "+1889" + suffix + "02", FirstName: "DelayedSubscriber"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
channels := NewChannelStore(pool)
|
||||||
|
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||||
|
CreatorUserID: owner.ID, Title: "Delayed Suggested " + suffix, Broadcast: true, Date: 1_700_020_000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
monoID := enabled.Channel.LinkedMonoforumID
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM suggested_post_approvals WHERE monoforum_id=$1`, monoID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=ANY($1::bigint[])`, []int64{monoID, created.Channel.ID})
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{owner.ID, subscriber.ID})
|
||||||
|
})
|
||||||
|
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||||
|
|
||||||
|
const now = 1_700_020_100
|
||||||
|
near, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||||
|
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
|
||||||
|
RandomID: 81, Message: "postgres near schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now - 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nearDate := now + 2*60
|
||||||
|
accepted, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
|
||||||
|
ScheduleDate: nearDate, Date: now,
|
||||||
|
})
|
||||||
|
if err != nil || accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil {
|
||||||
|
t.Fatalf("near schedule approval=%+v err=%v", accepted, err)
|
||||||
|
}
|
||||||
|
var monoPts, parentPts, monoEvents, parentEvents int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&monoPts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&parentPts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&monoEvents); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&parentEvents); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replay, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
|
||||||
|
ScheduleDate: nearDate, Date: now + 10*60,
|
||||||
|
})
|
||||||
|
if err != nil || !replay.Duplicate {
|
||||||
|
t.Fatalf("late replay=%+v err=%v", replay, err)
|
||||||
|
}
|
||||||
|
var gotMonoPts, gotParentPts, gotMonoEvents, gotParentEvents int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&gotMonoPts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&gotParentPts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&gotMonoEvents); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&gotParentEvents); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMonoPts != monoPts || gotParentPts != parentPts || gotMonoEvents != monoEvents || gotParentEvents != parentEvents {
|
||||||
|
t.Fatalf("late replay changed pts/events mono=%d/%d→%d/%d parent=%d/%d→%d/%d",
|
||||||
|
monoPts, monoEvents, gotMonoPts, gotMonoEvents, parentPts, parentEvents, gotParentPts, gotParentEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||||
|
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
|
||||||
|
RandomID: 82, Message: "postgres due schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now + 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
approvedAt := now + 30
|
||||||
|
dueResult, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||||
|
UserID: owner.ID, MonoforumID: monoID, MessageID: due.Message.ID,
|
||||||
|
ScheduleDate: now - 1, Date: approvedAt,
|
||||||
|
})
|
||||||
|
if err != nil || dueResult.State != domain.SuggestedPostStateCompleted || dueResult.Published == nil {
|
||||||
|
t.Fatalf("due approval=%+v err=%v", dueResult, err)
|
||||||
|
}
|
||||||
|
if dueResult.OriginalMessage.SuggestedPost.ScheduleDate != approvedAt ||
|
||||||
|
dueResult.ServiceMessage.Action == nil ||
|
||||||
|
dueResult.ServiceMessage.Action.SuggestedPostScheduleDate != approvedAt {
|
||||||
|
t.Fatalf("due effective dates original/action=%d/%+v, want %d",
|
||||||
|
dueResult.OriginalMessage.SuggestedPost.ScheduleDate, dueResult.ServiceMessage.Action, approvedAt)
|
||||||
|
}
|
||||||
|
var persistedDate int
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
SELECT schedule_date
|
||||||
|
FROM suggested_post_approvals
|
||||||
|
WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, due.Message.ID).Scan(&persistedDate); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if persistedDate != approvedAt {
|
||||||
|
t.Fatalf("persisted due schedule=%d, want %d", persistedDate, approvedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue