mirror of
https://github.com/discourse/discourse.git
synced 2026-03-04 01:15:08 +08:00
Two issues were found in BadgeGranter that could prevent badges from being granted. First, `Array#compact!` returns `nil` when no elements are removed. For PostAction triggers, `[post_id, related_post_id].compact!` returns `nil` when `related_post_id` is present (nothing to compact), causing the post_ids payload to be silently lost. Using `compact` instead always returns the array. Second, `process_queue!` had no error isolation between badges. A single badge with a broken SQL query would raise an exception and abort processing of all remaining badges in the queue. Since queue items are already popped from Redis at that point, they are lost. Each badge is now rescued individually so one failure doesn't block the rest. https://meta.discourse.org/t/394444
854 lines
28 KiB
Ruby
854 lines
28 KiB
Ruby
# 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 ".queue_badge_grant" do
|
|
it "includes both post_id and related_post_id for PostAction triggers" do
|
|
post_action = Struct.new(:post_id, :related_post_id).new(1, 2)
|
|
|
|
BadgeGranter.queue_badge_grant(Badge::Trigger::PostAction, post_action:)
|
|
raw = Discourse.redis.lpop(BadgeGranter.queue_key)
|
|
parsed = JSON.parse(raw)
|
|
|
|
expect(parsed["post_ids"]).to contain_exactly(1, 2)
|
|
end
|
|
end
|
|
|
|
describe ".process_queue!" do
|
|
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
|
|
|
|
it "continues processing other badges when one badge fails to backfill" do
|
|
query = <<~SQL
|
|
SELECT p.user_id, p.id post_id, p.updated_at granted_at
|
|
FROM badge_posts p
|
|
WHERE p.raw LIKE '%Give Me A Badge%'
|
|
AND (:backfill OR p.id IN (:post_ids))
|
|
SQL
|
|
|
|
broken_badge =
|
|
Fabricate(
|
|
:badge,
|
|
query: "broken",
|
|
trigger: Badge::Trigger::PostRevision,
|
|
target_posts: true,
|
|
)
|
|
good_badge =
|
|
Fabricate(:badge, query:, trigger: Badge::Trigger::PostRevision, target_posts: true)
|
|
|
|
create_post(user:, raw: "Give Me A Badge")
|
|
|
|
allow(BadgeGranter).to receive(:backfill).and_wrap_original do |method, badge, opts|
|
|
raise BadgeGranter::GrantError, "broken query" if badge.id == broken_badge.id
|
|
method.call(badge, opts)
|
|
end
|
|
|
|
expect { BadgeGranter.process_queue! }.not_to raise_error
|
|
expect(UserBadge.exists?(user:, badge: good_badge)).to eq(true)
|
|
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
|