diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 3f8bedd2..328dc3a0 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -1613,6 +1613,23 @@ const ( 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 // one command or lifecycle transition. OriginalEvent is an edit in the // monoforum; ServiceEvent is the approval/success/refund service message; an @@ -2332,7 +2349,11 @@ type ReadChannelHistoryResult struct { MaxID int StillUnreadCount int 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 后顺带推进 // General(topic 1) 的话题级已读水位(General 消息即频道根历史,被频道级已读覆盖)。 Forum bool diff --git a/internal/rpc/channels_public_preview_rpc_test.go b/internal/rpc/channels_public_preview_rpc_test.go index d4cdd678..486551c5 100644 --- a/internal/rpc/channels_public_preview_rpc_test.go +++ b/internal/rpc/channels_public_preview_rpc_test.go @@ -124,6 +124,13 @@ func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) { 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]) } + 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) 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]) } } + +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) + } + } +} diff --git a/internal/rpc/channels_read_reactions.go b/internal/rpc/channels_read_reactions.go index 02a16152..d0a5fbfb 100644 --- a/internal/rpc/channels_read_reactions.go +++ b/internal/rpc/channels_read_reactions.go @@ -58,6 +58,9 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead if r.deps.Channels == nil { return true, nil } + if req == nil { + return false, inputRequestInvalidErr() + } userID, _, err := r.currentUserID(ctx) if err != nil { return false, internalErr() @@ -75,6 +78,9 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead if err != nil { return false, channelInvalidErr(err) } + if read.ReadOnly { + return true, nil + } if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { return false, err } diff --git a/internal/rpc/messages_suggested_post.go b/internal/rpc/messages_suggested_post.go index dc009224..1649b54b 100644 --- a/internal/rpc/messages_suggested_post.go +++ b/internal/rpc/messages_suggested_post.go @@ -19,8 +19,6 @@ const ( ) const ( - minSuggestedPostScheduleDelay = 5 * 60 - maxSuggestedPostScheduleDelay = 31 * 24 * 60 * 60 maxSuggestedPostRejectComment = 1024 ) @@ -54,7 +52,7 @@ func (r *Router) onMessagesToggleSuggestedPostApproval(ctx context.Context, req } now := int(r.clock.Now().Unix()) scheduleDate, hasScheduleDate := req.GetScheduleDate() - if hasScheduleDate && (req.Reject || scheduleDate < now+minSuggestedPostScheduleDelay || scheduleDate > now+maxSuggestedPostScheduleDelay) { + if hasScheduleDate && (req.Reject || scheduleDate <= 0) { return nil, scheduleDateInvalidErr() } result, err := service.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{ diff --git a/internal/rpc/messages_suggested_post_rpc_test.go b/internal/rpc/messages_suggested_post_rpc_test.go index 2cfb9cf0..20977a5a 100644 --- a/internal/rpc/messages_suggested_post_rpc_test.go +++ b/internal/rpc/messages_suggested_post_rpc_test.go @@ -3,6 +3,7 @@ package rpc import ( "context" "testing" + "time" "github.com/iamxvbaba/td/bin" "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) { 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) diff --git a/internal/store/memory/channel_monoforum_test.go b/internal/store/memory/channel_monoforum_test.go index 37b48a8b..2b5460bc 100644 --- a/internal/store/memory/channel_monoforum_test.go +++ b/internal/store/memory/channel_monoforum_test.go @@ -41,6 +41,23 @@ func TestSetPaidMessagesPriceCreatesAndReusesMonoforum(t *testing.T) { if mono.TopMessageID == 0 || mono.Pts == 0 { 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}) if err != nil { t.Fatalf("list dialogs after enable: %v", err) diff --git a/internal/store/memory/channel_read.go b/internal/store/memory/channel_read.go index b69ae7ae..3b7fad9c 100644 --- a/internal/store/memory/channel_read.go +++ b/internal/store/memory/channel_read.go @@ -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) { s.mu.Lock() defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + channel, _, readOnly, err := s.channelForViewerLocked(req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelHistoryResult{}, err } @@ -260,6 +260,15 @@ func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChan if maxID <= 0 || 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] previous := member.ReadInboxMaxID changed := maxID > member.ReadInboxMaxID diff --git a/internal/store/memory/channel_suggested_post.go b/internal/store/memory/channel_suggested_post.go index 37dec1f5..3396d00d 100644 --- a/internal/store/memory/channel_suggested_post.go +++ b/internal/store/memory/channel_suggested_post.go @@ -85,8 +85,12 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges if req.ScheduleDate > 0 { scheduleDate = req.ScheduleDate } - if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) { - return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid + if !req.Reject { + effectiveDate, scheduleErr := domain.EffectiveSuggestedPostPublishDate(scheduleDate, req.Date) + if scheduleErr != nil { + return domain.ToggleSuggestedPostApprovalResult{}, scheduleErr + } + scheduleDate = effectiveDate } recipients := s.monoforumRecipientsLocked(parent.ID, original.SavedPeer.ID) base := domain.ToggleSuggestedPostApprovalResult{ @@ -136,13 +140,6 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges original.SuggestedPost.Accepted = true original.SuggestedPost.Rejected = false 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.Pts = s.nextChannelPtsLocked(mono.ID) s.messages[mono.ID][idx] = cloneChannelMessage(original) diff --git a/internal/store/memory/channel_suggested_post_test.go b/internal/store/memory/channel_suggested_post_test.go index 3e4d3c80..20c38ea3 100644 --- a/internal/store/memory/channel_suggested_post_test.go +++ b/internal/store/memory/channel_suggested_post_test.go @@ -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) { ctx := context.Background() store, _, mono, subscriber := newSuggestedPostMemoryFixture(t) diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go index f5f80a97..e0325301 100644 --- a/internal/store/postgres/channel_message_history.go +++ b/internal/store/postgres/channel_message_history.go @@ -756,7 +756,7 @@ WHERE ( } 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 { return domain.ReadChannelHistoryResult{}, err } @@ -764,6 +764,15 @@ func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.Re if maxID <= 0 || 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) if err != nil { return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err) diff --git a/internal/store/postgres/channel_monoforum_integration_test.go b/internal/store/postgres/channel_monoforum_integration_test.go index 149d0f43..8a81c251 100644 --- a/internal/store/postgres/channel_monoforum_integration_test.go +++ b/internal/store/postgres/channel_monoforum_integration_test.go @@ -71,6 +71,28 @@ func TestChannelStoreEnablingDirectMessagesCreatesMonoforum(t *testing.T) { if mfTop == 0 || mfPts == 0 { 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 // 渲染 Direct-Messages 容器):GetChannelDialogs([mono]) 的 chats[] 必须同时带 mono 与母频道。 coDelivery, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{monoID}) diff --git a/internal/store/postgres/channel_public_preview_integration_test.go b/internal/store/postgres/channel_public_preview_integration_test.go index 169dae66..84155971 100644 --- a/internal/store/postgres/channel_public_preview_integration_test.go +++ b/internal/store/postgres/channel_public_preview_integration_test.go @@ -75,6 +75,15 @@ func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) { if !found || history.Self.Status != domain.ChannelMemberLeft { 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}) if err != nil { t.Fatalf("filter public message audience: %v", err) @@ -111,14 +120,16 @@ func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) { previewDialog.Pts != sent.Event.Pts { t.Fatalf("public preview bootstrap dialog = %+v", previewDialog) } - var memberExists bool + var memberExists, dialogExists bool if err := pool.QueryRow(ctx, `SELECT EXISTS ( 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) } - if memberExists { - t.Fatal("public preview persisted a channel member row") + if memberExists || dialogExists { + t.Fatalf("public preview persisted member/dialog = %v/%v", memberExists, dialogExists) } if _, err := channels.JoinChannel(ctx, public.ID, viewer.ID, 1700009420+i); err != nil { t.Fatalf("join public peer: %v", err) diff --git a/internal/store/postgres/channel_suggested_post.go b/internal/store/postgres/channel_suggested_post.go index 9cde2a95..fa70cac2 100644 --- a/internal/store/postgres/channel_suggested_post.go +++ b/internal/store/postgres/channel_suggested_post.go @@ -118,8 +118,12 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma if req.ScheduleDate > 0 { scheduleDate = req.ScheduleDate } - if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) { - return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid + if !req.Reject { + 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) if err != nil { @@ -173,11 +177,6 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma } } else { 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, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date) if err != nil { diff --git a/internal/store/postgres/channel_suggested_post_integration_test.go b/internal/store/postgres/channel_suggested_post_integration_test.go index 4db4f512..ca75195d 100644 --- a/internal/store/postgres/channel_suggested_post_integration_test.go +++ b/internal/store/postgres/channel_suggested_post_integration_test.go @@ -133,3 +133,122 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) { 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) + } +}