diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d16b388769..c10fd1647a 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -54,6 +54,7 @@ "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", + "userAlreadyInChallenge": "User is already participating in this challenge.", "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\".", "partyMustbePrivate": "Parties must be private", "userAlreadyInGroup": "User already in that group.", diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js new file mode 100644 index 0000000000..3eda5716cd --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -0,0 +1,110 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/join', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser({ balance: 1}); + + await expect(user.post('/challenges/test/join')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser({ balance: 1}); + + await expect(user.post(`/challenges/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Joining a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let authorizedUser; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + authorizedUser = populatedGroup.members[0]; + + challenge = await generateChallenge(groupLeader, group); + }); + + it('returns an error when user doesn\'t have permissions to access the challenge', async () => { + let unauthorizedUser = await generateUser(); + + await expect(unauthorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('adds challenge to user challenges', async () => { + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await authorizedUser.sync(); + + expect(authorizedUser).to.have.property('challenges').to.include(challenge._id); + }); + + it('returns error when user has already joined the challenge', async () => { + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInChallenge'), + }); + }); + + it('increases memberCount of challenge', async () => { + let oldMemberCount = challenge.memberCount; + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + + expect(challenge).to.have.property('memberCount', oldMemberCount + 1); + }); + + it('syncs challenge tasks to joining user', async () => { + let taskText = 'A challenge task text'; + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + let tasks = await authorizedUser.get('/tasks/user'); + let tasksTexts = tasks.map((task) => { + return task.text; + }); + + expect(tasksTexts).to.include(taskText); + }); + + it('adds challenge tag to user tags', async () => { + let userTagsLength = (await authorizedUser.get('/tags')).length; + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await expect(authorizedUser.get('/tags')).to.eventually.have.length(userTagsLength + 1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 879b13b433..f0c682cf05 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; @@ -92,6 +93,42 @@ api.createChallenge = { }, }; +/** + * @api {post} /challenges/:challengeId/join Joins a challenge + * @apiVersion 3.0.0 + * @apiName JoinChallenge + * @apiGroup Challenge + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge the user joined + */ +api.joinChallenge = { + method: 'POST', + url: '/challenges/:challengeId/join', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challenge = await Challenge.findOne({ _id: req.params.challengeId }); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + if (!challenge.hasAccess(user)) throw new NotFound(res.t('challengeNotFound')); + + if (_.contains(user.challenges, challenge._id)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); + + challenge.memberCount += 1; + + // Add all challenge's tasks to user's tasks and save the challenge + await Q.all([challenge.syncToUser(user), challenge.save()]); + res.respond(200, challenge); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 @@ -144,7 +181,7 @@ api.getChallenge = { url: '/challenges/:challengeId', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 8234d0b7f3..39a7caa21b 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -22,7 +22,7 @@ let schema = new Schema({ leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? - memberCount: {type: Number, default: 0}, + memberCount: {type: Number, default: 1}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); @@ -65,6 +65,13 @@ function _syncableAttrs (task) { return _.omit(t, omitAttrs); } +schema.methods.hasAccess = function hasAccessToChallenge (user) { + let userGroups = user.guilds.slice(0); + if (user.party._id) userGroups.push(user.party._id); + userGroups.push('habitrpg'); // tavern challenges + return this.leader === user._id || userGroups.indexOf(this.groupId) !== -1; +}; + // Sync challenge to user, including tasks and tags. // Used when user joins the challenge or to force sync. schema.methods.syncToUser = async function syncChallengeToUser (user) {