mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-18 22:05:43 +08:00
Small action posts (closing, opening, pinning, archiving, category changes, auto-close, etc. — post_type 3) no longer count toward a topic's counters or unread tracking. They still occupy a post_number so the post stream stays gapless, but they contribute to nothing: not highest_post_number, highest_staff_post_number, posts_count, last_posted_at, last_post_user_id, word_count or bumped_at, and they never mark a topic unread or publish unread updates — for regular users and staff alike. Previously this exclusion only applied to private messages. Because small actions advanced highest_post_number, a topic whose only new post was a small action (say an auto-close at the end of a thread) was treated as unread: the nav "Unread (N)" badge counted it while /unread did not list it — the "Unread (N) but /unread is empty" phantom reported in https://meta.discourse.org/t/-/403986. More broadly, routine administrivia such as bulk closes and auto-close timers should not notify watchers or inflate reply counts (https://meta.discourse.org/t/-/404058). How it works: - Topic.next_post_number assigns small actions a post_number but advances no counter (whispers bump only the staff counter; small actions bump neither). - Topic.reset_highest / reset_all_highest! exclude them via two shared SQL fragments — public_post_types_sql (no whispers, no small actions) and staff_post_types_sql (no small actions) — collapsing the old regular-vs-PM branches into one. - PostCreator skips last_posted_at / last_post_user_id / word_count / bumped_at and the user post count for small actions; PostDestroyer's last-post lookup skips them too. - TopicTrackingState / PrivateMessageTopicTrackingState#publish_unread and the PostUpdateTopicTrackingState job return early for small actions. - Category time-based post counts, UserStat#calc_topic_reply_count! and the import.rake topic backfill exclude them as well. - A post-deploy migration recomputes the affected counters for existing topics and clamps any topic_users.last_read_post_number left pointing past the new highest (whisperers to highest_staff_post_number, everyone else to highest_post_number) so nobody is stuck with phantom read state. Keeping the unread count and the /unread list filtering on the same column is a separate, complementary fix that ships on its own. This supersedes the earlier proof-of-concept PRs #40290 and #39093.
100 lines
3.6 KiB
Ruby
Vendored
100 lines
3.6 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require Rails.root.join(
|
|
"db/post_migrate/20260602104726_recalculate_topic_counters_without_small_actions.rb",
|
|
)
|
|
|
|
RSpec.describe RecalculateTopicCountersWithoutSmallActions do
|
|
subject(:migrate) { described_class.new.up }
|
|
|
|
before do
|
|
@verbose = ActiveRecord::Migration.verbose
|
|
ActiveRecord::Migration.verbose = false
|
|
end
|
|
|
|
after { ActiveRecord::Migration.verbose = @verbose }
|
|
|
|
fab!(:author, :user)
|
|
fab!(:reader, :user)
|
|
fab!(:topic)
|
|
|
|
# Build a posts table that reflects reality, then force the topic + topic_users
|
|
# into the *pre-fix* inflated state (small action counted) so we can assert the
|
|
# migration corrects it.
|
|
def small_action!(post_number)
|
|
Fabricate(:post, topic: topic, user: Discourse.system_user).tap do |p|
|
|
p.update_columns(post_number:, post_type: Post.types[:small_action], raw: "")
|
|
end
|
|
end
|
|
|
|
it "recomputes counters and clamps read state for a trailing small action" do
|
|
op = Fabricate(:post, topic: topic, user: author)
|
|
op.update_columns(post_number: 1, post_type: Post.types[:regular])
|
|
small_action!(2)
|
|
|
|
topic.update_columns(
|
|
highest_post_number: 2,
|
|
highest_staff_post_number: 2,
|
|
posts_count: 2,
|
|
last_post_user_id: Discourse.system_user.id,
|
|
)
|
|
tracker =
|
|
Fabricate(
|
|
:topic_user,
|
|
topic: topic,
|
|
user: reader,
|
|
last_read_post_number: 2,
|
|
notification_level: TopicUser.notification_levels[:tracking],
|
|
)
|
|
|
|
migrate
|
|
|
|
topic.reload
|
|
expect(topic.highest_post_number).to eq(1)
|
|
expect(topic.highest_staff_post_number).to eq(1)
|
|
expect(topic.posts_count).to eq(1)
|
|
expect(topic.last_post_user_id).to eq(author.id)
|
|
# last_read pointed at the small action -> clamped back to the real highest.
|
|
expect(tracker.reload.last_read_post_number).to eq(1)
|
|
end
|
|
|
|
it "clamps whisperers to highest_staff_post_number and others to highest_post_number" do
|
|
SiteSetting.whispers_allowed_groups = "#{Group::AUTO_GROUPS[:staff]}"
|
|
admin = Fabricate(:admin)
|
|
|
|
op = Fabricate(:post, topic: topic, user: author)
|
|
op.update_columns(post_number: 1, post_type: Post.types[:regular])
|
|
whisper = Fabricate(:post, topic: topic, user: admin)
|
|
whisper.update_columns(post_number: 2, post_type: Post.types[:whisper])
|
|
small_action!(3)
|
|
|
|
# pre-fix inflated state: small action bumped both counters.
|
|
topic.update_columns(highest_post_number: 3, highest_staff_post_number: 3, posts_count: 2)
|
|
staff_tracker = Fabricate(:topic_user, topic: topic, user: admin, last_read_post_number: 3)
|
|
reader_tracker = Fabricate(:topic_user, topic: topic, user: reader, last_read_post_number: 3)
|
|
|
|
migrate
|
|
|
|
topic.reload
|
|
expect(topic.highest_post_number).to eq(1) # excludes whisper (2) and small action (3)
|
|
expect(topic.highest_staff_post_number).to eq(2) # excludes small action, keeps whisper
|
|
|
|
# whisperer keeps progress against the whisper; regular reader is clamped to public highest.
|
|
expect(staff_tracker.reload.last_read_post_number).to eq(2)
|
|
expect(reader_tracker.reload.last_read_post_number).to eq(1)
|
|
end
|
|
|
|
it "is idempotent and leaves already-correct topics untouched" do
|
|
op = Fabricate(:post, topic: topic, user: author)
|
|
op.update_columns(post_number: 1, post_type: Post.types[:regular])
|
|
small_action!(2)
|
|
topic.update_columns(highest_post_number: 1, highest_staff_post_number: 1, posts_count: 1)
|
|
|
|
expect { migrate }.not_to raise_error
|
|
migrate # second run is a no-op
|
|
|
|
topic.reload
|
|
expect(topic.highest_post_number).to eq(1)
|
|
expect(topic.posts_count).to eq(1)
|
|
end
|
|
end
|