mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-19 07:37:25 +01:00
feat(api-v3): finish first iteration of group.startQuest method
This commit is contained in:
@@ -1,42 +1,72 @@
|
|||||||
import { model as Group } from '../../../../../website/src/models/group';
|
import { model as Group } from '../../../../../website/src/models/group';
|
||||||
import { model as User } from '../../../../../website/src/models/user';
|
import { model as User } from '../../../../../website/src/models/user';
|
||||||
import { quests as questScrolls } from '../../../../../common/script/content';
|
import { quests as questScrolls } from '../../../../../common/script/content';
|
||||||
|
import * as email from '../../../../../website/src/libs/api-v3/email';
|
||||||
|
import Q from 'q';
|
||||||
|
|
||||||
describe('Group Model', () => {
|
describe('Group Model', () => {
|
||||||
context('Instance Methods', () => {
|
context('Instance Methods', () => {
|
||||||
let party;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
party = new Group({
|
|
||||||
type: 'party',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#startQuest', () => {
|
describe('#startQuest', () => {
|
||||||
|
let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sandbox.stub(email, 'sendTxn');
|
||||||
|
sandbox.spy(Q, 'allSettled');
|
||||||
|
|
||||||
|
party = new Group({
|
||||||
|
name: 'test party',
|
||||||
|
type: 'party',
|
||||||
|
privacy: 'private',
|
||||||
|
});
|
||||||
|
|
||||||
|
questLeader = new User({
|
||||||
|
party: { _id: party._id },
|
||||||
|
items: {
|
||||||
|
quests: {
|
||||||
|
whale: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
party.leader = questLeader._id;
|
||||||
|
|
||||||
|
participatingMember = new User({
|
||||||
|
party: { _id: party._id },
|
||||||
|
});
|
||||||
|
nonParticipatingMember = new User({
|
||||||
|
party: { _id: party._id },
|
||||||
|
});
|
||||||
|
undecidedMember = new User({
|
||||||
|
party: { _id: party._id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
party.save(),
|
||||||
|
questLeader.save(),
|
||||||
|
participatingMember.save(),
|
||||||
|
nonParticipatingMember.save(),
|
||||||
|
undecidedMember.save(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
context('Failure Conditions', () => {
|
context('Failure Conditions', () => {
|
||||||
it('throws an error if group is not a party', () => {
|
it('throws an error if group is not a party', async () => {
|
||||||
let guild = new Group({
|
let guild = new Group({
|
||||||
type: 'guild',
|
type: 'guild',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(() => {
|
await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected;
|
||||||
guild.startQuest();
|
|
||||||
}).to.throw('Must be a party to use this method');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws an error if party is not on a quest', () => {
|
it('throws an error if party is not on a quest', async () => {
|
||||||
expect(() => {
|
await expect(party.startQuest(participatingMember)).to.eventually.be.rejected;
|
||||||
party.startQuest();
|
|
||||||
}).to.throw('Party does not have a pending quest');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws an error if quest is already active', () => {
|
it('throws an error if quest is already active', async () => {
|
||||||
party.quest.key = 'whale';
|
party.quest.key = 'whale';
|
||||||
party.quest.active = true;
|
party.quest.active = true;
|
||||||
|
|
||||||
expect(() => {
|
await expect(party.startQuest(participatingMember)).to.eventually.be.rejected;
|
||||||
party.startQuest();
|
|
||||||
}).to.throw('Quest is already active');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,19 +74,16 @@ describe('Group Model', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
party.quest.key = 'whale';
|
party.quest.key = 'whale';
|
||||||
party.quest.active = false;
|
party.quest.active = false;
|
||||||
party.quest.leader = 'quest-leader';
|
party.quest.leader = questLeader._id;
|
||||||
party.quest.members = {
|
party.quest.members = { };
|
||||||
'quest-leader': true,
|
party.quest.members[questLeader._id] = true;
|
||||||
'participating-member': true,
|
party.quest.members[participatingMember._id] = true;
|
||||||
'non-participating-member': false,
|
party.quest.members[nonParticipatingMember._id] = false;
|
||||||
'undecided-member': null,
|
party.quest.members[undecidedMember._id] = null;
|
||||||
};
|
|
||||||
|
|
||||||
sandbox.stub(User, 'update').returns({ exec: sandbox.spy() });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('activates quest', () => {
|
it('activates quest', () => {
|
||||||
party.startQuest();
|
party.startQuest(participatingMember);
|
||||||
|
|
||||||
expect(party.quest.active).to.eql(true);
|
expect(party.quest.active).to.eql(true);
|
||||||
});
|
});
|
||||||
@@ -65,7 +92,7 @@ describe('Group Model', () => {
|
|||||||
let bossQuest = questScrolls.whale;
|
let bossQuest = questScrolls.whale;
|
||||||
party.quest.key = bossQuest.key;
|
party.quest.key = bossQuest.key;
|
||||||
|
|
||||||
party.startQuest();
|
party.startQuest(participatingMember);
|
||||||
|
|
||||||
expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp);
|
expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp);
|
||||||
});
|
});
|
||||||
@@ -74,7 +101,7 @@ describe('Group Model', () => {
|
|||||||
let rageBossQuest = questScrolls.trex_undead;
|
let rageBossQuest = questScrolls.trex_undead;
|
||||||
party.quest.key = rageBossQuest.key;
|
party.quest.key = rageBossQuest.key;
|
||||||
|
|
||||||
party.startQuest();
|
party.startQuest(participatingMember);
|
||||||
|
|
||||||
expect(party.quest.progress.rage).to.eql(0);
|
expect(party.quest.progress.rage).to.eql(0);
|
||||||
});
|
});
|
||||||
@@ -82,7 +109,7 @@ describe('Group Model', () => {
|
|||||||
it('sets up collection quest', () => {
|
it('sets up collection quest', () => {
|
||||||
let collectionQuest = questScrolls.vice2;
|
let collectionQuest = questScrolls.vice2;
|
||||||
party.quest.key = collectionQuest.key;
|
party.quest.key = collectionQuest.key;
|
||||||
party.startQuest();
|
party.startQuest(participatingMember);
|
||||||
|
|
||||||
expect(party.quest.progress.collect).to.eql({
|
expect(party.quest.progress.collect).to.eql({
|
||||||
lightCrystal: 0,
|
lightCrystal: 0,
|
||||||
@@ -92,7 +119,7 @@ describe('Group Model', () => {
|
|||||||
it('sets up collection quest with multiple items', () => {
|
it('sets up collection quest with multiple items', () => {
|
||||||
let collectionQuest = questScrolls.evilsanta2;
|
let collectionQuest = questScrolls.evilsanta2;
|
||||||
party.quest.key = collectionQuest.key;
|
party.quest.key = collectionQuest.key;
|
||||||
party.startQuest();
|
party.startQuest(participatingMember);
|
||||||
|
|
||||||
expect(party.quest.progress.collect).to.eql({
|
expect(party.quest.progress.collect).to.eql({
|
||||||
tracks: 0,
|
tracks: 0,
|
||||||
@@ -100,34 +127,135 @@ describe('Group Model', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates quest object for participating members', () => {
|
it('prunes non-participating members from quest members object', () => {
|
||||||
party.startQuest();
|
party.startQuest(participatingMember);
|
||||||
|
|
||||||
expect(User.update).to.be.calledTwice;
|
let expectedQuestMembers = {};
|
||||||
expect(User.update).to.not.be.calledWith({ _id: 'non-participating-member' });
|
expectedQuestMembers[questLeader._id] = true;
|
||||||
expect(User.update).to.not.be.calledWith({ _id: 'undecided-member' });
|
expectedQuestMembers[participatingMember._id] = true;
|
||||||
expect(User.update).to.be.calledWith(
|
|
||||||
{ _id: 'participating-member' },
|
expect(party.quest.members).to.eql(expectedQuestMembers);
|
||||||
sinon.match({ $set: { 'party.quest.key': 'whale' }}),
|
|
||||||
);
|
|
||||||
expect(User.update).to.be.calledWith(
|
|
||||||
{ _id: 'quest-leader' },
|
|
||||||
sinon.match({ $set: { 'party.quest.key': 'whale' }}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes quest scroll from quest leader', () => {
|
it('applies updates to user object directly if user is participating', async () => {
|
||||||
party.startQuest();
|
await party.startQuest(participatingMember);
|
||||||
|
|
||||||
expect(User.update).to.be.calledWith(
|
expect(participatingMember.party.quest.key).to.eql('whale');
|
||||||
{ _id: 'quest-leader' },
|
expect(participatingMember.party.quest.progress.down).to.eql(0);
|
||||||
sinon.match({ $inc: { 'items.quests.whale': -1 }}),
|
expect(participatingMember.party.quest.collect).to.eql({});
|
||||||
);
|
expect(participatingMember.party.quest.completed).to.eql(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends email to participating members that quest has started');
|
it('applies updates to other participating members', async () => {
|
||||||
|
await party.startQuest(nonParticipatingMember);
|
||||||
|
|
||||||
it('sends email only to members who have not opted out');
|
questLeader = await User.findById(questLeader._id);
|
||||||
|
participatingMember = await User.findById(participatingMember._id);
|
||||||
|
|
||||||
|
expect(participatingMember.party.quest.key).to.eql('whale');
|
||||||
|
expect(participatingMember.party.quest.progress.down).to.eql(0);
|
||||||
|
expect(participatingMember.party.quest.progress.collect).to.eql({});
|
||||||
|
expect(participatingMember.party.quest.completed).to.eql(null);
|
||||||
|
|
||||||
|
expect(questLeader.party.quest.key).to.eql('whale');
|
||||||
|
expect(questLeader.party.quest.progress.down).to.eql(0);
|
||||||
|
expect(questLeader.party.quest.progress.collect).to.eql({});
|
||||||
|
expect(questLeader.party.quest.completed).to.eql(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply updates to nonparticipating members', async () => {
|
||||||
|
await party.startQuest(participatingMember);
|
||||||
|
|
||||||
|
nonParticipatingMember = await User.findById(nonParticipatingMember ._id);
|
||||||
|
undecidedMember = await User.findById(undecidedMember._id);
|
||||||
|
|
||||||
|
expect(nonParticipatingMember.party.quest.key).to.not.eql('whale');
|
||||||
|
expect(undecidedMember.party.quest.key).to.not.eql('whale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes quest scroll from quest leader', async () => {
|
||||||
|
await party.startQuest(participatingMember);
|
||||||
|
|
||||||
|
questLeader = await User.findById(questLeader._id);
|
||||||
|
|
||||||
|
expect(questLeader.items.quests.whale).to.eql(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends email to participating members that quest has started', async () => {
|
||||||
|
participatingMember.preferences.emailNotifications.questStarted = true;
|
||||||
|
questLeader.preferences.emailNotifications.questStarted = true;
|
||||||
|
await Promise.all([
|
||||||
|
participatingMember.save(),
|
||||||
|
questLeader.save(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await party.startQuest(nonParticipatingMember);
|
||||||
|
|
||||||
|
expect(email.sendTxn).to.be.calledOnce;
|
||||||
|
|
||||||
|
let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
|
||||||
|
let typeOfEmail = email.sendTxn.args[0][1];
|
||||||
|
|
||||||
|
expect(memberIds).to.have.a.lengthOf(2);
|
||||||
|
expect(memberIds).to.include(participatingMember._id);
|
||||||
|
expect(memberIds).to.include(questLeader._id);
|
||||||
|
expect(typeOfEmail).to.eql('quest-started');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends email only to members who have not opted out', async () => {
|
||||||
|
participatingMember.preferences.emailNotifications.questStarted = false;
|
||||||
|
questLeader.preferences.emailNotifications.questStarted = true;
|
||||||
|
await Promise.all([
|
||||||
|
participatingMember.save(),
|
||||||
|
questLeader.save(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await party.startQuest(nonParticipatingMember);
|
||||||
|
|
||||||
|
expect(email.sendTxn).to.be.calledOnce;
|
||||||
|
|
||||||
|
let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
|
||||||
|
|
||||||
|
expect(memberIds).to.have.a.lengthOf(1);
|
||||||
|
expect(memberIds).to.not.include(participatingMember._id);
|
||||||
|
expect(memberIds).to.include(questLeader._id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send email to initiating member', async () => {
|
||||||
|
participatingMember.preferences.emailNotifications.questStarted = true;
|
||||||
|
questLeader.preferences.emailNotifications.questStarted = true;
|
||||||
|
await Promise.all([
|
||||||
|
participatingMember.save(),
|
||||||
|
questLeader.save(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await party.startQuest(participatingMember);
|
||||||
|
|
||||||
|
expect(email.sendTxn).to.be.calledOnce;
|
||||||
|
|
||||||
|
let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
|
||||||
|
|
||||||
|
expect(memberIds).to.have.a.lengthOf(1);
|
||||||
|
expect(memberIds).to.not.include(participatingMember._id);
|
||||||
|
expect(memberIds).to.include(questLeader._id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds participating members to background save operations', async () => {
|
||||||
|
await party.startQuest(nonParticipatingMember);
|
||||||
|
|
||||||
|
expect(Q.allSettled).to.be.calledOnce;
|
||||||
|
|
||||||
|
let savePromises = Q.allSettled.args[0][0];
|
||||||
|
expect(savePromises).to.have.a.lengthOf(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include initiating user in background save operations', async () => {
|
||||||
|
await party.startQuest(participatingMember);
|
||||||
|
|
||||||
|
expect(Q.allSettled).to.be.calledOnce;
|
||||||
|
let savePromises = Q.allSettled.args[0][0];
|
||||||
|
expect(savePromises).to.have.a.lengthOf(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { removeFromArray } from '../libs/api-v3/collectionManipulators';
|
|||||||
import { BadRequest } from '../libs/api-v3/errors';
|
import { BadRequest } from '../libs/api-v3/errors';
|
||||||
import * as firebase from '../libs/api-v2/firebase';
|
import * as firebase from '../libs/api-v2/firebase';
|
||||||
import baseModel from '../libs/api-v3/baseModel';
|
import baseModel from '../libs/api-v3/baseModel';
|
||||||
|
import { sendTxn as sendTxnEmail } from '../libs/api-v3/email';
|
||||||
import { quests as questScrolls } from '../../../common/script/content';
|
import { quests as questScrolls } from '../../../common/script/content';
|
||||||
import Q from 'q';
|
import Q from 'q';
|
||||||
import nconf from 'nconf';
|
import nconf from 'nconf';
|
||||||
@@ -169,11 +170,12 @@ schema.methods.isMember = function isGroupMember (user) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
schema.methods.startQuest = function startQuest () {
|
schema.methods.startQuest = async function startQuest (user) {
|
||||||
if (this.type !== 'party') throw new BadRequest('Must be a party to use this method');
|
if (this.type !== 'party') throw new BadRequest('Must be a party to use this method');
|
||||||
if (!this.quest.key) throw new BadRequest('Party does not have a pending quest');
|
if (!this.quest.key) throw new BadRequest('Party does not have a pending quest');
|
||||||
if (this.quest.active) throw new BadRequest('Quest is already active');
|
if (this.quest.active) throw new BadRequest('Quest is already active');
|
||||||
|
|
||||||
|
let userIsParticipating = this.quest.members[user._id];
|
||||||
let quest = questScrolls[this.quest.key];
|
let quest = questScrolls[this.quest.key];
|
||||||
let collected = {};
|
let collected = {};
|
||||||
if (quest.collect) {
|
if (quest.collect) {
|
||||||
@@ -193,38 +195,54 @@ schema.methods.startQuest = function startQuest () {
|
|||||||
this.quest.progress.collect = collected;
|
this.quest.progress.collect = collected;
|
||||||
}
|
}
|
||||||
|
|
||||||
_.each(this.quest.members, (participating, memberId) => {
|
// Changes quest.members to only include participating members
|
||||||
if (!participating) return;
|
// TODO: is that important? What does it matter if the non-participating members
|
||||||
|
// are still on the object?
|
||||||
|
// TODO: is it important to run clean quest progress on non-members like we did in v2?
|
||||||
|
this.quest.members = _.pick(this.quest.members, _.identity);
|
||||||
|
let nonUserQuestMembers = _.without(_.keys(this.quest.members), user._id);
|
||||||
|
|
||||||
let update = {
|
let members = await User.find(
|
||||||
$set: {
|
{ _id: { $in: nonUserQuestMembers } },
|
||||||
// Do *not* reset party.quest.progress.up
|
'party.quest items.quests auth.facebook auth.local preferences.emailNotifications',
|
||||||
// See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322
|
).exec();
|
||||||
'party.quest.key': this.quest.key,
|
|
||||||
'party.quest.progress.down': 0,
|
|
||||||
'party.quest.collect': collected,
|
|
||||||
'party.quest.completed': null,
|
|
||||||
},
|
|
||||||
$inc: { _v: 1 },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.quest.leader === memberId) {
|
if (userIsParticipating) {
|
||||||
update.$inc[`items.quests.${this.quest.key}`] = -1;
|
members.unshift(user); // put participating user at the beginning of the array
|
||||||
}
|
}
|
||||||
|
|
||||||
backgroundOperations.push(User.update({ _id: memberId }, update).exec());
|
_.each(members, (member) => {
|
||||||
|
member.party.quest.key = this.quest.key;
|
||||||
|
member.party.quest.progress.down = 0;
|
||||||
|
member.party.quest.collect = collected;
|
||||||
|
member.party.quest.completed = null;
|
||||||
|
member.markModified('party.quest');
|
||||||
|
|
||||||
|
if (this.quest.leader === member._id) {
|
||||||
|
member.items.quests[this.quest.key] -= 1;
|
||||||
|
member.markModified('items.quests');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member._id !== user._id) {
|
||||||
|
backgroundOperations.push(member.save());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO Add emails to users that quest has started to background ops
|
let usersToEmail = _.filter(members, (member) => {
|
||||||
|
return member.preferences.emailNotifications.questStarted !== false &&
|
||||||
|
member._id !== user._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
sendTxnEmail(usersToEmail, 'quest-started', [
|
||||||
|
{ name: 'PARTY_URL', content: '/#/options/groups/party' },
|
||||||
|
]);
|
||||||
|
|
||||||
// These operations should run in the background
|
// These operations should run in the background
|
||||||
// and not hold up the quest routes from resolving
|
// and not hold up the quest routes from resolving
|
||||||
// TODO: What here?
|
Q.allSettled(backgroundOperations).catch(err => {
|
||||||
// Q.all(backgroundOperations).then(() => {
|
// TODO: what to do with err?
|
||||||
// }).catch(err => {
|
throw err;
|
||||||
// TODO: How to handle errors?
|
});
|
||||||
// IE, user deleted their account?
|
|
||||||
// });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function chatDefaults (msg, user) {
|
export function chatDefaults (msg, user) {
|
||||||
|
|||||||
Reference in New Issue
Block a user