Merge branch 'api-v3-groups' into sabrecat/quest-accept

This commit is contained in:
Blade Barringer
2016-01-30 11:10:33 -06:00
52 changed files with 2387 additions and 469 deletions
-2
View File
@@ -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,
+2
View File
@@ -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.",
+6 -4
View File
@@ -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",
+49 -9
View File
@@ -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();
}
});
});*/
-5
View File
@@ -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
+5 -3
View File
@@ -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,
+4
View File
@@ -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
@@ -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;
});
});
});
@@ -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('');
});
});
@@ -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;
});
});
});
@@ -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);
});
});
});
@@ -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.');
});
});
@@ -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);
});
});
});
@@ -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'),
});
});
});
@@ -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);
});
});
+55 -69
View File
@@ -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;
});
});
@@ -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;
});
});
});
@@ -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({});
});
});
});
@@ -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);
});
});
@@ -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);
});
});
});
});
@@ -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;
});
});
});
});
@@ -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);
});
});
});
@@ -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);
});
});
});
@@ -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
});
});
@@ -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');
});
});
+10 -17
View File
@@ -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};
@@ -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');
});
});
});
-7
View File
@@ -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);
+4 -1
View File
@@ -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', () => {
@@ -53,4 +53,3 @@ describe('analytics middleware', () => {
expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase);
});
});
+24 -1
View File
@@ -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 {
+4 -2
View File
@@ -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);
});
});
};
+1 -1
View File
@@ -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) {
+4 -1
View File
@@ -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 = {}) {
-2
View File
@@ -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';
+21 -2
View File
@@ -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');
}
@@ -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);
});
});
-1
View File
@@ -5,5 +5,4 @@
--growl
--globals io
--compilers js:babel-core/register
--require test/api-legacy/api-helper
--require ./test/helpers/globals.helper
+194 -40
View File
@@ -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});
},
};
+140 -2
View File
@@ -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;
+58 -20
View File
@@ -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};
}
+7 -2
View File
@@ -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'));
+41 -25
View File
@@ -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();
@@ -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;
}
+11
View File
@@ -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);
});
});
}
+3
View File
@@ -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, {
+1
View File
@@ -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');
}
+1 -1
View File
@@ -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,
+26 -26
View File
@@ -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) {
+34 -23
View File
@@ -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?
});
}
+2 -2
View File
@@ -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)
+4 -6
View File
@@ -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();