diff --git a/.eslintrc b/.eslintrc index 7613632120..85937b0e6e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -23,7 +23,6 @@ "no-lone-blocks": 2, "no-loop-func": 2, "no-implicit-coercion": 2, - "no-implied-eval": 2, "no-native-reassign": 2, "no-new-func": 2, "no-new-wrappers": 2, @@ -59,7 +58,6 @@ "no-path-concat": 2, "arrow-spacing": 2, "constructor-super": 2, - "generator-star-spacing": 2, "no-arrow-condition": 2, "no-class-assign": 2, "no-const-assign": 2, diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index f030c020f7..cdb6718327 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -50,6 +50,7 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", + "onlyLeaderUpdateChal": "Only the challenge leader can update it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", @@ -66,6 +67,7 @@ "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", "questInviteNotFound": "No quest invitation found.", "guildQuestsNotSupported": "Guilds cannot be invited on quests.", "questNotFound": "Quest \"<%= key %>\" not found.", diff --git a/package.json b/package.json index fd5659a079..a068f199e1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cookie-parser": "^1.4.0", "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", + "csv-stringify": "^1.0.1", "domain-middleware": "~0.1.0", "estraverse": "^4.1.1", "express": "~4.13.3", @@ -93,12 +94,13 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test", + "test": "gulp test:api-v3", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", - "test:api-v3": "mocha test/api/v3 --recursive", - "test:api-v3:unit": "mocha test/api/v3/unit --recursive", - "test:api-v3:integration": "mocha test/api/v3/integration --recursive", + "test:api-v3": "gulp test:api-v3", + "test:api-v3:unit": "gulp test:api-v3:unit", + "test:api-v3:integration": "gulp test:api-v3:integration", + "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server", "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", "test:common": "mocha test/common", "test:content": "mocha test/content", diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 5fbd32cac8..b8ccf23f68 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -13,6 +13,8 @@ import Q from 'q'; import runSequence from 'run-sequence'; import os from 'os'; +// TODO rewrite + const TEST_SERVER_PORT = 3003 const TEST_DB = 'habitrpg_test' let server; @@ -42,9 +44,9 @@ let testBin = (string, additionalEnvVariables = '') => { additionalEnvVariables = additionalEnvVariables.split(' ').join('&&set '); additionalEnvVariables = 'set ' + additionalEnvVariables + '&&'; } - return `set NODE_ENV=testing&&${additionalEnvVariables}${string}`; + return `set NODE_ENV=test&&${additionalEnvVariables}${string}`; } else { - return `NODE_ENV=testing ${additionalEnvVariables} ${string}`; + return `NODE_ENV=test ${additionalEnvVariables} ${string}`; } }; @@ -342,13 +344,51 @@ gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => { }); }); +gulp.task('test:api-v3:unit', (done) => { + let runner = exec( + testBin('mocha test/api/v3/unit --recursive'), + (err, stdout, stderr) => done(err) + ) + + pipe(runner); +}); + +gulp.task('test:api-v3:integration', (done) => { + let runner = exec( + testBin('mocha test/api/v3/integration --recursive'), + (err, stdout, stderr) => done(err) + ) + + pipe(runner); +}); + +gulp.task('test:api-v3:integration:separate-server', (done) => { + let runner = exec( + testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), + (err, stdout, stderr) => done(err) + ) + + pipe(runner); +}); + +gulp.task('test:api-v3', (done) => { + runSequence( + 'lint', + 'test:api-v3:unit', + 'test:api-v3:integration', + done + ); +}); + +// Old tests tasks +/* gulp.task('test:api-v3', ['test:api-v3:unit', 'test:api-v3:integration']); gulp.task('test:api-v3:watch', ['test:api-v3:unit:watch', 'test:api-v3:integration:watch']); -gulp.task('test:api-v3:unit', (done) => { - runMochaTests('./test/api/v3/unit/**/*.js', null, done) -}); +gulp.task('test:api-v3:unit', (done) => {*/ +// runMochaTests('./test/api/v3/unit/**/*.js', null, done) +/*}); gulp.task('test:api-v3:unit:watch', () => { gulp.watch(['website/src/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); @@ -356,9 +396,9 @@ gulp.task('test:api-v3:unit:watch', () => { gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { process.env.API_VERSION = 'v3'; - awaitPort(TEST_SERVER_PORT).then(() => { - runMochaTests('./test/api/v3/integration/**/*.js', server, done) - }); + awaitPort(TEST_SERVER_PORT).then(() => {*/ +// runMochaTests('./test/api/v3/integration/**/*.js', server, done) +/* }); }); gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { @@ -430,4 +470,4 @@ gulp.task('test', ['test:all'], () => { console.log('\n\x1b[36mThanks for helping keep Habitica clean!\x1b[0m'); process.exit(); } -}); +});*/ diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 8115753727..0000000000 --- a/test/README.md +++ /dev/null @@ -1,5 +0,0 @@ -We need to clean up this directory. The *real* tests are in spec/ mock/ e2e/ and api.mocha.coffee. We want to: - -1. Move all old / deprecated tests from casper, test2, etc into spec, mock, e2e -1. Remove dependency of api.mocha.coffee on Derby, port it to Mongoose -1. Add better test-coverage diff --git a/test/api/README.md b/test/api/README.md index b5223cc0f4..ff3296721f 100644 --- a/test/api/README.md +++ b/test/api/README.md @@ -1,5 +1,7 @@ # So you want to write API integration tests? +@TODO rewrite + That's great! This README will serve as a quick primer for style conventions and practices for these tests. ## What is this? @@ -73,7 +75,7 @@ POST-groups_id_leave.test.js To mitigate [callback hell](http://callbackhell.com/) :imp:, we've written a helper method to generate a user object that can make http requests that [return promises](https://babeljs.io/docs/learn-es2015/#promises). This makes it very easy to chain together commands. All you need to do to make a subsequent request is return another promise and then call `.then((result) => {})` on the surrounding block, like so: ```js -it('does something', () => { +it('does something', () => { let user; return generateUser().then((_user) => { // We return the initial promise so this test can be run asyncronously @@ -97,7 +99,7 @@ it('does something', () => { If the test is simple, you can use the [chai-as-promised](http://chaijs.com/plugins/chai-as-promised) `return expect(somePromise).to.eventually` syntax to make your assertion. ```js -it('makes the party creator the leader automatically', () => { +it('makes the party creator the leader automatically', () => { return expect(user.post('/groups', { type: 'party', })).to.eventually.have.deep.property('leader._id', user._id); @@ -107,7 +109,7 @@ it('makes the party creator the leader automatically', () => { If the test is checking that the request returns an error, use the `.eventually.be.rejected.and.eql` syntax. ```js -it('returns an error', () => { +it('returns an error', () => { return expect(user.get('/groups/id-of-a-party-that-user-does-not-belong-to')) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/README.md b/test/api/v3/README.md new file mode 100644 index 0000000000..ad9b55a32c --- /dev/null +++ b/test/api/v3/README.md @@ -0,0 +1,4 @@ +# How to run tests: + +1. `npm test` is equivalent to `gulp test:api-v3` which will run, in order, `gulp lint`, `gulp test:api-v3:unit` and `gulp test:api-v3:integration`. If one of these fails, the whole `npm test` command blocks and fails. Each of these commands can also be run as a standalone command. +2. To run the server and the integrations tests in two different terminals (to better inspect the output in the server) run `npm start` in one and `npm test:api-v3:integration:separate-server` in the other diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js new file mode 100644 index 0000000000..e8bd7a66d9 --- /dev/null +++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js @@ -0,0 +1,95 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + sleep, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /challenges/:challengeId', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/test`)).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(); + + await expect(user.del(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Deleting a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let taskText = 'A challenge task text'; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup(); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + + challenge = await generateChallenge(groupLeader, group); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to delete the challenge', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderDeleteChal'), + }); + }); + + it('deletes challenge', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); + }); + + it('refunds gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); + }); + + it('sets broken and doesn\'t set winner flags for user\'s challenge tasks', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + let tasks = await groupLeader.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_DELETED'); + expect(testTask.challenge.winner).to.be.null; + }); + }); +}); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js new file mode 100644 index 0000000000..e4c1ceee34 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -0,0 +1,71 @@ +import { + generateUser, + createAndPopulateGroup, + generateChallenge, + translate as t, + sleep, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId/export/csv', () => { + let groupLeader; + let group; + let challenge; + let members; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + members: 3, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + members = populatedGroup.members; + + challenge = await generateChallenge(groupLeader, group); + await members[0].post(`/challenges/${challenge._id}/join`); + await members[1].post(`/challenges/${challenge._id}/join`); + await members[2].post(`/challenges/${challenge._id}/join`); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: 'Task 1'}, + {type: 'todo', text: 'Task 2'}, + ]); + await sleep(0.5); // Make sure tasks are synced to the users + await members[0].sync(); + await members[1].sync(); + await members[2].sync(); + }); + + it('fails if challenge doesn\'t exists', async () => { + await expect(user.get(`/challenges/${generateUUID()}/export/csv`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should return a valid CSV file with export data', async () => { + let res = await members[0].get(`/challenges/${challenge._id}/export/csv`); + let sortedMembers = _.sortBy([members[0], members[1], members[2], groupLeader], '_id'); + let splitRes = res.split('\n'); + + expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Task,Value,Notes'); + expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[5]).to.equal(''); + }); +}); diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js new file mode 100644 index 0000000000..9694b263a9 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js @@ -0,0 +1,123 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/leave', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post('/challenges/test/leave')).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(); + + await expect(user.post(`/challenges/${generateUUID()}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Leaving a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let notInChallengeUser; + let leavingUser; + let taskText; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 2, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + leavingUser = populatedGroup.members[0]; + notInChallengeUser = populatedGroup.members[1]; + + challenge = await generateChallenge(groupLeader, group); + + taskText = 'A challenge task text'; + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await leavingUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to view the challenge', async () => { + let unauthorizedUser = await generateUser(); + + await expect(unauthorizedUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns an error when user isn\'t a member of the challenge', async () => { + await expect(notInChallengeUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('challengeMemberNotFound'), + }); + }); + + it('removes challenge from user challenges', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await leavingUser.sync(); + + expect(leavingUser).to.have.property('challenges').to.not.include(challenge._id); + }); + + it('decreases memberCount of challenge', async () => { + let oldMemberCount = challenge.memberCount; + + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await challenge.sync(); + + expect(challenge).to.have.property('memberCount', oldMemberCount - 1); + }); + + it('unlinks challenge tasks from leaving user when remove-all is passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'remove-all', + }); + let tasks = await leavingUser.get('/tasks/user'); + let tasksTexts = tasks.map((task) => { + return task.text; + }); + + expect(tasksTexts).to.not.include(taskText); + }); + + it('doesn\'t unlink challenge tasks from leaving user when remove-all isn\'t passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'test', + }); + + let tasks = await leavingUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask).to.not.be.undefined; + expect(testTask.challenge).to.be.undefined; + }); + }); +}); diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js new file mode 100644 index 0000000000..98dfc20926 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -0,0 +1,139 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + sleep, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/winner/:winnerId', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/test/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when winnerId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/selectWinner/test`)).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(); + + await expect(user.post(`/challenges/${generateUUID()}/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Selecting winner for a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let winningUser; + let taskText = 'A challenge task text'; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + winningUser = populatedGroup.members[0]; + + challenge = await generateChallenge(groupLeader, group, { + prize: 1, + }); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await winningUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to select winner', async () => { + await expect(winningUser.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderDeleteChal'), + }); + }); + + it('returns an error when winning user isn\'t part of the challenge', async () => { + let notInChallengeUser = await generateUser(); + + await expect(groupLeader.post(`/challenges/${challenge._id}/selectWinner/${notInChallengeUser._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('winnerNotFound', {userId: notInChallengeUser._id}), + }); + }); + + it('deletes challenge after winner is selected', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); + }); + + it('adds challenge to winner\'s achievements', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(winningUser.sync()).to.eventually.have.deep.property('achievements.challenges').to.include(challenge.name); + }); + + it('gives winner gems as reward', async () => { + let oldBalance = winningUser.balance; + + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(winningUser.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); + }); + + it('doesn\'t refund gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance); + }); + + it('sets broken and winner flags for user\'s challenge tasks', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + let tasks = await winningUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_CLOSED'); + expect(testTask.challenge.winner).to.eql(winningUser.profile.name); + }); + }); +}); diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js new file mode 100644 index 0000000000..e916ab5b7a --- /dev/null +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -0,0 +1,76 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('PUT /challenges/:challengeId', () => { + let privateGuild, user, nonMember, challenge, member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestPrivateGuild', + type: 'guild', + privacy: 'private', + }, + members: 1, + }); + + privateGuild = group; + user = groupLeader; + + nonMember = await generateUser(); + member = members[0]; + + challenge = await generateChallenge(user, group); + await member.post(`/challenges/${challenge._id}/join`); + }); + + it('fails if the user can\'t view the challenge', async () => { + await expect(nonMember.put(`/challenges/${challenge._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should only allow the leader or an admin to update the challenge', async () => { + await expect(member.put(`/challenges/${challenge._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderUpdateChal'), + }); + }); + + it('only updates allowed fields', async () => { + let res = await user.put(`/challenges/${challenge._id}`, { + // ignored + prize: 33, + groupId: 'blabla', + memberCount: 33, + tasksOrder: 'new order', + official: true, + shortName: 'new short name', + + // applied + name: 'New Challenge Name', + description: 'New challenge description.', + leader: member._id, + }); + + expect(res.prize).to.equal(0); + expect(res.groupId).to.equal(privateGuild._id); + expect(res.memberCount).to.equal(2); + expect(res.tasksOrder).not.to.equal('new order'); + expect(res.official).to.equal(false); + expect(res.shortName).not.to.equal('new short name'); + + expect(res.leader).to.equal(member._id); + expect(res.name).to.equal('New Challenge Name'); + expect(res.description).to.equal('New challenge description.'); + }); +}); diff --git a/test/api/v3/integration/chat/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js new file mode 100644 index 0000000000..5d1f73f867 --- /dev/null +++ b/test/api/v3/integration/chat/DELETE-chat_id.test.js @@ -0,0 +1,81 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /groups/:groupId/chat/:chatId', () => { + let groupWithChat, message, user, userThatDidNotCreateChat, admin; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + }); + + groupWithChat = group; + user = groupLeader; + message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + userThatDidNotCreateChat = await generateUser(); + admin = await generateUser({'contributor.admin': true}); + }); + + context('Chat errors', () => { + it('returns an error is message does not exist', async () => { + let fakeChatId = generateUUID(); + await expect(user.del(`/groups/${groupWithChat._id}/chat/${fakeChatId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('returns an error when user does not have permission to delete', async () => { + await expect(userThatDidNotCreateChat.del(`/groups/${groupWithChat._id}/chat/${message.id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyCreatorOrAdminCanDeleteChat'), + }); + }); + }); + + context('Chat success', () => { + let nextMessage; + + beforeEach(async () => { + nextMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some new message' }); + nextMessage = nextMessage.message; + }); + + it('allows creator to delete a their message', async () => { + await user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('allows admin to delete another user\'s message', async () => { + await admin.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('returns empty when previous message parameter is passed and the last message was deleted', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${nextMessage.id}`)) + .to.eventually.be.empty; + }); + + it('returns the update chat when previous message parameter is passed and the chat is updated', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${message.id}`)) + .eventually + .is.an('array') + .to.include(message) + .to.be.lengthOf(1); + }); + }); +}); diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 47ba58b046..51a3abb164 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -5,11 +5,14 @@ import { import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { - let user, group; + let user, admin, anotherUser, group; const TEST_MESSAGE = 'Test Message'; before(async () => { user = await generateUser({balance: 1}); + admin = await generateUser({balance: 1, 'contributor.admin': true}); + anotherUser = await generateUser(); + group = await user.post('/groups', { name: 'Test Guild', type: 'guild', @@ -18,7 +21,7 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when chat message is not found', async () => { - return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) + await expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -27,79 +30,55 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when user tries to flag their own message', async () => { - return user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}) - .then((result) => { - return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatFlagOwnMessage'), - }); - }); + let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE }); + await expect(user.post(`/groups/${group._id}/chat/${message.message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagOwnMessage'), + }); }); it('Flags a chat', async () => { - let message; + let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/flag`); - }) - .then((result) => { - expect(result.flags[user._id]).to.equal(true); - expect(result.flagCount).to.equal(1); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.flags[user._id]).to.equal(true); - }); + let flagResult = await user.post(`/groups/${group._id}/chat/${message.id}/flag`); + expect(flagResult.flags[user._id]).to.equal(true); + expect(flagResult.flagCount).to.equal(1); + + let groupWithFlags = await admin.get(`/groups/${group._id}`); + + let messageToCheck = find(groupWithFlags.chat, {id: message.id}); + expect(messageToCheck.flags[user._id]).to.equal(true); }); it('Flags a chat with a higher flag acount when an admin flags the message', async () => { - let secondUser; - let message; + let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; - return generateUser({'contributor.admin': true}).then((generatedUser) => { - secondUser = generatedUser; - return user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - }) - .then((result) => { - message = result.message; - return secondUser.post(`/groups/${group._id}/chat/${message.id}/flag`); - }) - .then((result) => { - expect(result.flags[secondUser._id]).to.equal(true); - expect(result.flagCount).to.equal(5); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.flags[secondUser._id]).to.equal(true); - expect(messageToCheck.flagCount).to.equal(5); - }); + let flagResult = await admin.post(`/groups/${group._id}/chat/${message.id}/flag`); + expect(flagResult.flags[admin._id]).to.equal(true); + expect(flagResult.flagCount).to.equal(5); + + let groupWithFlags = await admin.get(`/groups/${group._id}`); + + let messageToCheck = find(groupWithFlags.chat, {id: message.id}); + expect(messageToCheck.flags[admin._id]).to.equal(true); + expect(messageToCheck.flagCount).to.equal(5); }); it('Returns an error when user tries to flag a message that is already flagged', async () => { - let message; + let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/flag`); - }) - .then(() => { - return expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatFlagAlreadyReported'), - }); - }); + await user.post(`/groups/${group._id}/chat/${message.id}/flag`); + + await expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagAlreadyReported'), + }); }); }); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index cf266431ba..d7ae6047df 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -1,36 +1,32 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { find } from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; - let group; + let groupWithChat; let testMessage = 'Test Message'; + let anotherUser; - before(() => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - - return generateUser({balance: 1}).then((generatedUser) => { - user = generatedUser; - }) - .then(() => { - return user.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((generatedGroup) => { - group = generatedGroup; + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + members: 1, }); + + user = groupLeader; + groupWithChat = group; + anotherUser = members[0]; }); - it('Returns an error when chat message is not found', () => { - return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/like`)) + it('Returns an error when chat message is not found', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat/incorrectMessage/like`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,59 +34,42 @@ describe('POST /chat/:chatId/like', () => { }); }); - it('Returns an error when user tries to like their own message', () => { - return user.post(`/groups/${group._id}/chat`, { message: testMessage}) - .then((result) => { - return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/like`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatLikeOwnMessage'), - }); - }); + it('Returns an error when user tries to like their own message', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + + await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatLikeOwnMessage'), + }); }); - it('Likes a chat', () => { - let message; + it('Likes a chat', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(true); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(true); - }); + let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + + expect(likeResult.likes[user._id]).to.equal(true); + + let groupWithChatLikes = await user.get(`/groups/${groupWithChat._id}`); + + let messageToCheck = find(groupWithChatLikes.chat, {id: message.message.id}); + expect(messageToCheck.likes[user._id]).to.equal(true); }); - it('Unlikes a chat', () => { - let message; + it('Unlikes a chat', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(true); - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(false); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(false); - }); + let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + expect(likeResult.likes[user._id]).to.equal(true); + + let unlikeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + expect(unlikeResult.likes[user._id]).to.equal(false); + + let groupWithoutChatLikes = await user.get(`/groups/${groupWithChat._id}`); + + let messageToCheck = find(groupWithoutChatLikes.chat, {id: message.message.id}); + expect(messageToCheck.likes[user._id]).to.equal(false); }); }); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index fe943bfb1c..99d1469af8 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,95 +1,81 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; describe('POST /chat', () => { - let user; + let user, groupWithChat, userWithChatRevoked, member; + let testMessage = 'Test Message'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + members: 2, }); + + user = groupLeader; + groupWithChat = group; + userWithChatRevoked = await members[0].update({'flags.chatRevoked': true}); + member = members[0]; }); - it('Returns an error when no message is provided', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = ''; - - return generateUser({balance: 1}).then((anotherUser) => { - return anotherUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, + it('Returns an error when no message is provided', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: ''})) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); - }) - .then((group) => { - return expect(user.post(`/groups/${group._id}/chat`, { message: testMessage})) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); }); - it('Returns an error when group is not found', () => { - let testMessage = 'Test Message'; - return expect(user.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ + it('Returns an error when group is not found', async () => { + await expect(user.post('/groups/invalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); }); - it('Returns an error when chat privileges are revoked', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = 'Test Message'; - let userWithoutChat; - - return generateUser({balance: 1, 'flags.chatRevoked': true}).then((generatedUser) => { - userWithoutChat = generatedUser; - - return userWithoutChat.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((group) => { - return expect(userWithoutChat.post(`/groups/${group._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Your chat privileges have been revoked.', - }); + it('Returns an error when chat privileges are revoked', async () => { + await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Your chat privileges have been revoked.', }); }); - it('creates a chat', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = 'Test Message'; - let anotherUser; + it('creates a chat', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser({balance: 1}).then((generatedUser) => { - anotherUser = generatedUser; + expect(message.message.id).to.exist; + }); - return anotherUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((group) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - expect(result.message.id).to.exist; + it('notifies other users of new messages for a guild', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + let memberWithNotification = await member.get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist; + }); + + it('notifies other users of new messages for a party', async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + members: 1, }); + + let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage}); + let memberWithNotification = await members[0].get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${group._id}`]).to.exist; }); }); diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js new file mode 100644 index 0000000000..8b22461a04 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -0,0 +1,63 @@ +import { + createAndPopulateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:id/chat/seen', () => { + context('Guild', () => { + let guild, guildLeader, guildMember, guildMessage; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + guild = group; + guildLeader = groupLeader; + guildMember = members[0]; + + guildMessage = await guildLeader.post(`/groups/${guild._id}/chat`, { message: 'Some guild message' }); + guildMessage = guildMessage.message; + }); + + it('clears new messages for a guild', async () => { + await guildMember.post(`/groups/${guild._id}/chat/seen`); + + let guildThatHasSeenChat = await guildMember.get('/user'); + + expect(guildThatHasSeenChat.newMessages).to.be.empty; + }); + }); + + context('Party', () => { + let party, partyLeader, partyMember, partyMessage; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + party = group; + partyLeader = groupLeader; + partyMember = members[0]; + + partyMessage = await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some party message' }); + partyMessage = partyMessage.message; + }); + + it('clears new messages for a party', async () => { + await partyMember.post(`/groups/${party._id}/chat/seen`); + + let partyMemberThatHasSeenChat = await partyMember.get('/user'); + + expect(partyMemberThatHasSeenChat.newMessages).to.be.empty; + }); + }); +}); diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js new file mode 100644 index 0000000000..87cad5d6f0 --- /dev/null +++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js @@ -0,0 +1,101 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:id/chat/:id/clearflags', () => { + let groupWithChat, message, author, nonAdmin, admin; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + groupWithChat = group; + author = groupLeader; + nonAdmin = members[0]; + admin = await generateUser({'contributor.admin': true}); + + message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/flag`); + }); + + context('Single Message', () => { + it('returns error when non-admin attempts to clear flags', async () => { + return expect(nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupChatAdminClearFlagCount'), + }); + }); + + it('returns error if message does not exist', async () => { + let fakeMessageID = generateUUID(); + + await expect(admin.post(`/groups/${groupWithChat._id}/chat/${fakeMessageID}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('clears flags and leaves old flags on the flag object', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + expect(messages[0].flagCount).to.eql(0); + expect(messages[0].flags).to.have.property(admin._id, true); + }); + }); + + context('admin user, group with multiple messages', () => { + let message2, message3, message4; + + before(async () => { + message2 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 2' }); + message2 = message2.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message2.id}/flag`); + + message3 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 3' }); + message3 = message3.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + await nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + + message4 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 4' }); + message4 = message4.message; + }); + + it('changes only the message that is flagged', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + + expect(messages).to.have.lengthOf(4); + + let messageThatWasUnflagged = messages[3]; + let messageWith1Flag = messages[2]; + let messageWith2Flag = messages[1]; + let messageWithoutFlags = messages[0]; + + expect(messageThatWasUnflagged.flagCount).to.eql(0); + expect(messageThatWasUnflagged.flags).to.have.property(admin._id, true); + + expect(messageWith1Flag.flagCount).to.eql(5); + expect(messageWith1Flag.flags).to.have.property(admin._id, true); + + expect(messageWith2Flag.flagCount).to.eql(6); + expect(messageWith2Flag.flags).to.have.property(admin._id, true); + expect(messageWith2Flag.flags).to.have.property(nonAdmin._id, true); + + expect(messageWithoutFlags.flagCount).to.eql(0); + expect(messageWithoutFlags.flags).to.eql({}); + }); + }); +}); diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js new file mode 100644 index 0000000000..b37b87f890 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -0,0 +1,96 @@ +import { + generateUser, + resetHabiticaDB, + generateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /groups', () => { + let user; + const NUMBER_OF_PUBLIC_GUILDS = 3; + const NUMBER_OF_USERS_PRIVATE_GUILDS = 1; + const NUMBER_OF_GROUPS_USER_CAN_VIEW = 5; + + before(async () => { + await resetHabiticaDB(); + + let leader = await generateUser({ balance: 10 }); + user = await generateUser({balance: 4}); + + let publicGuildUserIsMemberOf = await generateGroup(leader, { + name: 'public guild - is member', + type: 'guild', + privacy: 'public', + }); + await leader.post(`/groups/${publicGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); + await user.post(`/groups/${publicGuildUserIsMemberOf._id}/join`); + + await generateGroup(leader, { + name: 'public guild - is not member', + type: 'guild', + privacy: 'public', + }); + + let privateGuildUserIsMemberOf = await generateGroup(leader, { + name: 'private guild - is member', + type: 'guild', + privacy: 'private', + }); + await leader.post(`/groups/${privateGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); + await user.post(`/groups/${privateGuildUserIsMemberOf._id}/join`); + + await generateGroup(leader, { + name: 'private guild - is not member', + type: 'guild', + privacy: 'private', + }); + + await generateGroup(leader, { + name: 'party - is not member', + type: 'party', + privacy: 'private', + }); + + await user.post('/groups', { + name: 'party - is member', + type: 'party', + privacy: 'private', + }); + }); + + it('returns error when no query passed in', async () => { + await expect(user.get('/groups')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns only the tavern when tavern passed in as query', async () => { + await expect(user.get('/groups?type=tavern')) + .to.eventually.have.a.lengthOf(1) + .and.to.have.deep.property('[0]') + .and.to.have.property('_id', 'habitrpg'); + }); + + it('returns only the user\'s party when party passed in as query', async () => { + await expect(user.get('/groups?type=party')) + .to.eventually.have.a.lengthOf(1) + .and.to.have.deep.property('[0]'); + }); + + it('returns all public guilds when publicGuilds passed in as query', async () => { + await expect(user.get('/groups?type=publicGuilds')) + .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS); + }); + + it('returns all private guilds user is a part of when privateGuilds passed in as query', async () => { + await expect(user.get('/groups?type=privateGuilds')) + .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_PRIVATE_GUILDS); + }); + + it('returns a list of groups user has access to', async () => { + await expect(user.get('/groups?type=privateGuilds,publicGuilds,party,tavern')) + .to.eventually.have.lengthOf(NUMBER_OF_GROUPS_USER_CAN_VIEW); + }); +}); diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js new file mode 100644 index 0000000000..9cc2fce766 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups_id.test.js @@ -0,0 +1,302 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +import { + each, +} from 'lodash'; + +describe('GET /groups/:id', () => { + let typesOfGroups = {}; + typesOfGroups['public guild'] = { type: 'guild', privacy: 'public' }; + typesOfGroups['private guild'] = { type: 'guild', privacy: 'private' }; + typesOfGroups.party = { type: 'party', privacy: 'private' }; + + each(typesOfGroups, (groupDetails, groupType) => { + context(`Member of a ${groupType}`, () => { + let leader, member, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 30, + groupDetails, + }); + + leader = groupData.groupLeader; + member = groupData.members[0]; + createdGroup = groupData.group; + }); + + it('returns the group object', async () => { + let group = await member.get(`/groups/${createdGroup._id}`); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + + it('transforms leader id to leader object', async () => { + let group = await member.get(`/groups/${createdGroup._id}`); + + expect(group.leader._id).to.eql(leader._id); + expect(group.leader.profile.name).to.eql(leader.profile.name); + expect(group.leader.items).to.exist; + expect(group.leader.stats).to.exist; + expect(group.leader.achievements).to.exist; + expect(group.leader.contributor).to.exist; + }); + }); + }); + + context('Non-member of a public guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('returns the group object for a non-member', async () => { + let group = await nonMember.get(`/groups/${createdGroup._id}`); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-member of a private guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Non-member of a party', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Member of a party', () => { + let member, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + member = groupData.members[0]; + }); + + it('returns the user\'s party if an id of "party" is passed in', async () => { + let group = await member.get('/groups/party'); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-existent group', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns error if group does not exist', async () => { + await expect(user.get('/groups/group-that-does-not-exist')) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Flagged messages', () => { + let group; + + let chat1 = { + id: 'chat1', + text: 'chat 1', + flags: {}, + }; + + let chat2 = { + id: 'chat2', + text: 'chat 2', + flags: {}, + flagCount: 0, + }; + + let chat3 = { + id: 'chat3', + text: 'chat 3', + flags: { + 'user-id': true, + }, + flagCount: 1, + }; + + let chat4 = { + id: 'chat4', + text: 'chat 4', + flags: { + 'user-id': true, + 'other-user-id': true, + }, + flagCount: 2, + }; + + let chat5 = { + id: 'chat5', + text: 'chat 5', + flags: { + 'user-id': true, + 'other-user-id': true, + 'yet-another-user-id': true, + }, + flagCount: 3, + }; + + beforeEach(async () => { + let groupData = await createAndPopulateGroup({ + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + chat: [ + chat1, + chat2, + chat3, + chat4, + chat5, + ], + }, + }); + + group = groupData.group; + + await group.addChat([chat1, chat2, chat3, chat4, chat5]); + }); + + context('non-admin', () => { + let nonAdmin; + + beforeEach(async () => { + nonAdmin = await generateUser(); + }); + + it('does not include messages with a flag count of 2 or greater', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(3); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + }); + + it('does not include user ids in flags object', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql({}); + }); + }); + + context('admin', () => { + let admin; + + beforeEach(async () => { + admin = await generateUser({ + 'contributor.admin': true, + }); + }); + + it('includes all messages', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(5); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + expect(fetchedGroup.chat[3].id).to.eql(chat4.id); + expect(fetchedGroup.chat[4].id).to.eql(chat5.id); + }); + + it('includes user ids in flags object', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql(chat3.flags); + }); + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index b35409bb6c..d20c8b830d 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -1,6 +1,7 @@ import { generateUser, createAndPopulateGroup, + checkExistence, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -16,7 +17,47 @@ describe('POST /group/:groupId/join', () => { }); }); - context('Accepting invitation to a private guild', () => { + context('Joining a public guild', () => { + let user, joiningUser, publicGuild; + + beforeEach(async () => { + let {group, groupLeader} = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + }); + + publicGuild = group; + user = groupLeader; + joiningUser = await generateUser(); + }); + + it('allows non-invited users to join public guilds', async () => { + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(publicGuild._id); + }); + + it('promotes joining member in a public empty guild to leader', async () => { + await user.post(`/groups/${publicGuild._id}/leave`); + + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.deep.property('leader._id', joiningUser._id); + }); + + it('increments memberCount when joining guilds', async () => { + let oldMemberCount = publicGuild.memberCount; + + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); + }); + + context('Joining a private guild', () => { let user, invitedUser, guild; beforeEach(async () => { @@ -44,21 +85,6 @@ describe('POST /group/:groupId/join', () => { }); }); - it('allows non-invited users to join public guilds', async () => { - let publicGuild = (await createAndPopulateGroup({ - groupDetails: { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }, - })).group; - - let joiningUser = await generateUser(); - await joiningUser.post(`/groups/${publicGuild._id}/join`); - - await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').and.to.include(publicGuild._id); - }); - context('User is invited', () => { it('allows invited user to join private guilds', async () => { await invitedUser.post(`/groups/${guild._id}/join`); @@ -74,6 +100,14 @@ describe('POST /group/:groupId/join', () => { .to.not.include({id: guild._id}); }); + it('increments memberCount when joining guilds', async () => { + let oldMemberCount = guild.memberCount; + + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); + it('does not give basilist quest to inviter when joining a guild', async () => { await invitedUser.post(`/groups/${guild._id}/join`); @@ -90,7 +124,7 @@ describe('POST /group/:groupId/join', () => { }); }); - context('Accepting invitation to a party', () => { + context('Joining a party', () => { let user, invitedUser, party; beforeEach(async () => { @@ -130,6 +164,14 @@ describe('POST /group/:groupId/join', () => { await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id'); }); + it('increments memberCount when joining party', async () => { + let oldMemberCount = party.memberCount; + + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(invitedUser.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); + it('gives basilist quest item to the inviter when joining a party', async () => { await invitedUser.post(`/groups/${party._id}/join`); @@ -143,6 +185,37 @@ describe('POST /group/:groupId/join', () => { await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); }); + + it('deletes previous party where the user was the only member', async () => { + let userToInvite = await generateUser(); + let oldParty = await userToInvite.post('/groups', { // add user to a party + name: 'Another Test Party', + type: 'party', + }); + + await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(true); + await user.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('party._id', party._id); + await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(false); + }); + + xit('invites joining member to active quest', async () => { + // TODO start quest + + await invitedUser.post(`/groups/${party._id}/join`); + + invitedUser = await user.get('/user'); + party = await user.get(`/groups/${party._id}`); + + expect(user).to.have.deep.property('party.quest.RSVPNeeded', true); + expect(user).to.have.deep.property('party.quest.key', party.quest.key); + + expect(party.quest.members[invitedUser._id]).to.be.undefined; + }); }); }); }); diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js new file mode 100644 index 0000000000..0ebde39361 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -0,0 +1,130 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:groupId/removeMember/:memberId', () => { + let leader; + let invitedUser; + let guild; + let member; + let member2; + + beforeEach(async () => { + let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'private', + }, + invites: 1, + members: 2, + }); + + guild = group; + leader = groupLeader; + invitedUser = invitees[0]; + member = members[0]; + member2 = members[1]; + }); + + context('All Groups', () => { + it('returns an error when user is not member of the group', async () => { + let nonMember = await generateUser(); + + expect(nonMember.post(`/groups/${guild._id}/removeMember/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + type: 'NotAuthorized', + message: t('onlyLeaderCanRemoveMember'), + }); + }); + + it('returns an error when user is a non-leader member of a group', async () => { + expect(member2.post(`/groups/${guild._id}/removeMember/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + type: 'NotAuthorized', + message: t('onlyLeaderCanRemoveMember'), + }); + }); + + it('does not allow leader to remove themselves', async () => { + expect(leader.post(`/groups/${guild._id}/removeMember/${leader._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + text: t('messageGroupCannotRemoveSelf'), + }); + }); + }); + + context('Guilds', () => { + it('can remove other members', async () => { + await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); + let memberRemoved = await member.get('/user'); + + expect(memberRemoved.guilds.indexOf(guild._id)).eql(-1); + }); + + it('updates memberCount', async () => { + let oldMemberCount = guild.memberCount; + await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); + await expect(leader.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); + }); + + it('can remove other invites', async () => { + await leader.post(`/groups/${guild._id}/removeMember/${invitedUser._id}`); + + let invitedUserWithoutInvite = await invitedUser.get('/user'); + + expect(_.findIndex(invitedUserWithoutInvite.invitations.guilds, {id: guild._id})).eql(-1); + }); + }); + + context('Party', () => { + let party; + let partyleader; + let partyInvitedUser; + let partyMember; + + beforeEach(async () => { + let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + invites: 1, + members: 1, + }); + + party = group; + partyleader = groupLeader; + partyInvitedUser = invitees[0]; + partyMember = members[0]; + }); + + it('can remove other members', async () => { + await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); + + let memberRemoved = await partyMember.get('/user'); + + expect(memberRemoved.party._id).eql(undefined); + }); + + it('updates memberCount', async () => { + let oldMemberCount = party.memberCount; + await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); + await expect(partyleader.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); + }); + + it('can remove other invites', async () => { + await partyleader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`); + + let invitedUserWithoutInvite = await partyInvitedUser.get('/user'); + + expect(_.findIndex(invitedUserWithoutInvite.invitations.party, {id: party._id})).eql(-1); + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 42719c0fcd..55c25d9153 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -290,12 +290,14 @@ describe('Post /groups/:groupId/invite', () => { }); }); - it('returns an error when invited user is already in the party', async () => { + it('returns an error when invited user is already in a party of more than 1 member', async () => { let userToInvite = await generateUser(); + let userToInvite2 = await generateUser(); await inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], + uuids: [userToInvite._id, userToInvite2._id], }); await userToInvite.post(`/groups/${party._id}/join`); + await userToInvite2.post(`/groups/${party._id}/join`); await expect(inviter.post(`/groups/${party._id}/invite`, { uuids: [userToInvite._id], @@ -306,5 +308,18 @@ describe('Post /groups/:groupId/invite', () => { message: t('userAlreadyInAParty'), }); }); + + it('allow inviting an user to a party if he\'s partying solo', async () => { + let userToInvite = await generateUser(); + await userToInvite.post('/groups', { // add user to a party + name: 'Another Test Party', + type: 'party', + }); + + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id); + }); }); }); diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index fb6c7c84f5..a206d7b3ce 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -22,6 +22,21 @@ describe('GET /tasks/user', () => { expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); - // TODO complete after task scoring is done - it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); + it('returns completed todos sorted by completion date if req.query.includeCompletedTodos is specified', async () => { + let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'}); + let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'}); + + await user.sync(); + let initialTodoCount = user.tasksOrder.todos.length; + + await user.post(`/tasks/${todo2._id}/score/up`); + await user.post(`/tasks/${todo1._id}/score/up`); + await user.sync(); + + expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - 2); + + let allTodos = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); + expect(allTodos.length).to.equal(initialTodoCount); + expect(allTodos[allTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later + }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js new file mode 100644 index 0000000000..338808a4a1 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js @@ -0,0 +1,41 @@ +import { + generateUser, + generateGroup, + generateChallenge, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /tasks/clearCompletedTodos', () => { + it('deletes all completed todos except the ones from a challenge', async () => { + let user = await generateUser({balance: 1}); + let guild = await generateGroup(user); + let challenge = await generateChallenge(user, guild); + + let initialTodoCount = user.tasksOrder.todos.length; + await user.post('/tasks/user', [ + {text: 'todo 1', type: 'todo'}, + {text: 'todo 2', type: 'todo'}, + {text: 'todo 3', type: 'todo'}, + {text: 'todo 4', type: 'todo'}, + {text: 'todo 5', type: 'todo'}, + ]); + + await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'todo 6', + type: 'todo', + }); + + let tasks = await user.get('/tasks/user?type=todo'); + expect(tasks.length).to.equal(initialTodoCount + 6); + + for (let task of tasks) { + if (['todo 2', 'todo 3', 'todo 6'].indexOf(task.text) !== -1) { + await user.post(`/tasks/${task._id}/score/up`); // eslint-disable-line babel/no-await-in-loop + } + } + + await user.post('/tasks/clearCompletedTodos'); + let tasksUpdated = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); + expect(tasksUpdated.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge) + expect(tasksUpdated[tasksUpdated.length - 1].text).to.equal('todo 6'); + }); +}); diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 95960c704d..ebde4ee3ea 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -1,23 +1,16 @@ import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; +import mongoose from 'mongoose'; describe('Base model plugin', () => { - let schema = { - add () { - return true; - }, - statics: {}, - options: {}, - pre () { - return true; - }, - }; + let schema; beforeEach(() => { + schema = new mongoose.Schema(); sandbox.stub(schema, 'add'); }); it('adds a _id field to the schema', () => { - baseModel(schema); + schema.plugin(baseModel); expect(schema.add).to.be.calledWith(sinon.match({ _id: sinon.match.object, @@ -25,13 +18,13 @@ describe('Base model plugin', () => { }); it('can add timestamps fields', () => { - baseModel(schema, {timestamps: true}); + schema.plugin(baseModel, {timestamps: true}); expect(schema.add).to.be.calledTwice; }); it('can sanitize input objects', () => { - baseModel(schema, { + schema.plugin(baseModel, { noSet: ['noUpdateForMe'], }); @@ -44,7 +37,7 @@ describe('Base model plugin', () => { }); it('accepts an array of additional fields to sanitize at runtime', () => { - baseModel(schema, { + schema.plugin(baseModel, { noSet: ['noUpdateForMe'], }); @@ -58,7 +51,7 @@ describe('Base model plugin', () => { it('can make fields private', () => { - baseModel(schema, { + schema.plugin(baseModel, { private: ['amPrivate'], }); @@ -76,7 +69,7 @@ describe('Base model plugin', () => { toJSONTransform: sandbox.stub().returns(true), }; - baseModel(schema, options); + schema.plugin(baseModel, options); let objToTransform = {ok: true, amPrivate: true}; let privatized = schema.options.toJSON.transform({}, objToTransform); @@ -91,7 +84,7 @@ describe('Base model plugin', () => { sanitizeTransform: sandbox.stub().returns(true), }; - baseModel(schema, options); + schema.plugin(baseModel, options); expect(schema.options.toJSON.transform).to.exist; let objToSanitize = {ok: true, noUpdateForMe: true}; diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js new file mode 100644 index 0000000000..e32e953d51 --- /dev/null +++ b/test/api/v3/unit/libs/collectionManipulators.test.js @@ -0,0 +1,88 @@ +import mongoose from 'mongoose'; +import { + removeFromArray, +} from '../../../../../website/src/libs/api-v3/collectionManipulators'; + +describe('Collection Manipulators', () => { + describe('removeFromArray', () => { + it('removes element from array', () => { + let array = ['a', 'b', 'c', 'd']; + + removeFromArray(array, 'c'); + + expect(array).to.not.include('c'); + }); + + it('removes object from array', () => { + let array = [ + { id: 'a', foo: 'bar' }, + { id: 'b', foo: 'bar' }, + { id: 'c', foo: 'bar' }, + { id: 'd', foo: 'bar' }, + { id: 'e', foo: 'bar' }, + ]; + + removeFromArray(array, { id: 'c' }); + + expect(array).to.not.include({ id: 'c', foo: 'bar' }); + }); + + it('does not change array if value is not found', () => { + let array = ['a', 'b', 'c', 'd']; + + removeFromArray(array, 'z'); + + expect(array).to.have.a.lengthOf(4); + expect(array[0]).to.eql('a'); + expect(array[1]).to.eql('b'); + expect(array[2]).to.eql('c'); + expect(array[3]).to.eql('d'); + }); + + it('returns the removed element', () => { + let array = ['a', 'b', 'c']; + + let result = removeFromArray(array, 'b'); + + expect(result).to.eql('b'); + }); + + it('returns the removed object element', () => { + let array = [ + { id: 'a', foo: 'bar' }, + { id: 'b', foo: 'bar' }, + { id: 'c', foo: 'bar' }, + { id: 'd', foo: 'bar' }, + { id: 'e', foo: 'bar' }, + ]; + + let result = removeFromArray(array, { id: 'c' }); + + expect(result).to.eql({ id: 'c', foo: 'bar' }); + }); + + it('returns false if item is not found', () => { + let array = ['a', 'b', 'c']; + + let result = removeFromArray(array, 'z'); + + expect(result).to.eql(false); + }); + + it('persists removal of element when mongoose document is saved', async () => { + let schema = new mongoose.Schema({ + array: Array, + }); + let Model = mongoose.model('ModelToTestRemoveFromArray', schema); + let model = await new Model({ + array: ['a', 'b', 'c'], + }).save(); // Initial creation + + removeFromArray(model.array, 'b'); + + let savedModel = await model.save(); + + expect(savedModel.array).to.not.include('b'); + }); + }); +}); diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js index 1136f255a6..098bfefa21 100644 --- a/test/api/v3/unit/libs/i18n.test.js +++ b/test/api/v3/unit/libs/i18n.test.js @@ -31,13 +31,6 @@ describe('i18n', () => { }); }); - describe('localePath', () => { - it('is an absolute path to common/locales/', () => { - expect(localePath).to.match(/.*\/common\/locales\//); - expect(localePath); - }); - }); - describe('langCodes', () => { it('is a list of all the language codes', () => { expect(langCodes.sort()).to.eql(listOfLocales); diff --git a/test/api/v3/unit/libs/setupNconf.test.js b/test/api/v3/unit/libs/setupNconf.test.js index 1fbb046a85..e0647d38d2 100644 --- a/test/api/v3/unit/libs/setupNconf.test.js +++ b/test/api/v3/unit/libs/setupNconf.test.js @@ -1,5 +1,6 @@ import setupNconf from '../../../../../website/src/libs/api-v3/setupNconf'; +import path from 'path'; import nconf from 'nconf'; describe('setupNconf', () => { @@ -19,7 +20,9 @@ describe('setupNconf', () => { expect(nconf.argv).to.be.calledOnce; expect(nconf.env).to.be.calledOnce; expect(nconf.file).to.be.calledOnce; - expect(nconf.file).to.be.calledWithMatch('user', /\/config.json$/); + + let regexString = `\\${path.sep}config.json$`; + expect(nconf.file).to.be.calledWithMatch('user', new RegExp(regexString)); }); it('sets IS_PROD variable', () => { diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index bcaa7898e2..eb238c6fa9 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -53,4 +53,3 @@ describe('analytics middleware', () => { expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase); }); }); - diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 3fe7adbca7..f584826520 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -4,7 +4,7 @@ import { requester } from './requester'; import { getDocument as getDocumentFromMongo, updateDocument as updateDocumentInMongo, -} from './mongo'; +} from '../mongo'; import { assign, each, @@ -59,6 +59,29 @@ export class ApiGroup extends ApiObject { this._docType = 'groups'; } + + async addChat (chat) { + let group = this; + + if (!chat) { + chat = { + id: 'Test_ID', + text: 'Test message', + flagCount: 0, + timestamp: Date(), + likes: {}, + flags: {}, + uuid: group.leader, + contributor: {}, + backer: {}, + user: group.leader, + }; + } + + let update = { chat }; + + return await this.update(update); + } } export class ApiChallenge extends ApiObject { diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index a55da4d3d8..bcf580e653 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -1,8 +1,9 @@ /* eslint-disable no-use-before-define */ import superagent from 'superagent'; +import nconf from 'nconf'; -const API_TEST_SERVER_PORT = 3003; +const API_TEST_SERVER_PORT = nconf.get('PORT'); let apiVersion; // Sets up an abject that can make all REST requests @@ -51,7 +52,8 @@ function _requestMaker (user, method, additionalSets) { reject(parsedError); } - resolve(response.body); + let contentType = response.headers['content-type'] || ''; + resolve(contentType.indexOf('json') !== -1 ? response.body : response.text); }); }); }; diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 880f88f41a..4ee2e6cfbe 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -6,7 +6,7 @@ requester.setApiVersion('v3'); export { requester }; export { translate } from '../translate'; -export { checkExistence, resetHabiticaDB } from '../mongo'; +export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; export async function sleep (seconds) { diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index cefd1eeeeb..3370bd0887 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,10 +1,13 @@ import '../../website/src/libs/api-v3/i18n'; +import mongoose from 'mongoose'; import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; +import mongo from './mongo'; // eslint-disable-line -afterEach(() => { +afterEach((done) => { sandbox.restore(); + mongoose.connection.db.dropDatabase(done); }); export function generateUser (options = {}) { diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 5e526c1424..96064b8142 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,6 +1,4 @@ import mongoose from 'mongoose'; -import Q from 'q'; -mongoose.Promise = Q.Promise; import { wrap as wrapUser } from '../../common/script/index'; import { model as User } from '../../website/src/models/user'; diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 19f3ebfc71..9e92a85de9 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,5 +1,6 @@ /* eslint-disable no-undef */ -require('babel-core/register'); +/* eslint-disable global-require */ +/* eslint-disable no-process-env */ //------------------------------ // Global modules //------------------------------ @@ -12,7 +13,25 @@ global.expect = chai.expect; global.sinon = require('sinon'); global.sandbox = sinon.sandbox.create(); +import nconf from 'nconf'; +import mongoose from 'mongoose'; +import Q from 'q'; + //------------------------------ // Load nconf for unit tests //------------------------------ -require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); +if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose + require('../../website/src/libs/api-v3/setupNconf')('./config.json'); + // Use Q promises instead of mpromise in mongoose + mongoose.Promise = Q.Promise; + mongoose.connect(nconf.get('NODE_DB_URI')); +} else { // When running tests and the server in the same process + require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); + nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); + nconf.set('NODE_ENV', 'test'); + nconf.set('IS_TEST', true); + // We require src/server and npt src/index because + // 1. nconf is already setup + // 2. we don't need clustering + require('../../website/src/server'); +} diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/mongo.js similarity index 52% rename from test/helpers/api-integration/mongo.js rename to test/helpers/mongo.js index bbe22496b6..d99c195c3c 100644 --- a/test/helpers/api-integration/mongo.js +++ b/test/helpers/mongo.js @@ -1,24 +1,17 @@ -/* eslint-disable no-use-before-define */ - -import { MongoClient as mongo } from 'mongodb'; - -const DB_URI = 'mongodb://localhost/habitrpg_test'; +import mongoose from 'mongoose'; // Useful for checking things that have been deleted, // but you no longer have access to, // like private parties or users export async function checkExistence (collectionName, id) { - let db = await connectToMongo(); - return new Promise((resolve, reject) => { - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); collection.find({_id: id}, {_id: 1}).limit(1).toArray((findError, docs) => { if (findError) return reject(findError); let exists = docs.length > 0; - db.close(); resolve(exists); }); }); @@ -27,64 +20,67 @@ export async function checkExistence (collectionName, id) { // Specifically helpful for the GET /groups tests, // resets the db to an empty state and creates a tavern document export async function resetHabiticaDB () { - let db = await connectToMongo(); - return new Promise((resolve, reject) => { - db.dropDatabase((dbErr) => { + mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); - let groups = db.collection('groups'); + let groups = mongoose.connection.db.collection('groups'); - groups.insertOne({ - _id: 'habitrpg', - chat: [], - leader: '9', - name: 'HabitRPG', - type: 'guild', - privacy: 'public', - }, (insertErr) => { - if (insertErr) return reject(insertErr); + // For some mysterious reason after a dropDatabase there can still be a group... + groups.count({_id: 'habitrpg'}, (err, count) => { + if (err) return reject(err); + if (count > 0) return resolve(); - db.close(); - resolve(); + groups.insertOne({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }, (insertErr) => { + if (insertErr) return reject(insertErr); + + resolve(); + }); }); }); }); } export async function updateDocument (collectionName, doc, update) { - let db = await connectToMongo(); - - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); return new Promise((resolve) => { collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); - db.close(); resolve(); }); }); } export async function getDocument (collectionName, doc) { - let db = await connectToMongo(); - - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); return new Promise((resolve) => { collection.findOne({ _id: doc._id }, (lookupErr, found) => { if (lookupErr) throw new Error(`Error looking up ${collectionName}: ${lookupErr}`); - db.close(); resolve(found); }); }); } -export function connectToMongo () { - return new Promise((resolve, reject) => { - mongo.connect(DB_URI, (err, db) => { - if (err) return reject(err); - - resolve(db); - }); +before((done) => { + mongoose.connection.on('open', (err) => { + if (err) return done(err); + resetHabiticaDB() + .then(() => done()) + .catch(done); }); -} +}); + +after((done) => { + mongoose.connection.db.dropDatabase((err) => { + if (err) return done(err); + mongoose.connection.close(done); + }); +}); diff --git a/test/mocha.opts b/test/mocha.opts index bcdefd3e55..c55b351ceb 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -5,5 +5,4 @@ --growl --globals io --compilers js:babel-core/register ---require test/api-legacy/api-helper --require ./test/helpers/globals.helper diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index f0c682cf05..94eb0330e5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -3,16 +3,20 @@ import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; -import { model as User } from '../../models/user'; +import { + model as User, + nameFields, +} from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; import shared from '../../../../common'; import * as Tasks from '../../models/task'; -import { txnEmail } from '../../libs/api-v3/email'; +import { sendTxn as txnEmail } from '../../libs/api-v3/email'; import pushNotify from '../../libs/api-v3/pushNotifications'; import Q from 'q'; +import csvStringify from '../../libs/api-v3/csvStringify'; let api = {}; @@ -129,6 +133,44 @@ api.joinChallenge = { }, }; +/** + * @api {post} /challenges/:challengeId/leave Leaves a challenge + * @apiVersion 3.0.0 + * @apiName LeaveChallenge + * @apiGroup Challenge + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} empty An empty object + */ +api.leaveChallenge = { + method: 'POST', + url: '/challenges/:challengeId/leave', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all'; + + 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')); + + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + + if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound')); + + challenge.memberCount -= 1; + + // Unlink challenge's tasks from user's tasks and save the challenge + await Q.all([user.unlinkChallengeTasks(challenge._id, keep), challenge.save()]); + res.respond(200, {}); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 @@ -192,22 +234,156 @@ api.getChallenge = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); res.respond(200, challenge); }, }; +/** + * @api {get} /challenges/:challengeId/export/csv Export a challenge in CSV + * @apiVersion 3.0.0 + * @apiName ExportChallengeCsv + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge object + */ +api.exportChallengeCsv = { + method: 'GET', + url: '/challenges/:challengeId/export/csv', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findById(challengeId).select('_id groupId leader tasksOrder').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + + // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all + // results on the server so the perf difference isn't that big (hopefully) + + let [members, tasks] = await Q.all([ + User.find({challenges: challengeId}) + .select(nameFields) + .sort({_id: 1}) + .lean() // so we don't involve mongoose + .exec(), + + Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}}) + .sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(), + ]); + + let resArray = members.map(member => [member._id, member.profile.name]); + + // We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0] + let lastUserId; + let index = -1; + tasks.forEach(task => { + if (task.userId !== lastUserId) { + lastUserId = task.userId; + index++; + } + + resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes); + }); + + // The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks + let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => { + return result.concat(array); + }, []).sort(); + resArray.unshift(['UUID', 'name']); + _.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes')); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=${challengeId}.csv`, + }); + + let csvRes = await csvStringify(resArray); + res.status(200).send(csvRes); + }, +}; + +/** + * @api {put} /challenges/:challengeId Update a challenge + * @apiVersion 3.0.0 + * @apiName UpdateChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The updated challenge object + */ +api.updateChallenge = { + method: 'PUT', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findById(challengeId).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id name type privacy', optionalMembership: true}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal')); + + _.merge(challenge, Challenge.sanitizeUpdate(req.body)); + + let savedChal = await challenge.save(); + res.respond(200, savedChal); + }, +}; + // TODO everything here should be moved to a worker -// actually even for a worker it's probably just to big and will kill mongo -function _closeChal (challenge, broken = {}) { +// actually even for a worker it's probably just too big and will kill mongo +async function _closeChal (challenge, broken = {}) { let winner = broken.winner; let brokenReason = broken.broken; - let tasks = [ - // Delete the challenge - Challenge.remove({_id: challenge._id}).exec(), + // Delete the challenge + await Challenge.remove({_id: challenge._id}).exec(); + + // Refund the leader if the challenge is closed and the group not the tavern + if (challenge.groupId !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec(); + } + + // Update the challengeCount on the group + await Group.update({_id: challenge.groupId}, {$inc: {challengeCount: -1}}).exec(); + + // Award prize to winner and notify + if (winner) { + winner.achievements.challenges.push(challenge.name); + winner.balance += challenge.prize / 4; + let savedWinner = await winner.save(); + if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { + txnEmail(savedWinner, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: challenge.name}, + ]); + } + + pushNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate + } + + // Run some operations in the background withouth blocking the thread + let backgroundTasks = [ // And it's tasks Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges @@ -227,31 +403,9 @@ function _closeChal (challenge, broken = {}) { 'challenge.winner': winner && winner.profile.name, }, }, {multi: true}).exec(), - // Update the challengeCount on the group - Group.update({_id: challenge.groupId}, {$inc: {challengeCount: -1}}).exec(), ]; - // Refund the leader if the challenge is closed and the group not the tavern - if (challenge.groupId !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { - tasks.push(User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec()); - } - - // Award prize to winner and notify - if (winner) { - winner.achievements.challenges.push(challenge.name); - winner.balance += challenge.prize / 4; - tasks.push(winner.save().then(savedWinner => { - if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { - txnEmail(savedWinner, 'won-challenge', [ - {name: 'CHALLENGE_NAME', content: challenge.name}, - ]); - } - - pushNotify.sendNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate - })); - } - - return Q.allSettled(tasks); // TODO look if allSettled could be useful somewhere else + Q.allSettled(backgroundTasks); // TODO look if allSettled could be useful somewhere else // TODO catch and handle } @@ -270,7 +424,7 @@ api.deleteChallenge = { async handler (req, res) { let user = res.locals.user; - req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -279,16 +433,16 @@ api.deleteChallenge = { if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + // Close channel in background, some ops are run in the background without `await`ing + await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); }, }; /** - * @api {delete} /challenges/:challengeId Delete a challenge + * @api {post} /challenges/:challengeId/selectWinner/:winnerId Select winner for challenge * @apiVersion 3.0.0 - * @apiName DeleteChallenge + * @apiName SelectChallengeWinner * @apiGroup Challenge * * @apiSuccess {object} empty An empty object @@ -300,7 +454,7 @@ api.selectChallengeWinner = { async handler (req, res) { let user = res.locals.user; - req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); @@ -311,11 +465,11 @@ api.selectChallengeWinner = { if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); let winner = await User.findOne({_id: req.params.winnerId}).exec(); - if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); + if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId})); + // Close channel in background, some ops are run in the background without `await`ing + await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 212e064441..5c8d4fd3ed 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -4,8 +4,10 @@ import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import { NotFound, + NotAuthorized, } from '../../libs/api-v3/errors'; import _ from 'lodash'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { sendTxn } from '../../libs/api-v3/email'; import nconf from 'nconf'; @@ -105,7 +107,7 @@ api.postChat = { * @apiSuccess {Array} chat An array of chat messages */ api.likeChat = { - method: 'Post', + method: 'POST', url: '/groups/:groupId/chat/:chatId/like', middlewares: [authWithHeaders(), cron], async handler (req, res) { @@ -152,7 +154,7 @@ api.likeChat = { * @apiSuccess {Array} chat An array of chat messages */ api.flagChat = { - method: 'Post', + method: 'POST', url: '/groups/:groupId/chat/:chatId/flag', middlewares: [authWithHeaders(), cron], async handler (req, res) { @@ -256,4 +258,140 @@ api.flagChat = { }, }; +/** + * @api {post} /groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags + * @apiVersion 3.0.0 + * @apiName ClearFlags + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * @apiParam {chatId} chatId The chat message _id + * + * @apiSuccess {Object} An empty object + */ +api.clearChatFlags = { + method: 'Post', + url: '/groups/:groupId/chat/:chatId/clearflags', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatId = req.params.chatId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.contributor.admin) { + throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount')); + } + + let group = await Group.getGroup({user, groupId}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let message = _.find(group.chat, {id: chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + message.flagCount = 0; + + await Group.update( + {_id: group._id, 'chat.id': message.id}, + {$set: {'chat.$.flagCount': message.flagCount}} + ); + + res.respond(200, {}); + }, +}; + +/** + * @api {post} /groups/:groupId/chat/:chatId/seen Seen a group chat message + * @apiVersion 3.0.0 + * @apiName SeenChat + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * + * @apiSuccess {None} + */ +api.seenChat = { + method: 'POST', + url: '/groups/:groupId/chat/seen', + 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}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let update = {$unset: {}}; + update.$unset[`newMessages.${groupId}`] = true; + + await User.update({_id: user._id}, update).exec(); + res.respond(200); + }, +}; + +/** + * @api {delete} /groups/:groupId/chat/:chatId Delete chat message from a group + * @apiVersion 3.0.0 + * @apiName DeleteChat + * @apiGroup Chat + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} chatId The chat _id + * + * @apiSuccess {Array} The update chat array + * @apiSuccess {Object} An empty object when the previous message was deleted + */ +api.deleteChat = { + method: 'DELETE', + url: '/groups/:groupId/chat/:chatId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatId = req.params.chatId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'chat'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let message = _.find(group.chat, {id: chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + if (user._id !== message.uuid && !user.contributor.admin) { + throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat')); + } + + let lastClientMsg = req.query.previousMsg; + let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + + await Group.update( + {_id: group._id}, + {$pull: {chat: {id: chatId}}} + ); + + if (chatUpdated) { + group = group.toJSON(); + removeFromArray(group.chat, {id: chatId}); + res.respond(200, group.chat); + } else { + res.respond(200, {}); + } + }, +}; + export default api; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 05a68831e9..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -13,6 +13,7 @@ import { BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; +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'; @@ -86,14 +87,14 @@ api.getGroups = { // TODO validate types are acceptable? probably not necessary let types = req.query.type.split(','); - let groupFields = 'name description memberCount balance leader'; + let groupFields = 'name description memberCount balance'; let sort = '-memberCount'; let queries = []; types.forEach(type => { switch (type) { case 'party': - queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields, populateLeader: true})); + queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields})); break; case 'privateGuilds': queries.push(Group.find({ @@ -109,7 +110,9 @@ api.getGroups = { }).select(groupFields).sort(sort).exec()); // TODO use lean? break; case 'tavern': - queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields, populateLeader: true})); + if (types.indexOf('publicGuilds') === -1) { + queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields})); + } break; } }); @@ -152,6 +155,14 @@ api.getGroup = { let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (!user.contributor.admin) { + group = group.toJSON(); + _.remove(group.chat, function removeChat (chat) { + chat.flags = {}; + return chat.flagCount >= 2; + }); + } + res.respond(200, group); }, }; @@ -228,19 +239,26 @@ api.joinGroup = { if (group.quest.key && !group.quest.active) { user.party.quest.RSVPNeeded = true; user.party.quest.key = group.quest.key; + user.party.quest.progress = undefined; // Make sure to reset progress from ay previous quest group.quest.members[user._id] = undefined; group.markModified('quest.members'); } + // If user was in a different party (when partying solo you can be invited to a new party) + // make him leave that party before doing anything + if (user.party._id) { + let userPreviousParty = await Group.getGroup({user, groupId: user.party._id}); + if (userPreviousParty) await userPreviousParty.leave(user); + } + user.party._id = group._id; // Set group as user's party isUserInvited = true; } else if (group.type === 'guild') { - let i = _.findIndex(user.invitations.guilds, {id: group._id}); + let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id }); - if (i !== -1) { + if (hasInvitation) { isUserInvited = true; - user.invitations.guilds.splice(i, 1); // Remove invitation } else { isUserInvited = group.privacy === 'private' ? false : true; } @@ -350,39 +368,56 @@ api.removeGroupMember = { let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat if (!group) throw new NotFound(res.t('groupNotFound')); - let uuid = req.query.memberId; + let uuid = req.params.memberId; if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); let member = await User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); + // We're removing the user from a guild or a party? is the user invited only? - let isInGroup = member.party._id === group._id ? 'party' : member.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary - let isInvited = member.invitations.party.id === group._id ? 'party' : _.findIndex(member.invitations.guilds, {id: group._id}) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + let isInGroup; + if (member.party._id === group._id) { + isInGroup = 'party'; + } else if (member.guilds.indexOf(group._id) !== -1) { + isInGroup = 'guild'; + } + + let isInvited; + if (member.invitations.party && member.invitations.party.id === group._id) { + isInvited = 'party'; + } else if (_.findIndex(member.invitations.guilds, {id: group._id}) !== -1) { + isInvited = 'guild'; + } if (isInGroup) { group.memberCount -= 1; if (group.quest && group.quest.leader === member._id) { - group.quest.key = null; - group.quest.leader = null; // TODO markmodified? + group.quest.key = undefined; + group.quest.leader = undefined; } else if (group.quest && group.quest.members) { // remove member from quest - group.quest.members[member._id] = undefined; + group.quest.members[member._id] = undefined; // TODO remmeber to check these are mark modified everywhere + group.markModified('quest.members'); } - if (isInGroup === 'guild') _.pull(member.guilds, group._id); + if (isInGroup === 'guild') { + removeFromArray(member.guilds, group._id); + } if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - member.newMessages.group._id = undefined; + if (member.newMessages[group._id]) { + member.newMessages[group._id] = undefined; + member.markModified('newMessages'); + } if (group.quest && group.quest.active && group.quest.leader === member._id) { member.items.quests[group.quest.key] += 1; // TODO why this? } } else if (isInvited) { if (isInvited === 'guild') { - let i = _.findIndex(member.invitations.guilds, {id: group._id}); - if (i !== -1) member.invitations.guilds.splice(i, 1); + removeFromArray(member.invitations.guilds, { id: group._id }); } if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? } else { @@ -401,7 +436,7 @@ api.removeGroupMember = { }; async function _inviteByUUID (uuid, group, inviter, req, res) { - // @TODO: Add Push Notifications + // TODO: Add Push Notifications let userToInvite = await User.findById(uuid).exec(); if (!userToInvite) { @@ -420,11 +455,14 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (!_.isEmpty(userToInvite.invitations.party)) { throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); } + if (userToInvite.party._id) { - throw new NotAuthorized(res.t('userAlreadyInAParty')); + let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); + + // Allow user to be invited to a new party when they're partying solo + if (userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty')); } - // @TODO: Why was this here? - // req.body.type in 'guild', 'party' + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; } diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 62df59b651..142926ed64 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -76,7 +76,10 @@ function _getMembersForItem (type) { if (type === 'challenge-members') { challenge = await Challenge.findById(challengeId).select('_id type leader groupId').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + + // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge + // for example if you've been booted from it, are the leader or a site admin + group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); } else { group = await Group.getGroup({user, groupId, fields: '_id type'}); @@ -207,7 +210,9 @@ api.getChallengeMemberProgress = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge + // for example if you've been booted from it, are the leader or a site admin + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index acdd456beb..0197d38dbb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,6 +1,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as Tasks from '../../models/task'; import { model as Challenge } from '../../models/challenge'; import { @@ -129,6 +130,7 @@ async function _getTasks (req, res, user, challenge) { if (challenge) throw new BadRequest(res.t('noCompletedTodosChallenge')); // no completed todos for challenges let queryCompleted = Tasks.Task.find({ + userId: user._id, type: 'todo', completed: true, }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos @@ -300,7 +302,7 @@ api.updateTask = { delete req.body.tags; } - // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances + // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, bad for performances? // TODO regarding comment above make sure other models with nested fields are using this trick too _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) @@ -382,14 +384,12 @@ api.scoreTask = { // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list if (task.type === 'todo') { if (!wasCompleted && task.completed) { - let i = user.tasksOrder.todos.indexOf(task._id); - if (i !== -1) user.tasksOrder.todos.splice(i, 1); + removeFromArray(user.tasksOrder.todos, task._id); } else if (wasCompleted && !task.completed) { - let i = user.tasksOrder.todos.indexOf(task._id); - if (i === -1) { + let hasTask = removeFromArray(user.tasksOrder.todos, task._id); + if (!hasTask) { user.tasksOrder.todos.push(task._id); // TODO push at the top? } else { // If for some reason it hadn't been removed TODO ok? - user.tasksOrder.todos.splice(i, 1); user.tasksOrder.push(task._id); } } @@ -683,10 +683,8 @@ api.removeChecklistItem = { } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); - if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); - - task.checklist.splice(itemI, 1); + let hasItem = removeFromArray(task.checklist, { _id: req.params.itemId }); + if (!hasItem) throw new NotFound(res.t('checklistItemNotFound')); let savedTask = await task.save(); res.respond(200, {}); // TODO what to return @@ -768,24 +766,14 @@ api.removeTagFromTask = { if (!task) throw new NotFound(res.t('taskNotFound')); - let tagI = task.tags.indexOf(req.params.tagId); - if (tagI === -1) throw new NotFound(res.t('tagNotFound')); - - task.tags.splice(tagI, 1); + let hasTag = removeFromArray(task.tags, req.params.tagId); + if (!hasTag) throw new NotFound(res.t('tagNotFound')); await task.save(); res.respond(200, {}); // TODO what to return }, }; -// Remove a task from (user|challenge).tasksOrder -function _removeTaskTasksOrder (userOrChallenge, taskId, taskType) { - let list = userOrChallenge.tasksOrder[`${taskType}s`]; - let index = list.indexOf(taskId); - - if (index !== -1) list.splice(index, 1); -} - // TODO this method needs some limitation, like to check if the challenge is really broken? /** * @api {post} /tasks/unlink/:taskId Unlink a challenge task @@ -825,7 +813,7 @@ api.unlinkTask = { await task.save(); } else { // remove if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if - _removeTaskTasksOrder(user, taskId, task.type); + removeFromArray(user.tasksOrder[`${task.type}s`], taskId); await Q.all([user.save(), task.remove()]); } else { await task.remove(); @@ -837,7 +825,35 @@ api.unlinkTask = { }; /** - * @api {delete} /task/:taskId Delete a user task given its id + * @api {post} /tasks/clearCompletedTodos Delete user's completed todos + * @apiVersion 3.0.0 + * @apiName ClearCompletedTodos + * @apiGroup Task + * + * @apiSuccess {object} empty An empty object + */ +api.clearCompletedTodos = { + method: 'POST', + url: '/tasks/clearCompletedTodos', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + // Clear completed todos + // Do not delete challenges completed todos TODO unless the task is broken? + await Tasks.Task.remove({ + userId: user._id, + type: 'todo', + completed: true, + 'challenge.id': {$exists: false}, + }).exec(); + + res.respond(200, {}); + }, +}; + +/** + * @api {delete} /tasks/:taskId Delete a user task given its id * @apiVersion 3.0.0 * @apiName DeleteTask * @apiGroup Task @@ -875,7 +891,7 @@ api.deleteTask = { } if (task.type !== 'todo' || !task.completed) { - _removeTaskTasksOrder(challenge || user, taskId, task.type); + removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId); await Q.all([(challenge || user).save(), task.remove()]); } else { await task.remove(); diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js new file mode 100644 index 0000000000..ce40085552 --- /dev/null +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -0,0 +1,19 @@ +import { findIndex } from 'lodash'; + +export function removeFromArray (array, element) { + let elementIndex; + + if (typeof element === 'object') { + elementIndex = findIndex(array, element); + } else { + elementIndex = array.indexOf(element); + } + + if (elementIndex !== -1) { + let removedElement = array[elementIndex]; + array.splice(elementIndex, 1); + return removedElement; + } + + return false; +} diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js new file mode 100644 index 0000000000..3a597ff55c --- /dev/null +++ b/website/src/libs/api-v3/csvStringify.js @@ -0,0 +1,11 @@ +import csvStringify from 'csv-stringify'; +import Q from 'q'; + +export default function (input) { + return Q.promise((resolve, reject) => { + csvStringify(input, (err, output) => { + if (err) return reject(err); + return resolve(output); + }); + }); +} diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 0d00ca7f4b..3b259eda6b 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -4,12 +4,15 @@ import winston from 'winston'; import nconf from 'nconf'; const IS_PROD = nconf.get('IS_PROD'); +const IS_TEST = nconf.get('IS_TEST'); let logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly // log errors to console too +} else if (IS_TEST) { + // Do not log anything when testing } else { logger .add(winston.transports.Console, { diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js index e5a49bc3e9..f55f593bad 100644 --- a/website/src/libs/api-v3/setupNconf.js +++ b/website/src/libs/api-v3/setupNconf.js @@ -13,4 +13,5 @@ export default function setupNconf (file) { nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); + nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test'); } diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index aaebc476dc..288452e670 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -34,7 +34,7 @@ export default function cronMiddleware (req, res, next) { // Run cron cron({user, tasksByType, now, daysMissed, analytics}); - // Clean completed todos - 30 days for free users, 90 for subscribers + // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? Task.remove({ userId: user._id, diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 39a7caa21b..040a6c503f 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -10,9 +10,9 @@ let Schema = mongoose.Schema; let schema = new Schema({ name: {type: String, required: true}, - shortName: {type: String, required: true}, // TODO what is it? + shortName: {type: String, required: true}, description: String, - official: {type: Boolean, default: false}, // TODO only settable by admin + official: {type: Boolean, default: false}, tasksOrder: { habits: [{type: String, ref: 'Task'}], dailys: [{type: String, ref: 'Task'}], @@ -20,30 +20,20 @@ let schema = new Schema({ rewards: [{type: String, ref: 'Task'}], }, 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? + groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, memberCount: {type: Number, default: 1}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); schema.plugin(baseModel, { - noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], + noSet: ['_id', 'memberCount', 'tasksOrder'], + timestamps: true, }); -// Returns true if user has access to the challenge (can join) -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; -}; - -// Returns true if user can view the challenge -// Different from hasAccess because challenges of public guilds can be viewed by everyone -schema.methods.canView = function canViewChallenge (user, group) { - if (user.contributor.admin) return true; - if (group.type === 'guild' && group.privacy === 'public') return true; - return this.hasAccess(user); +// A list of additional fields that cannot be updated (but can be set on creation) +let noUpdate = ['groupId', 'official', 'shortName', 'prize']; +schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { + return this.sanitize(updateObj, noUpdate); }; // Returns true if user is a member of the challenge @@ -56,6 +46,23 @@ schema.methods.canModify = function canModifyChallenge (user) { return user.contributor.admin || this.leader === user._id; }; +// Returns true if user has access to the challenge (can join) +schema.methods.hasAccess = function hasAccessToChallenge (user) { + let userGroups = user.guilds.slice(0); // clone user.guilds so we don't modify the original + if (user.party._id) userGroups.push(user.party._id); + userGroups.push('habitrpg'); // tavern + return this.canModify(user) || userGroups.indexOf(this.groupId) !== -1; +}; + +// Returns true if user can view the challenge +// Different from hasAccess because challenges of public guilds can be viewed by everyone +// And also because you can see challenges of groups you've been removed from +schema.methods.canView = function canViewChallenge (user, group) { + if (group.type === 'guild' && group.privacy === 'public') return true; + if (this.isMember(user)) return true; + return this.hasAccess(user); +}; + // Takes a Task document and return a plain object of attributes that can be synced to the user function _syncableAttrs (task) { let t = task.toObject(); // lodash doesn't seem to like _.omit on Document @@ -65,13 +72,6 @@ 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) { diff --git a/website/src/models/group.js b/website/src/models/group.js index af592bb332..1bbb0145a5 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -7,9 +7,11 @@ import shared from '../../../common'; import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; +import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; +import nconf from 'nconf'; let Schema = mongoose.Schema; @@ -79,7 +81,7 @@ schema.plugin(baseModel, { // A list of additional fields that cannot be updated (but can be set on creation) let noUpdate = ['privacy', 'type']; schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return model.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define + return this.sanitize(updateObj, noUpdate); }; // TODO migration @@ -118,10 +120,10 @@ schema.statics.getGroup = function getGroup (options = {}) { let query; // When optionalMembership is true it's not required for the user to be a member of the group - if (optionalMembership === true) { - query = {_id: groupId}; - } else if (groupId === 'party' || user.party._id === groupId) { + if (groupId === 'party' || user.party._id === groupId) { query = {type: 'party', _id: user.party._id}; + } else if (optionalMembership === true) { + query = {_id: groupId}; } else if (user.guilds.indexOf(groupId) !== -1) { query = {type: 'guild', _id: groupId}; } else { @@ -147,8 +149,7 @@ schema.methods.removeGroupInvitations = async function removeGroupInvitations () if (group.type === 'party') { user.invitations.party = {}; // TODO mark modified } else { - let i = _.findIndex(user.invitations.guilds, {id: group._id}); - user.invitations.guilds.splice(i, 1); + removeFromArray(user.invitations.guilds, { id: group._id }); } return user.save(); }); @@ -204,9 +205,17 @@ schema.methods.sendChat = function sendChat (message, user) { // 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 { - User.update({ - _id: {$in: this.members, $ne: user ? user._id : ''}, - }, lastSeenUpdate, {multi: true}).exec(); + 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(); } }; @@ -505,17 +514,19 @@ export const INVITES_LIMIT = 100; export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) -model.count({_id: 'habitrpg'}, (err, ct) => { - if (err) throw err; - if (ct > 0) return; - - new model({ // eslint-disable-line babel/new-cap - _id: 'habitrpg', - leader: '9', // TODO change this user id - name: 'HabitRPG', - type: 'guild', - privacy: 'public', - }).save({ - validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise - }); // TODO catch/log? -}); +// do not run when testing as it's handled by the tests and can easily cause a race condition +if (!nconf.get('IS_TEST')) { + model.count({_id: 'habitrpg'}, (err, ct) => { + if (err) throw err; + if (ct > 0) return; + new model({ // eslint-disable-line babel/new-cap + _id: 'habitrpg', + leader: '9', // TODO change this user id + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }).save({ + validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise + }); // TODO catch/log? + }); +} diff --git a/website/src/models/task.js b/website/src/models/task.js index 3fd574af93..c6a326999a 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -48,13 +48,13 @@ TaskSchema.plugin(baseModel, { // A list of additional fields that cannot be set on creation (but can be set on updare) let noCreate = ['completed']; // TODO completed should be removed for updates too? TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { - return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define + return this.sanitize(createObj, noCreate); }; // A list of additional fields that cannot be updated (but can be set on creation) let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define + return this.sanitize(updateObj, noUpdate); }; // Sanitize checklist objects (disallowing _id) diff --git a/website/src/models/user.js b/website/src/models/user.js index 6ea4ede281..cc1ebccf54 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -6,6 +6,7 @@ import moment from 'moment'; import * as Tasks from './task'; import Q from 'q'; import { schema as TagSchema } from './tag'; +import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -653,7 +654,7 @@ schema.methods.isSubscribed = function isSubscribed () { return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion }; -// Unlink challenges tasks from user +// Unlink challenges tasks (and the challenge itself) from user schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { let user = this; let findQuery = { @@ -661,8 +662,7 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall 'challenge.id': challengeId, }; - let challengeIndex = user.challenges.indexOf(challengeId); - if (challengeIndex !== -1) user.challenges.splice(challengeIndex, 1); + removeFromArray(user.challenges, challengeId); if (keep === 'keep-all') { await Tasks.Task.update(findQuery, { @@ -675,9 +675,7 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall let taskPromises = tasks.map(task => { // Remove task from user.tasksOrder and delete them if (task.type !== 'todo' || !task.completed) { - let list = user.tasksOrder[`${task.type}s`]; - let index = list.indexOf(task._id); - if (index !== -1) list.splice(index, 1); + removeFromArray(user.tasksOrder[`${task.type}s`], task._id); } return task.remove();