mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
Previously, badges with custom SQL queries were hidden from the manual grant dropdown in the admin UI. This was because `manually_grantable?` returned false for any badge with a query, regardless of whether it was a system badge or not. This fix simplifies the logic: only system badges (the built-in Discourse badges like "Basic User", "Member", etc.) are excluded from manual granting. Custom badges with SQL queries can now be manually granted to users. Additionally, manually granted badges are now protected from auto-revocation. When a badge's SQL query runs via backfill, it will only revoke badges that were originally auto-granted (granted_by_id = -1), leaving manually granted badges intact. This ensures that an admin's deliberate decision to grant a badge isn't undone by the automated badge system. We considered adding a new `allow_manual_grant` setting per badge, which would give admins explicit control over dropdown visibility. However, this adds UI complexity and another knob to configure. The simpler approach covers the common case: if you created a custom badge, you probably want the option to grant it manually. Note: Some users may have been relying on the old behavior to hide badges from the dropdown by adding a dummy SQL query. This workaround will no longer work - those badges will now appear in the dropdown. Ref - t/139277 Ref - https://meta.discourse.org/t/296645
809 lines
27 KiB
Ruby
Vendored
809 lines
27 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe BadgeGranter do
|
|
fab!(:badge)
|
|
fab!(:user)
|
|
|
|
before { BadgeGranter.enable_queue }
|
|
|
|
after do
|
|
BadgeGranter.disable_queue
|
|
BadgeGranter.clear_queue!
|
|
end
|
|
|
|
describe ".revoke_ungranted_titles!" do
|
|
let(:user) { Fabricate(:user) }
|
|
let(:other_user) { Fabricate(:user) }
|
|
let(:badge) { Fabricate(:badge, allow_title: true) }
|
|
|
|
it "can revoke title of a single user" do
|
|
BadgeGranter.grant(badge, user)
|
|
user.update!(title: badge.name)
|
|
BadgeGranter.grant(badge, other_user)
|
|
other_user.update!(title: badge.name)
|
|
|
|
badge.update_column(:enabled, false)
|
|
BadgeGranter.revoke_ungranted_titles!([user.id])
|
|
|
|
expect(user.reload.title).to be_blank
|
|
expect(other_user.reload.title).to eq(badge.name)
|
|
end
|
|
|
|
it "revokes title when badge is not allowed as title" do
|
|
BadgeGranter.grant(badge, user)
|
|
user.update!(title: badge.name)
|
|
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
user.reload
|
|
expect(user.title).to eq(badge.name)
|
|
expect(user.user_profile.granted_title_badge_id).to eq(badge.id)
|
|
|
|
badge.update_column(:allow_title, false)
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
user.reload
|
|
expect(user.title).to be_blank
|
|
expect(user.user_profile.granted_title_badge_id).to be_nil
|
|
end
|
|
|
|
it "revokes title when badge is disabled" do
|
|
BadgeGranter.grant(badge, user)
|
|
user.update!(title: badge.name)
|
|
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
user.reload
|
|
expect(user.title).to eq(badge.name)
|
|
expect(user.user_profile.granted_title_badge_id).to eq(badge.id)
|
|
|
|
badge.update_column(:enabled, false)
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
user.reload
|
|
expect(user.title).to be_blank
|
|
expect(user.user_profile.granted_title_badge_id).to be_nil
|
|
end
|
|
|
|
it "revokes title when user badge is revoked" do
|
|
BadgeGranter.grant(badge, user)
|
|
user.update!(title: badge.name)
|
|
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
user.reload
|
|
expect(user.title).to eq(badge.name)
|
|
expect(user.user_profile.granted_title_badge_id).to eq(badge.id)
|
|
|
|
BadgeGranter.revoke(user.user_badges.first)
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
user.reload
|
|
expect(user.title).to be_blank
|
|
expect(user.user_profile.granted_title_badge_id).to be_nil
|
|
end
|
|
|
|
it "does not revoke custom title" do
|
|
user.title = "CEO"
|
|
user.save!
|
|
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
|
|
user.reload
|
|
expect(user.title).to eq("CEO")
|
|
end
|
|
|
|
it "does not revoke localized title" do
|
|
badge = Badge.find(Badge::Regular)
|
|
badge_name = nil
|
|
BadgeGranter.grant(badge, user)
|
|
|
|
I18n.with_locale(:de) do
|
|
badge_name = badge.display_name
|
|
user.update!(title: badge_name)
|
|
end
|
|
|
|
user.reload
|
|
expect(user.title).to eq(badge_name)
|
|
expect(user.user_profile.granted_title_badge_id).to eq(badge.id)
|
|
|
|
BadgeGranter.revoke_ungranted_titles!
|
|
user.reload
|
|
expect(user.title).to eq(badge_name)
|
|
expect(user.user_profile.granted_title_badge_id).to eq(badge.id)
|
|
end
|
|
end
|
|
|
|
describe "preview" do
|
|
it "can correctly preview" do
|
|
Fabricate(:user, email: "sam@gmail.com")
|
|
|
|
sql = <<~SQL
|
|
SELECT u.id user_id, null post_id, u.created_at granted_at
|
|
FROM users u
|
|
JOIN user_emails ue ON ue.user_id = u.id AND ue.primary
|
|
WHERE ue.email like '%gmail.com'
|
|
SQL
|
|
|
|
result = BadgeGranter.preview(sql, explain: true)
|
|
|
|
expect(result[:grant_count]).to eq(1)
|
|
expect(result[:query_plan]).to be_present
|
|
end
|
|
|
|
it "with badges containing trailing comments do not break generated SQL" do
|
|
query = Badge.find(1).query + "\n-- a comment"
|
|
expect(BadgeGranter.preview(query)[:errors]).to be_nil
|
|
end
|
|
end
|
|
|
|
describe ".backfill" do
|
|
it "has no broken badge queries" do
|
|
Badge.all.each { |b| BadgeGranter.backfill(b) }
|
|
end
|
|
|
|
it "can backfill the welcome badge" do
|
|
post = Fabricate(:post)
|
|
user2 = Fabricate(:user)
|
|
PostActionCreator.like(user2, post)
|
|
|
|
UserBadge.destroy_all
|
|
BadgeGranter.backfill(Badge.find(Badge::Welcome))
|
|
BadgeGranter.backfill(Badge.find(Badge::FirstLike))
|
|
|
|
b = UserBadge.find_by(user_id: post.user_id)
|
|
expect(b.post_id).to eq(post.id)
|
|
b.badge_id = Badge::Welcome
|
|
|
|
b = UserBadge.find_by(user_id: user2.id)
|
|
expect(b.post_id).to eq(post.id)
|
|
b.badge_id = Badge::FirstLike
|
|
end
|
|
|
|
it "should grant missing badges" do
|
|
nice_topic = Badge.find(Badge::NiceTopic)
|
|
good_topic = Badge.find(Badge::GoodTopic)
|
|
|
|
post = Fabricate(:post, like_count: 30)
|
|
|
|
2.times do
|
|
BadgeGranter.backfill(nice_topic, post_ids: [post.id])
|
|
BadgeGranter.backfill(good_topic)
|
|
end
|
|
|
|
# TODO add welcome
|
|
expect(post.user.user_badges.pluck(:badge_id)).to contain_exactly(
|
|
nice_topic.id,
|
|
good_topic.id,
|
|
)
|
|
expect(post.user.notifications.count).to eq(2)
|
|
|
|
data = post.user.notifications.last.data_hash
|
|
expect(data["badge_id"]).to eq(good_topic.id)
|
|
expect(data["badge_slug"]).to eq(good_topic.slug)
|
|
expect(data["username"]).to eq(post.user.username)
|
|
|
|
expect(nice_topic.grant_count).to eq(1)
|
|
expect(good_topic.grant_count).to eq(1)
|
|
end
|
|
|
|
it "should grant badges in the user locale" do
|
|
SiteSetting.allow_user_locale = true
|
|
|
|
nice_topic = Badge.find(Badge::NiceTopic)
|
|
name_english = nice_topic.name
|
|
|
|
user = Fabricate(:user, locale: "fr")
|
|
post = Fabricate(:post, like_count: 10, user:)
|
|
|
|
BadgeGranter.backfill(nice_topic)
|
|
|
|
notification_badge_name = JSON.parse(post.user.notifications.first.data)["badge_name"]
|
|
|
|
expect(notification_badge_name).not_to eq(name_english)
|
|
end
|
|
|
|
it "with badges containing trailing comments do not break generated SQL" do
|
|
badge = Fabricate(:badge)
|
|
badge.query = Badge.find(1).query + "\n-- a comment"
|
|
expect { BadgeGranter.backfill(badge) }.not_to raise_error
|
|
end
|
|
|
|
it 'does not notify about badges "for beginners" when user skipped new user tips' do
|
|
user.user_option.update!(skip_new_user_tips: true)
|
|
post = Fabricate(:post)
|
|
PostActionCreator.like(user, post)
|
|
|
|
expect { BadgeGranter.backfill(Badge.find(Badge::FirstLike)) }.to_not change {
|
|
Notification.where(user:).count
|
|
}
|
|
end
|
|
|
|
context "for sharing badges" do
|
|
fab!(:category)
|
|
fab!(:topic) { Fabricate(:topic, category:) }
|
|
fab!(:post) { Fabricate(:post, topic:) }
|
|
fab!(:current_user, :user)
|
|
|
|
it "grants nice share badge when threshold has been reached" do
|
|
Fabricate.times(25, :incoming_link, post:, user:)
|
|
|
|
BadgeGranter.backfill(Badge.find(Badge::NiceShare))
|
|
|
|
expect(UserBadge.where(user:, badge_id: Badge::NiceShare).count).to eq(1)
|
|
end
|
|
|
|
it "does not grant nice share badge when duplicates on IP address exists" do
|
|
Fabricate.times(23, :incoming_link, post:, user:)
|
|
|
|
_incoming_link_with_ip_address =
|
|
Fabricate(:incoming_link, post:, user:, ip_address: "1.1.1.1")
|
|
|
|
_incoming_link_with_ip_address_duplicate =
|
|
Fabricate(:incoming_link, post:, user:, ip_address: "1.1.1.1")
|
|
|
|
expect(IncomingLink.where(post:, user:).count).to eq(25)
|
|
|
|
BadgeGranter.backfill(Badge.find(Badge::NiceShare))
|
|
|
|
expect(UserBadge.where(user:, badge_id: Badge::NiceShare).count).to eq(0)
|
|
end
|
|
|
|
it "does not grant nice share badge when duplicates on current_user_id exists" do
|
|
Fabricate.times(23, :incoming_link, post:, user:, ip_address: nil)
|
|
|
|
_incoming_link_with_current_user_id =
|
|
Fabricate(:incoming_link, post:, user:, current_user_id: current_user.id, ip_address: nil)
|
|
|
|
_incoming_link_with_current_user_id_duplicate =
|
|
Fabricate(:incoming_link, post:, user:, current_user_id: current_user.id, ip_address: nil)
|
|
|
|
expect(IncomingLink.where(post:, user:).count).to eq(25)
|
|
|
|
BadgeGranter.backfill(Badge.find(Badge::NiceShare))
|
|
|
|
expect(UserBadge.where(user:, badge_id: Badge::NiceShare).count).to eq(0)
|
|
end
|
|
|
|
it "does not grant sharing badges to deleted users" do
|
|
Fabricate.times(25, :incoming_link, post:, user:)
|
|
user.destroy!
|
|
|
|
nice_share = Badge.find(Badge::NiceShare)
|
|
first_share = Badge.find(Badge::FirstShare)
|
|
|
|
BadgeGranter.backfill(nice_share)
|
|
BadgeGranter.backfill(first_share)
|
|
|
|
expect(UserBadge.where(user:).count).to eq(0)
|
|
end
|
|
end
|
|
|
|
it "auto revokes badges from users when badge is set to auto revoke and user no longer satisfy the badge's query" do
|
|
user.update!(username: "cool_username")
|
|
|
|
query = <<~SQL
|
|
SELECT users.id user_id, CURRENT_TIMESTAMP granted_at
|
|
FROM users
|
|
WHERE users.username = 'cool_username'
|
|
SQL
|
|
|
|
badge_for_having_cool_username = Fabricate(:badge, query:, auto_revoke: true)
|
|
|
|
granted_user_ids = []
|
|
|
|
BadgeGranter.backfill(
|
|
badge_for_having_cool_username,
|
|
granted_callback: ->(user_ids) { granted_user_ids.concat(user_ids) },
|
|
)
|
|
|
|
expect(granted_user_ids).to eq([user.id])
|
|
|
|
expect(UserBadge.exists?(user:, badge: badge_for_having_cool_username)).to eq(true)
|
|
|
|
user.update!(username: "not_cool_username")
|
|
|
|
revoked_user_ids = []
|
|
|
|
BadgeGranter.backfill(
|
|
badge_for_having_cool_username,
|
|
revoked_callback: ->(user_ids) { revoked_user_ids.concat(user_ids) },
|
|
)
|
|
|
|
expect(revoked_user_ids).to eq([user.id])
|
|
|
|
expect(UserBadge.exists?(user:, badge: badge_for_having_cool_username)).to eq(false)
|
|
end
|
|
|
|
it "does not revoke manually granted badges even when user no longer satisfies the query" do
|
|
query = <<~SQL
|
|
SELECT users.id user_id, CURRENT_TIMESTAMP granted_at
|
|
FROM users
|
|
WHERE users.username = 'cool_username'
|
|
SQL
|
|
|
|
badge_for_having_cool_username = Fabricate(:badge, query:, auto_revoke: true)
|
|
|
|
BadgeGranter.grant(badge_for_having_cool_username, user, granted_by: Fabricate(:admin))
|
|
|
|
expect(UserBadge.exists?(user:, badge: badge_for_having_cool_username)).to eq(true)
|
|
|
|
revoked_user_ids = []
|
|
|
|
BadgeGranter.backfill(
|
|
badge_for_having_cool_username,
|
|
revoked_callback: ->(user_ids) { revoked_user_ids.concat(user_ids) },
|
|
)
|
|
|
|
expect(revoked_user_ids).to be_empty
|
|
|
|
expect(UserBadge.exists?(user:, badge: badge_for_having_cool_username)).to eq(true)
|
|
end
|
|
|
|
it "revokes only auto-granted badges when user has both auto and manual grants of a multiple_grant badge" do
|
|
user.update!(username: "cool_username")
|
|
|
|
query = <<~SQL
|
|
SELECT users.id user_id, CURRENT_TIMESTAMP granted_at
|
|
FROM users
|
|
WHERE users.username = 'cool_username'
|
|
SQL
|
|
|
|
multiple_grant_badge = Fabricate(:badge, multiple_grant: true, query:, auto_revoke: true)
|
|
|
|
BadgeGranter.backfill(multiple_grant_badge)
|
|
|
|
auto_granted =
|
|
UserBadge.find_by(
|
|
user:,
|
|
badge: multiple_grant_badge,
|
|
granted_by_id: Discourse::SYSTEM_USER_ID,
|
|
)
|
|
expect(auto_granted).to be_present
|
|
|
|
manually_granted_badge =
|
|
BadgeGranter.grant(multiple_grant_badge, user, granted_by: Fabricate(:admin))
|
|
|
|
expect(UserBadge.where(user:, badge: multiple_grant_badge).count).to eq(2)
|
|
|
|
user.update!(username: "not_cool_username")
|
|
|
|
BadgeGranter.backfill(multiple_grant_badge)
|
|
|
|
remaining_badges = UserBadge.where(user:, badge: multiple_grant_badge)
|
|
expect(remaining_badges).to contain_exactly(manually_granted_badge)
|
|
end
|
|
end
|
|
|
|
describe "grant" do
|
|
it "allows overriding of granted_at does not notify old bronze" do
|
|
freeze_time
|
|
badge = Badge.create!(name: "a badge", badge_type_id: BadgeType::Bronze)
|
|
user_badge = BadgeGranter.grant(badge, user, created_at: 1.year.ago)
|
|
|
|
expect(user_badge.granted_at).to eq_time(1.year.ago)
|
|
expect(Notification.where(user:).count).to eq(0)
|
|
end
|
|
|
|
it "handles deleted badge" do
|
|
freeze_time
|
|
user_badge = BadgeGranter.grant(nil, user, created_at: 1.year.ago)
|
|
expect(user_badge).to eq(nil)
|
|
end
|
|
|
|
it "doesn't grant disabled badges" do
|
|
freeze_time
|
|
badge = Fabricate(:badge, badge_type_id: BadgeType::Bronze, enabled: false)
|
|
|
|
user_badge = BadgeGranter.grant(badge, user, created_at: 1.year.ago)
|
|
expect(user_badge).to eq(nil)
|
|
end
|
|
|
|
it "doesn't notify about badges 'for beginners' when user skipped new user tips" do
|
|
freeze_time
|
|
UserBadge.destroy_all
|
|
user.user_option.update!(skip_new_user_tips: true)
|
|
badge = Fabricate(:badge, badge_grouping_id: BadgeGrouping::GettingStarted)
|
|
|
|
expect { BadgeGranter.grant(badge, user) }.to_not change { Notification.where(user:).count }
|
|
end
|
|
|
|
it "notifies about the New User of the Month badge when user skipped new user tips" do
|
|
freeze_time
|
|
user.user_option.update!(skip_new_user_tips: true)
|
|
badge = Badge.find(Badge::NewUserOfTheMonth)
|
|
|
|
expect { BadgeGranter.grant(badge, user) }.to change { Notification.where(user:).count }
|
|
end
|
|
|
|
it "grants multiple badges" do
|
|
badge = Fabricate(:badge, multiple_grant: true)
|
|
user_badge = BadgeGranter.grant(badge, user)
|
|
user_badge = BadgeGranter.grant(badge, user)
|
|
expect(user_badge).to be_present
|
|
|
|
expect(UserBadge.where(user:).count).to eq(2)
|
|
end
|
|
|
|
it "updates is_favorite when granting multiple badges" do
|
|
badge = Fabricate(:badge, multiple_grant: true)
|
|
|
|
UserBadge.create(
|
|
badge:,
|
|
user:,
|
|
granted_by: Discourse.system_user,
|
|
granted_at: Time.now,
|
|
is_favorite: true,
|
|
)
|
|
|
|
user_badge = BadgeGranter.grant(badge, user)
|
|
|
|
expect(user_badge).to be_present
|
|
expect(user_badge.reload.is_favorite).to eq(true)
|
|
end
|
|
|
|
it "sets granted_at" do
|
|
day_ago = freeze_time 1.day.ago
|
|
user_badge = BadgeGranter.grant(badge, user)
|
|
|
|
expect(user_badge.granted_at).to eq_time(day_ago)
|
|
end
|
|
|
|
it "sets granted_by if the option is present" do
|
|
admin = Fabricate(:admin)
|
|
StaffActionLogger.any_instance.expects(:log_badge_grant).once
|
|
user_badge = BadgeGranter.grant(badge, user, granted_by: admin)
|
|
expect(user_badge.granted_by).to eq(admin)
|
|
end
|
|
|
|
it "defaults granted_by to the system user" do
|
|
StaffActionLogger.any_instance.expects(:log_badge_grant).never
|
|
user_badge = BadgeGranter.grant(badge, user)
|
|
expect(user_badge.granted_by_id).to eq(Discourse.system_user.id)
|
|
end
|
|
|
|
it "does not allow a regular user to grant badges" do
|
|
user_badge = BadgeGranter.grant(badge, user, granted_by: Fabricate(:user))
|
|
expect(user_badge).not_to be_present
|
|
end
|
|
|
|
it "increments grant_count on the badge and creates a notification" do
|
|
BadgeGranter.grant(badge, user)
|
|
expect(badge.reload.grant_count).to eq(1)
|
|
expect(
|
|
user.notifications.find_by(notification_type: Notification.types[:granted_badge]).data_hash[
|
|
"badge_id"
|
|
],
|
|
).to eq(badge.id)
|
|
end
|
|
|
|
it "does not fail when user is missing" do
|
|
BadgeGranter.grant(badge, nil)
|
|
expect(badge.reload.grant_count).to eq(0)
|
|
end
|
|
end
|
|
|
|
describe "revoke" do
|
|
fab!(:admin)
|
|
let!(:user_badge) { BadgeGranter.grant(badge, user) }
|
|
|
|
it "revokes the badge and does necessary cleanup" do
|
|
user.title = badge.name
|
|
user.save!
|
|
expect(badge.reload.grant_count).to eq(1)
|
|
StaffActionLogger.any_instance.expects(:log_badge_revoke).with(user_badge)
|
|
BadgeGranter.revoke(user_badge, revoked_by: admin)
|
|
expect(UserBadge.find_by(user:, badge:)).not_to be_present
|
|
expect(badge.reload.grant_count).to eq(0)
|
|
expect(
|
|
user.notifications.where(notification_type: Notification.types[:granted_badge]),
|
|
).to be_empty
|
|
expect(user.reload.title).to eq(nil)
|
|
end
|
|
|
|
context "when the badge name is customized, and the customized name is the same as the user title" do
|
|
let(:customized_badge_name) { "Merit Badge" }
|
|
|
|
before do
|
|
I18n.backend.store_translations(
|
|
:en,
|
|
{ badges: { Badge.i18n_name(badge.name) => { name: "Badge 0" } } },
|
|
)
|
|
TranslationOverride.upsert!(I18n.locale, Badge.i18n_key(badge.name), customized_badge_name)
|
|
end
|
|
|
|
it "revokes the badge and title and does necessary cleanup" do
|
|
user.title = customized_badge_name
|
|
user.save!
|
|
expect(badge.reload.grant_count).to eq(1)
|
|
StaffActionLogger.any_instance.expects(:log_badge_revoke).with(user_badge)
|
|
StaffActionLogger
|
|
.any_instance
|
|
.expects(:log_title_revoke)
|
|
.with(
|
|
user,
|
|
revoke_reason: "user title was same as revoked badge name or custom badge name",
|
|
previous_value: user_badge.user.title,
|
|
)
|
|
BadgeGranter.revoke(user_badge, revoked_by: admin)
|
|
expect(UserBadge.find_by(user:, badge:)).not_to be_present
|
|
expect(badge.reload.grant_count).to eq(0)
|
|
expect(
|
|
user.notifications.where(notification_type: Notification.types[:granted_badge]),
|
|
).to be_empty
|
|
expect(user.reload.title).to eq(nil)
|
|
end
|
|
|
|
after { TranslationOverride.revert!(I18n.locale, Badge.i18n_key(badge.name)) }
|
|
end
|
|
end
|
|
|
|
describe "revoke_all" do
|
|
it "deletes every user_badge record associated with that badge" do
|
|
described_class.grant(badge, user)
|
|
described_class.revoke_all(badge)
|
|
|
|
expect(UserBadge.exists?(badge:, user:)).to eq(false)
|
|
end
|
|
|
|
it "removes titles" do
|
|
another_title = "another title"
|
|
described_class.grant(badge, user)
|
|
user.update!(title: badge.name)
|
|
user2 = Fabricate(:user, title: another_title)
|
|
|
|
described_class.revoke_all(badge)
|
|
|
|
expect(user.reload.title).to be_nil
|
|
expect(user2.reload.title).to eq(another_title)
|
|
end
|
|
|
|
it "removes custom badge titles" do
|
|
custom_badge_title = "this is a badge title"
|
|
I18n.backend.store_translations(
|
|
:en,
|
|
{ badges: { Badge.i18n_name(badge.name) => { name: "Badge 0" } } },
|
|
)
|
|
TranslationOverride.create!(
|
|
translation_key: badge.translation_key,
|
|
value: custom_badge_title,
|
|
locale: "en",
|
|
)
|
|
described_class.grant(badge, user)
|
|
user.update!(title: custom_badge_title)
|
|
|
|
described_class.revoke_all(badge)
|
|
|
|
expect(user.reload.title).to be_nil
|
|
end
|
|
end
|
|
|
|
describe "update_badges" do
|
|
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
|
|
fab!(:liker) { Fabricate(:user, refresh_auto_groups: true) }
|
|
|
|
it "grants autobiographer" do
|
|
user.user_profile.bio_raw = "THIS IS MY bio it a long bio I like my bio"
|
|
user.uploaded_avatar_id = 10
|
|
user.user_profile.save
|
|
user.save
|
|
|
|
BadgeGranter.process_queue!
|
|
expect(UserBadge.where(user:, badge_id: Badge::Autobiographer).count).to eq(1)
|
|
end
|
|
|
|
it "grants read guidelines" do
|
|
user.user_stat.read_faq = Time.now
|
|
user.user_stat.save
|
|
|
|
BadgeGranter.process_queue!
|
|
expect(UserBadge.where(user:, badge_id: Badge::ReadGuidelines).count).to eq(1)
|
|
end
|
|
|
|
it "grants first link" do
|
|
post = create_post
|
|
post2 = create_post(raw: "#{Discourse.base_url}/t/slug/#{post.topic_id}")
|
|
|
|
BadgeGranter.process_queue!
|
|
expect(UserBadge.where(user_id: post2.user.id, badge_id: Badge::FirstLink).count).to eq(1)
|
|
end
|
|
|
|
it "grants first edit" do
|
|
SiteSetting.editing_grace_period = 0
|
|
post = create_post
|
|
user = post.user
|
|
|
|
expect(UserBadge.where(user:, badge_id: Badge::Editor).count).to eq(0)
|
|
|
|
PostRevisor.new(post).revise!(user, raw: "This is my new test 1235 123")
|
|
BadgeGranter.process_queue!
|
|
|
|
expect(UserBadge.where(user:, badge_id: Badge::Editor).count).to eq(1)
|
|
end
|
|
|
|
it "grants and revokes trust level badges" do
|
|
user.change_trust_level!(TrustLevel[4])
|
|
BadgeGranter.process_queue!
|
|
expect(UserBadge.where(user:, badge_id: Badge.trust_level_badge_ids).count).to eq(4)
|
|
|
|
user.change_trust_level!(TrustLevel[1])
|
|
BadgeGranter.backfill(Badge.find(1))
|
|
BadgeGranter.backfill(Badge.find(2))
|
|
expect(UserBadge.where(user:, badge_id: 1).first).not_to eq(nil)
|
|
expect(UserBadge.where(user:, badge_id: 2).first).to eq(nil)
|
|
end
|
|
|
|
it "grants system like badges" do
|
|
post = create_post(user:)
|
|
# Welcome badge
|
|
action = PostActionCreator.like(liker, post).post_action
|
|
BadgeGranter.process_queue!
|
|
expect(UserBadge.find_by(user:, badge_id: 5)).not_to eq(nil)
|
|
|
|
post = create_post(topic: post.topic, user:)
|
|
action = PostActionCreator.like(liker, post).post_action
|
|
|
|
# Nice post badge
|
|
post.update like_count: 10
|
|
|
|
BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action: action)
|
|
BadgeGranter.process_queue!
|
|
|
|
expect(UserBadge.find_by(user:, badge_id: Badge::NicePost)).not_to eq(nil)
|
|
expect(UserBadge.where(user:, badge_id: Badge::NicePost).count).to eq(1)
|
|
|
|
# Good post badge
|
|
post.update like_count: 25
|
|
BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action: action)
|
|
BadgeGranter.process_queue!
|
|
expect(UserBadge.find_by(user:, badge_id: Badge::GoodPost)).not_to eq(nil)
|
|
|
|
# Great post badge
|
|
post.update like_count: 50
|
|
BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action: action)
|
|
BadgeGranter.process_queue!
|
|
expect(UserBadge.find_by(user:, badge_id: Badge::GreatPost)).not_to eq(nil)
|
|
|
|
# Revoke badges on unlike
|
|
post.update like_count: 49
|
|
BadgeGranter.backfill(Badge.find(Badge::GreatPost))
|
|
expect(UserBadge.find_by(user:, badge_id: Badge::GreatPost)).to eq(nil)
|
|
end
|
|
|
|
it "triggers the 'user_badge_granted' DiscourseEvent per badge when badges are backfilled" do
|
|
post = create_post(user:)
|
|
PostActionCreator.like(liker, post).post_action
|
|
|
|
events = DiscourseEvent.track_events(:user_badge_granted) { BadgeGranter.process_queue! }
|
|
|
|
expect(events.length).to eq(2)
|
|
expect(events[0][:params]).to eq([Badge::FirstLike, liker.id])
|
|
expect(events[1][:params]).to eq([Badge::Welcome, user.id])
|
|
end
|
|
end
|
|
|
|
describe "notification locales" do
|
|
it "is using default locales when user locales are not set" do
|
|
SiteSetting.allow_user_locale = true
|
|
expect(BadgeGranter.notification_locale("")).to eq(SiteSetting.default_locale)
|
|
end
|
|
|
|
it "is using default locales when user locales are set but is not allowed" do
|
|
SiteSetting.allow_user_locale = false
|
|
expect(BadgeGranter.notification_locale("pl_PL")).to eq(SiteSetting.default_locale)
|
|
end
|
|
|
|
it "is using user locales when set and allowed" do
|
|
SiteSetting.allow_user_locale = true
|
|
expect(BadgeGranter.notification_locale("pl_PL")).to eq("pl_PL")
|
|
end
|
|
end
|
|
|
|
describe ".mass_grant" do
|
|
it "raises an error if the count argument is less than 1" do
|
|
expect do BadgeGranter.mass_grant(badge, user, count: 0) end.to raise_error(
|
|
ArgumentError,
|
|
"count can't be less than 1",
|
|
)
|
|
end
|
|
|
|
it "grants the badge to the user as many times as the count argument" do
|
|
BadgeGranter.mass_grant(badge, user, count: 10)
|
|
sequence = UserBadge.where(badge:, user:).pluck(:seq).sort
|
|
expect(sequence).to eq [*0...10]
|
|
|
|
BadgeGranter.mass_grant(badge, user, count: 10)
|
|
sequence = UserBadge.where(badge:, user:).pluck(:seq).sort
|
|
expect(sequence).to eq [*0...20]
|
|
end
|
|
end
|
|
|
|
describe ".enqueue_mass_grant_for_users" do
|
|
before { Jobs.run_immediately! }
|
|
|
|
it "returns a list of the entries that could not be matched to any users" do
|
|
results =
|
|
BadgeGranter.enqueue_mass_grant_for_users(
|
|
badge,
|
|
emails: ["fakeemail@discourse.invalid", user.email],
|
|
usernames: [user.username, "fakeusername"],
|
|
)
|
|
expect(results[:unmatched_entries]).to contain_exactly(
|
|
"fakeemail@discourse.invalid",
|
|
"fakeusername",
|
|
)
|
|
expect(results[:matched_users_count]).to eq(1)
|
|
expect(results[:unmatched_entries_count]).to eq(2)
|
|
end
|
|
|
|
context "when ensure_users_have_badge_once is true" do
|
|
it "ensures each user has the badge at least once and does not grant the badge multiple times to one user" do
|
|
BadgeGranter.grant(badge, user)
|
|
user_without_badge = Fabricate(:user)
|
|
|
|
Notification.destroy_all
|
|
results =
|
|
BadgeGranter.enqueue_mass_grant_for_users(
|
|
badge,
|
|
usernames: [
|
|
user.username,
|
|
user.username,
|
|
user_without_badge.username,
|
|
user_without_badge.username,
|
|
],
|
|
ensure_users_have_badge_once: true,
|
|
)
|
|
expect(results[:unmatched_entries]).to eq([])
|
|
expect(results[:matched_users_count]).to eq(2)
|
|
expect(results[:unmatched_entries_count]).to eq(0)
|
|
|
|
sequence = UserBadge.where(user:, badge:).pluck(:seq)
|
|
expect(sequence).to contain_exactly(0)
|
|
# no new badge/notification because user already had the badge
|
|
# before enqueue_mass_grant_for_users was called
|
|
expect(user.reload.notifications.size).to eq(0)
|
|
|
|
sequence = UserBadge.where(user: user_without_badge, badge:)
|
|
expect(sequence.pluck(:seq)).to contain_exactly(0)
|
|
notifications = user_without_badge.reload.notifications
|
|
expect(notifications.size).to eq(1)
|
|
expect(sequence.first.notification_id).to eq(notifications.first.id)
|
|
expect(notifications.first.notification_type).to eq(Notification.types[:granted_badge])
|
|
end
|
|
end
|
|
|
|
context "when ensure_users_have_badge_once is false" do
|
|
it "grants the badge to the users as many times as they appear in the emails and usernames arguments" do
|
|
badge.update!(multiple_grant: true)
|
|
user_without_badge = Fabricate(:user)
|
|
user_with_badge = Fabricate(:user).tap { |u| BadgeGranter.grant(badge, u) }
|
|
|
|
Notification.destroy_all
|
|
emails = [user_with_badge.email.titlecase, user_without_badge.email.titlecase] * 20
|
|
usernames = [user_with_badge.username.titlecase, user_without_badge.username.titlecase] * 20
|
|
|
|
results =
|
|
BadgeGranter.enqueue_mass_grant_for_users(
|
|
badge,
|
|
emails: emails,
|
|
usernames: usernames,
|
|
ensure_users_have_badge_once: false,
|
|
)
|
|
expect(results[:unmatched_entries]).to eq([])
|
|
expect(results[:matched_users_count]).to eq(2)
|
|
expect(results[:unmatched_entries_count]).to eq(0)
|
|
|
|
sequence = UserBadge.where(user: user_with_badge, badge:).pluck(:seq)
|
|
expect(sequence.size).to eq(40 + 1)
|
|
expect(sequence.sort).to eq [*0...(40 + 1)]
|
|
sequence = UserBadge.where(user: user_without_badge, badge:).pluck(:seq)
|
|
expect(sequence.size).to eq(40)
|
|
expect(sequence.sort).to eq [*0...40]
|
|
|
|
# each user gets 1 notification no matter how many times
|
|
# they're repeated in the file.
|
|
[user_without_badge, user_with_badge].each do |u|
|
|
notifications = u.reload.notifications
|
|
expect(notifications.size).to eq(1)
|
|
expect(notifications.map(&:notification_type).uniq).to contain_exactly(
|
|
Notification.types[:granted_badge],
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|