Prevent duplicate challenge tasks (#11502)

* Implement atomic user challenges update

Prevents multiple concurrent requests from passing through
Fixes #11295

* Move user challenges update to separate method

* Rename challenge syncToUser to syncTasksToUser

Now that adding the challenge to user is separated, this methods main purpose is to sync the tasks

* Fix lint errors
This commit is contained in:
Carl Vuorinen 2019-12-13 15:14:57 +02:00 committed by Matteo Pagliazzi
parent bdb3cf25c1
commit cd90a281c2
4 changed files with 60 additions and 17 deletions

View file

@ -10,7 +10,7 @@ async function syncChallengeToMembers (challenges) {
const promises = []; const promises = [];
users.forEach(user => { users.forEach(user => {
promises.push(challenge.syncToUser(user)); promises.push(challenge.syncTasksToUser(user));
promises.push(challenge.save()); promises.push(challenge.save());
promises.push(user.save()); promises.push(user.save());
}); });

View file

@ -90,7 +90,37 @@ describe('Challenge Model', () => {
expect(syncedTask.tags[0]).to.eql(challenge._id); expect(syncedTask.tags[0]).to.eql(challenge._id);
}); });
it('syncs a challenge to a user', async () => { it('adds a challenge to a user', async () => {
const newMember = new User({
guilds: [guild._id],
});
await newMember.save();
const addedSuccessfully = await challenge.addToUser(newMember);
const updatedNewMember = await User.findById(newMember._id);
expect(addedSuccessfully).to.eql(true);
expect(updatedNewMember.challenges).to.contain(challenge._id);
});
it('does not add a challenge to a user that already in the challenge', async () => {
const newMember = new User({
guilds: [guild._id],
challenges: [challenge._id],
});
await newMember.save();
const addedSuccessfully = await challenge.addToUser(newMember);
const updatedNewMember = await User.findById(newMember._id);
expect(addedSuccessfully).to.eql(false);
expect(updatedNewMember.challenges).to.contain(challenge._id);
expect(updatedNewMember.challenges.length).to.eql(1);
});
it('syncs challenge tasks to a user', async () => {
await challenge.addTasks([task]); await challenge.addTasks([task]);
const newMember = new User({ const newMember = new User({
@ -98,7 +128,7 @@ describe('Challenge Model', () => {
}); });
await newMember.save(); await newMember.save();
await challenge.syncToUser(newMember); await challenge.syncTasksToUser(newMember);
const updatedNewMember = await User.findById(newMember._id); const updatedNewMember = await User.findById(newMember._id);
const updatedNewMemberTasks = await Tasks.Task.find({ _id: { $in: updatedNewMember.tasksOrder[`${taskType}s`] } }); const updatedNewMemberTasks = await Tasks.Task.find({ _id: { $in: updatedNewMember.tasksOrder[`${taskType}s`] } });
@ -110,14 +140,13 @@ describe('Challenge Model', () => {
), ),
); );
expect(updatedNewMember.challenges).to.contain(challenge._id);
expect(updatedNewMember.tags[7].id).to.equal(challenge._id); expect(updatedNewMember.tags[7].id).to.equal(challenge._id);
expect(updatedNewMember.tags[7].name).to.equal(challenge.shortName); expect(updatedNewMember.tags[7].name).to.equal(challenge.shortName);
expect(syncedTask).to.exist; expect(syncedTask).to.exist;
expect(syncedTask.attribute).to.eql('str'); expect(syncedTask.attribute).to.eql('str');
}); });
it('syncs a challenge to a user with the existing task', async () => { it('syncs challenge tasks to a user with the existing task', async () => {
await challenge.addTasks([task]); await challenge.addTasks([task]);
let updatedLeader = await User.findOne({ _id: leader._id }); let updatedLeader = await User.findOne({ _id: leader._id });
@ -134,7 +163,7 @@ describe('Challenge Model', () => {
task.text = newTitle; task.text = newTitle;
task.attribute = 'int'; task.attribute = 'int';
await task.save(); await task.save();
await challenge.syncToUser(leader); await challenge.syncTasksToUser(leader);
updatedLeader = await User.findOne({ _id: leader._id }); updatedLeader = await User.findOne({ _id: leader._id });
updatedLeadersTasks = await Tasks.Task.find({ _id: { $in: updatedLeader.tasksOrder[`${taskType}s`] } }); updatedLeadersTasks = await Tasks.Task.find({ _id: { $in: updatedLeader.tasksOrder[`${taskType}s`] } });

View file

@ -254,19 +254,23 @@ api.joinChallenge = {
const challenge = await Challenge.findOne({ _id: req.params.challengeId }).exec(); const challenge = await Challenge.findOne({ _id: req.params.challengeId }).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge'));
const group = await Group.getGroup({ const group = await Group.getGroup({
user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true, user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true,
}); });
if (!group || !challenge.canJoin(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!group || !challenge.canJoin(user, group)) throw new NotFound(res.t('challengeNotFound'));
const addedSuccessfully = await challenge.addToUser(user);
if (!addedSuccessfully) {
throw new NotAuthorized(res.t('userAlreadyInChallenge'));
}
challenge.memberCount += 1; challenge.memberCount += 1;
addUserJoinChallengeNotification(user); addUserJoinChallengeNotification(user);
// Add all challenge's tasks to user's tasks and save the challenge // Add all challenge's tasks to user's tasks and save the challenge
const results = await Promise.all([challenge.syncToUser(user), challenge.save()]); const results = await Promise.all([challenge.syncTasksToUser(user), challenge.save()]);
const response = results[1].toJSON(); const response = results[1].toJSON();
response.group = getChallengeGroupResponse(group); response.group = getChallengeGroupResponse(group);

View file

@ -94,6 +94,23 @@ schema.methods.canJoin = function canJoinChallenge (user, group) {
return user.getGroups().indexOf(this.group) !== -1; return user.getGroups().indexOf(this.group) !== -1;
}; };
// Returns true if the challenge was successfully added to the user
// or false if the user already in the challenge
schema.methods.addToUser = async function addChallengeToUser (user) {
// Add challenge to users challenges atomically (with a condition that checks that it
// is not there already) to prevent multiple concurrent requests from passing through
// see https://github.com/HabitRPG/habitica/issues/11295
const result = await User.update(
{
_id: user._id,
challenges: { $nin: [this._id] },
},
{ $push: { challenges: this._id } },
).exec();
return !!result.nModified;
};
// Returns true if user can view the challenge // Returns true if user can view the challenge
// Different from canJoin because you can see challenges of groups // Different from canJoin because you can see challenges of groups
// you've been removed from if you're participating in them // you've been removed from if you're participating in them
@ -102,19 +119,12 @@ schema.methods.canView = function canViewChallenge (user, group) {
return this.canJoin(user, group); return this.canJoin(user, group);
}; };
// Sync challenge to user, including tasks and tags. // Sync challenge tasks to user, including tags.
// Used when user joins the challenge or to force sync. // Used when user joins the challenge or to force sync.
schema.methods.syncToUser = async function syncChallengeToUser (user) { schema.methods.syncTasksToUser = async function syncChallengeTasksToUser (user) {
const challenge = this; const challenge = this;
challenge.shortName = challenge.shortName || challenge.name; challenge.shortName = challenge.shortName || challenge.name;
// Add challenge to user.challenges
if (!_.includes(user.challenges, challenge._id)) {
// using concat because mongoose's protection against
// concurrent array modification isn't working as expected.
// see https://github.com/HabitRPG/habitica/pull/7787#issuecomment-232972394
user.challenges = user.challenges.concat([challenge._id]);
}
// Sync tags // Sync tags
const userTags = user.tags; const userTags = user.tags;
const i = _.findIndex(userTags, { id: challenge._id }); const i = _.findIndex(userTags, { id: challenge._id });