From 3bd806a0f0c3f6f80444fce3ef7ca66e184ab26e Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 21 Jan 2016 17:31:54 -0500 Subject: [PATCH 01/34] test(quests): accept route WIP --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupId_quests_accept.test.js | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c10fd1647a..bc71703017 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -65,5 +65,6 @@ "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked." + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "questInviteNotFound": "No quest invitation found." } diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js new file mode 100644 index 0000000000..44286e6e79 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -0,0 +1,51 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe.skip('POST /groups/:groupId/quests/accept', () => { + let questingGroup; + let leader; + let member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + leader = groupLeader; + member = members[0]; + }); + + context('failure conditions', () => { + it('does not accept quest without an invite', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInviteNotFound'), + }); + + )}; + + it('does not accept quest for a group in which user is not a member', () => { + + )}; + )}; + context('successfully accepting a quest invitation', () => { + it('joins a quest from an invitation', () => { + + )}; + + it('does not begin the quest if pending invitations remain', () => { + + )}; + + it('begins the quest if accepting the last pending invite', () => { + + )}; + )}; +)}; From 0ec63ca68d644a1cc68a1a9dc8e7712390370e75 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 22 Jan 2016 16:59:34 -0500 Subject: [PATCH 02/34] test(quests): invite and accept WIP --- common/locales/en/api-v3.json | 7 +- .../POST-groups_groupId_quests_accept.test.js | 2 +- .../POST-groups_groupId_quests_invite.test.js | 120 ++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index bc71703017..f030c020f7 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -66,5 +66,10 @@ "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", - "questInviteNotFound": "No quest invitation found." + "questInviteNotFound": "No quest invitation found.", + "guildQuestsNotSupported": "Guilds cannot be invited on quests.", + "questNotFound": "Quest \"<%= key %>\" not found.", + "questNotOwned": "You don't own that quest scroll.", + "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." } diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js index 44286e6e79..a50497555b 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -10,7 +10,7 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { let member; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ + let { group, groupLeader, members } = await createAndPopulateGroup( { type: 'party', privacy: 'private' }, members: 1, }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js new file mode 100644 index 0000000000..76ccf25e51 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -0,0 +1,120 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe.skip('POST /groups/:groupId/quests/invite', () => { + let questingGroup; + let leader; + let member; + const PET_QUEST = 'whale'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup( + { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + leader = groupLeader; + member = members[0]; + }); + + context('failure conditions', () => { + it('does not issue invites with an invalid group ID', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not issue invites for a group in which user is not a member', async () => { + let { alternateGroup } = await createAndPopulateGroup( + { type: 'party', privacy: 'private' }, + members: 1, + }); + + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + )}; + + it('does not issue invites for Guilds', async () => { + let { alternateGroup } = await createAndPopulateGroup( + { type: 'guild', privacy: 'public' }, + members: 1, + }); + + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + )}; + + it('does not issue invites with an invalid quest key', async () => { + const FAKE_QUEST = 'herkimer'; + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${FAKE_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotFound', {key: FAKE_QUEST}), + }); + )}; + + it('does not issue invites for a quest the user does not own', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questNotOwned'), + }); + )}; + + it('does not issue invites if the user is of insufficient Level', async () => { + const LEVELED_QUEST = 'atom1'; + const LEVELED_QUEST_REQ = 15; + leader.items.quests[LEVELED_QUEST] = 1; + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), + }); + )}; + + it('does not issue invites if a quest is already underway', async () => { + leader.items.quests[PET_QUEST] = 2; + + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + )}; + )}; + + context('successfully issuing a quest invitation', () => { + it('sends an invite to all party members', async () => { + leader.items.quests[PET_QUEST] = 1; + + await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ + + }]); + )}; + + it('allows non-leader party members to send invites', () => { + member.items.quests[PET_QUEST] = 1; + + await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ + + }]); + )}; + )}; +)}; From a414aeaf708b014834856648cacfa0e1f9a15662 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 22 Jan 2016 17:38:47 -0500 Subject: [PATCH 03/34] fix(test): linting --- .../POST-groups_groupId_quests_accept.test.js | 33 +++++++++-------- .../POST-groups_groupId_quests_invite.test.js | 37 +++++++++---------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js index a50497555b..e218d7e3d9 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -1,6 +1,5 @@ import { createAndPopulateGroup, - generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; @@ -10,8 +9,8 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { let member; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -22,30 +21,34 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { context('failure conditions', () => { it('does not accept quest without an invite', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`, {})) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInviteNotFound'), + }); + }); + + it('does not accept quest for a group in which user is not a member', async () => { await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('questInviteNotFound'), }); - - )}; - - it('does not accept quest for a group in which user is not a member', () => { - - )}; - )}; + }); + }); context('successfully accepting a quest invitation', () => { it('joins a quest from an invitation', () => { - )}; + }); it('does not begin the quest if pending invitations remain', () => { - )}; + }); it('begins the quest if accepting the last pending invite', () => { - )}; - )}; -)}; + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 76ccf25e51..e03043b09a 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -1,6 +1,5 @@ import { createAndPopulateGroup, - generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -12,8 +11,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { const PET_QUEST = 'whale'; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -32,8 +31,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for a group in which user is not a member', async () => { - let { alternateGroup } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { alternateGroup } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -42,11 +41,11 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotFound', message: t('groupNotFound'), }); - )}; + }); it('does not issue invites for Guilds', async () => { - let { alternateGroup } = await createAndPopulateGroup( - { type: 'guild', privacy: 'public' }, + let { alternateGroup } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'public' }, members: 1, }); @@ -55,7 +54,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('guildQuestsNotSupported'), }); - )}; + }); it('does not issue invites with an invalid quest key', async () => { const FAKE_QUEST = 'herkimer'; @@ -65,7 +64,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotFound', message: t('questNotFound', {key: FAKE_QUEST}), }); - )}; + }); it('does not issue invites for a quest the user does not own', async () => { await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ @@ -73,7 +72,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questNotOwned'), }); - )}; + }); it('does not issue invites if the user is of insufficient Level', async () => { const LEVELED_QUEST = 'atom1'; @@ -85,7 +84,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), }); - )}; + }); it('does not issue invites if a quest is already underway', async () => { leader.items.quests[PET_QUEST] = 2; @@ -97,8 +96,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questAlreadyUnderway'), }); - )}; - )}; + }); + }); context('successfully issuing a quest invitation', () => { it('sends an invite to all party members', async () => { @@ -107,14 +106,14 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ }]); - )}; + }); - it('allows non-leader party members to send invites', () => { + it('allows non-leader party members to send invites', async () => { member.items.quests[PET_QUEST] = 1; await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ }]); - )}; - )}; -)}; + }); + }); +}); From 834cca0ddcede568bd7f1651e5cf05f9dafb7456 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 29 Jan 2016 18:30:41 -0500 Subject: [PATCH 04/34] test(quests): finish invite route --- .../POST-groups_groupId_quests_invite.test.js | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index e03043b09a..7f43168b95 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -100,20 +100,28 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); context('successfully issuing a quest invitation', () => { + let inviteResponse = { + key: PET_QUEST, + active: false, + leader: leader._id, + members: {}, + progress: { + collect: {}, + }, + }; + inviteResponse.members[member._id] = null; + inviteResponse.members[leader._id] = null; + it('sends an invite to all party members', async () => { leader.items.quests[PET_QUEST] = 1; - await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ - - }]); + await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); }); it('allows non-leader party members to send invites', async () => { member.items.quests[PET_QUEST] = 1; - await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ - - }]); + await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); }); }); }); From 707170ec7e78141c35c5c7b2db91d61cebb62877 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 30 Jan 2016 13:35:05 -0600 Subject: [PATCH 05/34] feat(api-v3): Add failure conditions for quest invite route --- .../POST-groups_groupId_quests_invite.test.js | 54 ++++++++++++------- website/src/controllers/api-v3/groups.js | 39 ++++++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 7f43168b95..7fbbfa2ca6 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -3,8 +3,9 @@ import { translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; +import { quests as questScrolls } from '../../../../../common/script/content'; -describe.skip('POST /groups/:groupId/quests/invite', () => { +describe('POST /groups/:groupId/quests/invite/:questKey', () => { let questingGroup; let leader; let member; @@ -31,11 +32,13 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for a group in which user is not a member', async () => { - let { alternateGroup } = await createAndPopulateGroup({ + let { group } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); + let alternateGroup = group; + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -44,11 +47,13 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for Guilds', async () => { - let { alternateGroup } = await createAndPopulateGroup({ + let { group } = await createAndPopulateGroup({ groupDetails: { type: 'guild', privacy: 'public' }, members: 1, }); + let alternateGroup = group; + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -76,8 +81,12 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { it('does not issue invites if the user is of insufficient Level', async () => { const LEVELED_QUEST = 'atom1'; - const LEVELED_QUEST_REQ = 15; - leader.items.quests[LEVELED_QUEST] = 1; + const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl; + const leaderUpdate = {}; + leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1; + leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1; + + await leader.update(leaderUpdate); await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, @@ -87,9 +96,12 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites if a quest is already underway', async () => { - leader.items.quests[PET_QUEST] = 2; + const QUEST_IN_PROGRESS = 'atom1'; + const leaderUpdate = {}; + leaderUpdate[`items.quests.${PET_QUEST}`] = 1; - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await leader.update(leaderUpdate); + await questingGroup.update({ 'quest.key': QUEST_IN_PROGRESS }); await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, @@ -99,18 +111,22 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); }); - context('successfully issuing a quest invitation', () => { - let inviteResponse = { - key: PET_QUEST, - active: false, - leader: leader._id, - members: {}, - progress: { - collect: {}, - }, - }; - inviteResponse.members[member._id] = null; - inviteResponse.members[leader._id] = null; + context.skip('successfully issuing a quest invitation', () => { + let inviteResponse; + + beforeEach(() => { + inviteResponse = { + key: PET_QUEST, + active: false, + leader: leader._id, + members: {}, + progress: { + collect: {}, + }, + }; + inviteResponse.members[member._id] = null; + inviteResponse.members[leader._id] = null; + }); it('sends an invite to all party members', async () => { leader.items.quests[PET_QUEST] = 1; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..628d31f1f2 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,6 +17,7 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; +import { quests as questScrolls } from '../../../../common/script/content'; let api = {}; @@ -616,4 +617,42 @@ api.inviteToGroup = { }, }; +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + // TODO Logic for quest invite and send back quest object + res.respond(200, {}); + }, +}; + export default api; From c699874e36e00aedf4123340cc05d053d8a4dc8a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 17:55:55 -0600 Subject: [PATCH 06/34] feat: Add startQuest method on group model --- test/api/v3/unit/models/group.test.js | 134 ++++++++++++++++++++++++++ website/src/models/group.js | 60 ++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 test/api/v3/unit/models/group.test.js diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js new file mode 100644 index 0000000000..bdc49355f7 --- /dev/null +++ b/test/api/v3/unit/models/group.test.js @@ -0,0 +1,134 @@ +import { model as Group } from '../../../../../website/src/models/group'; +import { model as User } from '../../../../../website/src/models/user'; +import { quests as questScrolls } from '../../../../../common/script/content'; + +describe('Group Model', () => { + context('Instance Methods', () => { + let party; + + beforeEach(() => { + party = new Group({ + type: 'party', + }); + }); + + describe('#startQuest', () => { + context('Failure Conditions', () => { + it('throws an error if group is not a party', () => { + let guild = new Group({ + type: 'guild', + }); + + expect(() => { + guild.startQuest(); + }).to.throw('Must be a party to use this method'); + }); + + it('throws an error if party is not on a quest', () => { + expect(() => { + party.startQuest(); + }).to.throw('Party does not have a pending quest'); + }); + + it('throws an error if quest is already active', () => { + party.quest.key = 'whale'; + party.quest.active = true; + + expect(() => { + party.startQuest(); + }).to.throw('Quest is already active'); + }); + }); + + context('Successes', () => { + beforeEach(() => { + party.quest.key = 'whale'; + party.quest.active = false; + party.quest.leader = 'quest-leader'; + party.quest.members = { + 'quest-leader': true, + 'participating-member': true, + 'non-participating-member': false, + 'undecided-member': null, + }; + + sandbox.stub(User, 'update').returns({ exec: sandbox.spy() }); + }); + + it('activates quest', () => { + party.startQuest(); + + expect(party.quest.active).to.eql(true); + }); + + it('sets up boss quest', () => { + let bossQuest = questScrolls.whale; + party.quest.key = bossQuest.key; + + party.startQuest(); + + expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); + }); + + it('sets up rage meter for rage boss quest', () => { + let rageBossQuest = questScrolls.trex_undead; + party.quest.key = rageBossQuest.key; + + party.startQuest(); + + expect(party.quest.progress.rage).to.eql(0); + }); + + it('sets up collection quest', () => { + let collectionQuest = questScrolls.vice2; + party.quest.key = collectionQuest.key; + party.startQuest(); + + expect(party.quest.progress.collect).to.eql({ + lightCrystal: 0, + }); + }); + + it('sets up collection quest with multiple items', () => { + let collectionQuest = questScrolls.evilsanta2; + party.quest.key = collectionQuest.key; + party.startQuest(); + + expect(party.quest.progress.collect).to.eql({ + tracks: 0, + branches: 0, + }); + }); + + it('updates quest object for participating members', () => { + party.startQuest(); + + expect(User.update).to.be.calledTwice; + expect(User.update).to.not.be.calledWith({ _id: 'non-participating-member' }); + expect(User.update).to.not.be.calledWith({ _id: 'undecided-member' }); + expect(User.update).to.be.calledWith( + { _id: 'participating-member' }, + 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', () => { + party.startQuest(); + + expect(User.update).to.be.calledWith( + { _id: 'quest-leader' }, + sinon.match({ $inc: { 'items.quests.whale': -1 }}), + ); + }); + + it('sends email to participating members that quest has started'); + + it('sends email only to members who have not opted out'); + }); + }); + }); +}); diff --git a/website/src/models/group.js b/website/src/models/group.js index 1bbb0145a5..e416317a47 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -8,8 +8,10 @@ import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; +import { BadRequest } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; +import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; @@ -168,6 +170,64 @@ schema.methods.isMember = function isGroupMember (user) { } }; +schema.methods.startQuest = function startQuest () { + 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.active) throw new BadRequest('Quest is already active'); + + let quest = questScrolls[this.quest.key]; + let collected = {}; + if (quest.collect) { + collected = _.transform(quest.collect, (result, n, itemToCollect) => { + result[itemToCollect] = 0; + }); + } + + let backgroundOperations = []; + + this.markModified('quest'); + this.quest.active = true; + if (quest.boss) { + this.quest.progress.hp = quest.boss.hp; + if (quest.boss.rage) this.quest.progress.rage = 0; + } else if (quest.collect) { + this.quest.progress.collect = collected; + } + + _.each(this.quest.members, (participating, memberId) => { + if (!participating) return; + + let update = { + $set: { + // Do *not* reset party.quest.progress.up + // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 + '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) { + update.$inc[`items.quests.${this.quest.key}`] = -1; + } + + backgroundOperations.push(User.update({ _id: memberId }, update).exec()); + }); + + // TODO Add emails to users that quest has started to background ops + + // These operations should run in the background + // and not hold up the quest routes from resolving + // TODO: What here? + // Q.all(backgroundOperations).then(() => { + // }).catch(err => { + // TODO: How to handle errors? + // IE, user deleted their account? + // }); +}; + export function chatDefaults (msg, user) { let message = { id: shared.uuid(), From a7486821e5e11774b61d90ee2ac0172745fd9930 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 18:02:32 -0600 Subject: [PATCH 07/34] feat: Add quest details to group in quest invite route --- .../POST-groups_groupId_quests_invite.test.js | 74 ++++++++++++++----- website/src/controllers/api-v3/groups.js | 16 ++++ 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 7fbbfa2ca6..4311cf7911 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -111,33 +111,67 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - context.skip('successfully issuing a quest invitation', () => { - let inviteResponse; + context('successfully issuing a quest invitation', () => { + beforeEach(async () => { + const memberUpdate = {}; + memberUpdate[`items.quests.${PET_QUEST}`] = 1; - beforeEach(() => { - inviteResponse = { - key: PET_QUEST, - active: false, - leader: leader._id, - members: {}, - progress: { - collect: {}, - }, - }; - inviteResponse.members[member._id] = null; - inviteResponse.members[leader._id] = null; + await Promise.all([ + leader.update(memberUpdate), + member.update(memberUpdate), + ]); }); - it('sends an invite to all party members', async () => { - leader.items.quests[PET_QUEST] = 1; + xit('adds quest details to group object', async () => { + await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); + await questingGroup.sync(); + + let quest = questingGroup.quest; + + expect(quest.key).to.eql(PET_QUEST); + expect(quest.active).to.eql(false); + expect(quest.leader).to.eql(false); + expect(quest.members).to.have.property(leader._id, null); + expect(quest.members).to.have.property(member._id, null); + expect(quest).to.have.property('progress'); }); - it('allows non-leader party members to send invites', async () => { - member.items.quests[PET_QUEST] = 1; + xit('adds quest details to user objects', async () => { + await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); + await Promise.all([ + leader.sync(), + member.sync(), + ]); + + [leader, member].forEach((user) => { + let quest = user.party.quest; + + expect(quest.key).to.eql(PET_QUEST); + expect(quest.active).to.eql(false); + expect(quest.leader).to.eql(false); + expect(quest.members).to.have.property(leader._id, null); + expect(quest.members).to.have.property(member._id, null); + expect(quest).to.have.property('progress'); + }); + }); + + xit('sends back the quest object', async () => { + let inviteResponse = await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + expect(inviteResponse.key).to.eql(PET_QUEST); + expect(inviteResponse.active).to.eql(false); + expect(inviteResponse.leader).to.eql(false); + expect(inviteResponse.members).to.have.property(leader._id, null); + expect(inviteResponse.members).to.have.property(member._id, null); + expect(inviteResponse).to.have.property('progress'); + }); + + xit('allows non-leader party members to send invites', async () => { + let inviteResponse = await member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + expect(inviteResponse.key).to.eql(PET_QUEST); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 628d31f1f2..c663688163 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -650,7 +650,23 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + group.markModified('quest'); + group.quest.key = questKey; + group.quest.leader = user._id; + group.quest.members = {}; + + // let memberUpdate = { + // '$set': { + // 'party.quest.key': questKey, + // 'party.quest.progress.down': 0, + // 'party.quest.completed': null, + // }, + // }; + + // TODO collect members of party // TODO Logic for quest invite and send back quest object + + await group.save(); res.respond(200, {}); }, }; From e3c7d2834e6e5fa024afb71032c21177ca4124a7 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 21:13:52 -0600 Subject: [PATCH 08/34] refactor(api-v3): Move quest routes to separate file --- website/src/controllers/api-v3/groups.js | 55 ------------------- website/src/controllers/api-v3/quests.js | 68 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 website/src/controllers/api-v3/quests.js diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index c663688163..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,7 +17,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; -import { quests as questScrolls } from '../../../../common/script/content'; let api = {}; @@ -617,58 +616,4 @@ api.inviteToGroup = { }, }; -/** - * @api {post} /groups/:groupId/quests/invite Invite users to a quest - * @apiVersion 3.0.0 - * @apiName InviteToQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} Quest Object - */ -api.inviteToQuest = { - method: 'POST', - url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); - if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - - group.markModified('quest'); - group.quest.key = questKey; - group.quest.leader = user._id; - group.quest.members = {}; - - // let memberUpdate = { - // '$set': { - // 'party.quest.key': questKey, - // 'party.quest.progress.down': 0, - // 'party.quest.completed': null, - // }, - // }; - - // TODO collect members of party - // TODO Logic for quest invite and send back quest object - - await group.save(); - res.respond(200, {}); - }, -}; - export default api; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js new file mode 100644 index 0000000000..38020fcae5 --- /dev/null +++ b/website/src/controllers/api-v3/quests.js @@ -0,0 +1,68 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { + model as Group, +} from '../../models/group'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import { quests as questScrolls } from '../../../../common/script/content'; + +let api = {}; + +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + group.markModified('quest'); + group.quest.key = questKey; + group.quest.leader = user._id; + group.quest.members = {}; + + // let memberUpdate = { + // '$set': { + // 'party.quest.key': questKey, + // 'party.quest.progress.down': 0, + // 'party.quest.completed': null, + // }, + // }; + + // TODO collect members of party + // TODO Logic for quest invite and send back quest object + + await group.save(); + res.respond(200, {}); + }, +}; + +export default api; From d5148a7bf01f196d77c83a028190b277b51aaf47 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 1 Feb 2016 19:33:35 -0600 Subject: [PATCH 09/34] Added quest reject route and intial tests --- common/locales/en/quests.json | 3 +- .../POST-groups_groupid_quests_reject.test.js | 98 +++++++++++++++++++ website/src/controllers/api-v3/groups.js | 97 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js diff --git a/common/locales/en/quests.json b/common/locales/en/quests.json index 7b3fe7af43..5edae258e5 100644 --- a/common/locales/en/quests.json +++ b/common/locales/en/quests.json @@ -78,5 +78,6 @@ "whichQuestStart": "Which quest do you want to start?", "getMoreQuests": "Get more quests", "unlockedAQuest": "You unlocked a quest!", - "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!" + "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!", + "questInvitationDoesNotExist": "No quest invitation has been sent out yet." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js new file mode 100644 index 0000000000..e5f7517eff --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -0,0 +1,98 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/invite/:questKey', () => { + let questingGroup; + let member; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(member.post(`/groups/${generateUUID()}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when group is not a party', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(groupLeader.post(`/groups/${group._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('returns an error when quest is not found', async () => { + let questKey = 'fakeQuestName'; + + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${questKey}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotFound', { key: questKey }), + }); + }); + + it('returns an error when user is not on the quest', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questNotOwned'), + }); + }); + + it('returns an error when group is not on a quest', async () => { + await member.update(userQuestUpdate); + + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInvitationDoesNotExist'), + }); + }); + }); + + context('successfully quest rejection', () => { + it('rejects a quest invitation', async () => { + await member.update(userQuestUpdate); + await questingGroup.update({'quest.key': PET_QUEST}); + + await member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`); + + let userWithRejectInvitation = await member.get('/user'); + expect(userWithRejectInvitation.party.quest.key).to.be.null; + expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..785ea564ba 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,6 +17,8 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; +import { quests as questScrolls } from '../../../../common/script/content'; +import { mockAnalyticsService } from '../../libs/api-v3/analyticsService'; let api = {}; @@ -616,4 +618,99 @@ api.inviteToGroup = { }, }; +<<<<<<< e3c7d2834e6e5fa024afb71032c21177ca4124a7 +======= +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + // TODO Logic for quest invite and send back quest object + res.respond(200, {}); + }, +}; + +/** + * @api {post} /groups/:groupId/quests/reject Reject a quest + * @apiVersion 3.0.0 + * @apiName RejectQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} questKey The quest _id + * + * @apiSuccess {Object} Quest Object + */ +api.rejectQuest = { + method: 'POST', + url: '/groups/:groupId/quests/reject/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + + let analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }; + mockAnalyticsService.track('quest', analyticsData); + + // @TODO: Are we tracking members this way? + // group.quest.members[user._id] = false; + + user.party.quest.RSVPNeeded = false; + user.party.quest.key = null; + await user.save(); + + // questStart(req,res,next); + + res.respond(200, {}); + }, +}; +>>>>>>> Added quest reject route and intial tests export default api; From 0684e307909406b41bf11737dc9a0a4471a5b902 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 3 Feb 2016 17:04:57 -0600 Subject: [PATCH 10/34] Added quest cancel route and initial tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_cancel.test.js | 66 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 49 ++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..5179067850 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "cantCancelActiveQuest": "You can not cancel an active quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js new file mode 100644 index 0000000000..84111ab5ea --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -0,0 +1,66 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('cancels a quest', async () => { + await member.update(userQuestUpdate); + await questingGroup.update({'quest.key': PET_QUEST}); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + + await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + let userThatCanceled = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(userThatCanceled.party.quest.key).to.be.null; + expect(userThatCanceled.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members).to.be.empty; + }); + + it('returns an error when quest is active', async () => { + await questingGroup.update({'quest.active': true}); + await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cantCancelActiveQuest'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..8ebe969071 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -3,6 +3,9 @@ import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { + model as User, +} from '../../models/user'; import { NotFound, NotAuthorized, @@ -65,4 +68,50 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/cancel Cancels a quest + * @apiVersion 3.0.0 + * @apiName CancelQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Group Object + */ +api.cancelQuest = { + method: 'POST', + url: '/groups/:groupId/quests/cancel', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) + // Quest scroll has not yet left quest owner's inventory so no need to return it. + // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); + + group.quest = {key: null, progress: {}, leader: null, members: {}}; + group.markModified('quest'); + await group.save(); + + await User.update( + {'party._id': groupId}, + {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}, + {multi: true} + ); + + res.respond(200, group); + }, +}; + + export default api; From 442a654e94874586d5a56decef683190b71d9fc9 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 12:27:51 -0600 Subject: [PATCH 11/34] Removed questKey from url, added quest member support, removed extra tests, and fixed analytics import --- .../POST-groups_groupid_quests_reject.test.js | 46 ++------- website/src/controllers/api-v3/groups.js | 97 ------------------- website/src/controllers/api-v3/quests.js | 56 +++++++++++ 3 files changed, 66 insertions(+), 133 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index e5f7517eff..a222778948 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -30,7 +30,7 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { context('failure conditions', () => { it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/reject/${PET_QUEST}`)) + await expect(member.post(`/groups/${generateUUID()}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,43 +38,10 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - it('returns an error when group is not a party', async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(groupLeader.post(`/groups/${group._id}/quests/reject/${PET_QUEST}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('returns an error when quest is not found', async () => { - let questKey = 'fakeQuestName'; - - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${questKey}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questNotFound', { key: questKey }), - }); - }); - - it('returns an error when user is not on the quest', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questNotOwned'), - }); - }); - it('returns an error when group is not on a quest', async () => { await member.update(userQuestUpdate); - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + await expect(member.post(`/groups/${questingGroup._id}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -88,11 +55,18 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { await member.update(userQuestUpdate); await questingGroup.update({'quest.key': PET_QUEST}); - await member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`); + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + let rejectResult = await member.post(`/groups/${questingGroup._id}/quests/reject`); let userWithRejectInvitation = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + expect(userWithRejectInvitation.party.quest.key).to.be.null; expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members[member._id]).to.be.false; + expect(updatedGroup.quest).to.deep.equal(rejectResult); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 785ea564ba..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,8 +17,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; -import { quests as questScrolls } from '../../../../common/script/content'; -import { mockAnalyticsService } from '../../libs/api-v3/analyticsService'; let api = {}; @@ -618,99 +616,4 @@ api.inviteToGroup = { }, }; -<<<<<<< e3c7d2834e6e5fa024afb71032c21177ca4124a7 -======= -/** - * @api {post} /groups/:groupId/quests/invite Invite users to a quest - * @apiVersion 3.0.0 - * @apiName InviteToQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} Quest Object - */ -api.inviteToQuest = { - method: 'POST', - url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); - if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - - // TODO Logic for quest invite and send back quest object - res.respond(200, {}); - }, -}; - -/** - * @api {post} /groups/:groupId/quests/reject Reject a quest - * @apiVersion 3.0.0 - * @apiName RejectQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * @apiParam {string} questKey The quest _id - * - * @apiSuccess {Object} Quest Object - */ -api.rejectQuest = { - method: 'POST', - url: '/groups/:groupId/quests/reject/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - - let analyticsData = { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }; - mockAnalyticsService.track('quest', analyticsData); - - // @TODO: Are we tracking members this way? - // group.quest.members[user._id] = false; - - user.party.quest.RSVPNeeded = false; - user.party.quest.key = null; - await user.save(); - - // questStart(req,res,next); - - res.respond(200, {}); - }, -}; ->>>>>>> Added quest reject route and intial tests export default api; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..6a7423bc0a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -8,6 +8,8 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import { track } from '../../libs/api-v3/analyticsService'; +import Q from 'q'; let api = {}; @@ -65,4 +67,58 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/reject Reject a quest + * @apiVersion 3.0.0 + * @apiName RejectQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} questKey The quest _id + * + * @apiSuccess {Object} Quest Object + */ +api.rejectQuest = { + method: 'POST', + url: '/groups/:groupId/quests/reject', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + + let analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }; + track('quest', analyticsData); + + group.quest.members[user._id] = false; + group.markModified('quest.members'); + + user.party.quest.RSVPNeeded = false; + user.party.quest.key = null; + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + // questStart(req,res,next); + + res.respond(200, savedGroup.quest); + }, +}; + export default api; From 5ca663db57ec22ce98a44d2471664ec5de00eb24 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 12:31:38 -0600 Subject: [PATCH 12/34] Added quest leave route and initial tests --- common/locales/en/api-v3.json | 5 +- .../POST-groups_groupid_quests_leave.test.js | 85 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 54 ++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..c98411ed22 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,8 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "noActiveQuestToLeave": "No active quest to leave", + "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest", + "notPartOfQuest": "You are not part of the quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js new file mode 100644 index 0000000000..eec1e33842 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -0,0 +1,85 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(member.post(`/groups/${generateUUID()}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when quest is not active', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToLeave'), + }); + }); + + it('returns an error when quest leader attempts to leave', async () => { + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotLeaveQuest'), + }); + }); + + it('returns an error when non quest member attempts to leave', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notPartOfQuest'), + }); + }); + + it('leaves a quest', async () => { + await member.update(userQuestUpdate); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + + let leaveResult = await member.post(`/groups/${questingGroup._id}/quests/leave`); + let userThatLeft = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(userThatLeft.party.quest.key).to.be.null; + expect(userThatLeft.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members[member._id]).to.be.false; + expect(updatedGroup.quest).to.deep.equal(leaveResult); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..8944f06081 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -8,6 +8,7 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import Q from 'q'; let api = {}; @@ -65,4 +66,57 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/leave Leaves a quest + * @apiVersion 3.0.0 + * @apiName LeaveQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Empty Object + */ +api.leaveQuest = { + method: 'POST', + url: '/groups/:groupId/quests/leave', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (!(group.quest && group.quest.active)) { + throw new NotFound(res.t('noActiveQuestToLeave')); + } + + if (group.quest.leader === user._id) { + throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); + } + + if (!(group.quest.members && group.quest.members[user._id])) { + throw new NotAuthorized(res.t('notPartOfQuest')); + } + + group.quest.members[user._id] = false; + group.markModified('quest.members'); + + user.party.quest = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + }, +}; + export default api; From 6f606df211ff79c5c3a33dfc7465bef17631091a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 15:22:37 -0600 Subject: [PATCH 13/34] Added quest abort route and initial tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_abort.test.js | 78 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 52 +++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..2818fe6307 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "noActiveQuestToAbort": "There is no active quest to abort" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js new file mode 100644 index 0000000000..1ed6871307 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -0,0 +1,78 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/abort', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when quest is not active', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToAbort'), + }); + }); + + xit('returns an error when non quest leader attempts to abort', async () => { + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + await expect(member.post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotAbortQuest'), + }); + }); + + it('aborts a quest', async () => { + await member.update(userQuestUpdate); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + let abortResult = await leader.post(`/groups/${questingGroup._id}/quests/abort`); + let updatedMember = await member.get('/user'); + let updatedLeader = await leader.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(updatedMember.party.quest.key).to.be.null; + expect(updatedMember.party.quest.RSVPNeeded).to.be.false; + expect(updatedLeader.items.quests[PET_QUEST]).to.equal(1); + expect(updatedGroup.quest).to.deep.equal(abortResult); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..53e2274959 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -3,11 +3,13 @@ import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import Q from 'q'; let api = {}; @@ -65,4 +67,54 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/abort Abort a quest + * @apiVersion 3.0.0 + * @apiName AbortQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.abortQuest = { + method: 'POST', + url: '/groups/:groupId/quests/abort', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + // Abort a quest AFTER it has begun (see questCancel for BEFORE) + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort')); + + let memberUpdates = User.update( + {'party._id': groupId}, + { + $set: {'party.quest': Group.cleanQuestProgress()}, + $inc: {_v: 1}, + }, + {multi: true}, + ); + + let update = {$inc: {}}; + update.$inc[`items.quests.${group.quest.key}`] = 1; + let questLeaderUpdate = User.update({_id: group.quest.leader}, update).exec(); + + group.quest = {key: null, progress: {collect: {}}, leader: null, members: {}, extra: {}, active: false}; + group.markModified('quest'); + + let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); + + res.respond(200, groupSaved.quest); + }, +}; + export default api; From 18958bde5a5f76f4b6ae8969f0dce91046563f71 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 5 Feb 2016 11:06:40 +0100 Subject: [PATCH 14/34] review and fix group model methods --- website/src/models/group.js | 256 +++++++++++++++++------------------- 1 file changed, 119 insertions(+), 137 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index e416317a47..b9cf7acd98 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -104,9 +104,8 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { // TODO test schema.pre('remove', true, async function preRemoveGroup (next, done) { next(); - let group = this; try { - await group.removeGroupInvitations(); + await this.removeGroupInvitations(); done(); } catch (err) { done(err); @@ -257,7 +256,7 @@ schema.methods.sendChat = function sendChat (message, user) { this.chat.splice(200); // Kick off chat notifications in the background. // TODO refactor - let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; // TODO standardize this _v inc at the user level + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; if (this._id === 'habitrpg') { @@ -289,12 +288,12 @@ function _cleanQuestProgress (merge) { collect: {}, }, completed: null, - RSVPNeeded: false, // TODO absolutely change this cryptic name + RSVPNeeded: false, }; - if (merge) { // TODO why does it do 2 merges? + if (merge) { _.merge(clean, _.omit(merge, 'progress')); - _.merge(clean.progress, merge.progress); + if (merge.progress) _.merge(clean.progress, merge.progress); } return clean; @@ -303,6 +302,7 @@ function _cleanQuestProgress (merge) { schema.statics.cleanQuestProgress = _cleanQuestProgress; // Participants: Grant rewards & achievements, finish quest +// Returns the promise from update().exec() schema.methods.finishQuest = function finishQuest (quest) { let questK = quest.key; let updates = {$inc: {}, $set: {}}; @@ -344,48 +344,88 @@ schema.methods.finishQuest = function finishQuest (quest) { let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; this.quest = {}; this.markModified('quest'); - return User.update(q, updates, {multi: true}); + return User.update(q, updates, {multi: true}).exec(); }; function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -schema.statics.collectQuest = function collectQuest (user, progress) { - return this.findOne({ - type: 'party', - members: {$in: [user._id]}, - }).then(group => { - if (!_isOnQuest(user, progress, group)) return; - let quest = shared.content.quests[group.quest.key]; +// Returns a promise +schema.statics.collectQuest = async function collectQuest (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); - _.each(progress.collect, (v, k) => { - group.quest.progress.collect[k] += v; - }); + if (!_isOnQuest(user, progress, group)) return; + let quest = shared.content.quests[group.quest.key]; - let foundText = _.reduce(progress.collect, (m, v, k) => { - m.push(`${v} ${quest.collect[k].text('en')}`); - return m; - }, []); + _.each(progress.collect, (v, k) => { + group.quest.progress.collect[k] += v; + }); - foundText = foundText ? foundText.join(', ') : 'nothing'; - group.sendChat(`\`${user.profile.name} found ${foundText}.\``); - group.markModified('quest.progress.collect'); + let foundText = _.reduce(progress.collect, (m, v, k) => { + m.push(`${v} ${quest.collect[k].text('en')}`); + return m; + }, []); - // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { - return group.quest.progress.collect[k] < v.count; - })) return group.save(); + foundText = foundText ? foundText.join(', ') : 'nothing'; + group.sendChat(`\`${user.profile.name} found ${foundText}.\``); + group.markModified('quest.progress.collect'); - // TODO use promise - return group.finishQuest(quest) - .then(() => { - group.sendChat('`All items found! Party has received their rewards.`'); - return group.save(); - }); - }) - // TODO ok to catch even if we're returning a promise? - .catch(); + // Still needs completing + if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { + return group.quest.progress.collect[k] < v.count; + })) return group.save(); + + await group.finishQuest(quest); + group.sendChat('`All items found! Party has received their rewards.`'); + return group.save(); + // TODO cath? +}; + +schema.statics.bossQuest = async function bossQuest (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); + if (!_isOnQuest(user, progress, group)) return; + + let quest = shared.content.quests[group.quest.key]; + if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point, log? + + let down = progress.down * quest.boss.str; // multiply by boss strength + + group.quest.progress.hp -= progress.up; + // TODO Create a party preferred language option so emits like this can be localized + group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); + + // If boss has Rage, increment Rage as well + if (quest.boss.rage) { + group.quest.progress.rage += Math.abs(down); + if (group.quest.progress.rage >= quest.boss.rage.value) { + group.sendChat(quest.boss.rage.effect('en')); + group.quest.progress.rage = 0; + + // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; + if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; + } + } + + // Everyone takes damage + await User.update({ + _id: {$in: _.keys(group.quest.members)}, + }, { + $inc: {'stats.hp': down, _v: 1}, + }, {multi: true}).exec(); + + // Boss slain, finish quest + if (group.quest.progress.hp <= 0) { + group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); + + // Participants: Grant rewards & achievements, finish quest + await group.finishQuest(shared.content.quests[group.quest.key]); + return group.save(); + } + + return group.save(); + // TODO catch? }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` @@ -408,123 +448,65 @@ process.nextTick(() => { }); }); -// TODO promise? -schema.statics.tavernBoss = function tavernBoss (user, progress) { +// returns a promise +schema.statics.tavernBoss = async function tavernBoss (user, progress) { if (!progress) return; // hack: prevent crazy damage to world boss let dmg = Math.min(900, Math.abs(progress.up || 0)); let rage = -Math.min(900, Math.abs(progress.down || 0)); - this.findOne(tavernQ).exec() - .then(tavern => { - if (!(tavern && tavern.quest && tavern.quest.key)) return; + let tavern = await this.findOne(tavernQ).exec(); + if (!(tavern && tavern.quest && tavern.quest.key)) return; - let quest = shared.content.quests[tavern.quest.key]; + let quest = shared.content.quests[tavern.quest.key]; - if (tavern.quest.progress.hp <= 0) { - tavern.sendChat(quest.completionChat('en')); - tavern.finishQuest(quest, () => {}); - _.assign(tavernQuest, {extra: null}); - return tavern.save(); - } else { - // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, - // use those first - which allows us to update the boss on the go if things are too easy/hard. - if (!tavern.quest.extra) tavern.quest.extra = {}; - tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); - tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); + if (tavern.quest.progress.hp <= 0) { + tavern.sendChat(quest.completionChat('en')); + await tavern.finishQuest(quest); + _.assign(tavernQuest, {extra: null}); + return tavern.save(); + } else { + // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, + // use those first - which allows us to update the boss on the go if things are too easy/hard. + if (!tavern.quest.extra) tavern.quest.extra = {}; + tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); + tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); - if (tavern.quest.progress.rage >= quest.boss.rage.value) { - if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; + if (tavern.quest.progress.rage >= quest.boss.rage.value) { + if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; - let wd = tavern.quest.extra.worldDmg; - // Burnout attacks Ian, Seasonal Sorceress, tavern - let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary + let wd = tavern.quest.extra.worldDmg; + // Burnout attacks Ian, Seasonal Sorceress, tavern + let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary - if (!scene) { - tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); - tavern.quest.progress.rage = 0; // quest.boss.rage.value; - } else { - tavern.sendChat(quest.boss.rage[scene]('en')); - tavern.quest.extra.worldDmg[scene] = true; - tavern.quest.extra.worldDmg.recent = scene; - tavern.markModified('quest.extra.worldDmg'); - tavern.quest.progress.rage = 0; - if (quest.boss.rage.healing) { - tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; - } + if (!scene) { + tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); + tavern.quest.progress.rage = 0; // quest.boss.rage.value; + } else { + tavern.sendChat(quest.boss.rage[scene]('en')); + tavern.quest.extra.worldDmg[scene] = true; + tavern.quest.extra.worldDmg.recent = scene; + tavern.markModified('quest.extra.worldDmg'); + tavern.quest.progress.rage = 0; + if (quest.boss.rage.healing) { + tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; } } - - if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { - tavern.sendChat(quest.boss.desperation.text('en')); - tavern.quest.extra.desperate = true; - tavern.quest.extra.def = quest.boss.desperation.def; - tavern.quest.extra.str = quest.boss.desperation.str; - tavern.markModified('quest.extra'); - } - - _.assign(module.exports.tavernQuest, tavern.quest.toObject()); - return tavern.save(); - } - }) - .catch(err => { - throw err; - }); -}; - -schema.statics.bossQuest = function bossQuest (user, progress) { - return this.findOne({ - type: 'party', - members: {$in: [user._id]}, - }).exec() - .then(group => { - if (!_isOnQuest(user, progress, group)) return; - - let quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point - - let down = progress.down * quest.boss.str; // multiply by boss strength - - group.quest.progress.hp -= progress.up; - group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); // TODO Create a party preferred language option so emits like this can be localized - - // If boss has Rage, increment Rage as well - if (quest.boss.rage) { - group.quest.progress.rage += Math.abs(down); - if (group.quest.progress.rage >= quest.boss.rage.value) { - group.sendChat(quest.boss.rage.effect('en')); - group.quest.progress.rage = 0; - - // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage - if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; - if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; - } } - // Everyone takes damage - let promise = User.update({ - _id: {$in: _.keys(group.quest.members)}, - }, { - $inc: {'stats.hp': down, _v: 1}, - }, {multi: true}); - - // Boss slain, finish quest - if (group.quest.progress.hp <= 0) { - group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); - // Participants: Grant rewards & achievements, finish quest - - return promise - .then(() => group.finishQuest()) - .then(() => group.save()); + if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { + tavern.sendChat(quest.boss.desperation.text('en')); + tavern.quest.extra.desperate = true; + tavern.quest.extra.def = quest.boss.desperation.def; + tavern.quest.extra.str = quest.boss.desperation.str; + tavern.markModified('quest.extra'); } - return promise.then(() => group.save()); - }) - // TODO necessary to catch if we're returning a promise? - .catch(err => { - throw err; - }); + _.assign(tavernQuest, tavern.quest.toObject()); + return tavern.save(); + } + // TODO catch }; schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { From 03c9c2933f27f33e40616a07aa4eac73da0ee713 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 5 Feb 2016 09:28:43 -0600 Subject: [PATCH 15/34] feat(api-v3): finish first iteration of group.startQuest method --- test/api/v3/unit/models/group.test.js | 238 ++++++++++++++++++++------ website/src/models/group.js | 66 ++++--- 2 files changed, 225 insertions(+), 79 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index bdc49355f7..c5939b1424 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,42 +1,72 @@ import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; 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', () => { context('Instance Methods', () => { - let party; - - beforeEach(() => { - party = new Group({ - type: 'party', - }); - }); - 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', () => { - 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({ type: 'guild', }); - expect(() => { - guild.startQuest(); - }).to.throw('Must be a party to use this method'); + await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected; }); - it('throws an error if party is not on a quest', () => { - expect(() => { - party.startQuest(); - }).to.throw('Party does not have a pending quest'); + it('throws an error if party is not on a quest', async () => { + await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; }); - 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.active = true; - expect(() => { - party.startQuest(); - }).to.throw('Quest is already active'); + await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; }); }); @@ -44,19 +74,16 @@ describe('Group Model', () => { beforeEach(() => { party.quest.key = 'whale'; party.quest.active = false; - party.quest.leader = 'quest-leader'; - party.quest.members = { - 'quest-leader': true, - 'participating-member': true, - 'non-participating-member': false, - 'undecided-member': null, - }; - - sandbox.stub(User, 'update').returns({ exec: sandbox.spy() }); + party.quest.leader = questLeader._id; + party.quest.members = { }; + party.quest.members[questLeader._id] = true; + party.quest.members[participatingMember._id] = true; + party.quest.members[nonParticipatingMember._id] = false; + party.quest.members[undecidedMember._id] = null; }); it('activates quest', () => { - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.active).to.eql(true); }); @@ -65,7 +92,7 @@ describe('Group Model', () => { let bossQuest = questScrolls.whale; party.quest.key = bossQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); }); @@ -74,7 +101,7 @@ describe('Group Model', () => { let rageBossQuest = questScrolls.trex_undead; party.quest.key = rageBossQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.rage).to.eql(0); }); @@ -82,7 +109,7 @@ describe('Group Model', () => { it('sets up collection quest', () => { let collectionQuest = questScrolls.vice2; party.quest.key = collectionQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ lightCrystal: 0, @@ -92,7 +119,7 @@ describe('Group Model', () => { it('sets up collection quest with multiple items', () => { let collectionQuest = questScrolls.evilsanta2; party.quest.key = collectionQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ tracks: 0, @@ -100,34 +127,135 @@ describe('Group Model', () => { }); }); - it('updates quest object for participating members', () => { - party.startQuest(); + it('prunes non-participating members from quest members object', () => { + party.startQuest(participatingMember); - expect(User.update).to.be.calledTwice; - expect(User.update).to.not.be.calledWith({ _id: 'non-participating-member' }); - expect(User.update).to.not.be.calledWith({ _id: 'undecided-member' }); - expect(User.update).to.be.calledWith( - { _id: 'participating-member' }, - sinon.match({ $set: { 'party.quest.key': 'whale' }}), - ); - expect(User.update).to.be.calledWith( - { _id: 'quest-leader' }, - sinon.match({ $set: { 'party.quest.key': 'whale' }}), - ); + let expectedQuestMembers = {}; + expectedQuestMembers[questLeader._id] = true; + expectedQuestMembers[participatingMember._id] = true; + + expect(party.quest.members).to.eql(expectedQuestMembers); }); - it('removes quest scroll from quest leader', () => { - party.startQuest(); + it('applies updates to user object directly if user is participating', async () => { + await party.startQuest(participatingMember); - expect(User.update).to.be.calledWith( - { _id: 'quest-leader' }, - sinon.match({ $inc: { 'items.quests.whale': -1 }}), - ); + expect(participatingMember.party.quest.key).to.eql('whale'); + expect(participatingMember.party.quest.progress.down).to.eql(0); + 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); + }); }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index b9cf7acd98..1f18a311a7 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -11,6 +11,7 @@ import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import { BadRequest } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; 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 Q from 'q'; 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.quest.key) throw new BadRequest('Party does not have a pending quest'); 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 collected = {}; if (quest.collect) { @@ -193,38 +195,54 @@ schema.methods.startQuest = function startQuest () { this.quest.progress.collect = collected; } - _.each(this.quest.members, (participating, memberId) => { - if (!participating) return; + // Changes quest.members to only include participating members + // 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 = { - $set: { - // Do *not* reset party.quest.progress.up - // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 - 'party.quest.key': this.quest.key, - 'party.quest.progress.down': 0, - 'party.quest.collect': collected, - 'party.quest.completed': null, - }, - $inc: { _v: 1 }, - }; + let members = await User.find( + { _id: { $in: nonUserQuestMembers } }, + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications', + ).exec(); - if (this.quest.leader === memberId) { - update.$inc[`items.quests.${this.quest.key}`] = -1; + if (userIsParticipating) { + members.unshift(user); // put participating user at the beginning of the array + } + + _.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'); } - backgroundOperations.push(User.update({ _id: memberId }, update).exec()); + 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 // and not hold up the quest routes from resolving - // TODO: What here? - // Q.all(backgroundOperations).then(() => { - // }).catch(err => { - // TODO: How to handle errors? - // IE, user deleted their account? - // }); + Q.allSettled(backgroundOperations).catch(err => { + // TODO: what to do with err? + throw err; + }); }; export function chatDefaults (msg, user) { From 3b9c921c2f531a502e3334fcde4b58480dd6554e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 5 Feb 2016 10:25:49 -0600 Subject: [PATCH 16/34] feat(api-v3): First iteration of quest invite --- .../POST-groups_groupId_quests_invite.test.js | 59 ++++++++++++------- website/src/controllers/api-v3/quests.js | 52 ++++++++++++---- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 4311cf7911..973a51a1a5 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -1,6 +1,7 @@ import { createAndPopulateGroup, translate as t, + sleep, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; import { quests as questScrolls } from '../../../../../common/script/content'; @@ -122,8 +123,8 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { ]); }); - xit('adds quest details to group object', async () => { - await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('adds quest details to group object', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await questingGroup.sync(); @@ -131,47 +132,61 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { expect(quest.key).to.eql(PET_QUEST); expect(quest.active).to.eql(false); - expect(quest.leader).to.eql(false); - expect(quest.members).to.have.property(leader._id, null); + expect(quest.leader).to.eql(leader._id); + expect(quest.members).to.have.property(leader._id, true); expect(quest.members).to.have.property(member._id, null); expect(quest).to.have.property('progress'); }); - xit('adds quest details to user objects', async () => { - await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('adds quest details to user objects', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await sleep(0.1); // member updates happen in the background await Promise.all([ leader.sync(), member.sync(), ]); - [leader, member].forEach((user) => { - let quest = user.party.quest; - - expect(quest.key).to.eql(PET_QUEST); - expect(quest.active).to.eql(false); - expect(quest.leader).to.eql(false); - expect(quest.members).to.have.property(leader._id, null); - expect(quest.members).to.have.property(member._id, null); - expect(quest).to.have.property('progress'); - }); + expect(leader.party.quest.key).to.eql(PET_QUEST); + expect(member.party.quest.key).to.eql(PET_QUEST); + expect(leader.party.quest.RSVPNeeded).to.eql(false); + expect(member.party.quest.RSVPNeeded).to.eql(true); }); - xit('sends back the quest object', async () => { - let inviteResponse = await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('sends back the quest object', async () => { + let inviteResponse = await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); expect(inviteResponse.key).to.eql(PET_QUEST); expect(inviteResponse.active).to.eql(false); - expect(inviteResponse.leader).to.eql(false); - expect(inviteResponse.members).to.have.property(leader._id, null); + expect(inviteResponse.leader).to.eql(leader._id); + expect(inviteResponse.members).to.have.property(leader._id, true); expect(inviteResponse.members).to.have.property(member._id, null); expect(inviteResponse).to.have.property('progress'); }); - xit('allows non-leader party members to send invites', async () => { - let inviteResponse = await member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('allows non-party-leader party members to send invites', async () => { + let inviteResponse = await member.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await questingGroup.sync(); expect(inviteResponse.key).to.eql(PET_QUEST); + expect(questingGroup.quest.key).to.eql(PET_QUEST); + }); + + it('starts quest automatically if user is in a solo party', async () => { + let leaderDetails = { balance: 10 }; + leaderDetails[`items.quests.${PET_QUEST}`] = 1; + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + leaderDetails, + }); + + await groupLeader.post(`/groups/${group._id}/quests/invite/${PET_QUEST}`); + + await group.sync(); + + expect(group.quest.active).to.eql(true); }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..77cec58d6a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -1,14 +1,25 @@ +import _ from 'lodash'; +import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { + model as User, +} from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +function canStartQuestAutomatically (group) { + // If all members are either true (accepted) or false (rejected) return true + // If any member is null/undefined (undecided) return false + return _.every(group.quest.members, Boolean); +} + let api = {}; /** @@ -44,24 +55,43 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + let members = await User.find({ 'party._id': group._id }, 'auth.facebook auth.local preferences.emailNotifications').exec(); + let backgroundOperations = []; + group.markModified('quest'); group.quest.key = questKey; group.quest.leader = user._id; group.quest.members = {}; + group.quest.members[user._id] = true; - // let memberUpdate = { - // '$set': { - // 'party.quest.key': questKey, - // 'party.quest.progress.down': 0, - // 'party.quest.completed': null, - // }, - // }; + user.party.quest.RSVPNeeded = false; + user.party.quest.key = questKey; - // TODO collect members of party - // TODO Logic for quest invite and send back quest object + _.each(members, (member) => { + if (member._id !== user._id) { + group.quest.members[member._id] = null; + member.party.quest.RSVPNeeded = true; + member.party.quest.key = questKey; + // TODO: Send Quest invite email + backgroundOperations.push(member.save()); + } + }); - await group.save(); - res.respond(200, {}); + if (canStartQuestAutomatically(group)) { + group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + Q.allSettled(backgroundOperations).catch(err => { + // TODO what to do about errors in background ops + throw err; + }); }, }; From 35e6274cd62a3e2ef04d0d514726699b86324869 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 22:18:57 +0100 Subject: [PATCH 17/34] Move cron and preening code to server, add support for quests in cron --- common/script/api-v3/cron.js | 264 -------------- common/script/cron.js | 1 + website/src/controllers/api-v3/tasks.js | 2 +- .../src/libs}/api-v3/preening.js | 0 website/src/middlewares/api-v3/cron.js | 323 ++++++++++++++++-- website/src/models/group.js | 14 +- 6 files changed, 297 insertions(+), 307 deletions(-) delete mode 100644 common/script/api-v3/cron.js rename {common/script => website/src/libs}/api-v3/preening.js (100%) diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js deleted file mode 100644 index 6f8626c7d8..0000000000 --- a/common/script/api-v3/cron.js +++ /dev/null @@ -1,264 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; -import scoreTask from './scoreTask'; -import { preenUserHistory } from './preening'; -import common from '../../'; -import { - shouldDo, -} from '../cron'; - -let clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, -}; - -// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -// For incomplete Dailys, deduct experience -// Make sure to run this function once in a while as server will not take care of overnight calculations. -// And you have to run it every time client connects. -export default function cron (options = {}) { - let {user, tasksByType, analytics, now, daysMissed} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - // Reset the lastDrop count to zero - if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; - - // "Perfect Day" achievement for perfect-days - let perfect = true; - - // end-of-month perks for subscribers - let plan = user.purchased.plan; - if (user.isSubscribed()) { - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; // reset gem-cap - plan.dateUpdated = now; - // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks - // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 - plan.consecutive.count++; - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { // every 3 months - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) - } - } - - // If user cancelled subscription, we give them until 30day's end until it terminates - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); - - user.markModified('purchased.plan'); - } - } - - // User is resting at the inn. - // On cron, buffs are cleared and all dailies are reset without performing damage - if (user.preferences.sleep === true) { - user.stats.buffs = _.cloneDeep(clearBuffs); - - tasksByType.dailys.forEach((daily) => { - let completed = daily.completed; - let thatDay = moment(now).subtract({days: 1}); - - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - daily.checklist.forEach(box => box.completed = false); - } - daily.completed = false; - }); - - return; - } - - let multiDaysCountAsOneDay = true; - // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. - // When site-wide difficulty settings are introduced, this can be a user preference option. - - // Tally each task - let todoTally = 0; - - tasksByType.todos.forEach(task => { // make uncompleted todos redder - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - // TODO pass req for analytics? - }); - - todoTally += task.value; - }); - - let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were cun-hecked? - if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; - - tasksByType.dailys.forEach((task) => { - let completed = task.completed; - // Deduct points for missed Daily tasks - let EvadeTask = 0; - let scheduleMisses = daysMissed; - - if (completed) { - dailyChecked += 1; - } else { - // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - - for (let i = 0; i < daysMissed; i++) { - let thatDay = moment(now).subtract({days: i + 1}); - - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) break; - } - } - - if (scheduleMisses > EvadeTask) { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. - } - } - - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - task.completed = false; - - if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed - } - }); - - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; - } - }); - - // Finished tallying - user.history.todos.push({date: now, value: todoTally}); - - // tally experience - let expTally = user.stats.exp; - let lvl = 0; // iterator - while (lvl < user.stats.lvl - 1) { - lvl++; - expTally += common.tnl(lvl); - } - user.history.exp.push({date: now, value: expTally}); - - // preen user history so that it doesn't become a performance problem - // also for subscribed users but differentyly - // premium subscribers can keep their full history. - preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); - - if (perfect) { - user.achievements.perfect++; - let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); - user.stats.buffs = { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false, - }; - } else { - user.stats.buffs = _.cloneDeep(clearBuffs); - } - - // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit - // Adjust for fraction of dailies completed - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - - // After all is said and done, progress up user's effect on quest, return those values & reset the user's - let progress = user.party.quest.progress; - let _progress = _.cloneDeep(progress); - _.merge(progress, {down: 0, up: 0}); - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - // Clean PMs - keep 200 for subscribers and 50 for free users - // TODO tests - let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too - let numberOfPMs = Object.keys(user.inbox.messages).length; - if (Object.keys(user.inbox.messages).length > maxPMs) { - _(user.inbox.messages) - .sortBy('timestamp') - .takeRight(numberOfPMs - maxPMs) - .each(pm => { - user.inbox.messages[pm.id] = undefined; - }).value(); - - user.markModified('inbox.messages'); - } - - // Analytics - user.flags.cronCount++; - analytics.track('Cron', { - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user, // TODO is it really necessary passing the whole user object? - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down, - }); - - return _progress; -} diff --git a/common/script/cron.js b/common/script/cron.js index 639bbf6b65..7eca56cb54 100644 --- a/common/script/cron.js +++ b/common/script/cron.js @@ -1,3 +1,4 @@ +// TODO what can be moved to /website/src? /* ------------------------------------------------------ Cron and time / day functions diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0197d38dbb..e0c89b66ad 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -14,7 +14,7 @@ import Q from 'q'; import _ from 'lodash'; import moment from 'moment'; import scoreTask from '../../../../common/script/api-v3/scoreTask'; -import { preenHistory } from '../../../../common/script/api-v3/preening'; +import { preenHistory } from '../../libs/api-v3/preening'; let api = {}; diff --git a/common/script/api-v3/preening.js b/website/src/libs/api-v3/preening.js similarity index 100% rename from common/script/api-v3/preening.js rename to website/src/libs/api-v3/preening.js diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 288452e670..d57bfb28f8 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -2,15 +2,274 @@ import _ from 'lodash'; import moment from 'moment'; import { daysSince, + shouldDo, } from '../../../../common/script/cron'; -import cron from '../../../../common/script/api-v3/cron'; import common from '../../../../common'; import Task from '../../models/task'; import Q from 'q'; -// import Group from '../../models/group'; +import Group from '../../models/group'; +import User from '../../models/user'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import { preenUserHistory } from '../../libs/api-v3/preening'; + +let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, +}; + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export function cron (options = {}) { + let {user, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach(task => { // make uncompleted todos redder + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + todoTally += task.value; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos.push({date: now, value: todoTally}); + + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(clearBuffs); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + // Clean PMs - keep 200 for subscribers and 50 for free users + // TODO tests + let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too + let numberOfPMs = Object.keys(user.inbox.messages).length; + if (Object.keys(user.inbox.messages).length > maxPMs) { + _(user.inbox.messages) + .sortBy('timestamp') + .takeRight(numberOfPMs - maxPMs) + .each(pm => { + user.inbox.messages[pm.id] = undefined; + }).value(); + + user.markModified('inbox.messages'); + } + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} // TODO check that it's used everywhere -export default function cronMiddleware (req, res, next) { +export default async function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -32,7 +291,7 @@ export default function cronMiddleware (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - cron({user, tasksByType, now, daysMissed, analytics}); + let progress = cron({user, tasksByType, now, daysMissed, analytics}); // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? @@ -44,44 +303,36 @@ export default function cronMiddleware (req, res, next) { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), }, 'challenge.id': {$exists: false}, - }).exec(); // TODO catch error or at least log it + }).exec(); // TODO catch error or at least log it, wait before returning? let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; // if (ranCron) res.locals.wasModified = true; // TODO remove? if (!ranCron) return next(); - // TODO Group.tavernBoss(user, progress); - if (!quest || true /* TODO remove */) { - // Save user and tasks - let toSave = [user.save()]; - tasks.forEach(task => { - if (task.isModified) toSave.push(task.save()); + + // Group.tavernBoss(user, progress); + + // Save user and tasks + let toSave = [user.save()]; + tasks.forEach(task => { + if (task.isModified) toSave.push(task.save()); + }); + Q.all(toSave) + .then(saved => { + user = res.locals.user = saved[0]; + if (!quest) return; + + // If user is on a quest, roll for boss & player, or handle collections + let questType = quest.boss ? 'boss' : 'collect'; + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + return Group[`${questType}Quest`](user, progress) + .then(() => User.findById(user._id).exec()) // fetch the updated user... + .then(updatedUser => { + res.locals.user = updatedUser; }); - - return Q.all(toSave).then(() => next()).catch(next); - } - - // If user is on a quest, roll for boss & player, or handle collections - // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? - // TODO do - /* async.waterfall([ - function(cb){ - user.save(cb); // make sure to save the cron effects - }, - function(saved, count, cb){ - var type = quest.boss ? 'boss' : 'collect'; - Group[type+'Quest'](user,progress,cb); - }, - function(){ - var cb = arguments[arguments.length-1]; - // User has been updated in boss-grapple, reload - User.findById(user._id, cb); - } - ], function(err, saved) { - res.locals.user = saved; - next(err,saved); - user = progress = quest = null; - });*/ + }) + .then(() => next()) + .catch(next); }); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 1f18a311a7..4d991f2e6a 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; -import { BadRequest } from '../libs/api-v3/errors'; +import { InternalServerError } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; @@ -171,9 +171,9 @@ schema.methods.isMember = function isGroupMember (user) { }; schema.methods.startQuest = async function startQuest (user) { - 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.active) throw new BadRequest('Quest is already active'); + if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); + if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); + if (this.quest.active) throw new InternalServerError('Quest is already active'); let userIsParticipating = this.quest.members[user._id]; let quest = questScrolls[this.quest.key]; @@ -397,7 +397,6 @@ schema.statics.collectQuest = async function collectQuest (user, progress) { await group.finishQuest(quest); group.sendChat('`All items found! Party has received their rewards.`'); return group.save(); - // TODO cath? }; schema.statics.bossQuest = async function bossQuest (user, progress) { @@ -432,6 +431,10 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { }, { $inc: {'stats.hp': down, _v: 1}, }, {multi: true}).exec(); + // Apply changes the currently cronning user locally so we don't have to reload it to get the updated state + // TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167 + // must be notModified or otherwise could overwrite future changes + // if (down) user.stats.hp += down; // Boss slain, finish quest if (group.quest.progress.hp <= 0) { @@ -443,7 +446,6 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { } return group.save(); - // TODO catch? }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` From 2f3ae32a0e0e9f2fb12d31d17eb91eaea1437d80 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 23:30:49 +0100 Subject: [PATCH 18/34] finalize invite to quest route and startQuest method --- .../POST-groups_groupId_quests_accept.test.js | 0 .../POST-groups_groupId_quests_invite.test.js | 0 test/api/v3/unit/models/group.test.js | 2 +- website/src/controllers/api-v3/quests.js | 55 +++++++++--- website/src/libs/api-v3/analyticsService.js | 1 + .../src/libs/api-v3/collectionManipulators.js | 7 +- website/src/models/group.js | 87 +++++++++++-------- 7 files changed, 98 insertions(+), 54 deletions(-) rename test/api/v3/integration/{groups => quests}/POST-groups_groupId_quests_accept.test.js (100%) rename test/api/v3/integration/{groups => quests}/POST-groups_groupId_quests_invite.test.js (100%) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index c5939b1424..b86cc7a1d9 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -4,7 +4,7 @@ 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.skip('Group Model', () => { context('Instance Methods', () => { describe('#startQuest', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 77cec58d6a..05f4a69223 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; +import analytics from '../../libs/api-v3/analyticsService'; import { model as Group, } from '../../models/group'; @@ -12,6 +13,10 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; +import { + getUserInfo, + sendTxn as sendTxnEmail, +} from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; function canStartQuestAutomatically (group) { @@ -55,8 +60,11 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - let members = await User.find({ 'party._id': group._id }, 'auth.facebook auth.local preferences.emailNotifications').exec(); - let backgroundOperations = []; + let members = await User.find({ + 'party._id': group._id, + _id: {$ne: user._id}, + }).select('auth.facebook auth.local preferences.emailNotifications profile.name') + .exec(); group.markModified('quest'); group.quest.key = questKey; @@ -67,18 +75,22 @@ api.inviteToQuest = { user.party.quest.RSVPNeeded = false; user.party.quest.key = questKey; + await User.update({ + 'party._id': group._id, + _id: {$ne: user._id}, + }, { + $set: { + 'party.quest.RSVPNeeded': true, + 'party.quest.key': questKey, + }, + }, {multi: true}).exec(); + _.each(members, (member) => { - if (member._id !== user._id) { - group.quest.members[member._id] = null; - member.party.quest.RSVPNeeded = true; - member.party.quest.key = questKey; - // TODO: Send Quest invite email - backgroundOperations.push(member.save()); - } + group.quest.members[member._id] = null; }); if (canStartQuestAutomatically(group)) { - group.startQuest(user); + await group.startQuest(user); } let [savedGroup] = await Q.all([ @@ -88,9 +100,26 @@ api.inviteToQuest = { res.respond(200, savedGroup.quest); - Q.allSettled(backgroundOperations).catch(err => { - // TODO what to do about errors in background ops - throw err; + // send out invites + let inviterVars = getUserInfo(user, ['name', 'email']); + let membersToEmail = members.filter(member => { + return member.preferences.emailNotifications.invitedQuest !== false; + }); + sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [ + {name: 'QUEST_NAME', content: quest.text()}, + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email}, + {name: 'PARTY_URL', content: '/#/options/groups/party'}, + ]); + + // track that the inviting user has accepted the quest + analytics.track('quest', { + category: 'behavior', + owner: true, + response: 'accept', + gaLabel: 'accept', + questName: questKey, + uuid: user._id, }); }, }; diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index ae9ad6f60f..2c435da9dd 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -210,6 +210,7 @@ let _sendPurchaseDataToGoogle = (data) => { }); }; +// TODO log errors... function track (eventType, data) { return Q.all([ _sendDataToAmplitude(eventType, data), diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js index ce40085552..95d3981601 100644 --- a/website/src/libs/api-v3/collectionManipulators.js +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -1,9 +1,12 @@ -import { findIndex } from 'lodash'; +import { + findIndex, + isPlainObject, +} from 'lodash'; export function removeFromArray (array, element) { let elementIndex; - if (typeof element === 'object') { + if (isPlainObject(element)) { elementIndex = findIndex(array, element); } else { elementIndex = array.indexOf(element); diff --git a/website/src/models/group.js b/website/src/models/group.js index 4d991f2e6a..f8764d546e 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -171,6 +171,7 @@ schema.methods.isMember = function isGroupMember (user) { }; schema.methods.startQuest = async function startQuest (user) { + // not using i18n strings because these errors are meant for devs who forgot to pass some parameters if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); if (this.quest.active) throw new InternalServerError('Quest is already active'); @@ -184,8 +185,6 @@ schema.methods.startQuest = async function startQuest (user) { }); } - let backgroundOperations = []; - this.markModified('quest'); this.quest.active = true; if (quest.boss) { @@ -200,48 +199,60 @@ schema.methods.startQuest = async function startQuest (user) { // 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 members = await User.find( - { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications', - ).exec(); + let nonUserQuestMembers = _.keys(this.quest.members); + removeFromArray(nonUserQuestMembers, user._id); if (userIsParticipating) { - members.unshift(user); // put participating user at the beginning of the array + user.party.quest.key = this.quest.key; + user.party.quest.progress.down = 0; + user.party.quest.collect = collected; + user.party.quest.completed = null; + user.markModified('party.quest'); } - _.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'); + // Remove the quest from the quest leader items (if he's the current user) + if (this.quest.leader === user._id) { + user.items.quests[this.quest.key] -= 1; + user.markModified('items.quests'); + } else { // another user is starting the quest, update the leader separately + await User.update({_id: this.quest.leader}, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + $inc: { + [`items.quests${this.quest.key}`]: -1, + }, + }).exec(); + removeFromArray(nonUserQuestMembers, this.quest.leader); + } - if (this.quest.leader === member._id) { - member.items.quests[this.quest.key] -= 1; - member.markModified('items.quests'); - } + // update the remaining users + await User.update({ + _id: { $in: nonUserQuestMembers }, + }, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + }, { multi: true }).exec(); - if (member._id !== user._id) { - backgroundOperations.push(member.save()); - } - }); - - 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 - // and not hold up the quest routes from resolving - Q.allSettled(backgroundOperations).catch(err => { - // TODO: what to do with err? - throw err; + // send notifications in the background without blocking + User.find( + { _id: { $in: nonUserQuestMembers } }, + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications profile.name', + ).exec().then(membersToEmail => { + membersToEmail = _.filter(membersToEmail, (member) => { + return member.preferences.emailNotifications.questStarted !== false && + member._id !== user._id; + }); + sendTxnEmail(membersToEmail, 'quest-started', [ + { name: 'PARTY_URL', content: '/#/options/groups/party' }, + ]); }); }; From 0b336d8012c04bd268c209487613608c7e972f80 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 23:40:03 +0100 Subject: [PATCH 19/34] add missing dot in field path --- website/src/models/group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index f8764d546e..23524646d1 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -223,7 +223,7 @@ schema.methods.startQuest = async function startQuest (user) { 'party.quest.completed': null, }, $inc: { - [`items.quests${this.quest.key}`]: -1, + [`items.quests.${this.quest.key}`]: -1, }, }).exec(); removeFromArray(nonUserQuestMembers, this.quest.leader); From b6ed2f8c443c22f4ef74971f428c850a0694d05c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 10 Feb 2016 08:21:36 -0600 Subject: [PATCH 20/34] refactor: Move sleep function to seprate file --- test/helpers/api-integration/v3/index.js | 9 +-------- test/helpers/api-unit.helper.js | 2 ++ test/helpers/sleep.js | 7 +++++++ 3 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 test/helpers/sleep.js diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 4ee2e6cfbe..6ae15d7ca6 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -8,11 +8,4 @@ export { requester }; export { translate } from '../translate'; export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; - -export async function sleep (seconds) { - let milliseconds = seconds * 1000; - - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} +export { sleep } from '../../sleep'; diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 3370bd0887..1a590f8a3a 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -10,6 +10,8 @@ afterEach((done) => { mongoose.connection.db.dropDatabase(done); }); +export { sleep } from './sleep'; + export function generateUser (options = {}) { return new User(options).toObject(); } diff --git a/test/helpers/sleep.js b/test/helpers/sleep.js new file mode 100644 index 0000000000..f8dd9ab165 --- /dev/null +++ b/test/helpers/sleep.js @@ -0,0 +1,7 @@ +export async function sleep (seconds) { + let milliseconds = seconds * 1000; + + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} From 8ff4cfe709668aeed1a3b0846b84b5b28fc3e5ef Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 10 Feb 2016 08:22:08 -0600 Subject: [PATCH 21/34] tests: Finish start quest --- test/api/v3/unit/models/group.test.js | 76 ++++++++++++++++++++------- website/src/models/group.js | 9 +--- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index b86cc7a1d9..ef76e7b984 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,17 +1,16 @@ +import { sleep } from '../../../../helpers/api-unit.helper'; import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; import { quests as questScrolls } from '../../../../../common/script/content'; import * as email from '../../../../../website/src/libs/api-v3/email'; -import Q from 'q'; -describe.skip('Group Model', () => { +describe('Group Model', () => { context('Instance Methods', () => { describe('#startQuest', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; beforeEach(async () => { sandbox.stub(email, 'sendTxn'); - sandbox.spy(Q, 'allSettled'); party = new Group({ name: 'test party', @@ -173,14 +172,6 @@ describe.skip('Group Model', () => { 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; @@ -191,6 +182,8 @@ describe.skip('Group Model', () => { await party.startQuest(nonParticipatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -212,6 +205,8 @@ describe.skip('Group Model', () => { await party.startQuest(nonParticipatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -231,6 +226,8 @@ describe.skip('Group Model', () => { await party.startQuest(participatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -240,21 +237,62 @@ describe.skip('Group Model', () => { expect(memberIds).to.include(questLeader._id); }); - it('adds participating members to background save operations', async () => { + it('updates participting members (not including user)', async () => { + sandbox.spy(User, 'update'); + await party.startQuest(nonParticipatingMember); - expect(Q.allSettled).to.be.calledOnce; + let members = [questLeader._id, participatingMember._id]; - let savePromises = Q.allSettled.args[0][0]; - expect(savePromises).to.have.a.lengthOf(2); + expect(User.update).to.be.calledWith( + { _id: { $in: members } }, + { + $set: { + 'party.quest.key': 'whale', + 'party.quest.progress.down': 0, + 'party.quest.collect': {}, + 'party.quest.completed': null, + }, + } + ); }); - it('does not include initiating user in background save operations', async () => { + it('updates non-user quest leader and decrements quest scroll', async () => { + sandbox.spy(User, 'update'); + await party.startQuest(participatingMember); - expect(Q.allSettled).to.be.calledOnce; - let savePromises = Q.allSettled.args[0][0]; - expect(savePromises).to.have.a.lengthOf(1); + expect(User.update).to.be.calledWith( + { _id: questLeader._id }, + { + $inc: { + 'items.quests.whale': -1, + }, + } + ); + }); + + it('modifies the participating initiating user directly', async () => { + await party.startQuest(participatingMember); + + let userQuest = participatingMember.party.quest; + + expect(userQuest.key).to.eql('whale'); + expect(userQuest.progress.down).to.eql(0); + expect(userQuest.collect).to.eql({}); + expect(userQuest.completed).to.eql(null); + }); + + it('does not modify user if not participating', async () => { + await party.startQuest(nonParticipatingMember); + + expect(nonParticipatingMember.party.quest.key).to.not.eql('whale'); + }); + + it('removes the quest directly if initiating user is the quest leader', async () => { + await party.startQuest(questLeader); + + expect(questLeader.items.quests.whale).to.eql(0); }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index 23524646d1..4046b7d5ee 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -210,23 +210,16 @@ schema.methods.startQuest = async function startQuest (user) { user.markModified('party.quest'); } - // Remove the quest from the quest leader items (if he's the current user) + // Remove the quest from the quest leader items (if they are the current user) if (this.quest.leader === user._id) { user.items.quests[this.quest.key] -= 1; user.markModified('items.quests'); } else { // another user is starting the quest, update the leader separately await User.update({_id: this.quest.leader}, { - $set: { - 'party.quest.key': this.quest.key, - 'party.quest.progress.down': 0, - 'party.quest.collect': collected, - 'party.quest.completed': null, - }, $inc: { [`items.quests.${this.quest.key}`]: -1, }, }).exec(); - removeFromArray(nonUserQuestMembers, this.quest.leader); } // update the remaining users From fa14464a0c1e229eda8b4a364e8000da557b06ec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 17:07:34 +0100 Subject: [PATCH 22/34] add accept quest route --- website/src/controllers/api-v3/quests.js | 56 ++++++++++++- website/src/models/group.js | 102 +++++++++++------------ 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 05f4a69223..13584b139c 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -35,7 +35,7 @@ let api = {}; * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.inviteToQuest = { method: 'POST', @@ -124,4 +124,58 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/accept Accept a pending quest + * @apiVersion 3.0.0 + * @apiName AcceptQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} quest Quest Object + */ +api.acceptQuest = { + method: 'POST', + url: '/groups/:groupId/quests/accept', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); + + group.quest.members[user._id] = true; + user.party.quest.RSVPNeeded = false; + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + // track that an user has accepted the quest + analytics.track('quest', { + category: 'behavior', + owner: false, + response: 'accept', + gaLabel: 'accept', + questName: group.quest.key, + uuid: user._id, + }); + }, +}; + export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 4046b7d5ee..ff7a22b63c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -170,6 +170,57 @@ schema.methods.isMember = function isGroupMember (user) { } }; +export function chatDefaults (msg, user) { + let message = { + id: shared.uuid(), + text: msg, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + + if (user) { + _.defaults(message, { + uuid: user._id, + contributor: user.contributor && user.contributor.toObject(), + backer: user.backer && user.backer.toObject(), + user: user.profile.name, + }); + } else { + message.uuid = 'system'; + } + + return message; +} + +schema.methods.sendChat = function sendChat (message, user) { + this.chat.unshift(chatDefaults(message, user)); + this.chat.splice(200); + + // Kick off chat notifications in the background. // TODO refactor + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; + lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; + + if (this._id === 'habitrpg') { + // TODO For Tavern, only notify them if their name was mentioned + // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? + // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); + } else { + let query = {}; + + if (this.type === 'party') { + query['party._id'] = this._id; + } else { + query.guilds = this._id; + } + + query._id = { $ne: user ? user._id : ''}; + + User.update(query, lastSeenUpdate, {multi: true}).exec(); + } +}; + schema.methods.startQuest = async function startQuest (user) { // not using i18n strings because these errors are meant for devs who forgot to pass some parameters if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); @@ -249,57 +300,6 @@ schema.methods.startQuest = async function startQuest (user) { }); }; -export function chatDefaults (msg, user) { - let message = { - id: shared.uuid(), - text: msg, - timestamp: Number(new Date()), - likes: {}, - flags: {}, - flagCount: 0, - }; - - if (user) { - _.defaults(message, { - uuid: user._id, - contributor: user.contributor && user.contributor.toObject(), - backer: user.backer && user.backer.toObject(), - user: user.profile.name, - }); - } else { - message.uuid = 'system'; - } - - return message; -} - -schema.methods.sendChat = function sendChat (message, user) { - this.chat.unshift(chatDefaults(message, user)); - this.chat.splice(200); - - // Kick off chat notifications in the background. // TODO refactor - let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; - lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; - - if (this._id === 'habitrpg') { - // TODO For Tavern, only notify them if their name was mentioned - // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? - // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); - } else { - let query = {}; - - if (this.type === 'party') { - query['party._id'] = this._id; - } else { - query.guilds = this._id; - } - - query._id = { $ne: user ? user._id : ''}; - - User.update(query, lastSeenUpdate, {multi: true}).exec(); - } -}; - function _cleanQuestProgress (merge) { // TODO clone? (also in sendChat message) let clean = { From a2af6c390bea1e701002f7f3a12c992e132e36a3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 22:27:59 +0100 Subject: [PATCH 23/34] fix quest accept route and add tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupId_quests_accept.test.js | 85 ++++++++++++++++--- website/src/controllers/api-v3/quests.js | 4 + 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..a2495529e7 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "questAlreadyAccepted": "You already accepted the quest invitation." } diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js index e218d7e3d9..665a185279 100644 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js @@ -1,27 +1,37 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; -describe.skip('POST /groups/:groupId/quests/accept', () => { +describe('POST /groups/:groupId/quests/accept', () => { + const PET_QUEST = 'whale'; + let questingGroup; let leader; - let member; + let partyMembers; + let user; beforeEach(async () => { + user = await generateUser(); + let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); questingGroup = group; leader = groupLeader; - member = members[0]; + partyMembers = members; + + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); }); context('failure conditions', () => { it('does not accept quest without an invite', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`, {})) + await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -30,25 +40,80 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { }); it('does not accept quest for a group in which user is not a member', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) + await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', - message: t('questInviteNotFound'), + message: t('groupNotFound'), + }); + }); + + it('does not accept quest for a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('does not accept invite twice', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyAccepted'), + }); + }); + + it('does not accept invite for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), }); }); }); + context('successfully accepting a quest invitation', () => { - it('joins a quest from an invitation', () => { + it('joins a quest from an invitation', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await Promise.all([partyMembers[0].sync(), questingGroup.sync()]); + expect(leader.party.quest.RSVPNeeded).to.equal(false); + expect(questingGroup.quest.members[partyMembers[0]._id]); }); - it('does not begin the quest if pending invitations remain', () => { + it('does not begin the quest if pending invitations remain', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await questingGroup.sync(); + expect(questingGroup.quest.active).to.equal(false); }); - it('begins the quest if accepting the last pending invite', () => { + it('begins the quest if accepting the last pending invite', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + await questingGroup.sync(); + expect(questingGroup.quest.active).to.equal(true); }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 13584b139c..162954b7dd 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -12,6 +12,7 @@ import { import { NotFound, NotAuthorized, + BadRequest, } from '../../libs/api-v3/errors'; import { getUserInfo, @@ -151,7 +152,10 @@ api.acceptQuest = { if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); + group.markModified('quest'); group.quest.members[user._id] = true; user.party.quest.RSVPNeeded = false; From 879506b38a0662b29e4ccc60251758b0d73f886c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 12:50:28 +0100 Subject: [PATCH 24/34] update reject quest route --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_reject.test.js | 125 +++++++++++++----- website/src/controllers/api-v3/quests.js | 40 +++--- website/src/models/group.js | 1 - 4 files changed, 117 insertions(+), 52 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a2495529e7..62c4df4d8b 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -74,5 +74,6 @@ "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", - "questAlreadyAccepted": "You already accepted the quest invitation." + "questAlreadyAccepted": "You already accepted the quest invitation.", + "questAlreadyRejected": "You already rejected the quest invitation." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index a222778948..84e364f770 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -1,36 +1,37 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/invite/:questKey', () => { +describe('POST /groups/:groupId/quests/reject', () => { let questingGroup; - let member; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; + let partyMembers; + let user; + let leader; - before(async () => { - let { group, members } = await createAndPopulateGroup({ + const PET_QUEST = 'whale'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); context('failure conditions', () => { it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/reject`)) + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,35 +39,97 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - it('returns an error when group is not on a quest', async () => { - await member.update(userQuestUpdate); + it('does not accept quest for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); - await expect(member.post(`/groups/${questingGroup._id}/quests/reject`)) + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('returns an error when group is not on a quest', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('questInvitationDoesNotExist'), }); }); + + it('return an error when an user rejects an invite twice', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyRejected'), + }); + }); + + it('return an error when an user rejects an invite already accepted', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyAccepted'), + }); + }); + + it('does not reject invite for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + }); }); context('successfully quest rejection', () => { it('rejects a quest invitation', async () => { - await member.update(userQuestUpdate); - await questingGroup.update({'quest.key': PET_QUEST}); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + await partyMembers[0].sync(); + await questingGroup.sync(); - let rejectResult = await member.post(`/groups/${questingGroup._id}/quests/reject`); - let userWithRejectInvitation = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + expect(partyMembers[0].party.quest.key).to.be.null; + expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; + expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; + expect(questingGroup.quest.active).to.be.false; + }); - expect(userWithRejectInvitation.party.quest.key).to.be.null; - expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members[member._id]).to.be.false; - expect(updatedGroup.quest).to.deep.equal(rejectResult); + it('starts the quest when the last user reject', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.be.true; }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 0f1f991b7b..b4a789b392 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -19,13 +19,11 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import { track } from '../../libs/api-v3/analyticsService'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true // If any member is null/undefined (undecided) return false - return _.every(group.quest.members, Boolean); + return _.every(group.quest.members, _.isBoolean); } let api = {}; @@ -191,9 +189,8 @@ api.acceptQuest = { * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') - * @apiParam {string} questKey The quest _id * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.rejectQuest = { method: 'POST', @@ -209,32 +206,37 @@ api.rejectQuest = { let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - - let analyticsData = { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }; - track('quest', analyticsData); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); + if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected')); group.quest.members[user._id] = false; group.markModified('quest.members'); - user.party.quest.RSVPNeeded = false; - user.party.quest.key = null; + user.party.quest = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } let [savedGroup] = await Q.all([ group.save(), user.save(), ]); - // questStart(req,res,next); - res.respond(200, savedGroup.quest); + + analytics.track('quest', { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }); }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index ff7a22b63c..bb2c08d436 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -301,7 +301,6 @@ schema.methods.startQuest = async function startQuest (user) { }; function _cleanQuestProgress (merge) { - // TODO clone? (also in sendChat message) let clean = { key: null, progress: { From 6c1950972b8b5a3b61784959ba6402449096ab8f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 13:19:46 +0100 Subject: [PATCH 25/34] update quest cancel routes --- common/locales/en/api-v3.json | 4 +- .../POST-groups_groupid_quests_cancel.test.js | 139 +++++++++++++----- website/src/controllers/api-v3/quests.js | 12 +- website/src/models/group.js | 15 ++ 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7c5cc3b189..ec2be7a366 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -75,5 +75,7 @@ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", - "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality." + "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", + "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", + "questInvitationDoesNotExist": "No quest invitation has been sent out yet." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index 84111ab5ea..76f0102a5f 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -1,66 +1,137 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/leave', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; +describe('POST /groups/:groupId/quests/cancel', () => { + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(leader.post(`/groups/${generateUUID()}/quests/cancel`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not reject quest for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/cancel`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('cancels a quest', async () => { - await member.update(userQuestUpdate); - await questingGroup.update({'quest.key': PET_QUEST}); + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + await expect(guildLeader.post(`/groups/${guild._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); - await leader.post(`/groups/${questingGroup._id}/quests/cancel`); - let userThatCanceled = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + it('returns an error when group is not on a quest', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInvitationDoesNotExist'), + }); + }); - expect(userThatCanceled.party.quest.key).to.be.null; - expect(userThatCanceled.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members).to.be.empty; - }); + it('only the leader can cancel the quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - it('returns an error when quest is active', async () => { - await questingGroup.update({'quest.active': true}); - await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderCancelQuest'), + }); + }); + + it('does not cancel a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('cantCancelActiveQuest'), }); + }); + }); + + it('cancels a quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + + await Promise.all([ + leader.sync(), + partyMembers[0].sync(), + partyMembers[1].sync(), + questingGroup.sync(), + ]); + + let clean = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; + + expect(leader.party.quest).eql(clean); + expect(partyMembers[1].party.quest).eql(clean); + expect(partyMembers[0].party.quest).eql(clean); + + expect(questingGroup.quest).to.eql({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 3da381e26d..4cfad3155c 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -190,7 +190,7 @@ api.acceptQuest = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Group Object + * @apiSuccess {Object} quest Quest Object */ api.cancelQuest = { method: 'POST', @@ -210,20 +210,22 @@ api.cancelQuest = { let group = await Group.getGroup({user, groupId, fields: 'type quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); - + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest')); if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); - group.quest = {key: null, progress: {}, leader: null, members: {}}; + group.quest = Group.cleanGroupQuest(); group.markModified('quest'); await group.save(); await User.update( {'party._id': groupId}, - {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}, + {$set: {'party.quest': Group.cleanQuestProgress()}}, {multi: true} ); - res.respond(200, group); + res.respond(200, group.quest); }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index ff7a22b63c..1af2c8f7b5 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -300,6 +300,7 @@ schema.methods.startQuest = async function startQuest (user) { }); }; +// return a clean object for user.quest function _cleanQuestProgress (merge) { // TODO clone? (also in sendChat message) let clean = { @@ -321,8 +322,22 @@ function _cleanQuestProgress (merge) { return clean; } +// TODO move to User.cleanQuestProgress? schema.statics.cleanQuestProgress = _cleanQuestProgress; +// returns a clean object for group.quest +schema.statics.cleanGroupQuest = function cleanGroupQuest () { + return { + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }; +}; + // Participants: Grant rewards & achievements, finish quest // Returns the promise from update().exec() schema.methods.finishQuest = function finishQuest (quest) { From 354b6bc39e2b028cfc4e75816a38f14ffa02b93f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:07:45 +0100 Subject: [PATCH 26/34] update quest leave route --- .../POST-groups_groupid_quests_leave.test.js | 127 ++++++++++++------ website/src/controllers/api-v3/quests.js | 22 +-- 2 files changed, 90 insertions(+), 59 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js index eec1e33842..8b34278c08 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -1,85 +1,124 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /groups/:groupId/quests/leave', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/leave`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('returns an error when quest is not active', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToLeave'), + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, }); - }); - it('returns an error when quest leader attempts to leave', async () => { - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + await expect(guildLeader.post(`/groups/${guild._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('questLeaderCannotLeaveQuest'), + message: t('guildQuestsNotSupported'), }); - }); + }); - it('returns an error when non quest member attempts to leave', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + it('returns an error when quest is not active', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToLeave'), + }); + }); + + it('returns an error when quest leader attempts to leave', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotLeaveQuest'), + }); + }); + + it('returns an error when non quest member attempts to leave', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('notPartOfQuest'), }); + }); }); it('leaves a quest', async () => { - await member.update(userQuestUpdate); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + let leaveResult = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`); + await Promise.all([ + partyMembers[0].sync(), + questingGroup.sync(), + ]); - let leaveResult = await member.post(`/groups/${questingGroup._id}/quests/leave`); - let userThatLeft = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); - - expect(userThatLeft.party.quest.key).to.be.null; - expect(userThatLeft.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members[member._id]).to.be.false; - expect(updatedGroup.quest).to.deep.equal(leaveResult); + expect(partyMembers[0].party.quest).to.eql({ + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }); + expect(questingGroup.quest).to.deep.equal(leaveResult); + expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 1e1b6f176d..39da2cec29 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -19,7 +19,6 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true @@ -184,14 +183,14 @@ api.acceptQuest = { }; /** - * @api {post} /groups/:groupId/quests/leave Leaves a quest + * @api {post} /groups/:groupId/quests/leave Leaves the active quest * @apiVersion 3.0.0 * @apiName LeaveQuest * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Empty Object + * @apiSuccess {Object} quest Quest Object */ api.leaveQuest = { method: 'POST', @@ -207,19 +206,12 @@ api.leaveQuest = { if (validationErrors) throw validationErrors; let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); - - if (!(group.quest && group.quest.active)) { - throw new NotFound(res.t('noActiveQuestToLeave')); - } - - if (group.quest.leader === user._id) { - throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); - } - - if (!(group.quest.members && group.quest.members[user._id])) { - throw new NotAuthorized(res.t('notPartOfQuest')); - } + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave')); + if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); + if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest')); group.quest.members[user._id] = false; group.markModified('quest.members'); From dfb6ec2f48cea2e205913b74b16901d38285a9b9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:11:38 +0100 Subject: [PATCH 27/34] cancel quest: test response --- .../quests/POST-groups_groupid_quests_cancel.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index 76f0102a5f..daa995438a 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -100,7 +100,7 @@ describe('POST /groups/:groupId/quests/cancel', () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + let res = await leader.post(`/groups/${questingGroup._id}/quests/cancel`); await Promise.all([ leader.sync(), @@ -124,6 +124,7 @@ describe('POST /groups/:groupId/quests/cancel', () => { expect(partyMembers[1].party.quest).eql(clean); expect(partyMembers[0].party.quest).eql(clean); + expect(res).to.eql(questingGroup.quest); expect(questingGroup.quest).to.eql({ key: null, active: false, From ab187720a5fcc11f064d8e1654c7d3e84e0a7a71 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:14:07 +0100 Subject: [PATCH 28/34] reject quest: test response --- .../POST-groups_groupid_quests_reject.test.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index 84e364f770..1eb62aa0c6 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -110,17 +110,28 @@ describe('POST /groups/:groupId/quests/reject', () => { }); context('successfully quest rejection', () => { + let cleanUserQuestObj = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; + it('rejects a quest invitation', async () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + let res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); await partyMembers[0].sync(); await questingGroup.sync(); - expect(partyMembers[0].party.quest.key).to.be.null; - expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; + expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; expect(questingGroup.quest.active).to.be.false; + expect(res).to.eql(questingGroup.quest); }); it('starts the quest when the last user reject', async () => { From c79cb0efc6be139374dc4ad202e78cd382094035 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:32:25 +0100 Subject: [PATCH 29/34] update quest abort route --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_abort.test.js | 134 ++++++++++++------ website/src/controllers/api-v3/quests.js | 36 ++--- 3 files changed, 111 insertions(+), 62 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 72eb2f5c9e..671a046f8e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -75,5 +75,6 @@ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", - "noActiveQuestToAbort": "There is no active quest to abort." + "noActiveQuestToAbort": "There is no active quest to abort.", + "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 1ed6871307..e833dd16fe 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -1,78 +1,126 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/abort', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(leader.post(`/groups/${generateUUID()}/quests/abort`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/abort`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('returns an error when quest is not active', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToAbort'), + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, }); - }); - xit('returns an error when non quest leader attempts to abort', async () => { - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); - - await expect(member.post(`/groups/${questingGroup._id}/quests/abort`)) + await expect(guildLeader.post(`/groups/${guild._id}/quests/abort`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('questLeaderCannotAbortQuest'), + message: t('guildQuestsNotSupported'), }); + }); + + it('returns an error when quest is not active', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToAbort'), + }); + }); + + it('returns an error when non quest leader attempts to abort', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderAbortQuest'), + }); + }); }); it('aborts a quest', async () => { - await member.update(userQuestUpdate); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); + Promise.all([ + leader.sync(), + questingGroup.sync(), + partyMembers[0].sync(), + partyMembers[1].sync(), + ]); - let abortResult = await leader.post(`/groups/${questingGroup._id}/quests/abort`); - let updatedMember = await member.get('/user'); - let updatedLeader = await leader.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + let cleanUserQuestObj = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; - expect(updatedMember.party.quest.key).to.be.null; - expect(updatedMember.party.quest.RSVPNeeded).to.be.false; - expect(updatedLeader.items.quests[PET_QUEST]).to.equal(1); - expect(updatedGroup.quest).to.deep.equal(abortResult); + expect(leader.party.quest).to.eql(cleanUserQuestObj); + expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); + expect(partyMembers[1].party.quest).to.eql(cleanUserQuestObj); + expect(leader.items.quests[PET_QUEST]).to.equal(1); + expect(questingGroup.quest).to.deep.equal(abortResult); + expect(questingGroup.quest).to.eql({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index b3fb2e0e8e..ea49204f9a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -7,9 +7,6 @@ import { model as Group, } from '../../models/group'; import { model as User } from '../../models/user'; -import { - model as User, -} from '../../models/user'; import { NotFound, NotAuthorized, @@ -20,7 +17,6 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true @@ -185,14 +181,14 @@ api.acceptQuest = { }; /** - * @api {post} /groups/:groupId/quests/abort Abort a quest + * @api {post} /groups/:groupId/quests/abort Abort the current quest * @apiVersion 3.0.0 * @apiName AbortQuest * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.abortQuest = { method: 'POST', @@ -208,24 +204,28 @@ api.abortQuest = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + let group = await Group.getGroup({user, groupId, fields: 'type quest leader'}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort')); + if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest')); - let memberUpdates = User.update( - {'party._id': groupId}, - { + let memberUpdates = User.update({ + 'party._id': groupId + }, { $set: {'party.quest': Group.cleanQuestProgress()}, - $inc: {_v: 1}, + $inc: {_v: 1}, // TODO update middleware + }, {multi: true}).exec(); + + let questLeaderUpdate = User.update({ + _id: group.quest.leader + }, { + $inc: { + [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader }, - {multi: true}, - ); + }).exec(); - let update = {$inc: {}}; - update.$inc[`items.quests.${group.quest.key}`] = 1; - let questLeaderUpdate = User.update({_id: group.quest.leader}, update).exec(); - - group.quest = {key: null, progress: {collect: {}}, leader: null, members: {}, extra: {}, active: false}; + group.quest = Group.cleanGroupQuest(); group.markModified('quest'); let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); From 41c3276e66beed584ff5aabc3928ca8172cad46d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:36:21 +0100 Subject: [PATCH 30/34] fetch leader id when cancelling quest --- website/src/controllers/api-v3/quests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 4cfad3155c..fc1555f54d 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -208,7 +208,7 @@ api.cancelQuest = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + let group = await Group.getGroup({user, groupId, fields: 'type leader quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); From befdb189fc02f9458a3d67f3036f621bc353c677 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 21:09:14 +0100 Subject: [PATCH 31/34] fix tests for quests routes --- .../quests/POST-groups_groupid_quests_abort.test.js | 4 ++-- .../quests/POST-groups_groupid_quests_cancel.test.js | 6 +++--- .../quests/POST-groups_groupid_quests_leave.test.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 955d31bc92..850cf53646 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/leave', () => { +describe('POST /groups/:groupId/quests/abort', () => { let questingGroup; let partyMembers; let user; @@ -90,7 +90,7 @@ describe('POST /groups/:groupId/quests/leave', () => { await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); - Promise.all([ + await Promise.all([ leader.sync(), questingGroup.sync(), partyMembers[0].sync(), diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index daa995438a..f3bd03a180 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -120,9 +120,9 @@ describe('POST /groups/:groupId/quests/cancel', () => { RSVPNeeded: false, }; - expect(leader.party.quest).eql(clean); - expect(partyMembers[1].party.quest).eql(clean); - expect(partyMembers[0].party.quest).eql(clean); + expect(leader.party.quest).to.eql(clean); + expect(partyMembers[1].party.quest).to.eql(clean); + expect(partyMembers[0].party.quest).to.eql(clean); expect(res).to.eql(questingGroup.quest); expect(questingGroup.quest).to.eql({ diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js index 8b34278c08..65d781c163 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -88,7 +88,7 @@ describe('POST /groups/:groupId/quests/leave', () => { await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) + await expect(partyMembers[1].post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', From 4360f01936d787991a3d43aba4484bc80607952b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 12 Feb 2016 08:04:14 -0600 Subject: [PATCH 32/34] feat(api-v3): Add force-start quest route --- common/locales/en/api-v3.json | 4 +- ...-groups_groupId_quests_force-start.test.js | 126 ++++++++++++++++++ website/src/controllers/api-v3/quests.js | 53 ++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 87c63de581..649adf8c94 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -83,5 +83,7 @@ "questAlreadyRejected": "You already rejected the quest invitation.", "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", - "questInvitationDoesNotExist": "No quest invitation has been sent out yet." + "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", + "questNotPending": "There is no quest to start.", + "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js new file mode 100644 index 0000000000..b6d43f826b --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js @@ -0,0 +1,126 @@ +import { + createAndPopulateGroup, + translate as t, + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:groupId/quests/force-start', () => { + const PET_QUEST = 'whale'; + + let questingGroup; + let leader; + let partyMembers; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 2, + }); + + questingGroup = group; + leader = groupLeader; + partyMembers = members; + + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + }); + + context('failure conditions', () => { + it('does not force start a quest for a group in which user is not a member', async () => { + let nonMember = await generateUser(); + + await expect(nonMember.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not force start quest for a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('does not force start for a party without a pending quest', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotPending'), + }); + }); + + it('does not force start for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + }); + + it('does not allow non-quest leader or non-group leader to force start a quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questOrGroupLeaderOnlyStartQuest'), + }); + }); + }); + + context('successfully force starting a quest', () => { + it('allows quest leader to force start quest', async () => { + let questLeader = partyMembers[0]; + await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); + await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await questLeader.post(`/groups/${questingGroup._id}/quests/force-start`); + + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.eql(true); + }); + + it('allows group leader to force start quest', async () => { + let questLeader = partyMembers[0]; + await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); + await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await leader.post(`/groups/${questingGroup._id}/quests/force-start`); + + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.eql(true); + }); + + it('sends back the quest object', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + let quest = await leader.post(`/groups/${questingGroup._id}/quests/force-start`); + + expect(quest.active).to.eql(true); + expect(quest.key).to.eql(PET_QUEST); + expect(quest.members).to.eql({ + [`${leader._id}`]: true, + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index bf25b81396..e0ad3b86d4 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -238,6 +238,59 @@ api.rejectQuest = { }, }; + +/** + * @api {post} /groups/:groupId/quests/force-start Accept a pending quest + * @apiVersion 3.0.0 + * @apiName forceStart + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} quest Quest Object + */ +api.forceStart = { + method: 'POST', + url: '/groups/:groupId/quests/force-start', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questNotPending')); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest')); + + group.markModified('quest'); + + await group.startQuest(user); + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + analytics.track('quest', { + category: 'behavior', + owner: user._id === group.quest.leader, + response: 'force-start', + gaLabel: 'force-start', + questName: group.quest.key, + uuid: user._id, + }); + }, +}; + /** * @api {post} /groups/:groupId/quests/cancel Cancels a quest * @apiVersion 3.0.0 From 599510aa78b48cdbc8517eeddbcce159d53016a1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 12 Feb 2016 19:49:53 +0100 Subject: [PATCH 33/34] higher maxBuffer for integration tests --- tasks/gulp-tests.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index b8ccf23f68..c8af5e8b18 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -356,6 +356,7 @@ gulp.task('test:api-v3:unit', (done) => { gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), + {maxBuffer: 500*1028}, (err, stdout, stderr) => done(err) ) @@ -365,6 +366,7 @@ gulp.task('test:api-v3:integration', (done) => { gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), + {maxBuffer: 500*1028}, (err, stdout, stderr) => done(err) ) From edaf3ef4db7f29884fb73f4c8a6366bae3075e23 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 12 Feb 2016 19:50:50 +0100 Subject: [PATCH 34/34] fix typo: 1028->1024 bytes --- tasks/gulp-tests.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index c8af5e8b18..6256440998 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -356,7 +356,7 @@ gulp.task('test:api-v3:unit', (done) => { gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), - {maxBuffer: 500*1028}, + {maxBuffer: 500*1024}, (err, stdout, stderr) => done(err) ) @@ -366,7 +366,7 @@ gulp.task('test:api-v3:integration', (done) => { gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), - {maxBuffer: 500*1028}, + {maxBuffer: 500*1024}, (err, stdout, stderr) => done(err) )