FEATURE: Add option to grant badge multiple times to users using Bulk Award (#13571)

Currently when bulk-awarding a badge that can be granted multiple times, users in the CSV file are granted the badge once no matter how many times they're listed in the file and only if they don't have the badge already.

This PR adds a new option to the Badge Bulk Award feature so that it's possible to grant users a badge even if they already have the badge and as many times as they appear in the CSV file.
This commit is contained in:
Osama Sayegh 2021-07-15 05:53:26 +03:00 committed by GitHub
parent 0109edb847
commit 31aa701518
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 544 additions and 92 deletions

View File

@ -2,38 +2,98 @@ import Controller from "@ember/controller";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { extractError } from "discourse/lib/ajax-error";
import { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
export default Controller.extend({
saving: false,
replaceBadgeOwners: false,
grantExistingHolders: false,
fileSelected: false,
unmatchedEntries: null,
resultsMessage: null,
success: false,
unmatchedEntriesCount: 0,
actions: {
massAward() {
const file = document.querySelector("#massAwardCSVUpload").files[0];
resetState() {
this.setProperties({
saving: false,
unmatchedEntries: null,
resultsMessage: null,
success: false,
unmatchedEntriesCount: 0,
});
this.send("updateFileSelected");
},
if (this.model && file) {
const options = {
type: "POST",
processData: false,
contentType: false,
data: new FormData(),
};
@discourseComputed("fileSelected", "saving")
massAwardButtonDisabled(fileSelected, saving) {
return !fileSelected || saving;
},
options.data.append("file", file);
options.data.append("replace_badge_owners", this.replaceBadgeOwners);
@discourseComputed("unmatchedEntriesCount", "unmatchedEntries.length")
unmatchedEntriesTruncated(unmatchedEntriesCount, length) {
return unmatchedEntriesCount && length && unmatchedEntriesCount > length;
},
this.set("saving", true);
@action
updateFileSelected() {
this.set(
"fileSelected",
!!document.querySelector("#massAwardCSVUpload")?.files?.length
);
},
ajax(`/admin/badges/award/${this.model.id}`, options)
.then(() => {
bootbox.alert(I18n.t("admin.badges.mass_award.success"));
})
.catch(popupAjaxError)
.finally(() => this.set("saving", false));
} else {
bootbox.alert(I18n.t("admin.badges.mass_award.aborted"));
}
},
@action
massAward() {
const file = document.querySelector("#massAwardCSVUpload").files[0];
if (this.model && file) {
const options = {
type: "POST",
processData: false,
contentType: false,
data: new FormData(),
};
options.data.append("file", file);
options.data.append("replace_badge_owners", this.replaceBadgeOwners);
options.data.append("grant_existing_holders", this.grantExistingHolders);
this.resetState();
this.set("saving", true);
ajax(`/admin/badges/award/${this.model.id}`, options)
.then(
({
matched_users_count: matchedCount,
unmatched_entries: unmatchedEntries,
unmatched_entries_count: unmatchedEntriesCount,
}) => {
this.setProperties({
resultsMessage: I18n.t("admin.badges.mass_award.success", {
count: matchedCount,
}),
success: true,
});
if (unmatchedEntries.length) {
this.setProperties({
unmatchedEntries,
unmatchedEntriesCount,
});
}
}
)
.catch((error) => {
this.setProperties({
resultsMessage: extractError(error),
success: false,
});
})
.finally(() => this.set("saving", false));
} else {
bootbox.alert(I18n.t("admin.badges.mass_award.aborted"));
}
},
});

View File

@ -9,4 +9,9 @@ export default Route.extend({
);
}
},
setupController(controller) {
this._super(...arguments);
controller.resetState();
},
});

View File

