mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-05 11:23:47 +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
398 lines
12 KiB
Ruby
398 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
RSpec.describe Badge do
|
|
describe "Validations" do
|
|
subject(:badge) { Fabricate.build(:badge) }
|
|
|
|
it { is_expected.to validate_length_of(:name).is_at_most(100) }
|
|
it { is_expected.to validate_length_of(:description).is_at_most(500) }
|
|
it { is_expected.to validate_length_of(:long_description).is_at_most(1000) }
|
|
it { is_expected.to validate_presence_of(:name) }
|
|
it { is_expected.to validate_presence_of(:badge_type) }
|
|
it { is_expected.to validate_uniqueness_of(:name) }
|
|
end
|
|
|
|
it "has a valid system attribute for new badges" do
|
|
expect(Badge.create!(name: "test", badge_type_id: 1).system?).to be false
|
|
end
|
|
|
|
it "auto translates name" do
|
|
badge = Badge.find_by_name("Basic User")
|
|
name_english = badge.name
|
|
|
|
I18n.with_locale(:fr) { expect(badge.display_name).not_to eq(name_english) }
|
|
end
|
|
|
|
it "handles changes on badge description and long description correctly for system badges" do
|
|
badge = Badge.find_by_name("Basic User")
|
|
badge.description = badge.description.dup
|
|
badge.long_description = badge.long_description.dup
|
|
badge.save
|
|
badge.reload
|
|
|
|
expect(badge[:description]).to eq(nil)
|
|
expect(badge[:long_description]).to eq(nil)
|
|
|
|
badge.description = "testing"
|
|
badge.long_description = "testing it"
|
|
|
|
badge.save
|
|
badge.reload
|
|
|
|
expect(badge[:description]).to eq("testing")
|
|
expect(badge[:long_description]).to eq("testing it")
|
|
end
|
|
|
|
it "can ensure consistency" do
|
|
b = Badge.find_by_name("Basic User")
|
|
|
|
b.grant_count = 100
|
|
b.save
|
|
|
|
UserBadge.create!(
|
|
user_id: User.minimum(:id) - 1,
|
|
badge_id: b.id,
|
|
granted_at: 1.minute.ago,
|
|
granted_by_id: -1,
|
|
)
|
|
UserBadge.create!(
|
|
user_id: User.first.id,
|
|
badge_id: b.id,
|
|
granted_at: 1.minute.ago,
|
|
granted_by_id: -1,
|
|
)
|
|
|
|
Badge.ensure_consistency!
|
|
|
|
b.reload
|
|
expect(b.grant_count).to eq(1)
|
|
end
|
|
|
|
it "sanitizes the description" do
|
|
xss = "<b onmouseover=alert('Wufff!')>click me!</b><script>alert('TEST');</script>"
|
|
badge = Fabricate(:badge)
|
|
|
|
badge.update!(description: xss)
|
|
|
|
expect(badge.description).to eq("<b>click me!</b>alert('TEST');")
|
|
end
|
|
|
|
describe "#manually_grantable?" do
|
|
fab!(:badge) { Fabricate(:badge, name: "Test Badge") }
|
|
subject { badge.manually_grantable? }
|
|
|
|
context "when system badge" do
|
|
before { badge.system = true }
|
|
it { is_expected.to be false }
|
|
end
|
|
|
|
context "when has query" do
|
|
before { badge.query = "SELECT id FROM users" }
|
|
it { is_expected.to be true }
|
|
end
|
|
|
|
context "when not a system badge" do
|
|
before { badge.update_columns(system: false) }
|
|
it { is_expected.to be true }
|
|
end
|
|
end
|
|
|
|
describe "#image_url" do
|
|
before do
|
|
SiteSetting.enable_s3_uploads = true
|
|
SiteSetting.s3_cdn_url = "https://some-s3-cdn.amzn.com"
|
|
end
|
|
|
|
context "when the badge has an existing image" do
|
|
it "has a CDN url" do
|
|
upload = Fabricate(:upload_s3)
|
|
badge = Fabricate(:badge, image_upload_id: upload.id)
|
|
|
|
expect(badge.image_url).to start_with("https://some-s3-cdn.amzn.com")
|
|
end
|
|
end
|
|
|
|
context "when the badge does not have a related image" do
|
|
it "does not have a CDN url" do
|
|
upload = Fabricate(:upload_s3)
|
|
badge = Fabricate(:badge, image_upload_id: upload.id)
|
|
|
|
store = stub
|
|
store.expects(:remove_upload).returns(true)
|
|
Discourse.stubs(:store).returns(store)
|
|
|
|
upload.destroy!
|
|
|
|
expect(badge.reload.image_url).to eq(nil)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".i18n_name" do
|
|
it "transforms to lower case letters, and replaces spaces with underscores" do
|
|
expect(Badge.i18n_name("Basic User")).to eq("basic_user")
|
|
end
|
|
end
|
|
|
|
describe ".display_name" do
|
|
it "fetches from translations when i18n_name key exists" do
|
|
expect(Badge.display_name("basic_user")).to eq("Basic")
|
|
expect(Badge.display_name("Basic User")).to eq("Basic")
|
|
end
|
|
|
|
it "fallbacks to argument value when translation does not exist" do
|
|
expect(Badge.display_name("Not In Translations")).to eq("Not In Translations")
|
|
end
|
|
end
|
|
|
|
describe ".find_system_badge_id_from_translation_key" do
|
|
let(:translation_key) { "badges.regular.name" }
|
|
|
|
it "uses a translation key to get a system badge id, mainly to find which badge a translation override corresponds to" do
|
|
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(Badge::Regular)
|
|
end
|
|
|
|
context "when the translation key is snake case" do
|
|
let(:translation_key) { "badges.crazy_in_love.name" }
|
|
|
|
it "works to get the badge" do
|
|
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(
|
|
Badge::CrazyInLove,
|
|
)
|
|
end
|
|
end
|
|
|
|
context "when a translation key not for a badge is provided" do
|
|
let(:translation_key) { "reports.flags.title" }
|
|
it "returns nil" do
|
|
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(nil)
|
|
end
|
|
end
|
|
|
|
context "when translation key doesn't match its class" do
|
|
let(:translation_key) { "badges.licensed.long_description" }
|
|
|
|
it "returns nil" do
|
|
expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(nil)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "First Quote" do
|
|
let(:quoted_post_badge) { Badge.find(Badge::FirstQuote) }
|
|
|
|
it "Awards at the correct award date" do
|
|
freeze_time
|
|
post1 = create_post
|
|
|
|
raw = <<~RAW
|
|
[quote="#{post1.user.username}, post:#{post1.post_number}, topic:#{post1.topic_id}"]
|
|
lorem
|
|
[/quote]
|
|
RAW
|
|
|
|
post2 = create_post(raw: raw)
|
|
|
|
quoted_post = QuotedPost.find_by(post_id: post2.id)
|
|
freeze_time 1.year.from_now
|
|
quoted_post.update!(created_at: Time.now)
|
|
|
|
BadgeGranter.backfill(quoted_post_badge)
|
|
user_badge = post2.user.user_badges.find_by(badge_id: quoted_post_badge.id)
|
|
|
|
expect(user_badge.granted_at).to eq_time(post2.created_at)
|
|
end
|
|
end
|
|
|
|
describe "WikiEditor badge" do
|
|
it "is awarded" do
|
|
wiki_editor_badge = Badge.find(Badge::WikiEditor)
|
|
post = Fabricate(:post, wiki: true)
|
|
revisor = PostRevisor.new(post)
|
|
revisor.revise!(post.user, { raw: "I am editing a wiki" }, force_new_version: true)
|
|
|
|
BadgeGranter.backfill(wiki_editor_badge)
|
|
|
|
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::WikiEditor).count).to eq(1)
|
|
end
|
|
end
|
|
|
|
describe "PopularLink badge" do
|
|
let(:popular_link_badge) { Badge.find(Badge::PopularLink) }
|
|
|
|
before do
|
|
popular_link_badge.query = BadgeQueries.linking_badge(2)
|
|
popular_link_badge.save!
|
|
end
|
|
|
|
it "is awarded" do
|
|
post = create_post(raw: "https://www.discourse.org/")
|
|
|
|
TopicLinkClick.create_from(
|
|
url: "https://www.discourse.org/",
|
|
post_id: post.id,
|
|
topic_id: post.topic.id,
|
|
ip: "192.168.0.100",
|
|
)
|
|
BadgeGranter.backfill(popular_link_badge)
|
|
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::PopularLink).count).to eq(0)
|
|
|
|
TopicLinkClick.create_from(
|
|
url: "https://www.discourse.org/",
|
|
post_id: post.id,
|
|
topic_id: post.topic.id,
|
|
ip: "192.168.0.101",
|
|
)
|
|
BadgeGranter.backfill(popular_link_badge)
|
|
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::PopularLink).count).to eq(1)
|
|
end
|
|
|
|
it "is not awarded for links in a restricted category" do
|
|
category = Fabricate(:category)
|
|
post = create_post(raw: "https://www.discourse.org/", category: category)
|
|
|
|
category.set_permissions({})
|
|
category.save!
|
|
|
|
TopicLinkClick.create_from(
|
|
url: "https://www.discourse.org/",
|
|
post_id: post.id,
|
|
topic_id: post.topic.id,
|
|
ip: "192.168.0.100",
|
|
)
|
|
TopicLinkClick.create_from(
|
|
url: "https://www.discourse.org/",
|
|
post_id: post.id,
|
|
topic_id: post.topic.id,
|
|
ip: "192.168.0.101",
|
|
)
|
|
BadgeGranter.backfill(popular_link_badge)
|
|
expect(UserBadge.where(user_id: post.user.id, badge_id: Badge::PopularLink).count).to eq(0)
|
|
end
|
|
end
|
|
|
|
describe "FirstFlag badge" do
|
|
fab!(:flagging_user, :user)
|
|
fab!(:badge_enabled_category) { Fabricate(:category, allow_badges: true) }
|
|
fab!(:flagged_post) do
|
|
Fabricate(:post, topic: Fabricate(:topic, category: badge_enabled_category))
|
|
end
|
|
let(:first_flag_badge) { Badge.find(Badge::FirstFlag) }
|
|
|
|
context "when using an out-of-the-box flag" do
|
|
let!(:flag_post_action) do
|
|
Fabricate(:flag_post_action, post: flagged_post, user: flagging_user)
|
|
end
|
|
|
|
it "grants the badge" do
|
|
expect { BadgeGranter.backfill(first_flag_badge) }.to change {
|
|
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
|
|
}.by(1)
|
|
end
|
|
end
|
|
|
|
context "when using a custom flag" do
|
|
let!(:custom_flag) { Fabricate(:flag, name: "stahp", applies_to: %w[Post]) }
|
|
let!(:flag_post_action) do
|
|
Fabricate(
|
|
:flag_post_action,
|
|
post: flagged_post,
|
|
user: flagging_user,
|
|
post_action_type_id: PostActionType.types[:custom_stahp],
|
|
)
|
|
end
|
|
|
|
it "grants the badge" do
|
|
expect { BadgeGranter.backfill(first_flag_badge) }.to change {
|
|
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
|
|
}.by(1)
|
|
end
|
|
end
|
|
|
|
context "when the flag requires message" do
|
|
let!(:flag_post_action) do
|
|
Fabricate(
|
|
:flag_post_action,
|
|
post: flagged_post,
|
|
user: flagging_user,
|
|
post_action_type_id: PostActionType.types[:notify_user],
|
|
)
|
|
end
|
|
|
|
it "does not grant the badge" do
|
|
expect { BadgeGranter.backfill(first_flag_badge) }.not_to change {
|
|
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
|
|
}
|
|
end
|
|
end
|
|
|
|
context "when the flag is a like" do
|
|
let!(:flag_post_action) do
|
|
Fabricate(
|
|
:flag_post_action,
|
|
post: flagged_post,
|
|
user: flagging_user,
|
|
post_action_type_id: PostActionType.types[:like],
|
|
)
|
|
end
|
|
|
|
it "does not grant the badge" do
|
|
expect { BadgeGranter.backfill(first_flag_badge) }.not_to change {
|
|
UserBadge.where(user_id: flagging_user.id, badge_id: Badge::FirstFlag).count
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#seed" do
|
|
let(:badge_id) { Badge.maximum(:id) + 1 }
|
|
|
|
it "`allow_title` is not updated for existing records" do
|
|
Badge.seed do |b|
|
|
b.id = badge_id
|
|
b.name = "Foo"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.default_allow_title = true
|
|
end
|
|
|
|
badge = Badge.find(badge_id)
|
|
expect(badge.allow_title).to eq(true)
|
|
|
|
badge.update!(allow_title: false)
|
|
|
|
Badge.seed do |b|
|
|
b.id = badge_id
|
|
b.name = "Foo"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.default_allow_title = true
|
|
end
|
|
|
|
badge.reload
|
|
expect(badge.allow_title).to eq(false)
|
|
end
|
|
|
|
it "`enabled` is not updated for existing records" do
|
|
Badge.seed do |b|
|
|
b.id = badge_id
|
|
b.name = "Foo"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.default_enabled = false
|
|
end
|
|
|
|
badge = Badge.find(badge_id)
|
|
expect(badge.enabled).to eq(false)
|
|
|
|
badge.update!(enabled: true)
|
|
|
|
Badge.seed do |b|
|
|
b.id = badge_id
|
|
b.name = "Foo"
|
|
b.badge_type_id = BadgeType::Bronze
|
|
b.default_enabled = false
|
|
end
|
|
|
|
badge.reload
|
|
expect(badge.enabled).to eq(true)
|
|
end
|
|
end
|
|
end
|