0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-topic-voting/spec/lib/topic_merger_spec.rb
Régis Hanol 5cef23ef63
FEATURE: award badges based on topic votes received (#39493)
Adds four tiered badges to discourse-topic-voting so topic authors are
recognized when their ideas get traction:

- Daydreamer (Bronze, 1 vote)
- Brainstormer (Silver, 5 votes)
- Innovator (Silver, 15 votes)
- Visionary (Gold, 25 votes)

Badges are multi-grant and tied to the qualifying topic's first post, so
a user earns each tier once per topic that reaches the threshold.
Self-votes (voting on your own topic) are excluded. All badges are
disabled by default; the Silver and Gold tiers (Brainstormer, Innovator,
Visionary) allow the badge to be used as a title when enabled.

<img width="287" height="216" alt="image"
src="https://github.com/user-attachments/assets/3a84c2b2-6157-4504-b5a5-45e7c660e90c"
/>


### How it is wired

- `Votes::Cast` enqueues a `BackfillBadges` job after a vote is cast.
- `TopicMerger.merge` and `VoteReclaim` also enqueue the job so merged
or reclaimed topics are re-evaluated immediately rather than having to
wait for the daily consistency pass.
- The job calls `BadgeGranter.backfill` scoped to the topic's first
post. Queries join `badge_posts`, which already filters out deleted and
unlisted topics, read-restricted categories, and categories with
`allow_badges` disabled.
- Each query returns `granted_at` as the timestamp of the Nth qualifying
vote (via `ROW_NUMBER()`), so every tier reflects when that threshold
was actually crossed rather than when the most recent vote landed.
- Revocation (vote removed, topic deleted, category changed) runs on the
daily full backfill via `auto_revoke`, consistent with how
discourse-solved handles the same pattern.

### Notification handling

To avoid "granted badge" notification avalanches when the feature is
first enabled on a community with years of existing votes, core now
exposes a `:badge_granter_suppress_notification` modifier. The plugin
registers it and suppresses notifications for its four badges when the
qualifying vote is older than 2 weeks. Combined with the per-tier
`granted_at`, this means only the tier the author just crossed produces
a notification; lower tiers whose threshold was reached long ago stay
silent.

Ref - t/182304
2026-04-28 10:53:06 +10:00

140 lines
5.7 KiB
Ruby
Vendored

# frozen_string_literal: true
describe DiscourseTopicVoting::TopicMerger do
fab!(:user_0, :user)
fab!(:user_1, :user)
fab!(:user_2, :user)
fab!(:user_3, :user)
fab!(:user_4, :user)
fab!(:user_5, :user)
fab!(:category_1, :category)
fab!(:category_2, :category)
fab!(:topic_0) { Fabricate(:topic, category: category_1) }
fab!(:topic_1) { Fabricate(:topic, category: category_2) }
before { SiteSetting.topic_voting_enabled = true }
describe ".merge" do
it "does nothing when the source topic is still open" do
DiscourseTopicVoting::Vote.create!(user: user_0, topic: topic_0)
topic_0.update_vote_count
described_class.merge(topic_0, topic_1)
user_0.reload
expect(user_0.topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_0.id)
expect(user_0.topics_with_archived_vote.pluck(:topic_id)).to be_blank
end
it "archives moved votes when the destination topic is closed" do
topic_0.update_status("closed", true, Discourse.system_user)
topic_1.update_status("closed", true, Discourse.system_user)
DiscourseTopicVoting::Vote.create!(user: user_0, topic: topic_0)
topic_0.update_vote_count
described_class.merge(topic_0, topic_1)
user_0.reload
expect(user_0.topics_with_vote.pluck(:topic_id)).to be_blank
expect(user_0.topics_with_archived_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
expect(topic_0.reload.vote_count).to eq(0)
expect(topic_1.reload.vote_count).to eq(1)
end
it "enqueues the backfill badges job for the destination topic when votes move" do
topic_0.update_status("closed", true, Discourse.system_user)
DiscourseTopicVoting::Vote.create!(user: user_0, topic: topic_0)
topic_0.update_vote_count
expect { described_class.merge(topic_0, topic_1) }.to change(
Jobs::DiscourseTopicVoting::BackfillBadges.jobs,
:size,
).by(1)
expect(Jobs::DiscourseTopicVoting::BackfillBadges.jobs.last["args"].first).to include(
"topic_id" => topic_1.id,
)
end
end
context "when merging topics via move_posts (topic_merged)" do
let(:users) { [user_0, user_1, user_2, user_3, user_4, user_5] }
before do
SiteSetting.topic_voting_show_who_voted = false
Fabricate(:post, topic: topic_0, user: user_0)
Fabricate(:post, topic: topic_0, user: user_0)
DiscourseTopicVoting::Vote.create!(user: users[0], topic: topic_0)
DiscourseTopicVoting::Vote.create!(user: users[1], topic: topic_1)
DiscourseTopicVoting::Vote.create!(user: users[2], topic: topic_0)
DiscourseTopicVoting::Vote.create!(user: users[2], topic: topic_1)
DiscourseTopicVoting::Vote.create!(user: users[4], topic: topic_0, archive: true)
DiscourseTopicVoting::Vote.create!(user: users[5], topic: topic_0, archive: true)
DiscourseTopicVoting::Vote.create!(user: users[5], topic: topic_1)
[topic_0, topic_1].each { |t| t.update_vote_count }
end
it "moves votes when entire topic is merged" do
topic_0.move_posts(
Discourse.system_user,
topic_0.posts.pluck(:id),
destination_topic_id: topic_1.id,
)
users.each { |user| user.reload }
expect(users[0].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
expect(users[0].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[1].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
expect(users[1].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[2].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
expect(users[2].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[3].topics_with_vote.pluck(:topic_id)).to be_blank
expect(users[3].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[4].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
expect(users[4].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[5].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
expect(users[5].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(topic_0.reload.vote_count).to eq(0)
expect(topic_1.reload.vote_count).to eq(5)
merged_post = topic_0.posts.find_by(action_code: "split_topic")
expect(merged_post.raw).to include(I18n.t("topic_voting.votes_moved", count: 2))
expect(merged_post.raw).to include(I18n.t("topic_voting.duplicated_votes", count: 2))
end
it "does not move votes when not all posts are moved and the original topic does not get closed" do
topic_0.move_posts(
Discourse.system_user,
[topic_0.posts.order(:post_number).first.id],
destination_topic_id: topic_1.id,
)
users.each { |user| user.reload }
expect(users[0].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_0.id)
expect(users[0].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[1].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_1.id)
expect(users[1].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[2].topics_with_vote.pluck(:topic_id)).to contain_exactly(topic_0.id, topic_1.id)
expect(users[2].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[3].topics_with_vote.pluck(:topic_id)).to be_blank
expect(users[3].topics_with_archived_vote.pluck(:topic_id)).to be_blank
expect(users[4].topics_with_vote.pluck(:topic_id)).to be_blank
expect(users[4].topics_with_archived_vote.pluck(:topic_id)).to contain_exactly(topic_0.id)
expect(topic_0.reload.vote_count).to eq(4)
expect(topic_1.reload.vote_count).to eq(3)
end
end
end