@ -14,25 +14,62 @@
</div>
<div>
<h4>{{i18n "admin.badges.mass_award.upload_csv"}}</h4>
<input type="file" id="massAwardCSVUpload" accept=".csv">
<input type="file" id="massAwardCSVUpload" accept=".csv" onchange={{action "updateFileSelected"}}>
</div>
<div>
<label>
{{input type="checkbox" checked=replaceBadgeOwners}}
{{i18n "admin.badges.mass_award.replace_owners"}}
</label>
{{#if model.multiple_grant}}
<label class="grant-existing-holders">
{{input type="checkbox" checked=grantExistingHolders class="grant-existing-holders-checkbox"}}
{{i18n "admin.badges.mass_award.grant_existing_holders"}}
</label>
{{/if}}
</div>
{{d-button
class="btn-primary"
action=(action "massAward")
type="submit"
disabled=saving
disabled=massAwardButtonDisabled
icon="certificate"
label="admin.badges.mass_award.perform"}}
{{#link-to "adminBadges.index" class="btn btn-danger"}}
{{#link-to "adminBadges.index" class="btn btn-normal"}}
{{d-icon "times"}}
<span>{{i18n "cancel"}}</span>
{{/link-to}}
</form>
{{#if saving}}
{{i18n "uploading"}}
{{/if}}
{{#if resultsMessage}}
<p>
{{#if success}}
{{d-icon "check" class="bulk-award-status-icon success"}}
{{else}}
{{d-icon "times" class="bulk-award-status-icon failure"}}
{{/if}}
{{resultsMessage}}
</p>
{{#if unmatchedEntries.length}}
<p>
{{d-icon "exclamation-triangle" class="bulk-award-status-icon failure"}}
<span>
{{#if unmatchedEntriesTruncated}}
{{i18n "admin.badges.mass_award.csv_has_unmatched_users_truncated_list" count=unmatchedEntriesCount}}
{{else}}
{{i18n "admin.badges.mass_award.csv_has_unmatched_users"}}
{{/if}}
</span>
</p>
<ul>
{{#each unmatchedEntries as |entry|}}
<li>{{entry}}</li>
{{/each}}
</ul>
{{/if}}
{{/if}}
{{else}}
<span class="badge-required">{{i18n "admin.badges.mass_award.no_badge_selected"}}</span>
{{/if}}

View File

@ -0,0 +1,32 @@
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import I18n from "I18n";
acceptance("Admin - Badges - Mass Award", function (needs) {
needs.user();
test("when the badge can be granted multiple times", async function (assert) {
await visit("/admin/badges/award/new");
await click(
'.admin-badge-list-item span[data-badge-name="Both image and icon"]'
);
assert.equal(
query("label.grant-existing-holders").textContent.trim(),
I18n.t("admin.badges.mass_award.grant_existing_holders"),
"checkbox for granting existing holders is displayed"
);
});
test("when the badge can not be granted multiple times", async function (assert) {
await visit("/admin/badges/award/new");
await click('.admin-badge-list-item span[data-badge-name="Only icon"]');
assert.ok(
!exists(".grant-existing-holders"),
"checkbox for granting existing holders is not displayed"
);
});
});

View File

@ -1726,6 +1726,7 @@ export default {
name: "Both image and icon",
icon: "fa-rocket",
image_url: "/assets/some-image.png",
multiple_grant: true,
},
]
}

View File

@ -134,6 +134,18 @@
.award-badge {
margin: 15px 0 0 15px;
float: left;
max-width: 70%;
.bulk-award-status-icon {
margin-right: 3px;
&.success {
color: var(--success);
}
&.failure {
color: var(--danger);
}
}
.badge-preview {
min-height: 110px;

View File

@ -3,6 +3,8 @@
require 'csv'
class Admin::BadgesController < Admin::AdminController
MAX_CSV_LINES = 50_000
BATCH_SIZE = 200
def index
data = {
@ -52,37 +54,50 @@ class Admin::BadgesController < Admin::AdminController
end
replace_badge_owners = params[:replace_badge_owners] == 'true'
BadgeGranter.revoke_all(badge) if replace_badge_owners
ensure_users_have_badge_once = params[:grant_existing_holders] != 'true'
if !ensure_users_have_badge_once && !badge.multiple_grant?
render_json_error(
I18n.t('badges.mass_award.errors.cant_grant_multiple_times', badge_name: badge.display_name),
status: 422
)
return
end
batch_number = 1
line_number = 1
batch = []
usernames = []
emails = []
File.open(csv_file) do |csv|
mode = Email.is_valid?(CSV.parse_line(csv.first).first) ? 'email' : 'username'
csv.rewind
csv.each_line do |email_line|
line = CSV.parse_line(email_line).first
csv.each_line do |line|
line = CSV.parse_line(line).first&.strip
line_number += 1
if line.present?
batch << line
line_number += 1
if line.include?('@')
emails << line
else
usernames << line
end
end
# Split the emails in batches of 200 elements.
full_batch = csv.lineno % (BadgeGranter::MAX_ITEMS_FOR_DELTA * batch_number) == 0
last_batch_item = full_batch || csv.eof?
if last_batch_item
Jobs.enqueue(:mass_award_badge, users_batch: batch, badge_id: badge.id, mode: mode)
batch = []
batch_number += 1
if emails.size + usernames.size > MAX_CSV_LINES
return render_json_error I18n.t('badges.mass_award.errors.too_many_csv_entries', count: MAX_CSV_LINES), status: 400
end
end
end
BadgeGranter.revoke_all(badge) if replace_badge_owners
head :ok
results = BadgeGranter.enqueue_mass_grant_for_users(
badge,
emails: emails,
usernames: usernames,
ensure_users_have_badge_once: ensure_users_have_badge_once
)
render json: {
unmatched_entries: results[:unmatched_entries].first(100),
matched_users_count: results[:matched_users_count],
unmatched_entries_count: results[:unmatched_entries_count]
}, status: :ok
rescue CSV::MalformedCSVError
render_json_error I18n.t('badges.mass_award.errors.invalid_csv', line_number: line_number), status: 400
end
@ -147,6 +162,7 @@ class Admin::BadgesController < Admin::AdminController
end
private
def find_badge
params.require(:id)
Badge.find(params[:id])

View File

@ -3,20 +3,12 @@
module Jobs
class MassAwardBadge < ::Jobs::Base
def execute(args)
return unless mode = args[:mode]
badge = Badge.find_by(id: args[:badge_id])
user = User.find_by(id: args[:user])
return if user.blank?
badge = Badge.find_by(enabled: true, id: args[:badge])
return if badge.blank?
users = User.select(:id, :username, :locale)
if mode == 'email'
users = users.with_email(args[:users_batch])
else
users = users.where(username_lower: args[:users_batch].map!(&:downcase))
end
return if users.empty? || badge.nil?
BadgeGranter.mass_grant(badge, users)
BadgeGranter.mass_grant(badge, user, count: args[:count])
end
end
end

View File

@ -21,23 +21,87 @@ class BadgeGranter
BadgeGranter.new(badge, user, opts).grant
end
def self.mass_grant(badge, users)
return unless badge.enabled?
def self.enqueue_mass_grant_for_users(badge, emails: [], usernames: [], ensure_users_have_badge_once: true)
emails = emails.map(&:downcase)
usernames = usernames.map(&:downcase)
usernames_map_to_ids = {}
emails_map_to_ids = {}
if usernames.size > 0
usernames_map_to_ids = User.where(username_lower: usernames).pluck(:username_lower, :id).to_h
end
if emails.size > 0
emails_map_to_ids = User.with_email(emails).pluck('LOWER(user_emails.email)', :id).to_h
end
system_user_id = Discourse.system_user.id
now = Time.zone.now
user_badges = users.map { |u| { badge_id: badge.id, user_id: u.id, granted_by_id: system_user_id, granted_at: now, created_at: now } }
granted_badges = UserBadge.insert_all(user_badges, returning: %i[user_id])
count_per_user = {}
unmatched = Set.new
(usernames + emails).each do |entry|
id = usernames_map_to_ids[entry] || emails_map_to_ids[entry]
if id.blank?
unmatched << entry
next
end
users.each do |user|
if ensure_users_have_badge_once
count_per_user[id] = 1
else
count_per_user[id] ||= 0
count_per_user[id] += 1
end
end
existing_owners_ids = []
if ensure_users_have_badge_once
existing_owners_ids = UserBadge.where(badge: badge).distinct.pluck(:user_id)
end
count_per_user.each do |user_id, count|
next if ensure_users_have_badge_once && existing_owners_ids.include?(user_id)
Jobs.enqueue(
:mass_award_badge,
user: user_id,
badge: badge.id,
count: count
)
end
{
unmatched_entries: unmatched.to_a,
matched_users_count: count_per_user.size,
unmatched_entries_count: unmatched.size
}
end
def self.mass_grant(badge, user, count:)
return if !badge.enabled?
raise ArgumentError.new("count can't be less than 1") if count < 1
UserBadge.transaction do
DB.exec(<<~SQL * count, now: Time.zone.now, system: Discourse.system_user.id, user_id: user.id, badge_id: badge.id)
INSERT INTO user_badges
(granted_at, created_at, granted_by_id, user_id, badge_id, seq)
VALUES
(
:now,
:now,
:system,
:user_id,
:badge_id,
COALESCE((
SELECT MAX(seq) + 1
FROM user_badges
WHERE badge_id = :badge_id AND user_id = :user_id
), 0)
);
SQL
notification = send_notification(user.id, user.username, user.locale, badge)
DB.exec(
"UPDATE user_badges SET notification_id = :notification_id WHERE notification_id IS NULL AND user_id = :user_id AND badge_id = :badge_id",
notification_id: notification.id,
user_id: user.id,
badge_id: badge.id
)
DB.exec(<<~SQL, notification_id: notification.id, user_id: user.id, badge_id: badge.id)
UPDATE user_badges
SET notification_id = :notification_id
WHERE notification_id IS NULL AND user_id = :user_id AND badge_id = :badge_id
SQL
UserBadge.update_featured_ranks!(user.id)
end

View File

@ -5249,8 +5249,11 @@ en:
perform: "Award Badge to Users"
upload_csv: Upload a CSV with either user emails or usernames
aborted: Please upload a CSV containing either user emails or usernames
success: Your CSV was received and users will receive their badge shortly.
success: Your CSV was received and %{count} users will receive their badge shortly.
csv_has_unmatched_users: "The following entries are in the CSV file but they couldn't be matched to existing users, and therefore won't receive the badge:"
csv_has_unmatched_users_truncated_list: "There were %{count} entries in the CSV file that couldn't be matched to existing users, and therefore won't receive the badge. Due to the large number of unmatched entries, only the first 100 are shown:"
replace_owners: Remove the badge from previous owners
grant_existing_holders: Grant additional badges to existing badge holders
emoji:
title: "Emoji"

View File

@ -4449,7 +4449,9 @@ en:
mass_award:
errors:
invalid_csv: We encountered an error on line %{line_number}. Please confirm the CSV has one email per line.
too_many_csv_entries: Too many entries in the CSV file. Please provide a CSV file with no more than %{count} entries.
badge_disabled: Please enable the %{badge_name} badge first.
cant_grant_multiple_times: Can't grant the %{badge_name} badge multiple times to a single user.
editor:
name: Editor
description: First post edit

View File

@ -9,22 +9,13 @@ describe Jobs::MassAwardBadge do
let(:email_mode) { 'email' }
it 'creates the badge for an existing user' do
execute_job([user.email])
execute_job(user)
expect(UserBadge.where(user: user, badge: badge).exists?).to eq(true)
end
it 'works with multiple users' do
user_2 = Fabricate(:user)
execute_job([user.email, user_2.email])
expect(UserBadge.exists?(user: user, badge: badge)).to eq(true)
expect(UserBadge.exists?(user: user_2, badge: badge)).to eq(true)
end
it 'also creates a notification for the user' do
execute_job([user.email])
execute_job(user)
expect(Notification.exists?(user: user)).to eq(true)
expect(UserBadge.where.not(notification_id: nil).exists?(user: user, badge: badge)).to eq(true)
@ -35,14 +26,27 @@ describe Jobs::MassAwardBadge do
UserBadge.create!(badge_id: Badge::Member, user: user, granted_by: Discourse.system_user, granted_at: Time.now)
execute_job([user.email, user_2.email])
execute_job(user)
execute_job(user_2)
expect(UserBadge.find_by(user: user, badge: badge).featured_rank).to eq(2)
expect(UserBadge.find_by(user: user_2, badge: badge).featured_rank).to eq(1)
end
def execute_job(emails)
subject.execute(users_batch: emails, badge_id: badge.id, mode: 'email')
it 'grants a badge multiple times to a user' do
badge.update!(multiple_grant: true)
Notification.destroy_all
execute_job(user, count: 4, grant_existing_holders: true)
instances = UserBadge.where(user: user, badge: badge)
expect(instances.count).to eq(4)
expect(instances.pluck(:seq).sort).to eq((0...4).to_a)
notifications = Notification.where(user: user)
expect(notifications.count).to eq(1)
expect(instances.map(&:notification_id).uniq).to contain_exactly(notifications.first.id)
end
def execute_job(user, count: 1, grant_existing_holders: false)
subject.execute(user: user.id, badge: badge.id, count: count, grant_existing_holders: grant_existing_holders)
end
end
end

View File

@ -183,7 +183,7 @@ describe Admin::BadgesController do
end
describe '#mass_award' do
before { @user = Fabricate(:user, email: 'user1@test.com', username: 'username1') }
fab!(:user) { Fabricate(:user, email: 'user1@test.com', username: 'username1') }
it 'does nothing when there is no file' do
post "/admin/badges/award/#{badge.id}.json", params: { file: '' }
@ -210,9 +210,12 @@ describe Admin::BadgesController do
file = file_from_fixtures('user_emails.csv', 'csv')
UserBadge.destroy_all
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true)
expect(response.status).to eq(200)
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
expect(UserBadge.where(user: user, badge: badge).first.seq).to eq(0)
end
it 'awards the badge using a list of usernames' do
@ -222,7 +225,8 @@ describe Admin::BadgesController do
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true)
expect(response.status).to eq(200)
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
end
it 'works with a CSV containing nil values' do
@ -232,7 +236,22 @@ describe Admin::BadgesController do
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true)
expect(response.status).to eq(200)
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
end
it 'does not grant the badge again to a user if they already have the badge' do
Jobs.run_immediately!
badge.update!(multiple_grant: true)
BadgeGranter.grant(badge, user)
user.reload
file = file_from_fixtures('usernames_with_nil_values.csv', 'csv')
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
expect(response.status).to eq(200)
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
end
it 'fails when the badge is disabled' do
@ -244,6 +263,103 @@ describe Admin::BadgesController do
expect(response.status).to eq(422)
end
context "when grant_existing_holders is true" do
it "fails when the badge cannot be granted multiple times" do
file = file_from_fixtures('user_emails.csv', 'csv')
badge.update!(multiple_grant: false)
post "/admin/badges/award/#{badge.id}.json", params: {
file: fixture_file_upload(file),
grant_existing_holders: true
}
expect(response.status).to eq(422)
expect(response.parsed_body['errors']).to eq([
I18n.t("badges.mass_award.errors.cant_grant_multiple_times", badge_name: badge.name)
])
end
it "fails when CSV file contains more entries that it's allowed" do
badge.update!(multiple_grant: true)
csv = Tempfile.new
csv.write("#{user.username}\n" * 11)
csv.rewind
stub_const(Admin::BadgesController, "MAX_CSV_LINES", 10) do
post "/admin/badges/award/#{badge.id}.json", params: {
file: fixture_file_upload(csv),
grant_existing_holders: true
}
end
expect(response.status).to eq(400)
expect(response.parsed_body["errors"]).to include(I18n.t("badges.mass_award.errors.too_many_csv_entries", count: 10))
ensure
csv&.close
csv&.unlink
end
it "includes unmatched entries and the number of users who will receive the badge in the response" do
Jobs.run_immediately!
badge.update!(multiple_grant: true)
csv = Tempfile.new
content = [
"nonexistentuser",
"nonexistentuser",
"nonexistentemail@discourse.fake"
]
content << user.username
content << user.username
csv.write(content.join("\n"))
csv.rewind
post "/admin/badges/award/#{badge.id}.json", params: {
file: fixture_file_upload(csv),
grant_existing_holders: true
}
expect(response.status).to eq(200)
expect(response.parsed_body['unmatched_entries']).to contain_exactly(
"nonexistentuser",
"nonexistentemail@discourse.fake"
)
expect(response.parsed_body['matched_users_count']).to eq(1)
expect(response.parsed_body['unmatched_entries_count']).to eq(2)
expect(UserBadge.where(user: user, badge: badge).count).to eq(2)
ensure
csv&.close
csv&.unlink
end
it "grants the badge to the users in the CSV as many times as they appear in it" do
Jobs.run_immediately!
badge.update!(multiple_grant: true)
user_without_badge = Fabricate(:user)
user_with_badge = Fabricate(:user).tap { |u| BadgeGranter.grant(badge, u) }
csv_content = [
user_with_badge.email.titlecase,
user_with_badge.username.titlecase,
user_without_badge.email.titlecase,
user_without_badge.username.titlecase
] * 20
csv = Tempfile.new
csv.write(csv_content.join("\n"))
csv.rewind
post "/admin/badges/award/#{badge.id}.json", params: {
file: fixture_file_upload(csv),
grant_existing_holders: true
}
expect(response.status).to eq(200)
expect(response.parsed_body['unmatched_entries']).to eq([])
expect(response.parsed_body['matched_users_count']).to eq(2)
expect(response.parsed_body['unmatched_entries_count']).to eq(0)
sequence = UserBadge.where(user: user_with_badge, badge: badge).pluck(:seq)
expect(sequence.sort).to eq((0...(40 + 1)).to_a)
sequence = UserBadge.where(user: user_without_badge, badge: badge).pluck(:seq)
expect(sequence.sort).to eq((0...40).to_a)
ensure
csv&.close
csv&.unlink
end
end
end
end
end

View File

@ -486,4 +486,112 @@ describe BadgeGranter do
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: badge, user: user).pluck(:seq).sort
expect(sequence).to eq((0...10).to_a)
BadgeGranter.mass_grant(badge, user, count: 10)
sequence = UserBadge.where(badge: badge, user: user).pluck(:seq).sort
expect(sequence).to eq((0...20).to_a)
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: user, badge: 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: 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: badge).pluck(:seq)
expect(sequence.size).to eq(40 + 1)
expect(sequence.sort).to eq((0...(40 + 1)).to_a)
sequence = UserBadge.where(user: user_without_badge, badge: badge).pluck(:seq)
expect(sequence.size).to eq(40)
expect(sequence.sort).to eq((0...40).to_a)
# 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