From bf7fc985d0b75fd63651cfbcc7b2bffe6d0fc744 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 20:00:10 +0100 Subject: [PATCH 001/213] begins porting group model to es6 --- .eslintrc | 2 +- tasks/gulp-eslint.js | 2 + website/src/models/group.js | 368 +++++++++++++++++++----------------- 3 files changed, 199 insertions(+), 173 deletions(-) diff --git a/.eslintrc b/.eslintrc index b6bfb0f4fa..62e61b8df8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -75,7 +75,7 @@ "comma-style": [2, "last"], "comma-dangle": [2, "always-multiline"], "computed-property-spacing": [2, "never"], - "consistent-this": [2, "self"], + "consistent-this": [0, "self"], "func-names": 2, "func-style": [2, "declaration", { "allowArrowFunctions": true }], "block-spacing": [2, "always"], diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 695bf03c36..1e39fb7532 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -5,6 +5,8 @@ const SERVER_FILES = [ './website/src/**/api-v3/**/*.js', './website/src/models/user.js', './website/src/models/task.js', + './website/src/models/group.js', + './website/src/models/tag.js', './website/src/models/emailUnsubscription.js', './website/src/server.js', ]; diff --git a/website/src/models/group.js b/website/src/models/group.js index 0d3011cc38..282d2461f8 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -1,26 +1,28 @@ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var User = require('./user').model; -var shared = require('../../../common'); -var _ = require('lodash'); -var async = require('async'); -var logging = require('../libs/api-v2/logging'); -var Challenge = require('./../models/challenge').model; -var firebase = require('../libs/api-v2/firebase'); +import mongoose from 'mongoose'; +import { model as User} from './user'; +import shared from '../../../common'; +import _ from 'lodash'; +// var async = require('async'); +import logger from '../libs/api-v3/logger'; +// var Challenge = require('./../models/challenge').model; +import firebase from '../libs/api-v2/firebase'; +import baseModel from '../libs/api-v3/baseModel'; +import Q from 'q'; -// NOTE any change to groups' members in MongoDB will have to be run through the API +let Schema = mongoose.Schema; + +// NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync -var GroupSchema = new Schema({ - _id: {type: String, 'default': shared.uuid}, - name: String, +export let schema = new Schema({ + name: {type: String, required: true}, description: String, leader: {type: String, ref: 'User'}, - members: [{type: String, ref: 'User'}], - invites: [{type: String, ref: 'User'}], - type: {type: String, "enum": ['guild', 'party']}, - privacy: {type: String, "enum": ['private', 'public'], 'default':'private'}, - //_v: {type: Number,'default': 0}, - chat: Array, + members: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) + invites: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) + type: {type: String, enum: ['guild', 'party'], required: true}, + privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true}, + // _v: {type: Number,'default': 0}, // TODO ? + chat: Array, // TODO ? /* # [{ # timestamp: Date @@ -32,41 +34,52 @@ var GroupSchema = new Schema({ # }] */ leaderOnly: { // restrict group actions to leader (members can't do them) - challenges: {type:Boolean, 'default':false}, - //invites: {type:Boolean, 'default':false} + challenges: {type: Boolean, default: false, required: true}, + // invites: {type:Boolean, 'default':false} // TODO ? }, - memberCount: {type: Number, 'default': 0}, - challengeCount: {type: Number, 'default': 0}, - balance: Number, + memberCount: {type: Number, default: 0}, + challengeCount: {type: Number, default: 0}, + balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - challenges: [{type:'String', ref:'Challenge'}], // do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + challenges: [{type: String, ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, - active: {type:Boolean, 'default':false}, - leader: {type:String, ref:'User'}, - progress:{ + active: {type: Boolean, default: false}, + leader: {type: String, ref: 'User'}, + progress: { hp: Number, - collect: {type:Schema.Types.Mixed, 'default':{}}, // {feather: 5, ingot: 3} + collect: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, // {feather: 5, ingot: 3} rage: Number, // limit break / "energy stored in shell", for explosion-attacks }, - //Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click - //'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. - //TODO when booting user, remove from .joined and check again if we can now start the quest - members: Schema.Types.Mixed, - extra: Schema.Types.Mixed - } + // Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click + // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. + // TODO when booting user, remove from .joined and check again if we can now start the quest + members: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + extra: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + }, }, { - strict: 'throw', - minimize: false // So empty objects are returned + strict: true, + minimize: false, // So empty objects are returned }); +schema.plugin(baseModel, { + noSet: ['_id'], +}); + +// TODO migration /** * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration * to remove duplicates, then take these fucntions out */ -function removeDuplicates(doc){ +/* function removeDuplicates(doc){ // Remove duplicate members if (doc.members) { var uniqMembers = _.uniq(doc.members); @@ -74,219 +87,238 @@ function removeDuplicates(doc){ doc.members = uniqMembers; } } -} +}*/ // FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate() -// @see https://github.com/LearnBoost/mongoose/issues/964 -GroupSchema.pre('save', function(next){ - removeDuplicates(this); +// @see https://github.com/LearnBoost/mongoose/issues/964 -> Add update pre? +// TODO necessary? +schema.pre('save', function preSaveGroup (next) { + // removeDuplicates(this); this.memberCount = _.size(this.members); this.challengeCount = _.size(this.challenges); - next(); -}) - -GroupSchema.pre('remove', function(next) { - var group = this; - async.waterfall([ - function(cb) { - var invitationQuery = {}; - var groupType = group.type; - //Add an 's' to group type guild because the model has the plural version - if (group.type == "guild") groupType += "s"; - invitationQuery['invitations.' + groupType + '.id'] = group._id; - User.find(invitationQuery, cb); - }, - function(users, cb) { - if (users) { - users.forEach(function (user, index, array) { - if ( group.type == "party" ) { - user.invitations.party = {}; - } else { - var i = _.findIndex(user.invitations.guilds, {id: group._id}); - user.invitations.guilds.splice(i, 1); - } - user.save(); - }); - } - cb(); - } - ], next); + return next(); }); -GroupSchema.post('remove', function(group) { +schema.pre('remove', true, function preRemoveGroup (next, done) { + next(); + let group = this; + + // Remove invitations when group is deleted + // TODO verify it works fir everything + User.find({ + // TODO remove need for guilds s in migration? same for id -> _id + [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, + }).exec() + .then(users => { + return Q.all(users.map(user => { + if (group.type === 'party') { + user.invitations.party = {}; + } else { + let i = _.findIndex(user.invitations.guilds, {id: group._id}); + user.invitations.guilds.splice(i, 1); + } + return user.save(); // TODO update? + })); + }) + .then(done) + .catch(done); +}); + +schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -GroupSchema.methods.toJSON = function(){ - var doc = this.toObject(); - removeDuplicates(doc); - doc._isMember = this._isMember; +schema.methods.toJSON = function groupToJSON () { + let doc = this.toObject(); + // removeDuplicates(doc); + doc._isMember = this._isMember; // TODO ? - //fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). + // TODO migration + // fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). // Required as angular 1.3 is strict on dupes, and no message.id to `track by` - _.remove(doc.chat,function(msg){return !msg.id}); + _.remove(doc.chat, msg => !msg.id); + // TODO should not be needed here // @see pre('save') comment above this.memberCount = _.size(this.members); this.challengeCount = _.size(this.challenges); return doc; -} +}; -var chatDefaults = module.exports.chatDefaults = function(msg,user){ - var message = { +// TODO move to its own model +export function chatDefaults (msg, user) { + let message = { id: shared.uuid(), text: msg, - timestamp: +new Date, + timestamp: Number(new Date()), likes: {}, flags: {}, - flagCount: 0 + flagCount: 0, }; + if (user) { _.defaults(message, { uuid: user._id, contributor: user.contributor && user.contributor.toObject(), backer: user.backer && user.backer.toObject(), - user: user.profile.name + user: user.profile.name, }); } else { message.uuid = 'system'; } + return message; } -GroupSchema.methods.sendChat = function(message, user){ - var group = this; - group.chat.unshift(chatDefaults(message,user)); - group.chat.splice(200); - // Kick off chat notifications in the background. - var lastSeenUpdate = {$set:{}, $inc:{_v:1}}; - lastSeenUpdate['$set']['newMessages.'+group._id] = {name:group.name,value:true}; - if (group._id == 'habitrpg') { + +schema.methods.sendChat = function sendChat (message, user) { + this.chat.unshift(chatDefaults(message, user)); + this.chat.splice(200); + + // Kick off chat notifications in the background. // TODO refactor + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; // TODO standardize this _v inc at the user level + lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; + + if (this._id === 'habitrpg') { // TODO For Tavern, only notify them if their name was mentioned // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); } else { - mongoose.model('User').update({_id:{$in:group.members, $ne: user ? user._id : ''}},lastSeenUpdate,{multi:true}).exec(); + User.update({ + _id: {$in: this.members, $ne: user ? user._id : ''}, + }, lastSeenUpdate, {multi: true}).exec(); } -} +}; -var cleanQuestProgress = function(merge){ - var clean = { +function _cleanQuestProgress (merge) { + // TODO clone? (also in sendChat message) + let clean = { key: null, progress: { up: 0, down: 0, - collect: {} + collect: {}, }, completed: null, - RSVPNeeded: false + RSVPNeeded: false, // TODO absolutely change this cryptic name }; - merge = merge || {progress:{}}; - _.merge(clean, _.omit(merge,'progress')); - _.merge(clean.progress, merge.progress); - return clean; -} -GroupSchema.statics.cleanQuestProgress = cleanQuestProgress; -// Participants: Grant rewards & achievements, finish quest -GroupSchema.methods.finishQuest = function(quest, cb) { - var group = this; - var questK = quest.key; - var updates = {$inc:{},$set:{}}; - - updates['$inc']['achievements.quests.' + questK] = 1; - updates['$inc']['stats.gp'] = +quest.drop.gp; - updates['$inc']['stats.exp'] = +quest.drop.exp; - updates['$inc']['_v'] = 1; - if (group._id == 'habitrpg') { - updates['$set']['party.quest.completed'] = questK; // Just show the notif - } else { - updates['$set']['party.quest'] = cleanQuestProgress({completed: questK}); // clear quest progress + if (merge) { // TODO why does it do 2 merges? + _.merge(clean, _.omit(merge, 'progress')); + _.merge(clean.progress, merge.progress); } - _.each(quest.drop.items, function(item){ - var dropK = item.key; + return clean; +} + +schema.statics.cleanQuestProgress = _cleanQuestProgress; + +// Participants: Grant rewards & achievements, finish quest +// TODO transform in promise +schema.methods.finishQuest = function finishQuest (quest, cb) { + let questK = quest.key; + let updates = {$inc: {}, $set: {}}; + + updates.$inc[`achievements.quests.${questK}`] = 1; + updates.$inc['stats.gp'] = Number(quest.drop.gp); // TODO are this castings necessary? + updates.$inc['stats.exp'] = Number(quest.drop.exp); + updates.$inc._v = 1; + + if (this._id === 'habitrpg') { + updates.$set['party.quest.completed'] = questK; // Just show the notif + } else { + updates.$set['party.quest'] = _cleanQuestProgress({completed: questK}); // clear quest progress + } + + _.each(quest.drop.items, (item) => { + let dropK = item.key; + switch (item.type) { case 'gear': // TODO This means they can lose their new gear on death, is that what we want? - updates['$set']['items.gear.owned.'+dropK] = true; + updates.$set[`items.gear.owned.${dropK}`] = true; break; case 'eggs': case 'food': case 'hatchingPotions': case 'quests': - updates['$inc']['items.'+item.type+'.'+dropK] = _.where(quest.drop.items,{type:item.type,key:item.key}).length; + updates.$inc[`items.${item.type}.${dropK}`] = _.where(quest.drop.items, {type: item.type, key: item.key}).length; break; case 'pets': - updates['$set']['items.pets.'+dropK] = 5; + updates.$set[`items.pets.${dropK}`] = 5; break; case 'mounts': - updates['$set']['items.mounts.'+dropK] = true; + updates.$set[`items.mounts.${dropK}`] = true; break; } - }) - var q = group._id === 'habitrpg' ? {} : {_id:{$in:_.keys(group.quest.members)}}; - group.quest = {};group.markModified('quest'); - mongoose.model('User').update(q, updates, {multi:true}, cb); -} + }); -function isOnQuest(user,progress,group){ + let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; + this.quest = {}; + this.markModified('quest'); + User.update(q, updates, {multi: true}, cb); +}; + +function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -GroupSchema.statics.collectQuest = function(user, progress, cb) { - this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ - if (!isOnQuest(user,progress,group)) return cb(null); - var quest = shared.content.quests[group.quest.key]; +// TODO use promise +schema.statics.collectQuest = function collectQuest (user, progress, cb) { + this.findOne({ + type: 'party', + members: {$in: [user._id]}, + }).then(group => { + if (!_isOnQuest(user, progress, group)) return cb(); + let quest = shared.content.quests[group.quest.key]; - _.each(progress.collect,function(v,k){ + _.each(progress.collect, (v, k) => { group.quest.progress.collect[k] += v; }); - var foundText = _.reduce(progress.collect, function(m,v,k){ - m.push(v + ' ' + quest.collect[k].text('en')); + let foundText = _.reduce(progress.collect, (m, v, k) => { + m.push(`${v} ${quest.collect[k].text('en')}`); return m; }, []); + foundText = foundText ? foundText.join(', ') : 'nothing'; - group.sendChat("`" + user.profile.name + " found "+foundText+".`"); + group.sendChat(`\`${user.profile.name} found ${foundText}.\``); group.markModified('quest.progress.collect'); // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, function(v,k){ + if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { return group.quest.progress.collect[k] < v.count; })) return group.save(cb); - async.series([ - function(cb2){ - group.finishQuest(quest,cb2); - }, - function(cb2){ - group.sendChat('`All items found! Party has received their rewards.`'); - group.save(cb2); - } - ],cb); + // TODO use promise + group.finishQuest(quest, () => { + group.sendChat('`All items found! Party has received their rewards.`'); + group.save(cb); + }); }) -} + .catch(cb); +}; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` -module.exports.tavernQuest = {}; -var tavernQ = {_id:'habitrpg','quest.key':{$ne:null}}; +// we export an empty object that is then populated with the query-returned data +export let tavernQuest = {}; + process.nextTick(function(){ - mongoose.model('Group').findOne(tavernQ, function(err,tavern){ + mongoose.model('Group').findOne({_id: 'habitrpg', 'quest.key': {$ne: null}}, (err, tavern) => { + // TODO handle error? if (!tavern) return; // No tavern quest - var quest = tavern.quest.toObject(); // Using _assign so we don't lose the reference to the exported tavernQuest - _.assign(module.exports.tavernQuest, quest); + _.assign(tavernQuest, tavern.quest.toObject()); }); }); -GroupSchema.statics.tavernBoss = function(user,progress) { +schema.statics.tavernBoss = function tavernBoss (user, progress) { if (!progress) return; // hack: prevent crazy damage to world boss - var dmg = Math.min(900, Math.abs(progress.up||0)), - rage = -Math.min(900, Math.abs(progress.down||0)); + let dmg = Math.min(900, Math.abs(progress.up || 0)); + let rage = -Math.min(900, Math.abs(progress.down || 0)); async.waterfall([ function(cb){ @@ -339,12 +371,12 @@ GroupSchema.statics.tavernBoss = function(user,progress) { } ],function(err,res){ if (err === true) return; // no current quest - if (err) return logging.error(err); + if (err) return logger.error(err); dmg = rage = null; }) } -GroupSchema.statics.bossQuest = function(user, progress, cb) { +schema.statics.bossQuest = function bossQuest (user, progress, cb) { this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ if (!isOnQuest(user,progress,group)) return cb(null); var quest = shared.content.quests[group.quest.key]; @@ -386,7 +418,7 @@ GroupSchema.statics.bossQuest = function(user, progress, cb) { } // Remove user from this group -GroupSchema.methods.leave = function(user, keep, mainCb){ +schema.methods.leave = function leaveGroup (user, keep, mainCb){ if(!user) return mainCb(new Error('Missing user.')); if(keep && typeof keep === 'function'){ @@ -472,22 +504,14 @@ GroupSchema.methods.leave = function(user, keep, mainCb){ }); }; - -GroupSchema.methods.toJSON = function() { - var doc = this.toObject(); - - return doc; -}; - - -module.exports.schema = GroupSchema; -var Group = module.exports.model = mongoose.model("Group", GroupSchema); +export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) -Group.count({_id: 'habitrpg'}, function(err, ct){ +// TODO use promise +model.count({_id: 'habitrpg'}, (err, ct) => { if (ct > 0) return; - new Group({ + new model({ _id: 'habitrpg', chat: [], leader: '9', From 455eaf0932bee6cb3c1a344730dfeeb89913e3c6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 20:41:54 +0100 Subject: [PATCH 002/213] add skeleton for groups controller --- website/src/controllers/api-v3/groups.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 website/src/controllers/api-v3/groups.js diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js new file mode 100644 index 0000000000..cf8c3c9ca5 --- /dev/null +++ b/website/src/controllers/api-v3/groups.js @@ -0,0 +1,20 @@ +let api = {}; + +/** + * @api {get} /groups/:groupId Get a group by its id + * @apiVersion 3.0.0 + * @apiName GetGroup + * @apiGroup Group + * + * @apiSuccess {Object} group The group object + */ +api.getGroup = { + method: 'GET', + url: '/groups/:groupId', + middlewares: [authWithHeaders()], + handler (req, res, next) { + // ... + }, +}; + +export default api; From bbc47f5e000daabf61b55833b2fea9bf0f9fd1c6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 17 Dec 2015 16:11:45 +0100 Subject: [PATCH 003/213] finish porting group model --- .eslintrc | 2 +- website/src/controllers/api-v3/groups.js | 20 -- website/src/models/group.js | 311 +++++++++++------------ 3 files changed, 152 insertions(+), 181 deletions(-) delete mode 100644 website/src/controllers/api-v3/groups.js diff --git a/.eslintrc b/.eslintrc index 62e61b8df8..83cd0067d1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,6 @@ { "rules": { - "indent": [2, 2], + "indent": [2, 2, {"SwitchCase": 1}], "quotes": [2, "single"], "linebreak-style": [2, "unix"], "semi": [2, "always"], diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js deleted file mode 100644 index cf8c3c9ca5..0000000000 --- a/website/src/controllers/api-v3/groups.js +++ /dev/null @@ -1,20 +0,0 @@ -let api = {}; - -/** - * @api {get} /groups/:groupId Get a group by its id - * @apiVersion 3.0.0 - * @apiName GetGroup - * @apiGroup Group - * - * @apiSuccess {Object} group The group object - */ -api.getGroup = { - method: 'GET', - url: '/groups/:groupId', - middlewares: [authWithHeaders()], - handler (req, res, next) { - // ... - }, -}; - -export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 282d2461f8..fb9ca70ad2 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -3,8 +3,7 @@ import { model as User} from './user'; import shared from '../../../common'; import _ from 'lodash'; // var async = require('async'); -import logger from '../libs/api-v3/logger'; -// var Challenge = require('./../models/challenge').model; +import { model as Challenge} from './challenge'; import firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; @@ -214,8 +213,7 @@ function _cleanQuestProgress (merge) { schema.statics.cleanQuestProgress = _cleanQuestProgress; // Participants: Grant rewards & achievements, finish quest -// TODO transform in promise -schema.methods.finishQuest = function finishQuest (quest, cb) { +schema.methods.finishQuest = function finishQuest (quest) { let questK = quest.key; let updates = {$inc: {}, $set: {}}; @@ -256,20 +254,19 @@ schema.methods.finishQuest = function finishQuest (quest, cb) { let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; this.quest = {}; this.markModified('quest'); - User.update(q, updates, {multi: true}, cb); + return User.update(q, updates, {multi: true}); }; function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -// TODO use promise -schema.statics.collectQuest = function collectQuest (user, progress, cb) { - this.findOne({ +schema.statics.collectQuest = function collectQuest (user, progress) { + return this.findOne({ type: 'party', members: {$in: [user._id]}, }).then(group => { - if (!_isOnQuest(user, progress, group)) return cb(); + if (!_isOnQuest(user, progress, group)) return; let quest = shared.content.quests[group.quest.key]; _.each(progress.collect, (v, k) => { @@ -288,31 +285,40 @@ schema.statics.collectQuest = function collectQuest (user, progress, cb) { // Still needs completing if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { return group.quest.progress.collect[k] < v.count; - })) return group.save(cb); + })) return group.save(); // TODO use promise - group.finishQuest(quest, () => { + return group.finishQuest(quest) + .then(() => { group.sendChat('`All items found! Party has received their rewards.`'); - group.save(cb); + return group.save(); }); }) - .catch(cb); + // TODO ok to catch even if we're returning a promise? + .catch(); }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` // we export an empty object that is then populated with the query-returned data export let tavernQuest = {}; +let tavernQ = {_id: 'habitrpg', 'quest.key': {$ne: null}}; -process.nextTick(function(){ - mongoose.model('Group').findOne({_id: 'habitrpg', 'quest.key': {$ne: null}}, (err, tavern) => { - // TODO handle error? +// we use process.nextTick because at this point the model is not yet avalaible +process.nextTick(() => { + mongoose.model('Group') + .findOne(tavernQ).exec() + .then(tavern => { if (!tavern) return; // No tavern quest // Using _assign so we don't lose the reference to the exported tavernQuest _.assign(tavernQuest, tavern.quest.toObject()); + }) + .catch(err => { + throw err; }); }); +// TODO promise? schema.statics.tavernBoss = function tavernBoss (user, progress) { if (!progress) return; @@ -320,71 +326,78 @@ schema.statics.tavernBoss = function tavernBoss (user, progress) { let dmg = Math.min(900, Math.abs(progress.up || 0)); let rage = -Math.min(900, Math.abs(progress.down || 0)); - async.waterfall([ - function(cb){ - mongoose.model('Group').findOne(tavernQ,cb); - }, - function(tavern,cb){ - if (!(tavern && tavern.quest && tavern.quest.key)) return cb(true); + this.findOne(tavernQ).exec() + .then(tavern => { + if (!(tavern && tavern.quest && tavern.quest.key)) return; - var quest = shared.content.quests[tavern.quest.key]; - if (tavern.quest.progress.hp <= 0) { - tavern.sendChat(quest.completionChat('en')); - tavern.finishQuest(quest, function(){}); - tavern.save(cb); - _.assign(module.exports.tavernQuest, {extra: null}); - } else { - // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, - // use those first - which allows us to update the boss on the go if things are too easy/hard. - if (!tavern.quest.extra) tavern.quest.extra = {}; - tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); - tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); - if (tavern.quest.progress.rage >= quest.boss.rage.value) { - if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; - var wd = tavern.quest.extra.worldDmg; - var scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // Burnout attacks Ian, Seasonal Sorceress, tavern - if (!scene) { - tavern.sendChat('`'+quest.boss.name('en')+' tries to unleash '+quest.boss.rage.title('en')+', but is too tired.`'); - tavern.quest.progress.rage = 0 //quest.boss.rage.value; - } else { - tavern.sendChat(quest.boss.rage[scene]('en')); - tavern.quest.extra.worldDmg[scene] = true; - tavern.quest.extra.worldDmg.recent = scene; - tavern.markModified('quest.extra.worldDmg'); - tavern.quest.progress.rage = 0; - if (quest.boss.rage.healing) { - tavern.quest.progress.hp += (quest.boss.rage.healing * tavern.quest.progress.hp); - } + let quest = shared.content.quests[tavern.quest.key]; + + if (tavern.quest.progress.hp <= 0) { + tavern.sendChat(quest.completionChat('en')); + tavern.finishQuest(quest, () => {}); + _.assign(tavernQuest, {extra: null}); + return tavern.save(); + } else { + // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, + // use those first - which allows us to update the boss on the go if things are too easy/hard. + if (!tavern.quest.extra) tavern.quest.extra = {}; + tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); + tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); + + if (tavern.quest.progress.rage >= quest.boss.rage.value) { + if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; + + let wd = tavern.quest.extra.worldDmg; + // Burnout attacks Ian, Seasonal Sorceress, tavern + let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary + + if (!scene) { + tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); + tavern.quest.progress.rage = 0; // quest.boss.rage.value; + } else { + tavern.sendChat(quest.boss.rage[scene]('en')); + tavern.quest.extra.worldDmg[scene] = true; + tavern.quest.extra.worldDmg.recent = scene; + tavern.markModified('quest.extra.worldDmg'); + tavern.quest.progress.rage = 0; + if (quest.boss.rage.healing) { + tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; } } - if (quest.boss.desperation && (tavern.quest.progress.hp < quest.boss.desperation.threshold) && !tavern.quest.extra.desperate) { - tavern.sendChat(quest.boss.desperation.text('en')); - tavern.quest.extra.desperate = true; - tavern.quest.extra.def = quest.boss.desperation.def; - tavern.quest.extra.str = quest.boss.desperation.str; - tavern.markModified('quest.extra'); - } - - _.assign(module.exports.tavernQuest, tavern.quest.toObject()); - tavern.save(cb); } - } - ],function(err,res){ - if (err === true) return; // no current quest - if (err) return logger.error(err); - dmg = rage = null; - }) -} -schema.statics.bossQuest = function bossQuest (user, progress, cb) { - this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ - if (!isOnQuest(user,progress,group)) return cb(null); - var quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return cb(null); // FIXME why is this ever happening, progress should be defined at this point - var down = progress.down * quest.boss.str; // multiply by boss strength + if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { + tavern.sendChat(quest.boss.desperation.text('en')); + tavern.quest.extra.desperate = true; + tavern.quest.extra.def = quest.boss.desperation.def; + tavern.quest.extra.str = quest.boss.desperation.str; + tavern.markModified('quest.extra'); + } + + _.assign(module.exports.tavernQuest, tavern.quest.toObject()); + return tavern.save(); + } + }) + .catch(err => { + throw err; + }); +}; + +schema.statics.bossQuest = function bossQuest (user, progress) { + return this.findOne({ + type: 'party', + members: {$in: [user._id]}, + }).exec() + .then(group => { + if (!_isOnQuest(user, progress, group)) return; + + let quest = shared.content.quests[group.quest.key]; + if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point + + let down = progress.down * quest.boss.str; // multiply by boss strength group.quest.progress.hp -= progress.up; - group.sendChat("`" + user.profile.name + " attacks " + quest.boss.name('en') + " for " + (progress.up.toFixed(1)) + " damage, " + quest.boss.name('en') + " attacks party for " + Math.abs(down).toFixed(1) + " damage.`"); //TODO Create a party preferred language option so emits like this can be localized + group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); // TODO Create a party preferred language option so emits like this can be localized // If boss has Rage, increment Rage as well if (quest.boss.rage) { @@ -392,131 +405,109 @@ schema.statics.bossQuest = function bossQuest (user, progress, cb) { if (group.quest.progress.rage >= quest.boss.rage.value) { group.sendChat(quest.boss.rage.effect('en')); group.quest.progress.rage = 0; - if (quest.boss.rage.healing) group.quest.progress.hp += (group.quest.progress.hp * quest.boss.rage.healing); //TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + + // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; } } + // Everyone takes damage - var series = [ - function(cb2){ - mongoose.models.User.update({_id:{$in: _.keys(group.quest.members)}}, {$inc:{'stats.hp':down, _v:1}}, {multi:true}, cb2); - } - ] + let promise = User.update({ + _id: {$in: _.keys(group.quest.members)}, + }, { + $inc: {'stats.hp': down, _v: 1}, + }, {multi: true}); // Boss slain, finish quest if (group.quest.progress.hp <= 0) { - group.sendChat('`You defeated ' + quest.boss.name('en') + '! Questing party members receive the rewards of victory.`'); + group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); // Participants: Grant rewards & achievements, finish quest - series.push(function(cb2){ - group.finishQuest(quest,cb2); - }); + + return promise + .then(() => group.finishQuest()) + .then(() => group.save()); } - series.push(function(cb2){group.save(cb2)}); - async.series(series,cb); + return promise.then(() => group.save()); }) -} + // TODO necessary to catch if we're returning a promise? + .catch(err => { + throw err; + }); +}; // Remove user from this group -schema.methods.leave = function leaveGroup (user, keep, mainCb){ - if(!user) return mainCb(new Error('Missing user.')); +// TODO this is highly inefficient +schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { + let group = this; - if(keep && typeof keep === 'function'){ - mainCb = keep; - keep = null; - } - if(typeof keep !== 'string') keep = 'keep-all'; // can be also 'remove-all' - - var group = this; - - async.parallel([ + return Q.all([ // Remove user from group challenges - function(cb){ - async.waterfall([ - // Find relevant challenges - function(cb2) { - Challenge.find({ - _id: {$in: user.challenges}, // Challenges I am in - group: group._id // that belong to the group I am leaving - }, cb2); - }, - // Update each challenge - function(challenges, cb2) { - Challenge.update( - {_id: {$in: _.pluck(challenges, '_id')}}, - {$pull: {members: user._id}}, - {multi: true}, - function(err) { - cb2(err, challenges); // pass `challenges` above to cb - } - ); - }, - - // Unlink the challenge tasks from user - function(challenges, cb2) { - async.waterfall(challenges.map(function(chal) { - return function(cb3) { - var i = user.challenges.indexOf(chal._id) - if (~i) user.challenges.splice(i,1); - user.unlink({cid: chal._id, keep: keep}, cb3); - } - }), cb2); - } - ], cb); - }, + // First find relevant Challenges + Challenge.find({ + _id: {$in: user.challenges}, // Challenges I am in + group: group._id, // that belong to the group I am leaving + }).then(challenges => { + // Update each challenge + return Challenge.update( + {_id: {$in: _.pluck(challenges, '_id')}}, + {$pull: {members: user._id}}, + {multi: true} + ).then(() => challenges); // pass `challenges` above to next promise TODO ok to return a non-promise? + }).then(challenges => { + return Q.all(challenges.map(chal => { + let i = user.challenges.indexOf(chal._id); + if (i !== -1) user.challenges.splice(i, 1); + return user.unlink({cid: chal._id, keep}); + })); + }), // Update the group - function(cb){ + (() => { // If user is the last one in group and group is private, delete it - if(group.members.length === 1 && ( + if (group.members.length === 1 && ( group.type === 'party' || - (group.type === 'guild' && group.privacy === 'private') - )){ - group.remove(cb) - }else{ // otherwise just remove a member - var update = {$pull: {members: user._id}}; + group.type === 'guild' && group.privacy === 'private' + )) return group.remove(); - // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - var leader = group.leader; + // otherwise just remove a member + let update = {$pull: {members: user._id}}; + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) + let leader = group.leader; - if(leader == user._id || !~group.members.indexOf(leader)){ - var seniorMember = _.find(group.members, function (m) {return m != user._id}); + if (leader === user._id || group.members.indexOf(leader) === -1) { + let seniorMember = _.find(group.members, m => m !== user._id); - // could not exist in case of public guild with 1 member who is leaving - if(seniorMember){ - if (leader == user._id || !~group.members.indexOf(leader)) { - update['$set'] = update['$set'] || {}; - update['$set'].leader = seniorMember; - } - } - } - - update['$inc'] = {memberCount: -1}; - Group.update({_id: group._id}, update, cb); + // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving + if (seniorMember) update.$set = {leader: seniorMember}; } - } - ], function(err){ - if(err) return mainCb(err); + update.$inc = {memberCount: -1}; + return mongoose.model('Group').update({_id: group._id}, update); + })(), + ]).then(() => { firebase.removeUserFromGroup(group._id, user._id); - return mainCb(); + return; // TODO ok not to return promise? + }).catch(err => { // TODO do we have to catch err if we return the promise? + throw err; }); }; export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) -// TODO use promise model.count({_id: 'habitrpg'}, (err, ct) => { + if (err) throw err; if (ct > 0) return; - new model({ + new model({ // eslint-disable-line new-cap _id: 'habitrpg', chat: [], leader: '9', name: 'HabitRPG', type: 'guild', - privacy: 'public' + privacy: 'public', }).save(); }); From 18c49493e02fcb5d7adac764e31c5e2b86b36af7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 17 Dec 2015 16:27:17 +0100 Subject: [PATCH 004/213] fixes tests --- test/api-legacy/api-helper.js | 2 ++ test/helpers/common.helper.js | 2 ++ website/src/models/group.js | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js index 57817173c2..9ade0a2aa2 100644 --- a/test/api-legacy/api-helper.js +++ b/test/api-legacy/api-helper.js @@ -6,6 +6,8 @@ superagentDefaults = require("superagent-defaults"); global.request = superagentDefaults(); global.mongoose = require("mongoose"); +var Q = require('q'); +mongoose.Promise = Q.Promise; global.moment = require("moment"); diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 96064b8142..5e526c1424 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,4 +1,6 @@ import mongoose from 'mongoose'; +import Q from 'q'; +mongoose.Promise = Q.Promise; import { wrap as wrapUser } from '../../common/script/index'; import { model as User } from '../../website/src/models/user'; diff --git a/website/src/models/group.js b/website/src/models/group.js index fb9ca70ad2..fd4c0bbe0d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -305,7 +305,7 @@ let tavernQ = {_id: 'habitrpg', 'quest.key': {$ne: null}}; // we use process.nextTick because at this point the model is not yet avalaible process.nextTick(() => { - mongoose.model('Group') + model // eslint-disable-line no-use-before-define .findOne(tavernQ).exec() .then(tavern => { if (!tavern) return; // No tavern quest @@ -485,7 +485,7 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { } update.$inc = {memberCount: -1}; - return mongoose.model('Group').update({_id: group._id}, update); + return model.update({_id: group._id}, update); // eslint-disable-line no-use-before-define })(), ]).then(() => { firebase.removeUserFromGroup(group._id, user._id); From 21f93c9399371f525df7d4eae8d639cfb7c1166a Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Wed, 16 Dec 2015 17:29:20 -0500 Subject: [PATCH 005/213] WIP(chat): v3 controller --- common/locales/en/api-v3.json | 4 ++- website/src/controllers/api-v3/chat.js | 49 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 website/src/controllers/api-v3/chat.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 935fdf2a1d..d223c66961 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -27,5 +27,7 @@ "positionRequired": "\"position\" is required and must be a number.", "cantMoveCompletedTodo": "Can't move a completed todo.", "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", - "alreadyTagged": "The task is already tagged with given tag." + "alreadyTagged": "The task is already tagged with given tag.", + "groupIdRequired": "\"groupId\" must be a valid UUID", + "groupNotFound": "Group not found." } diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js new file mode 100644 index 0000000000..aa56b03f73 --- /dev/null +++ b/website/src/controllers/api-v3/chat.js @@ -0,0 +1,49 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as Group } from '../../models/group'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +/** + * @api {get} /groups/:groupId/chat Get chat messages from a group + * @apiVersion 3.0.0 + * @apiName GetChat + * @apiGroup Chat + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.getChat = { + method: 'GET', + url: '/groups/:groupId/chat', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let query = groupId === 'party' ? + Group.findOne({type: 'party', members: {$in: [user._id]}}) : + Group.findOne({$or: [ + {_id: groupId, privacy: 'public'}, + {_id: groupId, privacy: 'private', members: {$in: [user._id]}}, + ]}); + + query.exec() + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + res.respond(200, group.chat); + }) + .catch(next); + }, +}; + +export default api; From c3b981dc91e3704fcc75bc5aa6799924aca0dd3f Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 17 Dec 2015 18:48:10 -0500 Subject: [PATCH 006/213] test(api): chat WIP --- test/api/v3/integration/chat/GET-chat.test.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/api/v3/integration/chat/GET-chat.test.js diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js new file mode 100644 index 0000000000..4b4ebd9fdf --- /dev/null +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -0,0 +1,41 @@ +import { + createAndPopulateGroup, + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe.only('GET /groups/:groupId/chat', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('public Guild', () => { + let group; + + before(() => { + return createAndPopulateGroup({groupDetails: { + type: 'guild', + privacy: 'public', + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }}) + .then((createdGroup) => { + group = createdGroup; + }); + }); + + it('returns Guild chat', () => { + return api.get('/groups/' + group._id + '/chat') + .then((getChat) => { + expect(getChat).to.eql(group.chat); + }); + }); + }); +}); From c7b3e3c3e14b0dea858ba7f7070197f093ee82b4 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 17 Dec 2015 18:50:26 -0500 Subject: [PATCH 007/213] fix(test): Remove only --- test/api/v3/integration/chat/GET-chat.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 4b4ebd9fdf..75b5a36d15 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -4,7 +4,7 @@ import { requester, } from '../../../../helpers/api-integration.helper'; -describe.only('GET /groups/:groupId/chat', () => { +describe('GET /groups/:groupId/chat', () => { let user, api; before(() => { From c1daada82c31028ff365957ddb80fbca1e3b2653 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 15:10:37 +0100 Subject: [PATCH 008/213] do not store members or invitations on group doc --- website/src/models/group.js | 4 ++-- website/src/models/user.js | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index fd4c0bbe0d..9bf5313858 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -2,8 +2,8 @@ import mongoose from 'mongoose'; import { model as User} from './user'; import shared from '../../../common'; import _ from 'lodash'; -// var async = require('async'); import { model as Challenge} from './challenge'; +import validator from 'validator'; import firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; @@ -41,7 +41,7 @@ export let schema = new Schema({ balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - challenges: [{type: String, ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, active: {type: Boolean, default: false}, diff --git a/website/src/models/user.js b/website/src/models/user.js index 379763aeed..2ddfccc99b 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -206,11 +206,6 @@ export let schema = new Schema({ todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined }, - invitations: { - guilds: {type: Array, default: []}, - party: Schema.Types.Mixed, // TODO dictionary - }, - // TODO we're storing too many fields here, find a way to reduce them items: { gear: { @@ -324,10 +319,21 @@ export let schema = new Schema({ lastCron: {type: Date, default: Date.now}, // {GROUP_ID: Boolean}, represents whether they have unseen chat messages - newMessages: {type: Schema.Types.Mixed, default: {}}, + newMessages: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, + + challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}], + + invitations: { + guilds: {type: Array}, // TODO what are we storing here + party: Schema.Types.Mixed, // TODO dictionary TODO what are we storing here? + }, + + guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}], party: { - // id // FIXME can we use a populated doc instead of fetching party separate from user? + _id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'}, order: {type: String, default: 'level'}, orderAscending: {type: String, default: 'ascending'}, quest: { @@ -440,7 +446,6 @@ export let schema = new Schema({ }, tags: [TagSchema], - challenges: [{type: String, ref: 'Challenge'}], inbox: { newMessages: {type: Number, default: 0}, @@ -470,7 +475,7 @@ export let schema = new Schema({ schema.plugin(baseModel, { // TODO revisit a lot of things are missing - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats'], + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From ddf77e0e35e3f9f4a3c903ab73b13b86790cbd8f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:00:49 +0100 Subject: [PATCH 009/213] add cliff for completed todos and PMs --- common/script/api-v3/cron.js | 27 +++++++++++++++++++------- website/src/middlewares/api-v3/cron.js | 17 +++++++++++++--- website/src/models/user.js | 4 +++- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js index 1bc8f29927..132adafe32 100644 --- a/common/script/api-v3/cron.js +++ b/common/script/api-v3/cron.js @@ -1,6 +1,7 @@ import moment from 'moment'; import _ from 'lodash'; import scoreTask from './scoreTask'; +import preenUserHistory from './preenHistory'; import common from '../../'; import { shouldDo, @@ -20,7 +21,7 @@ let clearBuffs = { // Make sure to run this function once in a while as server will not take care of overnight calculations. // And you have to run it every time client connects. export default function cron (options = {}) { - let {user, tasks, tasksByType, analytics, now, daysMissed} = options; + let {user, tasksByType, analytics, now, daysMissed} = options; user.auth.timestamps.loggedin = now; user.lastCron = now; @@ -93,8 +94,7 @@ export default function cron (options = {}) { // Tally each task let todoTally = 0; - tasksByType.todos.forEach((task) => { // make uncompleted todos redder - let completed = task.completed; + tasksByType.todos.forEach(task => { // make uncompleted todos redder scoreTask({ task, user, @@ -104,8 +104,7 @@ export default function cron (options = {}) { // TODO pass req for analytics? }); - let absVal = completed ? Math.abs(task.value) : task.value; - todoTally += absVal; + todoTally += task.value; }); let dailyChecked = 0; // how many dailies were checked? @@ -184,7 +183,8 @@ export default function cron (options = {}) { }); // Finished tallying - user.history.todos({date: now, value: todoTally}); + user.history.todos.push({date: now, value: todoTally}); + // tally experience let expTally = user.stats.exp; let lvl = 0; // iterator @@ -197,7 +197,7 @@ export default function cron (options = {}) { // preen user history so that it doesn't become a performance problem // also for subscribed users but differentyly // premium subscribers can keep their full history. - user.fns.preenUserHistory(tasks); + preenUserHistory(user, tasksByType); if (perfect) { user.achievements.perfect++; @@ -231,6 +231,19 @@ export default function cron (options = {}) { _.merge(progress, {down: 0, up: 0}); progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + // Clean PMs - keep 200 for subscribers and 50 for free users + let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too + let numberOfPMs = Object.keys(user.inbox.messages).length; + if (Object.keys(user.inbox.messages).length > maxPMs) { + _(user.inbox.messages) + .sortBy('timestamp') + .takeRight(numberOfPMs - maxPMs) + .each(pm => { + user.inbox.messages[pm.id] = undefined; + }).value(); + + user.markModified('inbox.messages'); + } // Analytics user.flags.cronCount++; diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index d0c24d700d..0d6d46b900 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import moment from 'moment'; import { daysSince, } from '../../../../common/script/cron'; @@ -15,7 +16,7 @@ export default function cronMiddleware (req, res, next) { let now = new Date(); let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences)); - if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here? + if (daysMissed <= 0) return next(); // Fetch active tasks (no completed todos) Task.find({ @@ -30,13 +31,23 @@ export default function cronMiddleware (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - cron({user, tasks, tasksByType, now, daysMissed, analytics}); + cron({user, tasksByType, now, daysMissed, analytics}); + + // Clean completed todos - 30 days for free users, 90 for subscribers + Task.remove({ + userId: user._id, + type: 'todo', + completed: true, + dateCompleted: { + $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), + }, + }).exec(); // TODO catch error or at least log it let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; // if (ranCron) res.locals.wasModified = true; // TODO remove? - if (!ranCron) return next(null, user); // TODO why are we passing user to next? + if (!ranCron) return next(); // TODO Group.tavernBoss(user, progress); if (!quest || true /* TODO remove */) return user.save(next); diff --git a/website/src/models/user.js b/website/src/models/user.js index 2ddfccc99b..26b38b31d3 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -450,7 +450,9 @@ export let schema = new Schema({ inbox: { newMessages: {type: Number, default: 0}, blocks: {type: Array, default: []}, - messages: {type: Schema.Types.Mixed, default: {}}, + messages: {type: Schema.Types.Mixed, default: () => { + return {}; + }}, optOut: {type: Boolean, default: false}, }, tasksOrder: { From e53bd5079a06a51a12afccfca0e755aaa3681944 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:29:57 +0100 Subject: [PATCH 010/213] adapt chat routes to groups saved on user model --- common/script/api-v3/cron.js | 1 + website/src/controllers/api-v3/chat.js | 21 +++++++++++++-------- website/src/models/group.js | 4 ++-- website/src/models/user.js | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js index 132adafe32..604b6254f4 100644 --- a/common/script/api-v3/cron.js +++ b/common/script/api-v3/cron.js @@ -232,6 +232,7 @@ export default function cron (options = {}) { progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); // Clean PMs - keep 200 for subscribers and 50 for free users + // TODO tests let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too let numberOfPMs = Object.keys(user.inbox.messages).length; if (Object.keys(user.inbox.messages).length > maxPMs) { diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index aa56b03f73..e94546ddac 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -30,16 +30,21 @@ api.getChat = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - let query = groupId === 'party' ? - Group.findOne({type: 'party', members: {$in: [user._id]}}) : - Group.findOne({$or: [ - {_id: groupId, privacy: 'public'}, - {_id: groupId, privacy: 'private', members: {$in: [user._id]}}, - ]}); + let query; - query.exec() - .then((group) => { + if (groupId === 'party' || user.party._id === groupId) { + query = {type: 'party', _id: user.party._id}; + } else if (user.guilds.indexOf(groupId)) { + query = {type: 'guild', _id: groupId}; + } else { + query = {type: 'guild', privacy: 'public', _id: groupId}; + } + + Group + .findOne(query, 'chat').exec() + .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); + res.respond(200, group.chat); }) .catch(next); diff --git a/website/src/models/group.js b/website/src/models/group.js index 9bf5313858..07f9ec9b16 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -127,7 +127,7 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -schema.methods.toJSON = function groupToJSON () { +/*schema.methods.toJSON = function groupToJSON () { let doc = this.toObject(); // removeDuplicates(doc); doc._isMember = this._isMember; // TODO ? @@ -143,7 +143,7 @@ schema.methods.toJSON = function groupToJSON () { this.challengeCount = _.size(this.challenges); return doc; -}; +};*/ // TODO move to its own model export function chatDefaults (msg, user) { diff --git a/website/src/models/user.js b/website/src/models/user.js index 26b38b31d3..40a9fbcaa0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -479,7 +479,7 @@ schema.plugin(baseModel, { // TODO revisit a lot of things are missing noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations'], private: ['auth.local.hashed_password', 'auth.local.salt'], - toJSONTransform: function toJSON (doc) { + toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? doc.filters = {}; doc._tmp = this._tmp; // be sure to send down drop notifs From 7adc06031259b237b09be3c95d115cc84c62f0f6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:32:37 +0100 Subject: [PATCH 011/213] add GET group --- website/src/controllers/api-v3/chat.js | 2 +- website/src/controllers/api-v3/groups.js | 54 ++++++++++++++++++++++++ website/src/models/group.js | 2 +- 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 website/src/controllers/api-v3/groups.js diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index e94546ddac..1cf68464d1 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -34,7 +34,7 @@ api.getChat = { if (groupId === 'party' || user.party._id === groupId) { query = {type: 'party', _id: user.party._id}; - } else if (user.guilds.indexOf(groupId)) { + } else if (user.guilds.indexOf(groupId) !== -1) { query = {type: 'guild', _id: groupId}; } else { query = {type: 'guild', privacy: 'public', _id: groupId}; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js new file mode 100644 index 0000000000..477ba2a101 --- /dev/null +++ b/website/src/controllers/api-v3/groups.js @@ -0,0 +1,54 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as Group } from '../../models/group'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +/** + * @api {get} /groups/:groupId Get group + * @apiVersion 3.0.0 + * @apiName GetGroup + * @apiGroup Group + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Object} group The group object + */ +api.getGroup = { + method: 'GET', + url: '/groups/:groupId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let query; + + if (groupId === 'party' || user.party._id === groupId) { + query = {type: 'party', _id: user.party._id}; + } else if (user.guilds.indexOf(groupId) !== -1) { + query = {type: 'guild', _id: groupId}; + } else { + query = {type: 'guild', privacy: 'public', _id: groupId}; + } + + Group + .findOne(query).exec() + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + res.respond(200, group); + }) + .catch(next); + }, +}; + +export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 07f9ec9b16..c28334dd17 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -127,7 +127,7 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -/*schema.methods.toJSON = function groupToJSON () { +/* schema.methods.toJSON = function groupToJSON () { let doc = this.toObject(); // removeDuplicates(doc); doc._isMember = this._isMember; // TODO ? From 6b430e68663076b036dc2993b76b5d3862b2bc79 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 16:38:10 +0100 Subject: [PATCH 012/213] add getGroup static method and refactor controllers to use it --- website/src/controllers/api-v3/chat.js | 14 +------------- website/src/controllers/api-v3/groups.js | 14 +------------- website/src/models/group.js | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 1cf68464d1..7a3346d60f 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -23,25 +23,13 @@ api.getChat = { middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; - let groupId = req.params.groupId; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - let query; - - if (groupId === 'party' || user.party._id === groupId) { - query = {type: 'party', _id: user.party._id}; - } else if (user.guilds.indexOf(groupId) !== -1) { - query = {type: 'guild', _id: groupId}; - } else { - query = {type: 'guild', privacy: 'public', _id: groupId}; - } - - Group - .findOne(query, 'chat').exec() + Group.getGroup(user, req.params.groupId, 'chat') .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 477ba2a101..7617f3ea99 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -23,25 +23,13 @@ api.getGroup = { middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; - let groupId = req.params.groupId; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - let query; - - if (groupId === 'party' || user.party._id === groupId) { - query = {type: 'party', _id: user.party._id}; - } else if (user.guilds.indexOf(groupId) !== -1) { - query = {type: 'guild', _id: groupId}; - } else { - query = {type: 'guild', privacy: 'public', _id: groupId}; - } - - Group - .findOne(query).exec() + Group.getGroup(user, req.params.groupId) .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/models/group.js b/website/src/models/group.js index c28334dd17..0fe32464d0 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -145,6 +145,20 @@ schema.post('remove', function postRemoveGroup (group) { return doc; };*/ +schema.statics.getGroup = function getGroup (user, groupId, fields) { + let query; + + if (groupId === 'party' || user.party._id === groupId) { + query = {type: 'party', _id: user.party._id}; + } else if (user.guilds.indexOf(groupId) !== -1) { + query = {type: 'guild', _id: groupId}; + } else { + query = {type: 'guild', privacy: 'public', _id: groupId}; + } + + return this.findOne(query, fields).exec(); // TODO catch errors here? +}; + // TODO move to its own model export function chatDefaults (msg, user) { let message = { From 1132e3971dece77a9aa4423bbd30cbfc595fc042 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 17:26:49 +0100 Subject: [PATCH 013/213] initial implementation for GET groups --- common/locales/en/api-v3.json | 3 +- website/src/controllers/api-v3/groups.js | 71 +++++++++++++++++++++++- website/src/models/group.js | 1 + 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d223c66961..206a0bcd35 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -29,5 +29,6 @@ "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", "alreadyTagged": "The task is already tagged with given tag.", "groupIdRequired": "\"groupId\" must be a valid UUID", - "groupNotFound": "Group not found." + "groupNotFound": "Group not found.", + "groupTypesRequired": "You must supply a valid \"type\" query string." } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 7617f3ea99..7074168198 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -1,12 +1,81 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import Q from 'q'; +import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; import { NotFound, + BadRequest, } from '../../libs/api-v3/errors'; let api = {}; +/** + * @api {get} /groups Get groups + * @apiVersion 3.0.0 + * @apiName GetGroups + * @apiGroup Group + * + * @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, privateGuilds, publicGuilds, tavern + * + * @apiSuccess {Array} groups An array of the requested groups + */ +api.getGroups = { + method: 'GET', + url: '/groups', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); // TODO better validation + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + // TODO validate types are acceptable? probably not necessary + let types = req.query.type.split(','); + let groupFields = 'name description memberCount balance leader'; + let sort = '-memberCount'; + let queries = []; + + types.forEach(type => { + switch (type) { + case 'party': + queries.push(Group.getGroup(user, 'party', groupFields)); + break; + case 'privateGuilds': + queries.push(Group.find({ + type: 'guild', + privacy: 'private', + _id: {$in: user.guilds}, + }).select(groupFields).sort(sort).exec()); // TODO isMember + break; + case 'publicGuilds': + queries.push(Group.find({ + type: 'guild', + privacy: 'public', + }).select(groupFields).sort(sort).exec()); // TODO use lean? isMember + break; + case 'tavern': + queries.push(Group.getGroup(user, 'habitrpg', groupFields)); + break; + } + }); + + // If no valid value for type was supplied, return an error + if (queries.length === 0) return next(new BadRequest(res.t('groupTypesRequired'))); + + Q.all(queries) // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + .then(results => { + res.respond(200, _.reduce(results, (m, v) => { + if (_.isEmpty(v)) return m; + return m.concat(Array.isArray(v) ? v : [v]); + }, [])); + }) + .catch(next); + }, +}; + /** * @api {get} /groups/:groupId Get group * @apiVersion 3.0.0 @@ -29,7 +98,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - Group.getGroup(user, req.params.groupId) + Group.getGroup(user, req.params.groupId, true) .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/models/group.js b/website/src/models/group.js index 0fe32464d0..e6d125fd89 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -145,6 +145,7 @@ schema.post('remove', function postRemoveGroup (group) { return doc; };*/ +// TODO populate, isMember? schema.statics.getGroup = function getGroup (user, groupId, fields) { let query; From c4ea3efb2eb793a6d81e858ac7be3e61cc307aad Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 17:37:26 +0100 Subject: [PATCH 014/213] initial implementation for POST group --- website/src/controllers/api-v3/groups.js | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 7074168198..ad12f9e7cc 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -6,10 +6,56 @@ import { model as Group } from '../../models/group'; import { NotFound, BadRequest, + NotAuthorized, } from '../../libs/api-v3/errors'; +import firebase from '../../libs/api-v3/firebase'; let api = {}; +/** + * @api {post} /groups Create group + * @apiVersion 3.0.0 + * @apiName CreateGroup + * @apiGroup Group + * + * @apiSuccess {Object} group The group object + */ +api.createGroup = { + method: 'POST', + url: '/groups', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let group = new Group(req.body); // TODO validate empty body + + group.leader = user._id; + + if (group.type === 'guild') { + if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); + + group.balance = 1; + user.balance--; + + user.guilds.push(group._id); + } else { + if (user.party._id) return next(new NotAuthorized(res.t('messageGroupAlreadyInParty'))); + user.party._id = group._id; + } + + Q.all([ + user.save(), + group.save(), + ]).then(results => { + let savedGroup = results[1]; + + firebase.updateGroupData(savedGroup); + firebase.addUserToGroup(savedGroup._id, user._id); + return res.respond(201, savedGroup); // TODO populate + }) + .catch(next); + }, +}; + /** * @api {get} /groups Get groups * @apiVersion 3.0.0 From d8fae3a067e0a8c50825e5b6f2e5981a5f97d539 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 18 Dec 2015 17:45:56 +0100 Subject: [PATCH 015/213] add sanitization to group creation --- website/src/controllers/api-v3/groups.js | 5 +++-- website/src/models/group.js | 11 +++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index ad12f9e7cc..25826d2d04 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -26,19 +26,20 @@ api.createGroup = { middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; - let group = new Group(req.body); // TODO validate empty body + let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body group.leader = user._id; if (group.type === 'guild') { if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); group.balance = 1; - user.balance--; + user.balance--; user.guilds.push(group._id); } else { if (user.party._id) return next(new NotAuthorized(res.t('messageGroupAlreadyInParty'))); + user.party._id = group._id; } diff --git a/website/src/models/group.js b/website/src/models/group.js index e6d125fd89..6e3dec592d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -15,9 +15,7 @@ let Schema = mongoose.Schema; export let schema = new Schema({ name: {type: String, required: true}, description: String, - leader: {type: String, ref: 'User'}, - members: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) - invites: [{type: String, ref: 'User'}], // TODO do we need this? could depend on back-ref instead (User.find({group:GID}) + leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, type: {type: String, enum: ['guild', 'party'], required: true}, privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true}, // _v: {type: Number,'default': 0}, // TODO ? @@ -41,7 +39,7 @@ export let schema = new Schema({ balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) + // challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, active: {type: Boolean, default: false}, @@ -57,6 +55,7 @@ export let schema = new Schema({ // Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. // TODO when booting user, remove from .joined and check again if we can now start the quest + // TODO as long as quests are party only we can keep it here members: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -70,7 +69,7 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id'], + noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'], }); // TODO migration @@ -145,7 +144,7 @@ schema.post('remove', function postRemoveGroup (group) { return doc; };*/ -// TODO populate, isMember? +// TODO populate (invites too), isMember? schema.statics.getGroup = function getGroup (user, groupId, fields) { let query; From 33525b105f7f55ad6a25dab191a01ad7a9ddcdeb Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Fri, 18 Dec 2015 23:43:57 -0600 Subject: [PATCH 016/213] Added initial group POST tests --- .../v3/integration/groups/POST-groups.test.js | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/api/v3/integration/groups/POST-groups.test.js diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js new file mode 100644 index 0000000000..6d1aec25d5 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -0,0 +1,130 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /group', () => { + let user, api; + + beforeEach(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('Guilds', () => { + it('returns an error when a user with insufficient funds attempts to create a guild', () => { + let groupName = "Test Public Guild"; + let groupType = "guild"; + + return expect( + api.post('/groups', { + name: groupName, + type: groupType + }) + ) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageInsufficientGems'), + }); + }); + + context('public guild', () => { + it('creates a group', () => { + let groupName = "Test Public Guild"; + let groupType = "guild"; + let tmpUser; + + return generateUser({balance: 1}).then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType + }); + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + }) + .then(() => { + api = requester(user); + }); + }); + }); + + context('private guild', () => { + it('creates a group', () => { + let groupName = "Test Private Guild"; + let groupType = "guild"; + let groupPrivacy = "private"; + let tmpUser; + + return generateUser({balance: 1}).then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy + }); + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + expect(result.privacy).to.equal(groupPrivacy); + }) + .then(() => { + api = requester(tmpUser); + }); + }); + }); + }); + + context('Parties', () => { + it('creates a party', () => { + let groupName = "Test Party"; + let groupType = "party"; + + return api.post('/groups', { + name: groupName, + type: groupType + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + }) + }); + + it('prevents user in a party from creating a party', () => { + let tmpUser; + let groupName = "Test Party"; + let groupType = "party"; + + return generateUser().then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType + }); + }) + .then(() => { + return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupAlreadyInParty'), + }); + }) + .then(() => { + api = requester(user); + }); + }); + }); +}); From 3e87c8d3e9cdc7580f80b8b2157352cd3f927605 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 19 Dec 2015 18:07:39 +0100 Subject: [PATCH 017/213] fix some tests --- test/api/v3/integration/chat/GET-chat.test.js | 9 +- .../v3/integration/groups/POST-groups.test.js | 185 +++++++++--------- test/helpers/api-integration.helper.js | 6 +- website/src/controllers/api-v3/groups.js | 3 +- website/src/models/group.js | 2 +- website/src/models/user.js | 2 +- 6 files changed, 101 insertions(+), 106 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 75b5a36d15..f1fe89c5d6 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -1,6 +1,7 @@ import { createAndPopulateGroup, generateUser, + generateGroup, requester, } from '../../../../helpers/api-integration.helper'; @@ -8,8 +9,9 @@ describe('GET /groups/:groupId/chat', () => { let user, api; before(() => { - return generateUser().then((generatedUser) => { + return generateUser({balance: 2}).then((generatedUser) => { user = generatedUser; + console.log(user._id, user.balance) api = requester(user); }); }); @@ -18,14 +20,15 @@ describe('GET /groups/:groupId/chat', () => { let group; before(() => { - return createAndPopulateGroup({groupDetails: { + return generateGroup(user, { + name: 'test group', type: 'guild', privacy: 'public', chat: [ 'Hello', 'Welcome to the Guild', ], - }}) + }) .then((createdGroup) => { group = createdGroup; }); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 6d1aec25d5..85269fe6b7 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -7,17 +7,17 @@ import { describe('POST /group', () => { let user, api; - beforeEach(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - api = requester(user); - }); - }); + beforeEach(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); - context('Guilds', () => { + context('Guilds', () => { it('returns an error when a user with insufficient funds attempts to create a guild', () => { - let groupName = "Test Public Guild"; - let groupType = "guild"; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; return expect( api.post('/groups', { @@ -32,99 +32,90 @@ describe('POST /group', () => { }); }); - context('public guild', () => { - it('creates a group', () => { - let groupName = "Test Public Guild"; - let groupType = "guild"; - let tmpUser; + context('public guild', () => { + it('creates a group', () => { + let groupName = 'Test Public Guild'; + let groupType = 'guild'; - return generateUser({balance: 1}).then((generatedUser) => { - tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { - name: groupName, - type: groupType - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - }) - .then(() => { - api = requester(user); - }); - }); - }); - - context('private guild', () => { - it('creates a group', () => { - let groupName = "Test Private Guild"; - let groupType = "guild"; - let groupPrivacy = "private"; - let tmpUser; - - return generateUser({balance: 1}).then((generatedUser) => { - tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.privacy).to.equal(groupPrivacy); - }) - .then(() => { - api = requester(tmpUser); - }); - }); - }); - }); - - context('Parties', () => { - it('creates a party', () => { - let groupName = "Test Party"; - let groupType = "party"; - - return api.post('/groups', { - name: groupName, - type: groupType - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - }) - }); - - it('prevents user in a party from creating a party', () => { - let tmpUser; - let groupName = "Test Party"; - let groupType = "party"; - - return generateUser().then((generatedUser) => { - tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { + return generateUser({balance: 1}).then((generatedUser) => { + let api2 = requester(generatedUser); + return api2.post('/groups', { name: groupName, type: groupType }); }) - .then(() => { - return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupAlreadyInParty'), - }); - }) - .then(() => { - api = requester(user); + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); }); }); - }); + }); + + context('private guild', () => { + it('creates a group', () => { + let groupName = 'Test Private Guild'; + let groupType = 'guild'; + let groupPrivacy = 'private'; + let tmpUser; + + return generateUser({balance: 1}).then((generatedUser) => { + let api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy + }); + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + expect(result.privacy).to.equal(groupPrivacy); + }); + }); + }); + }); + + context('Parties', () => { + it('creates a party', () => { + let groupName = "Test Party"; + let groupType = "party"; + + return api.post('/groups', { + name: groupName, + type: groupType + }) + .then((result) => { + expect(result._id).to.exist; + expect(result.name).to.equal(groupName); + expect(result.type).to.equal(groupType); + }) + }); + + it('prevents user in a party from creating a party', () => { + let tmpUser; + let groupName = "Test Party"; + let groupType = "party"; + + return generateUser().then((generatedUser) => { + tmpUser = generatedUser; + api = requester(tmpUser); + return api.post('/groups', { + name: groupName, + type: groupType + }); + }) + .then(() => { + return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupAlreadyInParty'), + }); + }) + .then(() => { + api = requester(user); + }); + }); + }); }); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 973414a3a8..9b865ccb85 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -103,15 +103,15 @@ export function generateUser (update = {}) { // Generates a new group. Requires a user object, which // will will become the groups leader. Takes an update // argument which will update group -export function generateGroup (leader, update = {}) { +export function generateGroup (leader, details = {}, update = {}) { let request = _requestMaker(leader, 'post'); return new Promise((resolve, reject) => { - request('/groups').then((group) => { + request('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); }).catch(reject); - }); + }).catch(reject); }); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 25826d2d04..c431db872f 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -8,7 +8,7 @@ import { BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; -import firebase from '../../libs/api-v3/firebase'; +import * as firebase from '../../libs/api-v3/firebase'; let api = {}; @@ -31,6 +31,7 @@ api.createGroup = { group.leader = user._id; if (group.type === 'guild') { + console.log(user._id, user.balance) if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); group.balance = 1; diff --git a/website/src/models/group.js b/website/src/models/group.js index 6e3dec592d..54c54b473a 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -4,7 +4,7 @@ import shared from '../../../common'; import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; -import firebase from '../libs/api-v2/firebase'; +import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import Q from 'q'; diff --git a/website/src/models/user.js b/website/src/models/user.js index 40a9fbcaa0..54da99fb5c 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -477,7 +477,7 @@ export let schema = new Schema({ schema.plugin(baseModel, { // TODO revisit a lot of things are missing - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations'], + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From 8b79f74e54e32ff7d64740d708d1a6a79ff9c317 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 19 Dec 2015 18:23:13 +0100 Subject: [PATCH 018/213] fix tests --- test/api/v3/integration/chat/GET-chat.test.js | 4 +++- test/helpers/api-integration.helper.js | 2 +- website/src/controllers/api-v3/groups.js | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index f1fe89c5d6..206eac6f75 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -11,7 +11,6 @@ describe('GET /groups/:groupId/chat', () => { before(() => { return generateUser({balance: 2}).then((generatedUser) => { user = generatedUser; - console.log(user._id, user.balance) api = requester(user); }); }); @@ -24,6 +23,7 @@ describe('GET /groups/:groupId/chat', () => { name: 'test group', type: 'guild', privacy: 'public', + }, { chat: [ 'Hello', 'Welcome to the Guild', @@ -40,5 +40,7 @@ describe('GET /groups/:groupId/chat', () => { expect(getChat).to.eql(group.chat); }); }); + + // TODO tests that you can only access your groups' chat }); }); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 9b865ccb85..81718c36c8 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -110,7 +110,7 @@ export function generateGroup (leader, details = {}, update = {}) { request('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); - }).catch(reject); + }); }).catch(reject); }); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index c431db872f..8843b7aff7 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -31,7 +31,6 @@ api.createGroup = { group.leader = user._id; if (group.type === 'guild') { - console.log(user._id, user.balance) if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); group.balance = 1; From 83364c1d56ccfac61805539be5048a088e0d921b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 21 Dec 2015 11:53:47 +0100 Subject: [PATCH 019/213] add leaveGroup, joinGroup and remove member from group methods --- common/locales/en/api-v3.json | 8 +- website/src/controllers/api-v3/chat.js | 2 +- website/src/controllers/api-v3/groups.js | 209 ++++++++++++++++++++++- website/src/models/group.js | 5 +- 4 files changed, 219 insertions(+), 5 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 206a0bcd35..39c0f96bb3 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -30,5 +30,11 @@ "alreadyTagged": "The task is already tagged with given tag.", "groupIdRequired": "\"groupId\" must be a valid UUID", "groupNotFound": "Group not found.", - "groupTypesRequired": "You must supply a valid \"type\" query string." + "groupTypesRequired": "You must supply a valid \"type\" query string.", + "questLeaderCannotLeaveGroup": "You cannot leave your party when you have started a quest. Abort the quest first.", + "cannotLeaveWhileActiveQuest": "You cannot leave party during an active quest. Please leave the quest first.", + "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", + "memberCannotRemoveYourself": "You cannot remove yourself!", + "groupMemberNotFound": "User not found among group's members", + "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"" } diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 7a3346d60f..f0025d6e53 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -13,7 +13,7 @@ let api = {}; * @apiName GetChat * @apiGroup Chat * - * @apiParam {UUID} groupId The group _id + * @apiParam {string} groupId The group _id (or 'party') * * @apiSuccess {Array} chat An array of chat messages */ diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8843b7aff7..a54e501a36 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -3,12 +3,14 @@ import Q from 'q'; import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; import * as firebase from '../../libs/api-v3/firebase'; +import txnEmail from '../../libs/api-v3/email'; let api = {}; @@ -129,7 +131,7 @@ api.getGroups = { * @apiName GetGroup * @apiGroup Group * - * @apiParam {UUID} groupId The group _id + * @apiParam {string} groupId The group _id (or 'party') * * @apiSuccess {Object} group The group object */ @@ -145,7 +147,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - Group.getGroup(user, req.params.groupId, true) + Group.getGroup(user, req.params.groupId) .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); @@ -155,4 +157,207 @@ api.getGroup = { }, }; +/** + * @api {post} /groups/:groupId/join Join a group + * @apiVersion 3.0.0 + * @apiName JoinGroup + * @apiGroup Group + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Object} empty An empty object + */ +api.joinGroup = { + method: 'POST', + url: '/groups/:groupId/join', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + let isUserInvited = false; + + if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { + user.invitations.party = undefined; // Clear invite + + // invite new user to pending quest + if (group.quest.key && !group.quest.active) { + user.party.quest.RSVPNeeded = true; + user.party.quest.key = group.quest.key; + group.quest.members[user._id] = undefined; + group.markModified('quest.members'); + } + + user.party._id = group._id; // Set group as user's party + + isUserInvited = true; + } else if (group.type === 'guild' && user.invitations.guilds) { + let i = _.findIndex(user.invitations.guilds, {id: group._id}); + + if (i !== -1) { + isUserInvited = true; + user.invitations.guilds.splice(i, 1); // Remove invitation + } else { + isUserInvited = group.privacy === 'private' ? false : true; + } + } + + if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds + if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); + + if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + + Q.all([ + group.save(), + user.save(), + User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter + ]).then(() => { + firebase.addUserToGroup(group._id, user._id); + res.respond(200, {}); // TODO what to return? + }); + }) + .catch(next); + }, +}; + +/** + * @api {post} /groups/:groupId/leave Leave a group + * @apiVersion 3.0.0 + * @apiName LeaveGroup + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string="remove-all","keep-all"} keep Wheter to keep or not challenges' tasks, as an optional query string + * + * @apiSuccess {Object} empty An empty object + */ +api.leaveGroup = { + method: 'POST', + url: '/groups/:groupId/leave', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + // When removing the user from challenges, should we keep the tasks? + req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + // During quests, checke wheter user can leave + if (group.type === 'party') { + if (group.quest && group.quest.leader === user._id) { + throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup')); + } + + if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { + throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest')); + } + } + + return group.leave(user, req.query.keep); + }) + .then(() => res.respond(200, {})) + .catch(next); + }, +}; + +// Send an email to the removed user with an optional message from the leader +function _sendMessageToRemoved (group, removedUser, message) { + if (removedUser.preferences.emailNotifications.kickedGroup !== false) { + txnEmail(removedUser, `kicked-from-${group.type}`, [ + {name: 'GROUP_NAME', content: group.name}, + {name: 'MESSAGE', content: message}, + {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'}, + {name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'}, + ]); + } +} + +/** + * @api {post} /groups/:groupId/removeMember/:memberId Remove a member from a group + * @apiVersion 3.0.0 + * @apiName RemoveGroupMember + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {UUID} memberId The _id of the member to remove + * @apiParam {string} message The message to send to the removed members, as a query string // TODO in req.body? + * + * @apiSuccess {Object} empty An empty object + */ +api.removeGroupMember = { + method: 'POST', + url: '/groups/:groupId/removeMember/:memberId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let group; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('groupId', res.t('userIdRequired')).notEmpty().isUUID(); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + .then(foundGroup => { + group = foundGroup; + let uuid = req.query.memberId; + + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); + if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); + + return User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); + }).then(member => { + // 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' : member.invitations.guilds.indexOf(group._id) !== 1 ? 'guild' : undefined; // eslint-disable-line no-nested-ternary + + if (isInGroup) { + group.memberCount -= 1; + + if (group.quest && group.quest.leader === member._id) { + group.quest.key = null; + group.quest.leader = null; // TODO markmodified? + } else if (group.quest && group.quest.members) { + // remove member from quest + group.quest.members[member._id] = undefined; + } + + if (isInGroup === 'guild') _.pull(member.guilds, group._id); + if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? + + member.newMessages.group._id = undefined; + + if (group.quest && group.quest.active && group.quest.leader === member._id) { + user.items.quests[group.quest.key] += 1; // TODO why this? + } + } if (isInvited) { + if (isInvited === 'guild') _.pull(user.invitations.guilds, group._id); + if (isInvited === 'party') user.invitations.party._id = undefined; // TODO remove quest information too? + } else { + throw new NotFound(res.t('groupMemberNotFound')); + } + + let message = req.query.message; + if (message) _sendMessageToRemoved(group, member, message); + + return Q.all([ + member.save(), + group.save(), + ]); + }) + .then(() => res.respond(200, {})) + .catch(next); + }, +}; + export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 54c54b473a..fa59f61aec 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -156,7 +156,10 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { query = {type: 'guild', privacy: 'public', _id: groupId}; } - return this.findOne(query, fields).exec(); // TODO catch errors here? + return this + .findOne(query) + .select(fields) + .exec(); // TODO catch errors here? }; // TODO move to its own model From 99be74b6f72ebb647f2bc67f82d311c4f216b215 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 21 Dec 2015 12:20:10 +0100 Subject: [PATCH 020/213] misc fixes --- website/src/controllers/api-v3/auth.js | 1 + website/src/controllers/api-v3/groups.js | 15 +++++++++------ website/src/models/group.js | 23 +++++++++++++++++------ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 2c7e00cadc..b0ab6b0674 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -259,6 +259,7 @@ api.deleteSocial = { if (!user.auth.local.username) return next(new NotAuthorized(res.t('cantDetachFb'))); // TODO move to model validation? User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}) + .exec() .then(() => res.respond(200)) .catch(next); }, diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index a54e501a36..1c948a5912 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -186,7 +186,7 @@ api.joinGroup = { let isUserInvited = false; if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { - user.invitations.party = undefined; // Clear invite + user.invitations.party = {}; // Clear invite TODO mark modified? // invite new user to pending quest if (group.quest.key && !group.quest.active) { @@ -319,7 +319,7 @@ api.removeGroupMember = { }).then(member => { // 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' : member.invitations.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 if (isInGroup) { group.memberCount -= 1; @@ -338,11 +338,14 @@ api.removeGroupMember = { member.newMessages.group._id = undefined; if (group.quest && group.quest.active && group.quest.leader === member._id) { - user.items.quests[group.quest.key] += 1; // TODO why this? + member.items.quests[group.quest.key] += 1; // TODO why this? } - } if (isInvited) { - if (isInvited === 'guild') _.pull(user.invitations.guilds, group._id); - if (isInvited === 'party') user.invitations.party._id = undefined; // TODO remove quest information too? + } else if (isInvited) { + if (isInvited === 'guild') { + let i = _.findIndex(member.invitations.guilds, {id: group._id}); + if (i !== -1) member.invitations.guilds.splice(i, 1); + } + if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? } else { throw new NotFound(res.t('groupMemberNotFound')); } diff --git a/website/src/models/group.js b/website/src/models/group.js index fa59f61aec..8687df181d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -97,6 +97,7 @@ schema.pre('save', function preSaveGroup (next) { return next(); }); +// TODO test schema.pre('remove', true, function preRemoveGroup (next, done) { next(); let group = this; @@ -104,18 +105,19 @@ schema.pre('remove', true, function preRemoveGroup (next, done) { // Remove invitations when group is deleted // TODO verify it works fir everything User.find({ - // TODO remove need for guilds s in migration? same for id -> _id + // TODO id -> _id ? [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, }).exec() .then(users => { return Q.all(users.map(user => { if (group.type === 'party') { - user.invitations.party = {}; + user.invitations.party = {}; // TODO mark modified } else { let i = _.findIndex(user.invitations.guilds, {id: group._id}); user.invitations.guilds.splice(i, 1); } - return user.save(); // TODO update? + + return user.save(); })); }) .then(done) @@ -489,8 +491,14 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { group.type === 'guild' && group.privacy === 'private' )) return group.remove(); - // otherwise just remove a member - let update = {$pull: {members: user._id}}; + let update = {}; + // otherwise just remove a member TODO create User.methods.removeFromGroup? + if (group.type === 'guild') { + _.pull(user.guilds, group._id); + } else { + user.party._id = undefined; + } + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) let leader = group.leader; @@ -502,7 +510,10 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { } update.$inc = {memberCount: -1}; - return model.update({_id: group._id}, update); // eslint-disable-line no-use-before-define + return Q.all([ + model.update({_id: group._id}, update).exec(), // eslint-disable-line no-use-before-define + user.save(), + ]); })(), ]).then(() => { firebase.removeUserFromGroup(group._id, user._id); From 67da5a977aed2a1c6996faf58867a11c3aa07ea7 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Mon, 21 Dec 2015 17:33:16 -0500 Subject: [PATCH 021/213] test(chat): user not member of group Currently failing because group is not getting defined correctly? --- test/api/v3/integration/chat/GET-chat.test.js | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 206eac6f75..265b80da54 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -1,8 +1,8 @@ import { - createAndPopulateGroup, generateUser, generateGroup, requester, + translate as t, } from '../../../../helpers/api-integration.helper'; describe('GET /groups/:groupId/chat', () => { @@ -19,15 +19,18 @@ describe('GET /groups/:groupId/chat', () => { let group; before(() => { - return generateGroup(user, { - name: 'test group', - type: 'guild', - privacy: 'public', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], + return generateUser({balance: 2}) + .then((generatedLeader) => { + generateGroup(generatedLeader, { + name: 'test group', + type: 'guild', + privacy: 'public', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }); }) .then((createdGroup) => { group = createdGroup; @@ -40,7 +43,36 @@ describe('GET /groups/:groupId/chat', () => { expect(getChat).to.eql(group.chat); }); }); + }); - // TODO tests that you can only access your groups' chat + context('private Guild', () => { + let group; + + before(() => { + return generateGroup(user, { + name: 'test group', + type: 'guild', + privacy: 'private', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }) + .then((createdGroup) => { + group = createdGroup; + }); + }); + + it('returns error if user is not member of requested private group', () => { + return expect( + api.get('/groups/' + group._id + '/chat') + ) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); }); }); From a48ece7a343feff687b05980da48fc591aa5f10a Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Mon, 21 Dec 2015 18:45:24 -0500 Subject: [PATCH 022/213] fix(test): return functions --- test/api/v3/integration/chat/GET-chat.test.js | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 265b80da54..e6f2aeeb8a 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -5,11 +5,11 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('GET /groups/:groupId/chat', () => { +describe.only('GET /groups/:groupId/chat', () => { let user, api; before(() => { - return generateUser({balance: 2}).then((generatedUser) => { + return generateUser().then((generatedUser) => { user = generatedUser; api = requester(user); }); @@ -21,7 +21,7 @@ describe('GET /groups/:groupId/chat', () => { before(() => { return generateUser({balance: 2}) .then((generatedLeader) => { - generateGroup(generatedLeader, { + return generateGroup(generatedLeader, { name: 'test group', type: 'guild', privacy: 'public', @@ -49,15 +49,18 @@ describe('GET /groups/:groupId/chat', () => { let group; before(() => { - return generateGroup(user, { - name: 'test group', - type: 'guild', - privacy: 'private', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], + return generateUser({balance: 2}) + .then((generatedLeader) => { + return generateGroup(generatedLeader, { + name: 'test group', + type: 'guild', + privacy: 'private', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], + }); }) .then((createdGroup) => { group = createdGroup; From 77414ca49a416da1731f01135868b32cc636c5f8 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 22 Dec 2015 08:48:47 -0600 Subject: [PATCH 023/213] Added chat post route and initial tests --- .../api/v3/integration/chat/POST-chat.test.js | 98 +++++++++++++++++++ website/src/controllers/api-v3/chat.js | 56 +++++++++++ 2 files changed, 154 insertions(+) create mode 100644 test/api/v3/integration/chat/POST-chat.test.js diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js new file mode 100644 index 0000000000..f427ac460d --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -0,0 +1,98 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /chat', () => { + let user; + let api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('Returns an error when no message is provided', () => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + let testMessage = ''; + let api2; + + return generateUser({balance: 1}).then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((group) => { + return expect(api.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(api.post('/groups/nvalidID/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 api2; + + return generateUser({balance: 1, 'flags.chatRevoked': true}).then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((group) => { + return expect(api2.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('creates a chat', () => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + let testMessage = 'Test Message'; + let api2; + + return generateUser({balance: 1}).then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((group) => { + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + expect(result.message.id).to.exist; + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index f0025d6e53..d3a22a1fc8 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -39,4 +39,60 @@ api.getChat = { }, }; +/** + * @api {post} /groups/:groupId/chat Post chat message to a group + * @apiVersion 3.0.0 + * @apiName PostCat + * @apiGroup Chat + * + * @apiParam {UUID} groupId The group _id + * @apiParam {message} message The chat's message + * @apiParam {previousMsg} previousMsg The previous chat message which will force a return of the full group chat + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.postChat = { + method: 'POST', + url: '/groups/:groupId/chat', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatUpdated; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, groupId) + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party' && user.flags.chatRevoked) { + throw new NotFound('Your chat privileges have been revoked.'); + } + + let lastClientMsg = req.query.previousMsg; + chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + + group.sendChat(req.body.message, user); + + if (group.type === 'party') { + user.party.lastMessageSeen = group.chat[0].id; + user.save(); + } + return group.save(); + }) + .then((group) => { + if (chatUpdated) { + res.respond(200, {chat: group.chat}); + } else { + res.respond(200, {message: group.chat[0]}); + } + }) + .catch(next); + }, +}; + export default api; From dff464489a18581b7eb916a317255ecccffe3e27 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 24 Dec 2015 18:52:14 -0600 Subject: [PATCH 024/213] Added chat like route and tests --- .../integration/chat/POST-chat.like.test.js | 68 +++++++++++++++++++ website/src/controllers/api-v3/chat.js | 54 +++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 test/api/v3/integration/chat/POST-chat.like.test.js diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js new file mode 100644 index 0000000000..b5f70d6ba4 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -0,0 +1,68 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /chat', () => { + let user; + let api; + let group; + let testMessage = 'Test Message'; + + before(() => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + + return generateUser({balance: 1}).then((generatedUser) => { + user = generatedUser; + api = requester(user); + }) + .then(() => { + return api.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((generatedGroup) => { + group = generatedGroup; + }); + }); + + it('Returns an error when chat message is not found', () => { + return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/like`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('Returns an error when user tries to like their own message', () => { + return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + .then((result) => { + return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/like`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatLikeOwnMessage'), + }); + }); + }); + + it('Likes a chat', () => { + let api2; + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + return api.post(`/groups/${group._id}/chat/${result.message.id}/like`); + }) + .then((result) => { + expect(result[0].likes[user._id]).to.equal(true); + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index d3a22a1fc8..f04bd031d9 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -4,6 +4,7 @@ import { model as Group } from '../../models/group'; import { NotFound, } from '../../libs/api-v3/errors'; +import _ from 'lodash'; let api = {}; @@ -95,4 +96,57 @@ api.postChat = { }, }; +/** + * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message + * @apiVersion 3.0.0 + * @apiName LikeChat + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * @apiParam {chatId} chatId The chat message _id + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.likeChat = { + method: 'Post', + url: '/groups/:groupId/chat/:chatId/like', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, groupId) + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + let message = _.find(group.chat, {id: req.params.chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); + + if (!message.likes) message.likes = {}; + if (message.likes[user._id]) { + delete message.likes[user._id]; + } else { + message.likes[user._id] = true; + } + + let messageIndex = group.chat.indexOf(message); + group.chat[messageIndex].likes = message.likes; + + return group.save(); + }) + .then((group) => { + if (!group) throw new NotFound(res.t('groupNotFound')); + res.respond(200, group.chat); + }) + .catch(next); + }, +}; + export default api; From 54109f0e62b8d92ec0c1f707f11450562a195ed7 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 27 Dec 2015 00:27:06 -0600 Subject: [PATCH 025/213] Removed redundant code and changed like route to return only altered message --- test/api/v3/integration/chat/POST-chat.like.test.js | 4 ++-- website/src/controllers/api-v3/chat.js | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index b5f70d6ba4..1096aa11e3 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -4,7 +4,7 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('POST /chat', () => { +describe('POST /chat/:chatId/like', () => { let user; let api; let group; @@ -62,7 +62,7 @@ describe('POST /chat', () => { return api.post(`/groups/${group._id}/chat/${result.message.id}/like`); }) .then((result) => { - expect(result[0].likes[user._id]).to.equal(true); + expect(result.likes[user._id]).to.equal(true); }); }); }); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index f04bd031d9..d8b79b3979 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -114,6 +114,7 @@ api.likeChat = { handler (req, res, next) { let user = res.locals.user; let groupId = req.params.groupId; + let message; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); @@ -124,7 +125,7 @@ api.likeChat = { Group.getGroup(user, groupId) .then((group) => { if (!group) throw new NotFound(res.t('groupNotFound')); - let message = _.find(group.chat, {id: req.params.chatId}); + message = _.find(group.chat, {id: req.params.chatId}); if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); @@ -136,14 +137,11 @@ api.likeChat = { message.likes[user._id] = true; } - let messageIndex = group.chat.indexOf(message); - group.chat[messageIndex].likes = message.likes; - return group.save(); }) .then((group) => { if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group.chat); + res.respond(200, message); }) .catch(next); }, From 6ae2c5fa893781102b2b651ebf4493d3414d6902 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 27 Dec 2015 17:24:55 +0100 Subject: [PATCH 026/213] wip on groups controller --- common/locales/en/api-v3.json | 4 +- website/src/controllers/api-v3/groups.js | 202 +++++++++++++++++++- website/src/middlewares/api-v3/index.js | 2 + website/src/middlewares/api-v3/setupBody.js | 5 + website/src/models/group.js | 2 + 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 website/src/middlewares/api-v3/setupBody.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 39c0f96bb3..50726fccef 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -36,5 +36,7 @@ "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", - "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"" + "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", + "canOnlyInviteEmailUuid": "Can only invite using uuids or emails but not both at the same time.", + "inviteMissingEmail": "Missing email address in invite." } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 1c948a5912..cf42b8b6de 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -10,10 +10,13 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import * as firebase from '../../libs/api-v3/firebase'; -import txnEmail from '../../libs/api-v3/email'; +import { txnEmail } from '../../libs/api-v3/email'; +// import { encrypt } from '../../libs/api-v3/encryption'; let api = {}; +// TODO shall we accept party as groupId in all routes? + /** * @api {post} /groups Create group * @apiVersion 3.0.0 @@ -250,6 +253,9 @@ api.leaveGroup = { // When removing the user from challenges, should we keep the tasks? req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']); + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); @@ -305,11 +311,16 @@ api.removeGroupMember = { let group; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkParams('groupId', res.t('userIdRequired')).notEmpty().isUUID(); + req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat .then(foundGroup => { group = foundGroup; + if (!group) throw new NotFound(res.t('groupNotFound')); + let uuid = req.query.memberId; if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); @@ -363,4 +374,191 @@ api.removeGroupMember = { }, }; +/* function _inviteByUUIDs (uuids, group, inviter, req, res, next) { + async.each(uuids, function(uuid, cb){ + User.findById(uuid, function(err,invite){ + if (err) return cb(err); + if (!invite) + return cb({code:400,err:'User with id "' + uuid + '" not found'}); + if (group.type == 'guild') { + if (_.contains(group.members,uuid)) + return cb({code:400, err: "User already in that group"}); + if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) + return cb({code:400, err:"User already invited to that group"}); + sendInvite(); + } else if (group.type == 'party') { + if (invite.invitations && !_.isEmpty(invite.invitations.party)) + return cb({code: 400,err:"User already pending invitation."}); + Group.find({type: 'party', members: {$in: [uuid]}}, function(err, groups){ + if (err) return cb(err); + if (!_.isEmpty(groups) && groups[0].members.length > 1) { + return cb({code: 400, err: "User already in a party."}) + } + sendInvite(); + }); + } + + function sendInvite (){ + if(group.type === 'guild'){ + invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); + + pushNotify.sendNotify(invite, shared.i18n.t('invitedGuild'), group.name); + }else{ + //req.body.type in 'guild', 'party' + invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; + + pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); + } + + group.invites.push(invite._id); + + async.series([ + function(cb){ + invite.save(cb); + } + ], function(err, results){ + if (err) return cb(err); + + if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ + var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); + var emailVars = [ + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} + ]; + + if(group.type == 'guild'){ + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} + ); + }else{ + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: '/#/options/groups/party'} + ) + } + + utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); + } + + cb(); + }); + } + }); + }, function(err){ + if(err) return err.code ? res.json(err.code, {err: err.err}) : next(err); + + async.series([ + function(cb) { + group.save(cb); + }, + function(cb) { + // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? + populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ + if(err) return next(err); + + res.json(populatedGroup); + }); + } + ]); + }); +}; + +function _inviteByEmails (emails, group, inviter, req, res, next) { + let usersAlreadyRegistered = []; + let invitesToSend = []; + + return Q.all(emails.forEach(invite => { + if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); + + return User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email} + ]}) + .select({_id: true, 'preferences.emailNotifications': true}) + .exec() + .then(userToContact => { + if(userToContact){ + usersAlreadyRegistered.push(userToContact._id); // TODO does it work not returning + } else { + // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query + // TODO absolutely refactor this horrible code + let link = `?partyInvite=${utils.encrypt(JSON.stringify({id: group._id, inviter: inviter, name: group.name}))}`; + + let inviterVars = getUserInfo(inviter, ['name', 'email']); + let variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} + ]; + + if(group.type == 'guild'){ + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // TODO implement "users can only be invited once" + // Check for the email address not to be unsubscribed + return EmailUnsubscription.findOne({email: invite.email}).exec() + .then(unsubscribed => { + if (!unsubscribed) utils.txnEmail(invite, ('invite-friend' + (group.type == 'guild' ? '-guild' : '')), variables); + }); + } + }); + })) + .then(() => { + if (usersAlreadyRegistered.length > 0){ + return _inviteByUUIDs(usersAlreadyRegistered, group, inviter, req, res, next); + } + + res.respond(200, {}); // TODO what to return? + }); +}; */ + +/** + * @api {post} /groups/:groupId/invite Invite users to a group using their UUIDs or email addresses + * @apiVersion 3.0.0 + * @apiName InviteToGroup + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiParam {array} emails An array of emails addresses to invite (optional) (inside body) + * @apiParam {array} uuids An array of uuids to invite (optional) (inside body) + * @apiParam {string} inviter The inviters' name (optional) (inside body) + * + * @apiSuccess {Object} empty An empty object + */ +/* api.inviteToGroup = { + method: 'POST', + url: '/groups/:groupId/invite', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat TODO other fields too? + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + let uuids = req.body.uuids; + let emails = req.body.emails; + + if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } else if (Array.isArray(uuids)) { + return _inviteByUUIDs(uuids, group, user, req, res, next); + } else if (Array.isArray(emails)) { + return _inviteByEmails(emails, group, user, req, res, next) + } else { + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } + }) + .catch(next); + }, +};*/ + export default api; diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js index fdb2db7bd8..01da4d4004 100644 --- a/website/src/middlewares/api-v3/index.js +++ b/website/src/middlewares/api-v3/index.js @@ -9,6 +9,7 @@ import notFoundHandler from './notFound'; import nconf from 'nconf'; import morgan from 'morgan'; import responseHandler from './response'; +import setupBody from './setupBody'; const IS_PROD = nconf.get('IS_PROD'); const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING'); @@ -23,6 +24,7 @@ export default function attachMiddlewares (app) { app.use(bodyParser.json()); app.use(expressValidator()); app.use(analytics); + app.use(setupBody); app.use(responseHandler); app.use(getUserLanguage); diff --git a/website/src/middlewares/api-v3/setupBody.js b/website/src/middlewares/api-v3/setupBody.js new file mode 100644 index 0000000000..82e1b798bb --- /dev/null +++ b/website/src/middlewares/api-v3/setupBody.js @@ -0,0 +1,5 @@ +// TODO tests? +export default function setupBodyMiddleware (req, res, next) { + req.body = req.body || {}; + next(); +} diff --git a/website/src/models/group.js b/website/src/models/group.js index 8687df181d..2c5cdecd35 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -162,6 +162,8 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { .findOne(query) .select(fields) .exec(); // TODO catch errors here? + + // TODO purge chat flags info? }; // TODO move to its own model From 20092f3ddb8cc2d0b75d9ae95b02bc7fe7fcfc5d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 10:46:34 +0100 Subject: [PATCH 027/213] port challenge model --- tasks/gulp-eslint.js | 1 + website/src/models/challenge.js | 186 ++++++++++++++++++-------------- website/src/models/task.js | 8 +- website/src/models/user.js | 1 + 4 files changed, 111 insertions(+), 85 deletions(-) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 1e39fb7532..0fe0ce5ef9 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -6,6 +6,7 @@ const SERVER_FILES = [ './website/src/models/user.js', './website/src/models/task.js', './website/src/models/group.js', + './website/src/models/challenge.js', './website/src/models/tag.js', './website/src/models/emailUnsubscription.js', './website/src/server.js', diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index a59f0d0939..795d353fdf 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -1,55 +1,55 @@ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var TaskSchemas = require('./task'); +import mongoose from 'mongoose'; +import Q from 'q'; +import validator from 'validator'; +import baseModel from '../libs/api-v3/baseModel'; +import _ from 'lodash'; +import * as Tasks from './task'; -var ChallengeSchema = new Schema({ - _id: {type: String, 'default': shared.uuid}, - name: String, - shortName: String, +let Schema = mongoose.Schema; + +let schema = new Schema({ + name: {type: String, required: true}, + shortName: {type: String, required: true}, // TODO what is it? description: String, - official: {type: Boolean,'default':false}, - //habits: [TaskSchemas.HabitSchema], - //dailys: [TaskSchemas.DailySchema], - //todos: [TaskSchemas.TodoSchema], - //rewards: [TaskSchemas.RewardSchema], - leader: {type: String, ref: 'User'}, - group: {type: String, ref: 'Group'}, - timestamp: {type: Date, 'default': Date.now}, - members: [{type: String, ref: 'User'}], - memberCount: {type: Number, 'default': 0}, - prize: {type: Number, 'default': 0} + official: {type: Boolean, default: false}, + tasksOrder: { + habits: [{type: String, ref: 'Task'}], + dailys: [{type: String, ref: 'Task'}], + todos: [{type: String, ref: 'Task'}], + rewards: [{type: String, ref: 'Task'}], + }, + leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? + memberCount: {type: Number, default: 0}, + prize: {type: Number, default: 0, required: true}, }); -ChallengeSchema.virtual('tasks').get(function () { - var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); - var tasks = _.object(_.pluck(tasks,'id'), tasks); - return tasks; +schema.plugin(baseModel, { + noSet: ['_id', 'memberCount', 'tasksOrder'], + toJSONTransform: function userToJSON (doc) { + // TODO fixme + // TODO this works? + doc._isMember = this._isMember; + + return doc; + }, }); -ChallengeSchema.methods.toJSON = function(){ - var doc = this.toObject(); - doc._isMember = this._isMember; - return doc; -} -// -------------- // Syncing logic -// -------------- -function syncableAttrs(task) { - var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument +function _syncableAttrs (task) { + let t = task.toObject(); // lodash doesn't seem to like _.omit on EmbeddedDocument // only sync/compare important attrs - var omitAttrs = 'challenge history tags completed streak notes'.split(' '); - if (t.type != 'reward') omitAttrs.push('value'); + let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO use whitelist instead of blacklist? + if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } -/** - * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers - */ -function comparableData(obj) { +// TODO redo +// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers +/* function comparableData(obj) { return JSON.stringify( _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) .sortBy('id') // we don't want to update if they're sort-order is different @@ -59,62 +59,86 @@ function comparableData(obj) { .value()) } -ChallengeSchema.methods.isOutdated = function(newData) { +ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { return comparableData(this) !== comparableData(newData); -} +}*/ -/** - * Syncs all new tasks, deleted tasks, etc to the user object - * @param user - * @return nothing, user is modified directly. REMEMBER to save the user! - */ -ChallengeSchema.methods.syncToUser = function(user, cb) { - if (!user) return; - var self = this; - self.shortName = self.shortName || self.name; +// Syncs all new tasks, deleted tasks, etc to the user object +schema.methods.syncToUser = function syncChallengeToUser (user) { + if (!user) throw new Error('User required.'); + + let challenge = this; + challenge.shortName = challenge.shortName || challenge.name; // Add challenge to user.challenges - if (!_.contains(user.challenges, self._id)) { - user.challenges.push(self._id); - } + if (!_.contains(user.challenges, challenge._id)) user.challenges.push(challenge._id); // Sync tags - var tags = user.tags || []; - var i = _.findIndex(tags, {id: self._id}) - if (~i) { - if (tags[i].name !== self.shortName) { + let userTags = user.tags; + let i = _.findIndex(userTags, {_id: challenge._id}); + + if (i !== -1) { + if (userTags[i].name !== challenge.shortName) { // update the name - it's been changed since - user.tags[i].name = self.shortName; + userTags[i].name = challenge.shortName; } } else { - user.tags.push({ - id: self._id, - name: self.shortName, - challenge: true + userTags.push({ + _id: challenge._id, + name: challenge.shortName, + challenge: true, }); } // Sync new tasks and updated tasks - _.each(self.tasks, function(task){ - var list = user[task.type+'s']; - var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); - if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided - userTask.challenge = {id:self._id}; - userTask.tags = userTask.tags || {}; - userTask.tags[self._id] = true; - _.merge(userTask, syncableAttrs(task)); - }) + return Q.all([ + // Find original challenge tasks + Tasks.Task.find({ + userId: {$exists: false}, + 'challenge.id': challenge._id, + }).exec(), + // Find user's tasks linked to this challenge + Tasks.Task.find({ + userId: user._id, + 'challenge.id': challenge._id, + }).exec(), + ]) + .then(results => { + let challengeTasks = results[0]; + let userTasks = results[1]; + let toSave = []; // An array of things to save - // Flag deleted tasks as "broken" - _.each(user.tasks, function(task){ - if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { - task.challenge.broken = 'TASK_DELETED'; - } - }) + challengeTasks.forEach(chalTask => { + let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); - user.save(cb); + if (!matchingTask) { // If the task is new, create it + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; + matchingTask.userId = user._id; + user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); + } else { + _.merge(matchingTask, _syncableAttrs(chalTask)); + // Make sure the task is in user.tasksOrder TODO necessary? + let orderList = user.tasksOrder[`${chalTask.type}s`]; + if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); + } + + if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided + if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing + toSave.push(matchingTask.save()); + }); + + // Flag deleted tasks as "broken" + userTasks.forEach(userTask => { + if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { + userTask.challenge.broken = 'TASK_DELETED'; + toSave.push(userTask.save()); + } + }); + + toSave.push(user.save()); + return Q.all(toSave); + }); }; - -module.exports.schema = ChallengeSchema; -module.exports.model = mongoose.model("Challenge", ChallengeSchema); +export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/task.js b/website/src/models/task.js index a4e376446d..74f8d07c53 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -24,12 +24,12 @@ export let TaskSchema = new Schema({ value: {type: Number, default: 0}, // redness or cost for rewards priority: {type: Number, default: 1, required: true}, // TODO enum? attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, - userId: {type: String, ref: 'User'}, // When null it belongs to a challenge + userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge challenge: { - id: {type: String, ref: 'Challenge'}, - taskId: {type: String, ref: 'Task'}, // When null but challenge.id defined it's the original task - broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED TODO enum + id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, + taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task + broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? }, }, _.defaults({ diff --git a/website/src/models/user.js b/website/src/models/user.js index 54da99fb5c..8dff4bcafc 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -481,6 +481,7 @@ schema.plugin(baseModel, { private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? + // TODO this works? doc.filters = {}; doc._tmp = this._tmp; // be sure to send down drop notifs From 01e2bb56df07ccde8db0666c5a6c5899e4cffbc5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 11:18:58 +0100 Subject: [PATCH 028/213] port getChallenges --- website/src/controllers/api-v3/challenges.js | 46 ++++++++++++++++++++ website/src/models/group.js | 1 + 2 files changed, 47 insertions(+) create mode 100644 website/src/controllers/api-v3/challenges.js diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js new file mode 100644 index 0000000000..72c920eb3d --- /dev/null +++ b/website/src/controllers/api-v3/challenges.js @@ -0,0 +1,46 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as Challenge } from '../../models/challenge'; + +let api = {}; + +/** + * @api {get} /challenges Get challenges for a user + * @apiVersion 3.0.0 + * @apiName GetChallenges + * @apiGroup Challenge + * + * @apiSuccess {Array} challenges An array of challenges + */ +api.getChallenges = { + method: 'GET', + url: '/challenges', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + let groups = user.guilds || []; + if (user.party._id) groups.push(user.party._id); + groups.push('habitrpg'); // Public challenges + + Challenge.find({ + $or: [ + {_id: {$in: user.challenges}}, // Challenges where the user is participating + {group: {$in: groups}}, // Challenges in groups where I'm a member + {leader: user._id}, // Challenges where I'm the leader + ], + _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit + }) + .sort('-official -timestamp') + // TODO populate + // .populate('group', '_id name type') + // .populate('leader', 'profile.name') + .exec() + .then(challenges => { + res.respond(200, challenges); + }) + .catch(next); + }, +}; + +export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 2c5cdecd35..5c26330cc7 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -13,6 +13,7 @@ let Schema = mongoose.Schema; // NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API // changes made directly to the db will cause Firebase to get out of sync export let schema = new Schema({ + // TODO don't break validation on _id === 'habitrpg' name: {type: String, required: true}, description: String, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, From ba0fb884dd7c47c7477e82979d661e54e1f03359 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 12:14:56 +0100 Subject: [PATCH 029/213] port create challenge --- common/locales/en/api-v3.json | 5 +- website/src/controllers/api-v3/challenges.js | 92 ++++++++++++++++++++ website/src/models/challenge.js | 9 +- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 50726fccef..df92fe6a07 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -38,5 +38,8 @@ "groupMemberNotFound": "User not found among group's members", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", "canOnlyInviteEmailUuid": "Can only invite using uuids or emails but not both at the same time.", - "inviteMissingEmail": "Missing email address in invite." + "inviteMissingEmail": "Missing email address in invite.", + "onlyGroupLeaderChal": "Only the group leader can create challenges", + "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", + "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount." } diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 72c920eb3d..267ffd8138 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -1,9 +1,101 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; +import { model as Group } from '../../models/group'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import * as Tasks from './task'; +import Q from 'q'; let api = {}; +/** + * @api {post} /challenges Create a new challenge + * @apiVersion 3.0.0 + * @apiName CreateChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} challenge The newly created challenge + */ +api.getChallenges = { + method: 'POST', + url: '/challenges', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkBody('group', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + let groupId = req.body.group; + let prize = req.body.prize; + + Group.getGroup(user, groupId, '-chat') + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderChal')); + } + + if (groupId === 'habitrpg' && prize < 1) { + throw new NotAuthorized(res.t('pubChalsMinPrize')); + } + + if (prize > 0) { + let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; + let prizeCost = prize / 4; + + if (prizeCost > user.balance + groupBalance) { + throw new NotAuthorized(res.t('cantAfford')); + } + + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + let remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; + } + } + + let tasks = req.body.tasks || []; // TODO validate + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + let challenge = new Challenge(Challenge.sanitize(req.body)); + + let toSave = tasks.map(tasks, taskToCreate => { + // TODO validate type + let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); + task.challenge.id = challenge._id; + challenge.tasksOrder[`${task.type}s`].push(task._id); + return task.save(); + }); + + + toSave.unshift(challenge, group); + return Q.all(toSave); + }) + .then(results => { + let savedChal = results[0]; + + user.challenges.push(savedChal._id); // TODO save user only after group created, so that we can account for failed validation. Revisit in other places + return savedChal.syncToUser(user) // (it also saves the user) + .then(() => res.respond(201, savedChal)); + }) + .catch(next); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 795d353fdf..d5b3bf1a1b 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -22,18 +22,11 @@ let schema = new Schema({ group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? memberCount: {type: Number, default: 0}, - prize: {type: Number, default: 0, required: true}, + prize: {type: Number, default: 0, min: 0}, }); schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'tasksOrder'], - toJSONTransform: function userToJSON (doc) { - // TODO fixme - // TODO this works? - doc._isMember = this._isMember; - - return doc; - }, }); From 488685ceff7a4b0a616bd4e49892b6af7876f6f4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 12:15:28 +0100 Subject: [PATCH 030/213] typo --- website/src/controllers/api-v3/challenges.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 267ffd8138..3ca89076f6 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -19,7 +19,7 @@ let api = {}; * * @apiSuccess {object} challenge The newly created challenge */ -api.getChallenges = { +api.createChallenge = { method: 'POST', url: '/challenges', middlewares: [authWithHeaders(), cron], From ceb20742dc43200106ca642d13d4981a17862c4a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 12:21:15 +0100 Subject: [PATCH 031/213] fix an import --- website/src/controllers/api-v3/challenges.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3ca89076f6..6f17b8ce74 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -6,7 +6,7 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; -import * as Tasks from './task'; +import * as Tasks from '../../models/task'; import Q from 'q'; let api = {}; From b3feb997d358f34e79ef98274b7b9ed0a9c3e453 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 28 Dec 2015 08:09:17 -0600 Subject: [PATCH 032/213] Change group save to Group update, added flags route and tests, and made eslint edits --- .../integration/chat/POST-chat.flag.test.js | 126 ++++++++++++++++ .../integration/chat/POST-chat.like.test.js | 37 ++++- website/src/controllers/api-v3/chat.js | 139 +++++++++++++++++- 3 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/chat/POST-chat.flag.test.js diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js new file mode 100644 index 0000000000..5f4954d28a --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -0,0 +1,126 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; +import _ from 'lodash'; + +describe('POST /chat/:chatId/flag', () => { + let user; + let api; + let group; + let testMessage = 'Test Message'; + + before(() => { + let groupName = 'Test Guild'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + + return generateUser({balance: 1}).then((generatedUser) => { + user = generatedUser; + api = requester(user); + }) + .then(() => { + return api.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + }) + .then((generatedGroup) => { + group = generatedGroup; + }); + }); + + it('Returns an error when chat message is not found', () => { + return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('Returns an error when user tries to flag their own message', () => { + return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + .then((result) => { + return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagOwnMessage'), + }); + }); + }); + + it('Flags a chat', () => { + let api2; + let message; + + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api.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 api.get(`/groups/${group._id}`); + }) + .then((updatedGroup) => { + let messageToCheck = _.find(updatedGroup.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', () => { + let api2; + let secondUser; + let message; + + return generateUser({'contributor.admin': true}).then((generatedUser) => { + secondUser = generatedUser; + api2 = requester(generatedUser); + return api.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api2.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 api.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); + }); + }); + + it('Returns an error when user tries to flag a message that is already flagged', () => { + let api2; + let message; + + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api.post(`/groups/${group._id}/chat/${message.id}/flag`); + }) + .then(() => { + return expect(api.post(`/groups/${group._id}/chat/${message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagAlreadyReported'), + }); + }); + }); +}); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 1096aa11e3..66403221bd 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import _ from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; @@ -54,15 +55,49 @@ describe('POST /chat/:chatId/like', () => { it('Likes a chat', () => { let api2; + let message; + return generateUser().then((generatedUser) => { api2 = requester(generatedUser); return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { - return api.post(`/groups/${group._id}/chat/${result.message.id}/like`); + message = result.message; + return api.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(true); + return api.get(`/groups/${group._id}`); + }) + .then((updatedGroup) => { + let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + expect(messageToCheck.likes[user._id]).to.equal(true); + }); + }); + + it('Unlikes a chat', () => { + let api2; + let message; + + return generateUser().then((generatedUser) => { + api2 = requester(generatedUser); + return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + }) + .then((result) => { + message = result.message; + return api.post(`/groups/${group._id}/chat/${message.id}/like`); + }) + .then((result) => { + expect(result.likes[user._id]).to.equal(true); + return api.post(`/groups/${group._id}/chat/${message.id}/like`); + }) + .then((result) => { + expect(result.likes[user._id]).to.equal(undefined); + return api.get(`/groups/${group._id}`); + }) + .then((updatedGroup) => { + let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + expect(messageToCheck.likes[user._id]).to.equal(undefined); }); }); }); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index d8b79b3979..7fc6f7deda 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -1,10 +1,13 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, } from '../../libs/api-v3/errors'; import _ from 'lodash'; +import { sendTxn } from '../../libs/api-v3/email'; +import nconf from 'nconf'; let api = {}; @@ -137,10 +140,140 @@ api.likeChat = { message.likes[user._id] = true; } - return group.save(); + return Group.update( + {_id: group._id, 'chat.id': message.id}, + {$set: {'chat.$.likes': message.likes}} + ); }) - .then((group) => { - if (!group) throw new NotFound(res.t('groupNotFound')); + .then((groupSaved) => { + if (!groupSaved) throw new NotFound(res.t('groupNotFound')); + res.respond(200, message); + }) + .catch(next); + }, +}; + +/** + * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message + * @apiVersion 3.0.0 + * @apiName LikeChat + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * @apiParam {chatId} chatId The chat message _id + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.flagChat = { + method: 'Post', + url: '/groups/:groupId/chat/:chatId/flag', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let groupId = req.params.groupId; + let message; + let group; + let author; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, groupId) + .then((groupFound) => { + if (!groupFound) throw new NotFound(res.t('groupNotFound')); + group = groupFound; + message = _.find(group.chat, {id: req.params.chatId}); + + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); + + return User.findOne({_id: message.uuid}, {auth: 1}); + }) + .then((foundAuthor) => { + author = foundAuthor; + + // Log user ids that have flagged the message + if (!message.flags) message.flags = {}; + if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); + message.flags[user._id] = true; + + // Log total number of flags (publicly viewable) + if (!message.flagCount) message.flagCount = 0; + if (user.contributor.admin) { + // Arbitraty amount, higher than 2 + message.flagCount = 5; + } else { + message.flagCount++; + } + + // return group.save(); + return Group.update( + {_id: group._id, 'chat.id': message.id}, + {$set: { + 'chat.$.flags': message.flags, + 'chat.$.flagCount': message.flagCount, + }}); + }) + .then((group2) => { + if (!group2) throw new NotFound(res.t('groupNotFound')); + + let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL'); + addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo; + + if (Array.isArray(addressesToSendTo)) { + addressesToSendTo = addressesToSendTo.map((email) => { + return {email, canSend: true}; + }); + } else { + addressesToSendTo = {email: addressesToSendTo}; + } + + let reporterEmailContent; + if (user.auth.local) { + reporterEmailContent = user.auth.local.email; + } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) { + reporterEmailContent = user.auth.facebook.emails[0].value; + } + + let authorEmailContent; + if (author.auth.local) { + authorEmailContent = author.auth.local.email; + } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) { + authorEmailContent = author.auth.facebook.emails[0].value; + } + + let groupUrl; + if (group._id === 'habitrpg') { + groupUrl = '/#/options/groups/tavern'; + } else if (group.type === 'guild') { + groupUrl = `/#/options/groups/guilds/{$group._id}`; + } else { + groupUrl = 'party'; + } + + sendTxn(addressesToSendTo, 'flag-report-to-mods', [ + {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, + {name: 'MESSAGE_TEXT', content: message.text}, + + {name: 'REPORTER_USERNAME', content: user.profile.name}, + {name: 'REPORTER_UUID', content: user._id}, + {name: 'REPORTER_EMAIL', content: reporterEmailContent}, + {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`}, + + {name: 'AUTHOR_USERNAME', content: message.user}, + {name: 'AUTHOR_UUID', content: message.uuid}, + {name: 'AUTHOR_EMAIL', content: authorEmailContent}, + {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`}, + + {name: 'GROUP_NAME', content: group.name}, + {name: 'GROUP_TYPE', content: group.type}, + {name: 'GROUP_ID', content: group._id}, + {name: 'GROUP_URL', content: groupUrl}, + ]); res.respond(200, message); }) .catch(next); From 9748ffb0a68f321f343be9fb7df1a5ade5632ecc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 28 Dec 2015 19:45:34 +0100 Subject: [PATCH 033/213] add updateGroup route --- website/src/controllers/api-v3/groups.js | 39 ++++++++++++++++++++++++ website/src/models/group.js | 6 ++++ 2 files changed, 45 insertions(+) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index cf42b8b6de..0373d02984 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -160,6 +160,45 @@ api.getGroup = { }, }; +/** + * @api {put} /groups/:groupId Update group + * @apiVersion 3.0.0 + * @apiName UpdateGroup + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} group The updated group object + */ +api.updateGroup = { + method: 'PUT', + url: '/groups/:groupId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Group.getGroup(user, req.params.groupId) + .then(group => { + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); + + _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); + + return group.save(); + }).then(savedGroup => { + res.respond(200, savedGroup); + firebase.updateGroupData(savedGroup); + }) + .catch(next); + }, +}; + /** * @api {post} /groups/:groupId/join Join a group * @apiVersion 3.0.0 diff --git a/website/src/models/group.js b/website/src/models/group.js index 5c26330cc7..fd79686977 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -73,6 +73,12 @@ schema.plugin(baseModel, { noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'], }); +// 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 +}; + // TODO migration /** * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration From cb388eea4015d206877d1664d6e48336cd12c68d Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 28 Dec 2015 14:08:10 -0600 Subject: [PATCH 034/213] Ensured that only one like or flag is updated during query --- website/src/controllers/api-v3/chat.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 7fc6f7deda..dbd0625188 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -133,16 +133,22 @@ api.likeChat = { if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); + let update; + if (!message.likes) message.likes = {}; if (message.likes[user._id]) { delete message.likes[user._id]; + update = {'$unset': {}}; + update.$unset[`chat.$.likes.${user._id}`] = ''; } else { message.likes[user._id] = true; + update = {'$set': {}}; + update.$set[`chat.$.likes.${user._id}`] = true; } return Group.update( {_id: group._id, 'chat.id': message.id}, - {$set: {'chat.$.likes': message.likes}} + update ); }) .then((groupSaved) => { @@ -196,10 +202,13 @@ api.flagChat = { .then((foundAuthor) => { author = foundAuthor; + let update = {$set: {}}; + // Log user ids that have flagged the message if (!message.flags) message.flags = {}; if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); message.flags[user._id] = true; + update.$set[`chat.$.flags.${user._id}`] = true; // Log total number of flags (publicly viewable) if (!message.flagCount) message.flagCount = 0; @@ -209,14 +218,12 @@ api.flagChat = { } else { message.flagCount++; } + update.$set['chat.$.flagCount'] = message.flagCount; - // return group.save(); return Group.update( {_id: group._id, 'chat.id': message.id}, - {$set: { - 'chat.$.flags': message.flags, - 'chat.$.flagCount': message.flagCount, - }}); + update + ); }) .then((group2) => { if (!group2) throw new NotFound(res.t('groupNotFound')); From 2750446e7b11c05c16afdc1beb11857766b99f28 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 28 Dec 2015 18:45:34 -0600 Subject: [PATCH 035/213] Updated POST like route to persist data between updates --- .../api/v3/integration/chat/POST-chat.like.test.js | 4 ++-- website/src/controllers/api-v3/chat.js | 14 ++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 66403221bd..cda9df4e1c 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -92,12 +92,12 @@ describe('POST /chat/:chatId/like', () => { return api.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { - expect(result.likes[user._id]).to.equal(undefined); + expect(result.likes[user._id]).to.equal(false); return api.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(undefined); + expect(messageToCheck.likes[user._id]).to.equal(false); }); }); }); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index dbd0625188..2bfb4c6847 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -133,18 +133,12 @@ api.likeChat = { if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); - let update; + let update = {$set: {}}; if (!message.likes) message.likes = {}; - if (message.likes[user._id]) { - delete message.likes[user._id]; - update = {'$unset': {}}; - update.$unset[`chat.$.likes.${user._id}`] = ''; - } else { - message.likes[user._id] = true; - update = {'$set': {}}; - update.$set[`chat.$.likes.${user._id}`] = true; - } + + message.likes[user._id] = !message.likes[user._id]; + update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; return Group.update( {_id: group._id, 'chat.id': message.id}, From 547e8a04f50c4ff0673c9f5c105d7d85ec56acd3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 14:43:29 +0100 Subject: [PATCH 036/213] cleanup group model --- website/src/controllers/api-v2/groups.js | 1 + website/src/controllers/api-v3/groups.js | 4 ++-- website/src/models/group.js | 30 +----------------------- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js index de4d21022c..5f840c4330 100644 --- a/website/src/controllers/api-v2/groups.js +++ b/website/src/controllers/api-v2/groups.js @@ -1101,6 +1101,7 @@ api.questLeave = function(req, res, next) { }); } +// TODO port to api v3? in tojson? function _purgeFlagInfoFromChat(group, user) { group.chat = _.filter(group.chat, function(message) { return !message.flagCount || message.flagCount < 2; }); _.each(group.chat, function (message) { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 0373d02984..8f96cc09ae 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -567,7 +567,7 @@ function _inviteByEmails (emails, group, inviter, req, res, next) { * * @apiSuccess {Object} empty An empty object */ -/* api.inviteToGroup = { +api.inviteToGroup = { method: 'POST', url: '/groups/:groupId/invite', middlewares: [authWithHeaders(), cron], @@ -598,6 +598,6 @@ function _inviteByEmails (emails, group, inviter, req, res, next) { }) .catch(next); }, -};*/ +}; export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index fd79686977..95f5a82b94 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -94,16 +94,6 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { } }*/ -// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate() -// @see https://github.com/LearnBoost/mongoose/issues/964 -> Add update pre? -// TODO necessary? -schema.pre('save', function preSaveGroup (next) { - // removeDuplicates(this); - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); - return next(); -}); - // TODO test schema.pre('remove', true, function preRemoveGroup (next, done) { next(); @@ -135,24 +125,6 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -/* schema.methods.toJSON = function groupToJSON () { - let doc = this.toObject(); - // removeDuplicates(doc); - doc._isMember = this._isMember; // TODO ? - - // TODO migration - // fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). - // Required as angular 1.3 is strict on dupes, and no message.id to `track by` - _.remove(doc.chat, msg => !msg.id); - - // TODO should not be needed here - // @see pre('save') comment above - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); - - return doc; -};*/ - // TODO populate (invites too), isMember? schema.statics.getGroup = function getGroup (user, groupId, fields) { let query; @@ -170,7 +142,7 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { .select(fields) .exec(); // TODO catch errors here? - // TODO purge chat flags info? + // TODO purge chat flags info? in tojson? }; // TODO move to its own model From 71a910ccfba8be97056e83082ae099a53364fd58 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 14:45:29 +0100 Subject: [PATCH 037/213] temporary fix for eslint --- website/src/controllers/api-v3/groups.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8f96cc09ae..5866797d40 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -589,9 +589,9 @@ api.inviteToGroup = { if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time throw new BadRequest(res.t('canOnlyInviteEmailUuid')); } else if (Array.isArray(uuids)) { - return _inviteByUUIDs(uuids, group, user, req, res, next); + // return _inviteByUUIDs(uuids, group, user, req, res, next); } else if (Array.isArray(emails)) { - return _inviteByEmails(emails, group, user, req, res, next) + // return _inviteByEmails(emails, group, user, req, res, next); } else { throw new BadRequest(res.t('canOnlyInviteEmailUuid')); } From 61fc490f847a973a065efbccaa1a73d9d7caf223 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 18:35:34 +0100 Subject: [PATCH 038/213] port challenges\' tasks scoring --- common/script/api-v3/preenHistory.js | 2 +- website/src/controllers/api-v3/tasks.js | 21 ++++++++++++++++++++- website/src/models/task.js | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js index 50ffb1518f..64ae4e4715 100644 --- a/common/script/api-v3/preenHistory.js +++ b/common/script/api-v3/preenHistory.js @@ -62,7 +62,7 @@ export function preenHistory (history) { } export function preenUserHistory (user, tasksByType, minHistLen = 7) { - tasksByType.habits.concat(user.dailys).forEach((task) => { + tasksByType.habits.concat(tasksByType.dailys).forEach((task) => { if (task.history.length > minHistLen) { task.history = preenHistory(user, task.history); task.markModified('history'); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index ef7180088e..be77c97d70 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -7,10 +7,12 @@ import { NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; +import { model as Challenge } from '../../models/challenge'; import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; let api = {}; @@ -292,7 +294,24 @@ api.scoreTask = { sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); - // TODO sync challenge + // TODO test? + if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { + Tasks.Task.findOne({ + _id: task.challenge.taskId + }).exec() + .then(chalTask => { + chalTask.value += delta; + if (t.type == 'habit' || t.type == 'daily') { + chalTask.history.push({value: chalTask.value, date: Number(new Date())}); + // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? + chalTask.history = preenHistory(user, chalTask.history); + chalTask.markModified('history'); + } + + return chalTask.save(); + }); + //.catch(next) TODO what to do here + } }); }) .catch(next); diff --git a/website/src/models/task.js b/website/src/models/task.js index 74f8d07c53..b31676da10 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -21,13 +21,13 @@ export let TaskSchema = new Schema({ type: String, validate: [validator.isUUID, 'Invalid uuid.'], }], - value: {type: Number, default: 0}, // redness or cost for rewards + value: {type: Number, default: 0, required: true}, // redness or cost for rewards Required because it must be settable (for rewards) priority: {type: Number, default: 1, required: true}, // TODO enum? attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']}, userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge challenge: { - id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, + id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? From 411bfe1bb6f545b025d6e5c99fe67aeb0ee7f6cb Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Tue, 29 Dec 2015 20:51:16 +0200 Subject: [PATCH 039/213] Fix GET chat tests stopping execution of other tests --- test/api/v3/integration/chat/GET-chat.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index e6f2aeeb8a..5681c8b818 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -5,7 +5,7 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe.only('GET /groups/:groupId/chat', () => { +describe('GET /groups/:groupId/chat', () => { let user, api; before(() => { From 6003dad24ba20cd38f7bf7bf4310d38ca8bafc1b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 20:57:20 +0100 Subject: [PATCH 040/213] misc fixes, port challenge task scoring, delete chal and select winner --- common/locales/en/api-v3.json | 9 +- website/src/controllers/api-v3/challenges.js | 142 ++++++++++++++++++- website/src/controllers/api-v3/tasks.js | 7 +- website/src/controllers/pushNotifications.js | 1 + 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index df92fe6a07..2ace1f7773 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -15,7 +15,7 @@ "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", "onlySocialAttachLocal": "Local auth can only be added to a social account.", "invalidReqParams": "Invalid request parameters.", - "taskIdRequired": "\"taskId\" must be a valid UUID", + "taskIdRequired": "\"taskId\" must be a valid UUID.", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", @@ -41,5 +41,10 @@ "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", - "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount." + "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount.", + "challengeIdRequired": "\"challengeId\" must be a valid UUID.", + "winnerIdRequired": "\"winnerId\" must be a valid UUID.", + "challengeNotFound": "Challenge not found.", + "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." } diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 6f17b8ce74..1d950446fa 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,11 +2,15 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; 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 { 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 pushNotify from '../../libs/api-v3/pushNotifications'; import Q from 'q'; let api = {}; @@ -68,6 +72,8 @@ api.createChallenge = { } } + group.challengeCount += 1; + let tasks = req.body.tasks || []; // TODO validate req.body.leader = user._id; req.body.official = user.contributor.admin && req.body.official; @@ -81,14 +87,11 @@ api.createChallenge = { return task.save(); }); - toSave.unshift(challenge, group); return Q.all(toSave); }) .then(results => { let savedChal = results[0]; - - user.challenges.push(savedChal._id); // TODO save user only after group created, so that we can account for failed validation. Revisit in other places return savedChal.syncToUser(user) // (it also saves the user) .then(() => res.respond(201, savedChal)); }) @@ -135,4 +138,137 @@ api.getChallenges = { }, }; +// 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 = {}) { + let winner = broken.winner; + let brokenReason = broken.broken; + + let tasks = [ + // Delete the challenge + Challenge.remove({_id: challenge._id}).exec(), + // 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 + User.update({ + challenges: {$in: [challenge._id]}, + 'tags._id': challenge._id, + }, { + $set: {'tags.$.challenge': false}, + $pull: {challenges: challenge._id}, + }, {multi: true}).exec(), + // Break users' tasks + Tasks.Task.update({ + 'challenge.id': challenge._id, + }, { + $set: { + 'challenge.broken': brokenReason, + 'challenge.winner': winner && winner.profile.name, + }, + }, {multi: true}).exec(), + // Update the challengeCount on the group + Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(), + ]; + + // Refund the leader if the challenge is closed and the group not the tavern + if (challenge.group !== '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 allSettle could be useful somewhere else + // TODO catch and handle +} + +/** + * @api {delete} /challenges/:challengeId Delete a challenge + * @apiVersion 3.0.0 + * @apiName DeleteChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} empty An empty object + */ +api.deleteChallenge = { + method: 'DELETE', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Challenge.findOne({_id: req.params.challengeId}) + .exec() + .then(challenge => { + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); + }) + .catch(next); + }, +}; + +/** + * @api {delete} /challenges/:challengeId Delete a challenge + * @apiVersion 3.0.0 + * @apiName DeleteChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} empty An empty object + */ +api.selectChallengeWinner = { + method: 'POST', + url: '/challenges/:challengeId/selectWinner/:winnerId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let challenge; + + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Challenge.findOne({_id: req.params.challengeId}) + .exec() + .then(challengeFound => { + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + + challenge = challengeFound; + + return User.findOne({_id: req.params.winnerId}).exec(); + }) + .then(winner => { + if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); + + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); + }) + .catch(next); + }, +}; + export default api; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index be77c97d70..99e217e401 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -7,7 +7,6 @@ import { NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; -import { model as Challenge } from '../../models/challenge'; import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; @@ -297,11 +296,11 @@ api.scoreTask = { // TODO test? if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { Tasks.Task.findOne({ - _id: task.challenge.taskId + _id: task.challenge.taskId, }).exec() .then(chalTask => { chalTask.value += delta; - if (t.type == 'habit' || t.type == 'daily') { + if (chalTask.type === 'habit' || chalTask.type === 'daily') { chalTask.history.push({value: chalTask.value, date: Number(new Date())}); // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? chalTask.history = preenHistory(user, chalTask.history); @@ -310,7 +309,7 @@ api.scoreTask = { return chalTask.save(); }); - //.catch(next) TODO what to do here + // .catch(next) TODO what to do here } }); }) diff --git a/website/src/controllers/pushNotifications.js b/website/src/controllers/pushNotifications.js index d1c728f365..860cfbf56a 100644 --- a/website/src/controllers/pushNotifications.js +++ b/website/src/controllers/pushNotifications.js @@ -1,3 +1,4 @@ +// TODO move to /api-v2 var api = module.exports; var _ = require('lodash'); var nconf = require('nconf'); From 08e0c670892f0dba1f589c107e6a9cb3f71b814f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 20:57:37 +0100 Subject: [PATCH 041/213] refactor pushNotifications --- website/src/libs/api-v3/pushNotifications.js | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 website/src/libs/api-v3/pushNotifications.js diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js new file mode 100644 index 0000000000..1db4c740bd --- /dev/null +++ b/website/src/libs/api-v3/pushNotifications.js @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import nconf from 'nconf'; +import pushNotify from 'push-notify'; + +const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY'); + +let gcm = GCM_API_KEY ? pushNotify.gcm({ + apiKey: GCM_API_KEY, + retries: 3, +}) : undefined; + +// TODO log +if (gcm) { + gcm.on('transmitted', (/* result, message, registrationId */) => { + // console.info("transmitted", result, message, registrationId); + }); + + gcm.on('transmissionError', (/* error, message, registrationId */) => { + // console.info("transmissionError", error, message, registrationId); + }); + + gcm.on('updated', (/* result, registrationId */) => { + // console.info("updated", result, registrationId); + }); +} + +export default function sendNotify (user, title, message, timeToLive = 15) { + // TODO need investigation: + // https://github.com/HabitRPG/habitrpg/issues/5252 + + if (!user) throw new Error('User is required'); + + _.each(user.pushDevices, pushDevice => { + switch (pushDevice.type) { + case 'android': + if (gcm) { + gcm.send({ + registrationId: pushDevice.regId, + // collapseKey: 'COLLAPSE_KEY', + delayWhileIdle: true, + timeToLive, + data: { + title, + message, + }, + }); + } + + break; + + case 'ios': + break; + } + }); +} From a5aeb6917e5d0819aa6455053a841b5e09848f43 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 29 Dec 2015 21:36:52 +0100 Subject: [PATCH 042/213] allow getGroup to optionally work for groups where the user is not a member --- website/src/controllers/api-v3/groups.js | 2 +- website/src/libs/api-v3/pushNotifications.js | 1 + website/src/models/group.js | 17 +++++++++-------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 5866797d40..8a3ce9630b 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -221,7 +221,7 @@ api.joinGroup = { let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat + Group.getGroup(user, req.params.groupId, '-chat', true) // Do not fetch chat and work even if the user is not yet a member of the group .then(group => { if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js index 1db4c740bd..fbf5e2666a 100644 --- a/website/src/libs/api-v3/pushNotifications.js +++ b/website/src/libs/api-v3/pushNotifications.js @@ -24,6 +24,7 @@ if (gcm) { }); } +// TODO test export default function sendNotify (user, title, message, timeToLive = 15) { // TODO need investigation: // https://github.com/HabitRPG/habitrpg/issues/5252 diff --git a/website/src/models/group.js b/website/src/models/group.js index 95f5a82b94..f425a6a667 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -126,10 +126,13 @@ schema.post('remove', function postRemoveGroup (group) { }); // TODO populate (invites too), isMember? -schema.statics.getGroup = function getGroup (user, groupId, fields) { +schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) { let query; - if (groupId === 'party' || user.party._id === groupId) { + // 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) { query = {type: 'party', _id: user.party._id}; } else if (user.guilds.indexOf(groupId) !== -1) { query = {type: 'guild', _id: groupId}; @@ -137,12 +140,10 @@ schema.statics.getGroup = function getGroup (user, groupId, fields) { query = {type: 'guild', privacy: 'public', _id: groupId}; } - return this - .findOne(query) - .select(fields) - .exec(); // TODO catch errors here? - - // TODO purge chat flags info? in tojson? + let mQuery = this.findOne(query); + if (fields) mQuery.select(fields); + return mQuery.exec(); // TODO catch errors here? + // TODO purge chat flags info? in tojson? }; // TODO move to its own model From dc3407e1af3157bea6104f94653885f262ec7e31 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 08:44:02 -0600 Subject: [PATCH 043/213] tests(api): Convert groups tests to use user['HTTP_METHOD'] syntax --- test/api/v3/integration/chat/GET-chat.test.js | 8 ++-- .../integration/chat/POST-chat.flag.test.js | 39 +++++++------------ .../integration/chat/POST-chat.like.test.js | 33 +++++++--------- .../api/v3/integration/chat/POST-chat.test.js | 31 +++++++-------- .../v3/integration/groups/POST-groups.test.js | 24 ++++-------- 5 files changed, 53 insertions(+), 82 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 5681c8b818..26ed88f70b 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -1,17 +1,15 @@ import { generateUser, generateGroup, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; describe('GET /groups/:groupId/chat', () => { - let user, api; + let user; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -38,7 +36,7 @@ describe('GET /groups/:groupId/chat', () => { }); it('returns Guild chat', () => { - return api.get('/groups/' + group._id + '/chat') + return user.get('/groups/' + group._id + '/chat') .then((getChat) => { expect(getChat).to.eql(group.chat); }); @@ -69,7 +67,7 @@ describe('GET /groups/:groupId/chat', () => { it('returns error if user is not member of requested private group', () => { return expect( - api.get('/groups/' + group._id + '/chat') + user.get('/groups/' + group._id + '/chat') ) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 5f4954d28a..c5178b7af8 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -1,13 +1,11 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import _ from 'lodash'; describe('POST /chat/:chatId/flag', () => { let user; - let api; let group; let testMessage = 'Test Message'; @@ -18,10 +16,9 @@ describe('POST /chat/:chatId/flag', () => { return generateUser({balance: 1}).then((generatedUser) => { user = generatedUser; - api = requester(user); }) .then(() => { - return api.post('/groups', { + return user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -33,7 +30,7 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when chat message is not found', () => { - return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) + return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -42,9 +39,9 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when user tries to flag their own message', () => { - return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + return user.post(`/groups/${group._id}/chat`, { message: testMessage}) .then((result) => { - return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) + return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -54,21 +51,19 @@ describe('POST /chat/:chatId/flag', () => { }); it('Flags a chat', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/flag`); + 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 api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); @@ -77,23 +72,21 @@ describe('POST /chat/:chatId/flag', () => { }); it('Flags a chat with a higher flag acount when an admin flags the message', () => { - let api2; let secondUser; let message; return generateUser({'contributor.admin': true}).then((generatedUser) => { secondUser = generatedUser; - api2 = requester(generatedUser); - return api.post(`/groups/${group._id}/chat`, { message: testMessage}); + return user.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api2.post(`/groups/${group._id}/chat/${message.id}/flag`); + 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 api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); @@ -103,19 +96,17 @@ describe('POST /chat/:chatId/flag', () => { }); it('Returns an error when user tries to flag a message that is already flagged', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/flag`); + return user.post(`/groups/${group._id}/chat/${message.id}/flag`); }) .then(() => { - return expect(api.post(`/groups/${group._id}/chat/${message.id}/flag`)) + return expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index cda9df4e1c..40f530174c 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -1,13 +1,11 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; import _ from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; - let api; let group; let testMessage = 'Test Message'; @@ -18,10 +16,9 @@ describe('POST /chat/:chatId/like', () => { return generateUser({balance: 1}).then((generatedUser) => { user = generatedUser; - api = requester(user); }) .then(() => { - return api.post('/groups', { + return user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -33,7 +30,7 @@ describe('POST /chat/:chatId/like', () => { }); it('Returns an error when chat message is not found', () => { - return expect(api.post(`/groups/${group._id}/chat/incorrectMessage/like`)) + return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/like`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -42,9 +39,9 @@ describe('POST /chat/:chatId/like', () => { }); it('Returns an error when user tries to like their own message', () => { - return api.post(`/groups/${group._id}/chat`, { message: testMessage}) + return user.post(`/groups/${group._id}/chat`, { message: testMessage}) .then((result) => { - return expect(api.post(`/groups/${group._id}/chat/${result.message.id}/like`)) + return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/like`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -54,20 +51,18 @@ describe('POST /chat/:chatId/like', () => { }); it('Likes a chat', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/like`); + return user.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(true); - return api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); @@ -76,24 +71,22 @@ describe('POST /chat/:chatId/like', () => { }); it('Unlikes a chat', () => { - let api2; let message; - return generateUser().then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return generateUser().then((anotherUser) => { + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { message = result.message; - return api.post(`/groups/${group._id}/chat/${message.id}/like`); + return user.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(true); - return api.post(`/groups/${group._id}/chat/${message.id}/like`); + return user.post(`/groups/${group._id}/chat/${message.id}/like`); }) .then((result) => { expect(result.likes[user._id]).to.equal(false); - return api.get(`/groups/${group._id}`); + return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index f427ac460d..37403ed12d 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,17 +1,14 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; describe('POST /chat', () => { let user; - let api; before(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -20,18 +17,16 @@ describe('POST /chat', () => { let groupType = 'guild'; let groupPrivacy = 'public'; let testMessage = ''; - let api2; - return generateUser({balance: 1}).then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post('/groups', { + return generateUser({balance: 1}).then((anotherUser) => { + return anotherUser.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, }); }) .then((group) => { - return expect(api.post(`/groups/${group._id}/chat`, { message: testMessage})) + return expect(user.post(`/groups/${group._id}/chat`, { message: testMessage})) .to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -42,7 +37,7 @@ describe('POST /chat', () => { it('Returns an error when group is not found', () => { let testMessage = 'Test Message'; - return expect(api.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ + return expect(user.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), @@ -54,18 +49,19 @@ describe('POST /chat', () => { let groupType = 'guild'; let groupPrivacy = 'public'; let testMessage = 'Test Message'; - let api2; + let userWithoutChat; return generateUser({balance: 1, 'flags.chatRevoked': true}).then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post('/groups', { + userWithoutChat = generatedUser; + + return userWithoutChat.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, }); }) .then((group) => { - return expect(api2.post(`/groups/${group._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + 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.', @@ -78,18 +74,19 @@ describe('POST /chat', () => { let groupType = 'guild'; let groupPrivacy = 'public'; let testMessage = 'Test Message'; - let api2; + let anotherUser; return generateUser({balance: 1}).then((generatedUser) => { - api2 = requester(generatedUser); - return api2.post('/groups', { + anotherUser = generatedUser; + + return anotherUser.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, }); }) .then((group) => { - return api2.post(`/groups/${group._id}/chat`, { message: testMessage}); + return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); }) .then((result) => { expect(result.message.id).to.exist; diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 85269fe6b7..f77e0fb5ae 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -1,16 +1,14 @@ import { generateUser, - requester, translate as t, } from '../../../../helpers/api-integration.helper'; describe('POST /group', () => { - let user, api; + let user; beforeEach(() => { return generateUser().then((generatedUser) => { user = generatedUser; - api = requester(user); }); }); @@ -20,7 +18,7 @@ describe('POST /group', () => { let groupType = 'guild'; return expect( - api.post('/groups', { + user.post('/groups', { name: groupName, type: groupType }) @@ -38,8 +36,7 @@ describe('POST /group', () => { let groupType = 'guild'; return generateUser({balance: 1}).then((generatedUser) => { - let api2 = requester(generatedUser); - return api2.post('/groups', { + return generatedUser.post('/groups', { name: groupName, type: groupType }); @@ -60,8 +57,7 @@ describe('POST /group', () => { let tmpUser; return generateUser({balance: 1}).then((generatedUser) => { - let api2 = requester(generatedUser); - return api2.post('/groups', { + return generatedUser.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy @@ -82,7 +78,7 @@ describe('POST /group', () => { let groupName = "Test Party"; let groupType = "party"; - return api.post('/groups', { + return user.post('/groups', { name: groupName, type: groupType }) @@ -93,28 +89,24 @@ describe('POST /group', () => { }) }); - it('prevents user in a party from creating a party', () => { + it('prevents user in a party from creating another party', () => { let tmpUser; let groupName = "Test Party"; let groupType = "party"; return generateUser().then((generatedUser) => { tmpUser = generatedUser; - api = requester(tmpUser); - return api.post('/groups', { + return tmpUser.post('/groups', { name: groupName, type: groupType }); }) .then(() => { - return expect(api.post('/groups')).to.eventually.be.rejected.and.eql({ + return expect(tmpUser.post('/groups')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupAlreadyInParty'), }); - }) - .then(() => { - api = requester(user); }); }); }); From 59b8ba0c85a2b6129ee80d658c8c527aad40dc38 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Dec 2015 17:14:20 +0100 Subject: [PATCH 044/213] add TODOs to tavern creation --- website/src/models/group.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index f425a6a667..376c3fbd1b 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -513,9 +513,8 @@ model.count({_id: 'habitrpg'}, (err, ct) => { if (ct > 0) return; new model({ // eslint-disable-line new-cap - _id: 'habitrpg', - chat: [], - leader: '9', + _id: 'habitrpg', // TODO hmm this will probably break everything + leader: '9', // TODO change this user id name: 'HabitRPG', type: 'guild', privacy: 'public', From 25b0c3be2bfc46675c57549b648f5de714d9462a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Dec 2015 22:59:36 +0100 Subject: [PATCH 045/213] porh auth controller to es7 async functions --- .eslintrc | 14 +- package.json | 5 +- website/src/controllers/api-v3/auth.js | 243 +++++++++++++------------ website/src/libs/api-v3/setupRoutes.js | 8 +- website/src/models/group.js | 2 +- 5 files changed, 144 insertions(+), 128 deletions(-) diff --git a/.eslintrc b/.eslintrc index 83cd0067d1..c124bdda89 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,6 @@ { + "parser": "babel-eslint", + "plugins": ["babel"], "rules": { "indent": [2, 2, {"SwitchCase": 1}], "quotes": [2, "single"], @@ -43,7 +45,7 @@ "no-self-compare": 2, "no-return-assign": 2, "no-redeclare": 2, - "strict": [2, "global"], + "strict": [0, "global"], "no-delete-var": 2, "no-label-var": 2, "no-shadow-restricted-names": 2, @@ -64,7 +66,7 @@ "no-dupe-class-members": 2, "no-this-before-super": 2, "no-var": 2, - "object-shorthand": 2, + "object-shorthand": 0, "prefer-const": 0, "prefer-spread": 2, "prefer-template": 2, @@ -81,7 +83,7 @@ "block-spacing": [2, "always"], "key-spacing": [2, {"beforeColon": false, "afterColon": true}], "max-nested-callbacks": [2, 3], - "new-cap": 2, + "new-cap": 0, "new-parens": 2, "newline-after-var": 0, "no-array-constructor": 2, @@ -106,7 +108,11 @@ "space-unary-ops": 2, "spaced-comment": [2, "always", { "exceptions": ["-"]}], "padded-blocks": [2, "never"], - "no-multiple-empty-lines": [2, {"max": 2}] + "no-multiple-empty-lines": [2, {"max": 2}], + "generator-star-spacing": 0, + "babel/new-cap": 2, + "babel/object-shorthand": 2, + "babel/no-await-in-loop": 2, }, "env": { "es6": true, diff --git a/package.json b/package.json index 2201a372c5..434c63fde7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel-core": "^5.8.34", + "babel-eslint": "^4.1.6", "babelify": "^6.x.x", "body-parser": "^1.14.1", "bower": "~1.3.12", @@ -21,6 +22,7 @@ "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", "domain-middleware": "~0.1.0", + "eslint-plugin-babel": "^3.0.0", "estraverse": "^4.1.1", "express": "~4.13.3", "express-csv": "~0.6.0", @@ -110,7 +112,7 @@ "event-stream": "^3.2.2", "expect.js": "~0.2.0", "istanbul": "^0.3.14", - "phantomjs": "^1.9", + "phantomjs": "^1.9.18", "karma": "~0.13.15", "karma-babel-preprocessor": "^5.0.0", "karma-chai-plugins": "~0.6.0", @@ -123,7 +125,6 @@ "mongodb": "^2.0.46", "mongoskin": "~0.6.1", "nock": "^2.17.0", - "phantomjs": "^1.9.18", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index b0ab6b0674..abaf84e7cb 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -5,6 +5,7 @@ import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, } from '../../libs/api-v3/errors'; +import Q from 'q'; import * as passwordUtils from '../../libs/api-v3/password'; import { model as User } from '../../models/user'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; @@ -29,7 +30,7 @@ api.registerLocal = { method: 'POST', middlewares: [authWithHeaders(true)], url: '/user/auth/local/register', - handler (req, res, next) { + async handler (req, res) { let fbUser = res.locals.user; // If adding local auth to social user // TODO check user doesn't have local auth req.checkBody({ @@ -45,7 +46,7 @@ api.registerLocal = { }); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let { email, username, password } = req.body; @@ -55,72 +56,70 @@ api.registerLocal = { let lowerCaseUsername = username.toLowerCase(); // Search for duplicates using lowercase version of username - User.findOne({$or: [ + let user = User.findOne({$or: [ {'auth.local.email': email}, {'auth.local.lowerCaseUsername': lowerCaseUsername}, - ]}, {'auth.local': 1}) - .exec() - .then((user) => { - if (user) { - if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken')); - // Check that the lowercase username isn't already used - if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken')); - } + ]}, {'auth.local': 1}).exec(); - let salt = passwordUtils.makeSalt(); - let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase - let newUser = { - auth: { - local: { - username, - lowerCaseUsername, - email, - salt, - hashed_password, // eslint-disable-line camelcase - }, + if (user) { + if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken')); + // Check that the lowercase username isn't already used + if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken')); + } + + let salt = passwordUtils.makeSalt(); + let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase + let newUser = { + auth: { + local: { + username, + lowerCaseUsername, + email, + salt, + hashed_password, // eslint-disable-line camelcase }, - preferences: { - language: req.language, - }, - }; + }, + preferences: { + language: req.language, + }, + }; - if (fbUser) { - if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); - fbUser.auth.local = newUser; - return fbUser.save(); - } else { - newUser = new User(newUser); - newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? - return newUser.save(); - } - }) - .then((savedUser) => { - if (savedUser.auth.facebook.id) { - res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields - } else { - res.respond(201, savedUser); - } + let savedUser; - // Clean previous email preferences - EmailUnsubscription - .remove({email: savedUser.auth.local.email}) - .then(() => sendTxnEmail(savedUser, 'welcome')); + if (fbUser) { + if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal')); + fbUser.auth.local = newUser; + savedUser = await fbUser.save(); + } else { + newUser = new User(newUser); + newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere? + savedUser = await newUser.save(); + } - if (!savedUser.auth.facebook.id) { - res.analytics.track('register', { - category: 'acquisition', - type: 'local', - gaLabel: 'local', - uuid: savedUser._id, - }); - } - }) - .catch(next); + if (savedUser.auth.facebook.id) { + res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields + } else { + res.respond(201, savedUser); + } + + // Clean previous email preferences + EmailUnsubscription + .remove({email: savedUser.auth.local.email}) + .then(() => sendTxnEmail(savedUser, 'welcome')); + + if (!savedUser.auth.facebook.id) { + res.analytics.track('register', { + category: 'acquisition', + type: 'local', + gaLabel: 'local', + uuid: savedUser._id, + }); + } }, }; -function _loginRes (user, req, res, next) { - if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id}))); +function _loginRes (user, req, res) { + if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id})); res.respond(200, {id: user._id, apiToken: user.apiToken}); } @@ -140,7 +139,7 @@ api.loginLocal = { method: 'POST', url: '/user/auth/local/login', middlewares: [cron], - handler (req, res, next) { + async handler (req, res) { req.checkBody({ username: { notEmpty: true, @@ -153,7 +152,7 @@ api.loginLocal = { }); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; req.sanitizeBody('username').trim(); req.sanitizeBody('password').trim(); @@ -167,75 +166,82 @@ api.loginLocal = { login = {'auth.local.username': username}; } - User - .findOne(login, {auth: 1, apiToken: 1}).exec() - .then((user) => { - // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); - let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec(); - if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); - _loginRes(user, ...arguments); - }) - .catch(next); + // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."}); + let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt); + + if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials')); + _loginRes(user, ...arguments); }, }; +function _passportFbProfile (accessToken) { + let deferred = Q.defer(); + + passport._strategies.facebook.userProfile(accessToken, (err, profile) => { + if (err) { + deferred.rejec(); + } else { + deferred.resolve(profile); + } + }); + + return deferred.promise; +} + // Called as a callback by Facebook (or other social providers) api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 middlewares: [cron], - handler (req, res, next) { + async handler (req, res) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; - if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported'))); + if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); - passport._strategies[network].userProfile(accessToken, (err, profile) => { - if (err) return next(err); + // TODO promise here? + // TODO throwing inside a callback still bubblet up to the async handler? + let profile = await _passportFbProfile(accessToken); - User.findOne({ - [`auth.${network}.id`]: profile.id, - }, {_id: 1, apiToken: 1, auth: 1}).exec() - .then((user) => { - // User already signed up - if (user) { - return _loginRes(user, ...arguments); - } else { // Create new user - user = new User({ - auth: { - [network]: profile, - }, - preferences: { - language: req.language, - }, - }); - user.registeredThrough = req.headers['x-client']; + let user = await User.findOne({ + [`auth.${network}.id`]: profile.id, + }, {_id: 1, apiToken: 1, auth: 1}).exec(); - user.save() - .then((savedUser) => { - _loginRes(user, ...arguments); + // User already signed up + if (user) { + _loginRes(user, ...arguments); + } else { // Create new user + user = new User({ + auth: { + [network]: profile, + }, + preferences: { + language: req.language, + }, + }); + user.registeredThrough = req.headers['x-client']; - // Clean previous email preferences - if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { - EmailUnsubscription - .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) - .exec() - .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks - } + let savedUser = await user.save(); - res.analytics.track('register', { - category: 'acquisition', - type: network, - gaLabel: network, - uuid: savedUser._id, - }); - }) - .catch(next); - } - }) - .catch(next); - }); + _loginRes(user, ...arguments); + + // Clean previous email preferences + if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) { + EmailUnsubscription + .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()}) + .exec() + .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks + } + + res.analytics.track('register', { + category: 'acquisition', + type: network, + gaLabel: network, + uuid: savedUser._id, + }); + } }, }; @@ -251,17 +257,16 @@ api.deleteSocial = { method: 'DELETE', url: '/user/auth/social/:network', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let network = req.params.network; - if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported'))); - if (!user.auth.local.username) return next(new NotAuthorized(res.t('cantDetachFb'))); // TODO move to model validation? + if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); + if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb')); - User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}) - .exec() - .then(() => res.respond(200)) - .catch(next); + await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec(); + + res.respond(200, {}); }, }; diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index bdb587d50c..bfe2ab93e7 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -4,7 +4,11 @@ import express from 'express'; import _ from 'lodash'; const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); -let router = express.Router(); // eslint-disable-line new-cap +let router = express.Router(); // eslint-disable-line babel/new-cap + +// Wrapper function to handler `async` route handlers that return promises +// It takes the async function, execute it and pass any error to next (args[2]) +let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]); fs .readdirSync(CONTROLLERS_PATH) @@ -17,7 +21,7 @@ fs let {method, url, middlewares = [], handler} = action; method = method.toLowerCase(); - router[method](url, ...middlewares, handler); + router[method](url, ...middlewares, _wrapAsyncFn(handler)); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index 376c3fbd1b..eb85e7662f 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -512,7 +512,7 @@ model.count({_id: 'habitrpg'}, (err, ct) => { if (err) throw err; if (ct > 0) return; - new model({ // eslint-disable-line new-cap + new model({ // eslint-disable-line babel/new-cap _id: 'habitrpg', // TODO hmm this will probably break everything leader: '9', // TODO change this user id name: 'HabitRPG', From f76c9d025f9c0b2207e7e7617ce1c27cb8e70221 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 30 Dec 2015 23:00:54 +0100 Subject: [PATCH 046/213] remove comments --- website/src/controllers/api-v3/auth.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index abaf84e7cb..a1d2c1b82a 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -201,8 +201,6 @@ api.loginSocial = { if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported')); - // TODO promise here? - // TODO throwing inside a callback still bubblet up to the async handler? let profile = await _passportFbProfile(accessToken); let user = await User.findOne({ From aebe2fa400841632a9fc935782dc4caebd76a9bd Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 31 Dec 2015 11:46:21 +0100 Subject: [PATCH 047/213] finish porting to async/away syntax --- website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/challenges.js | 160 +++---- website/src/controllers/api-v3/chat.js | 259 +++++------ website/src/controllers/api-v3/groups.js | 315 ++++++-------- website/src/controllers/api-v3/tags.js | 43 +- website/src/controllers/api-v3/tasks.js | 431 +++++++++---------- website/src/controllers/api-v3/user.js | 2 +- 7 files changed, 560 insertions(+), 652 deletions(-) diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index a1d2c1b82a..c66b254e9b 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -56,7 +56,7 @@ api.registerLocal = { let lowerCaseUsername = username.toLowerCase(); // Search for duplicates using lowercase version of username - let user = User.findOne({$or: [ + let user = await User.findOne({$or: [ {'auth.local.email': email}, {'auth.local.lowerCaseUsername': lowerCaseUsername}, ]}, {'auth.local': 1}).exec(); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 1d950446fa..5a19663f74 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -27,75 +27,72 @@ api.createChallenge = { method: 'POST', url: '/challenges', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkBody('group', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let groupId = req.body.group; let prize = req.body.prize; - Group.getGroup(user, groupId, '-chat') - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, groupId, '-chat'); + if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { - throw new NotAuthorized(res.t('onlyGroupLeaderChal')); + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { + throw new NotAuthorized(res.t('onlyGroupLeaderChal')); + } + + if (groupId === 'habitrpg' && prize < 1) { + throw new NotAuthorized(res.t('pubChalsMinPrize')); + } + + if (prize > 0) { + let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; + let prizeCost = prize / 4; + + if (prizeCost > user.balance + groupBalance) { + throw new NotAuthorized(res.t('cantAfford')); } - if (groupId === 'habitrpg' && prize < 1) { - throw new NotAuthorized(res.t('pubChalsMinPrize')); + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + let remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; } + } - if (prize > 0) { - let groupBalance = group.balance && group.leader === user._id ? group.balance : 0; - let prizeCost = prize / 4; + group.challengeCount += 1; - if (prizeCost > user.balance + groupBalance) { - throw new NotAuthorized(res.t('cantAfford')); - } + let tasks = req.body.tasks || []; // TODO validate + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + let challenge = new Challenge(Challenge.sanitize(req.body)); - if (groupBalance >= prizeCost) { - // Group pays for all of prize - group.balance -= prizeCost; - } else if (groupBalance > 0) { - // User pays remainder of prize cost after group - let remainder = prizeCost - group.balance; - group.balance = 0; - user.balance -= remainder; - } else { - // User pays for all of prize - user.balance -= prizeCost; - } - } + let toSave = tasks.map(tasks, taskToCreate => { + // TODO validate type + let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); + task.challenge.id = challenge._id; + challenge.tasksOrder[`${task.type}s`].push(task._id); + return task.save(); + }); - group.challengeCount += 1; + toSave.unshift(challenge, group); - let tasks = req.body.tasks || []; // TODO validate - req.body.leader = user._id; - req.body.official = user.contributor.admin && req.body.official; - let challenge = new Challenge(Challenge.sanitize(req.body)); + let results = await Q.all(toSave); + let savedChal = results[0]; - let toSave = tasks.map(tasks, taskToCreate => { - // TODO validate type - let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); - task.challenge.id = challenge._id; - challenge.tasksOrder[`${task.type}s`].push(task._id); - return task.save(); - }); - - toSave.unshift(challenge, group); - return Q.all(toSave); - }) - .then(results => { - let savedChal = results[0]; - return savedChal.syncToUser(user) // (it also saves the user) - .then(() => res.respond(201, savedChal)); - }) - .catch(next); + await savedChal.syncToUser(user); // (it also saves the user) + res.respond(201, savedChal); }, }; @@ -111,14 +108,14 @@ api.getChallenges = { method: 'GET', url: '/challenges', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groups = user.guilds || []; if (user.party._id) groups.push(user.party._id); groups.push('habitrpg'); // Public challenges - Challenge.find({ + let challenges = await Challenge.find({ $or: [ {_id: {$in: user.challenges}}, // Challenges where the user is participating {group: {$in: groups}}, // Challenges in groups where I'm a member @@ -130,11 +127,9 @@ api.getChallenges = { // TODO populate // .populate('group', '_id name type') // .populate('leader', 'profile.name') - .exec() - .then(challenges => { - res.respond(200, challenges); - }) - .catch(next); + .exec(); + + res.respond(200, challenges); }, }; @@ -190,7 +185,7 @@ function _closeChal (challenge, broken = {}) { })); } - return Q.allSettled(tasks); // TODO look if allSettle could be useful somewhere else + return Q.allSettled(tasks); // TODO look if allSettled could be useful somewhere else // TODO catch and handle } @@ -206,25 +201,21 @@ api.deleteChallenge = { method: 'DELETE', url: '/challenges/:challengeId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Challenge.findOne({_id: req.params.challengeId}) - .exec() - .then(challenge => { - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); - res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); - }) - .catch(next); + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); }, }; @@ -240,34 +231,25 @@ api.selectChallengeWinner = { method: 'POST', url: '/challenges/:challengeId/selectWinner/:winnerId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; - let challenge; req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Challenge.findOne({_id: req.params.challengeId}) - .exec() - .then(challengeFound => { - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); - challenge = challengeFound; + 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})); - return User.findOne({_id: req.params.winnerId}).exec(); - }) - .then(winner => { - if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); - - res.respond(200, {}); - // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); - }) - .catch(next); + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 2bfb4c6847..48dc2e440d 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -25,21 +25,18 @@ api.getChat = { method: 'GET', url: '/groups/:groupId/chat', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, 'chat') - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, 'chat'); + if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group.chat); - }) - .catch(next); + res.respond(200, group.chat); }, }; @@ -59,7 +56,7 @@ api.postChat = { method: 'POST', url: '/groups/:groupId/chat', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; let chatUpdated; @@ -68,34 +65,31 @@ api.postChat = { req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, groupId) - .then((group) => { - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party' && user.flags.chatRevoked) { - throw new NotFound('Your chat privileges have been revoked.'); - } + let group = await Group.getGroup(user, groupId); - let lastClientMsg = req.query.previousMsg; - chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party' && user.flags.chatRevoked) { + throw new NotFound('Your chat privileges have been revoked.'); + } - group.sendChat(req.body.message, user); + let lastClientMsg = req.query.previousMsg; + chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; - if (group.type === 'party') { - user.party.lastMessageSeen = group.chat[0].id; - user.save(); - } - return group.save(); - }) - .then((group) => { - if (chatUpdated) { - res.respond(200, {chat: group.chat}); - } else { - res.respond(200, {message: group.chat[0]}); - } - }) - .catch(next); + group.sendChat(req.body.message, user); + + if (group.type === 'party') { + user.party.lastMessageSeen = group.chat[0].id; + user.save(); // TODO why this is non-blocking? must catch? + } + + let savedGroup = await group.save(); + if (chatUpdated) { + res.respond(200, {chat: savedGroup.chat}); + } else { + res.respond(200, {message: savedGroup.chat[0]}); + } }, }; @@ -114,42 +108,35 @@ api.likeChat = { method: 'Post', url: '/groups/:groupId/chat/:chatId/like', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; - let message; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, groupId) - .then((group) => { - if (!group) throw new NotFound(res.t('groupNotFound')); - message = _.find(group.chat, {id: req.params.chatId}); - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + let group = await Group.getGroup(user, groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); - if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); + let message = _.find(group.chat, {id: req.params.chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); - let update = {$set: {}}; + let update = {$set: {}}; - if (!message.likes) message.likes = {}; + if (!message.likes) message.likes = {}; - message.likes[user._id] = !message.likes[user._id]; - update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; + message.likes[user._id] = !message.likes[user._id]; + update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; - return Group.update( - {_id: group._id, 'chat.id': message.id}, - update - ); - }) - .then((groupSaved) => { - if (!groupSaved) throw new NotFound(res.t('groupNotFound')); - res.respond(200, message); - }) - .catch(next); + await Group.update( + {_id: group._id, 'chat.id': message.id}, + update + ); + res.respond(200, message); }, }; @@ -168,116 +155,104 @@ api.flagChat = { method: 'Post', url: '/groups/:groupId/chat/:chatId/flag', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let groupId = req.params.groupId; - let message; - let group; - let author; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, groupId) - .then((groupFound) => { - if (!groupFound) throw new NotFound(res.t('groupNotFound')); - group = groupFound; - message = _.find(group.chat, {id: req.params.chatId}); + let group = await Group.getGroup(user, groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); + let message = _.find(group.chat, {id: req.params.chatId}); - if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); - if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); + if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); - return User.findOne({_id: message.uuid}, {auth: 1}); - }) - .then((foundAuthor) => { - author = foundAuthor; + let author = await User.findOne({_id: message.uuid}, {auth: 1}); - let update = {$set: {}}; + let update = {$set: {}}; - // Log user ids that have flagged the message - if (!message.flags) message.flags = {}; - if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); - message.flags[user._id] = true; - update.$set[`chat.$.flags.${user._id}`] = true; + // Log user ids that have flagged the message + if (!message.flags) message.flags = {}; + if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported')); + message.flags[user._id] = true; + update.$set[`chat.$.flags.${user._id}`] = true; - // Log total number of flags (publicly viewable) - if (!message.flagCount) message.flagCount = 0; - if (user.contributor.admin) { - // Arbitraty amount, higher than 2 - message.flagCount = 5; - } else { - message.flagCount++; - } - update.$set['chat.$.flagCount'] = message.flagCount; + // Log total number of flags (publicly viewable) + if (!message.flagCount) message.flagCount = 0; + if (user.contributor.admin) { + // Arbitraty amount, higher than 2 + message.flagCount = 5; + } else { + message.flagCount++; + } + update.$set['chat.$.flagCount'] = message.flagCount; - return Group.update( - {_id: group._id, 'chat.id': message.id}, - update - ); - }) - .then((group2) => { - if (!group2) throw new NotFound(res.t('groupNotFound')); + await Group.update( + {_id: group._id, 'chat.id': message.id}, + update + ); - let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL'); - addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo; + let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL'); + addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo; - if (Array.isArray(addressesToSendTo)) { - addressesToSendTo = addressesToSendTo.map((email) => { - return {email, canSend: true}; - }); - } else { - addressesToSendTo = {email: addressesToSendTo}; - } + if (Array.isArray(addressesToSendTo)) { + addressesToSendTo = addressesToSendTo.map((email) => { + return {email, canSend: true}; + }); + } else { + addressesToSendTo = {email: addressesToSendTo}; + } - let reporterEmailContent; - if (user.auth.local) { - reporterEmailContent = user.auth.local.email; - } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) { - reporterEmailContent = user.auth.facebook.emails[0].value; - } + let reporterEmailContent; + if (user.auth.local) { + reporterEmailContent = user.auth.local.email; + } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) { + reporterEmailContent = user.auth.facebook.emails[0].value; + } - let authorEmailContent; - if (author.auth.local) { - authorEmailContent = author.auth.local.email; - } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) { - authorEmailContent = author.auth.facebook.emails[0].value; - } + let authorEmailContent; + if (author.auth.local) { + authorEmailContent = author.auth.local.email; + } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) { + authorEmailContent = author.auth.facebook.emails[0].value; + } - let groupUrl; - if (group._id === 'habitrpg') { - groupUrl = '/#/options/groups/tavern'; - } else if (group.type === 'guild') { - groupUrl = `/#/options/groups/guilds/{$group._id}`; - } else { - groupUrl = 'party'; - } + let groupUrl; + if (group._id === 'habitrpg') { + groupUrl = '/#/options/groups/tavern'; + } else if (group.type === 'guild') { + groupUrl = `/#/options/groups/guilds/{$group._id}`; + } else { + groupUrl = 'party'; + } - sendTxn(addressesToSendTo, 'flag-report-to-mods', [ - {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, - {name: 'MESSAGE_TEXT', content: message.text}, + sendTxn(addressesToSendTo, 'flag-report-to-mods', [ + {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()}, + {name: 'MESSAGE_TEXT', content: message.text}, - {name: 'REPORTER_USERNAME', content: user.profile.name}, - {name: 'REPORTER_UUID', content: user._id}, - {name: 'REPORTER_EMAIL', content: reporterEmailContent}, - {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`}, + {name: 'REPORTER_USERNAME', content: user.profile.name}, + {name: 'REPORTER_UUID', content: user._id}, + {name: 'REPORTER_EMAIL', content: reporterEmailContent}, + {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`}, - {name: 'AUTHOR_USERNAME', content: message.user}, - {name: 'AUTHOR_UUID', content: message.uuid}, - {name: 'AUTHOR_EMAIL', content: authorEmailContent}, - {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`}, + {name: 'AUTHOR_USERNAME', content: message.user}, + {name: 'AUTHOR_UUID', content: message.uuid}, + {name: 'AUTHOR_EMAIL', content: authorEmailContent}, + {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`}, - {name: 'GROUP_NAME', content: group.name}, - {name: 'GROUP_TYPE', content: group.type}, - {name: 'GROUP_ID', content: group._id}, - {name: 'GROUP_URL', content: groupUrl}, - ]); - res.respond(200, message); - }) - .catch(next); + {name: 'GROUP_NAME', content: group.name}, + {name: 'GROUP_TYPE', content: group.type}, + {name: 'GROUP_ID', content: group._id}, + {name: 'GROUP_URL', content: groupUrl}, + ]); + + res.respond(200, message); }, }; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8a3ce9630b..1edf18b026 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -29,36 +29,31 @@ api.createGroup = { method: 'POST', url: '/groups', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body group.leader = user._id; if (group.type === 'guild') { - if (user.balance < 1) return next(new NotAuthorized(res.t('messageInsufficientGems'))); + if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems')); group.balance = 1; user.balance--; user.guilds.push(group._id); } else { - if (user.party._id) return next(new NotAuthorized(res.t('messageGroupAlreadyInParty'))); + if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty')); user.party._id = group._id; } - Q.all([ - user.save(), - group.save(), - ]).then(results => { - let savedGroup = results[1]; + let results = await Q.all([user.save(), group.save()]); + let savedGroup = results[1]; - firebase.updateGroupData(savedGroup); - firebase.addUserToGroup(savedGroup._id, user._id); - return res.respond(201, savedGroup); // TODO populate - }) - .catch(next); + firebase.updateGroupData(savedGroup); + firebase.addUserToGroup(savedGroup._id, user._id); + return res.respond(201, savedGroup); // TODO populate }, }; @@ -76,13 +71,13 @@ api.getGroups = { method: 'GET', url: '/groups', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); // TODO better validation let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; // TODO validate types are acceptable? probably not necessary let types = req.query.type.split(','); @@ -115,16 +110,14 @@ api.getGroups = { }); // If no valid value for type was supplied, return an error - if (queries.length === 0) return next(new BadRequest(res.t('groupTypesRequired'))); + if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - Q.all(queries) // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 - .then(results => { - res.respond(200, _.reduce(results, (m, v) => { - if (_.isEmpty(v)) return m; - return m.concat(Array.isArray(v) ? v : [v]); - }, [])); - }) - .catch(next); + let results = await Q.all(queries); // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + + res.respond(200, _.reduce(results, (m, v) => { + if (_.isEmpty(v)) return m; + return m.concat(Array.isArray(v) ? v : [v]); + }, [])); }, }; @@ -142,21 +135,18 @@ api.getGroup = { method: 'GET', url: '/groups/:groupId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId) - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group); - }) - .catch(next); + res.respond(200, group); }, }; @@ -174,28 +164,24 @@ api.updateGroup = { method: 'PUT', url: '/groups/:groupId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId) - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId); + if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); + if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); - _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); + _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); - return group.save(); - }).then(savedGroup => { - res.respond(200, savedGroup); - firebase.updateGroupData(savedGroup); - }) - .catch(next); + let savedGroup = await group.save(); + res.respond(200, savedGroup); + firebase.updateGroupData(savedGroup); }, }; @@ -213,60 +199,57 @@ api.joinGroup = { method: 'POST', url: '/groups/:groupId/join', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat', true) // Do not fetch chat and work even if the user is not yet a member of the group - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat', true); // Do not fetch chat and work even if the user is not yet a member of the group + if (!group) throw new NotFound(res.t('groupNotFound')); - let isUserInvited = false; + let isUserInvited = false; - if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { - user.invitations.party = {}; // Clear invite TODO mark modified? + if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { + user.invitations.party = {}; // Clear invite TODO mark modified? - // invite new user to pending quest - if (group.quest.key && !group.quest.active) { - user.party.quest.RSVPNeeded = true; - user.party.quest.key = group.quest.key; - group.quest.members[user._id] = undefined; - group.markModified('quest.members'); - } - - user.party._id = group._id; // Set group as user's party - - isUserInvited = true; - } else if (group.type === 'guild' && user.invitations.guilds) { - let i = _.findIndex(user.invitations.guilds, {id: group._id}); - - if (i !== -1) { - isUserInvited = true; - user.invitations.guilds.splice(i, 1); // Remove invitation - } else { - isUserInvited = group.privacy === 'private' ? false : true; - } + // invite new user to pending quest + if (group.quest.key && !group.quest.active) { + user.party.quest.RSVPNeeded = true; + user.party.quest.key = group.quest.key; + group.quest.members[user._id] = undefined; + group.markModified('quest.members'); } - if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds - if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); + user.party._id = group._id; // Set group as user's party - if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + isUserInvited = true; + } else if (group.type === 'guild' && user.invitations.guilds) { + let i = _.findIndex(user.invitations.guilds, {id: group._id}); - Q.all([ - group.save(), - user.save(), - User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter - ]).then(() => { - firebase.addUserToGroup(group._id, user._id); - res.respond(200, {}); // TODO what to return? - }); - }) - .catch(next); + if (i !== -1) { + isUserInvited = true; + user.invitations.guilds.splice(i, 1); // Remove invitation + } else { + isUserInvited = group.privacy === 'private' ? false : true; + } + } + + if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds + if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite')); + + if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + + await Q.all([ + group.save(), + user.save(), + User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter + ]); + + firebase.addUserToGroup(group._id, user._id); + res.respond(200, {}); // TODO what to return? }, }; @@ -285,7 +268,7 @@ api.leaveGroup = { method: 'POST', url: '/groups/:groupId/leave', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); @@ -293,27 +276,24 @@ api.leaveGroup = { req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + if (!group) throw new NotFound(res.t('groupNotFound')); - // During quests, checke wheter user can leave - if (group.type === 'party') { - if (group.quest && group.quest.leader === user._id) { - throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup')); - } - - if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { - throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest')); - } + // During quests, checke wheter user can leave + if (group.type === 'party') { + if (group.quest && group.quest.leader === user._id) { + throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup')); } - return group.leave(user, req.query.keep); - }) - .then(() => res.respond(200, {})) - .catch(next); + if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { + throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest')); + } + } + + await group.leave(user, req.query.keep); + res.respond(200, {}); }, }; @@ -345,71 +325,65 @@ api.removeGroupMember = { method: 'POST', url: '/groups/:groupId/removeMember/:memberId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; - let group; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat - .then(foundGroup => { - group = foundGroup; - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + if (!group) throw new NotFound(res.t('groupNotFound')); - let uuid = req.query.memberId; + let uuid = req.query.memberId; - if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); - if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); + if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember')); + if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself')); - return User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec(); - }).then(member => { - // 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 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 - if (isInGroup) { - group.memberCount -= 1; + if (isInGroup) { + group.memberCount -= 1; - if (group.quest && group.quest.leader === member._id) { - group.quest.key = null; - group.quest.leader = null; // TODO markmodified? - } else if (group.quest && group.quest.members) { - // remove member from quest - group.quest.members[member._id] = undefined; - } - - if (isInGroup === 'guild') _.pull(member.guilds, group._id); - if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - - member.newMessages.group._id = undefined; - - 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); - } - if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? - } else { - throw new NotFound(res.t('groupMemberNotFound')); + if (group.quest && group.quest.leader === member._id) { + group.quest.key = null; + group.quest.leader = null; // TODO markmodified? + } else if (group.quest && group.quest.members) { + // remove member from quest + group.quest.members[member._id] = undefined; } - let message = req.query.message; - if (message) _sendMessageToRemoved(group, member, message); + if (isInGroup === 'guild') _.pull(member.guilds, group._id); + if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - return Q.all([ - member.save(), - group.save(), - ]); - }) - .then(() => res.respond(200, {})) - .catch(next); + member.newMessages.group._id = undefined; + + 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); + } + if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified? + } else { + throw new NotFound(res.t('groupMemberNotFound')); + } + + let message = req.query.message; + if (message) _sendMessageToRemoved(group, member, message); + + await Q.all([ + member.save(), + group.save(), + ]); + res.respond(200, {}); }, }; @@ -571,32 +545,29 @@ api.inviteToGroup = { method: 'POST', url: '/groups/:groupId/invite', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Group.getGroup(user, req.params.groupId, '-chat') // Do not fetch chat TODO other fields too? - .then(group => { - if (!group) throw new NotFound(res.t('groupNotFound')); + let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat TODO other fields too? + if (!group) throw new NotFound(res.t('groupNotFound')); - let uuids = req.body.uuids; - let emails = req.body.emails; + let uuids = req.body.uuids; + let emails = req.body.emails; - if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } else if (Array.isArray(uuids)) { - // return _inviteByUUIDs(uuids, group, user, req, res, next); - } else if (Array.isArray(emails)) { - // return _inviteByEmails(emails, group, user, req, res, next); - } else { - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } - }) - .catch(next); + if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } else if (Array.isArray(uuids)) { + // return _inviteByUUIDs(uuids, group, user, req, res, next); + } else if (Array.isArray(emails)) { + // return _inviteByEmails(emails, group, user, req, res, next); + } else { + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } }, }; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 20d3f2f5ed..27191bf0ee 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -20,18 +20,15 @@ api.createTag = { method: 'POST', url: '/tags', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; user.tags.push(Tag.sanitize(req.body)); + let savedUser = await user.save(); - user.save() - .then((savedUser) => { - let l = savedUser.tags.length; - let tag = savedUser.tags[l - 1]; - res.respond(201, tag); - }) - .catch(next); + let l = savedUser.tags.length; + let tag = savedUser.tags[l - 1]; + res.respond(201, tag); }, }; @@ -47,7 +44,7 @@ api.getTags = { method: 'GET', url: '/tags', middlewares: [authWithHeaders(), cron], - handler (req, res) { + async handler (req, res) { let user = res.locals.user; res.respond(200, user.tags); }, @@ -67,16 +64,16 @@ api.getTag = { method: 'GET', url: '/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let tag = user.tags.id(req.params.tagId); - if (!tag) return next(new NotFound(res.t('tagNotFound'))); + if (!tag) throw new NotFound(res.t('tagNotFound')); res.respond(200, tag); }, }; @@ -95,7 +92,7 @@ api.updateTag = { method: 'PUT', url: '/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); @@ -104,16 +101,15 @@ api.updateTag = { let tagId = req.params.tagId; let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let tag = user.tags.id(tagId); - if (!tag) return next(new NotFound(res.t('tagNotFound'))); + if (!tag) throw new NotFound(res.t('tagNotFound')); _.merge(tag, Tag.sanitize(req.body)); - user.save() - .then((savedUser) => res.respond(200, savedUser.tags.id(tagId))) - .catch(next); + let savedUser = await user.save(); + res.respond(200, savedUser.tags.id(tagId)); }, }; @@ -131,21 +127,20 @@ api.deleteTag = { method: 'DELETE', url: '/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let tag = user.tags.id(req.params.tagId); - if (!tag) return next(new NotFound(res.t('tagNotFound'))); + if (!tag) throw new NotFound(res.t('tagNotFound')); tag.remove(); - user.save() - .then(() => res.respond(200, {})) - .catch(next); + await user.save(); + res.respond(200, {}); }, }; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 99e217e401..f67a25b10f 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -29,11 +29,11 @@ api.createTask = { method: 'POST', url: '/tasks', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let taskType = req.body.type; @@ -43,12 +43,12 @@ api.createTask = { user.tasksOrder[`${taskType}s`].unshift(newTask._id); - Q.all([ + let results = await Q.all([ newTask.save(), user.save(), - ]) - .then((results) => res.respond(201, results[0])) - .catch(next); + ]); + + res.respond(201, results[0]); }, }; @@ -67,11 +67,11 @@ api.getTasks = { method: 'GET', url: '/tasks', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let query = {userId: user._id}; @@ -95,16 +95,15 @@ api.getTasks = { dateCompleted: 1, }); - Q.all([ + let results = await Q.all([ queryCompleted.exec(), Tasks.Task.find(query).exec(), - ]) - .then((results) => res.respond(200, results[1].concat(results[0]))) - .catch(next); + ]); + + res.respond(200, results[1].concat(results[0])); } else { - Tasks.Task.find(query).exec() - .then((tasks) => res.respond(200, tasks)) - .catch(next); + let tasks = await Tasks.Task.find(query).exec(); + res.respond(200, tasks); } }, }; @@ -123,23 +122,21 @@ api.getTask = { method: 'GET', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - res.respond(200, task); - }) - .catch(next); + }).exec(); + + if (!task) throw new NotFound(res.t('taskNotFound')); + res.respond(200, task); }, }; @@ -157,7 +154,7 @@ api.updateTask = { method: 'PUT', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -165,37 +162,36 @@ api.updateTask = { // TODO make sure tags are updated correctly (they aren't set as modified!) maybe use specific routes let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); + }).exec(); - // If checklist is updated -> replace the original one - if (req.body.checklist) { - task.checklist = req.body.checklist; - delete req.body.checklist; - } + if (!task) throw new NotFound(res.t('taskNotFound')); - // If tags are updated -> replace the original ones - if (req.body.tags) { - task.tags = req.body.tags; - delete req.body.tags; - } + // If checklist is updated -> replace the original one + if (req.body.checklist) { + task.checklist = req.body.checklist; + delete req.body.checklist; + } - // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very 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) - // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() - // see https://github.com/Automattic/mongoose/issues/2749 - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) - .catch(next); + // If tags are updated -> replace the original ones + if (req.body.tags) { + task.tags = req.body.tags; + 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 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) + // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() + // see https://github.com/Automattic/mongoose/issues/2749 + + let savedTask = await task.save(); + res.respond(200, savedTask); }, }; @@ -239,81 +235,82 @@ api.scoreTask = { method: 'POST', url: '/tasks/:taskId/score/:direction', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let direction = req.params.direction; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); + }).exec(); - let wasCompleted = task.completed; - if (task.type === 'daily' || task.type === 'todo') { - task.completed = direction === 'up'; // TODO move into scoreTask - } + if (!task) throw new NotFound(res.t('taskNotFound')); - let delta = scoreTask({task, user, direction}, req); - // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) - if (direction === 'up') user.fns.randomDrop({task, delta}, req); + let wasCompleted = task.completed; + if (task.type === 'daily' || task.type === 'todo') { + task.completed = direction === 'up'; // TODO move into 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); - } else if (wasCompleted && !task.completed) { - let i = user.tasksOrder.todos.indexOf(task._id); - if (i === -1) { - 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); - } + let delta = scoreTask({task, user, direction}, req); + // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) + if (direction === 'up') user.fns.randomDrop({task, delta}, req); + + // 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); + } else if (wasCompleted && !task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i === -1) { + 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); } } + } - return Q.all([ - user.save(), - task.save(), - ]).then((results) => { - let savedUser = results[0]; + let results = await Q.all([ + user.save(), + task.save(), + ]); - let userStats = savedUser.stats.toJSON(); - let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); - res.respond(200, resJsonData); + let savedUser = results[0]; - sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + let userStats = savedUser.stats.toJSON(); + let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); + res.respond(200, resJsonData); - // TODO test? - if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { - Tasks.Task.findOne({ - _id: task.challenge.taskId, - }).exec() - .then(chalTask => { - chalTask.value += delta; - if (chalTask.type === 'habit' || chalTask.type === 'daily') { - chalTask.history.push({value: chalTask.value, date: Number(new Date())}); - // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? - chalTask.history = preenHistory(user, chalTask.history); - chalTask.markModified('history'); - } + sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); - return chalTask.save(); - }); - // .catch(next) TODO what to do here + // TODO test? + if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { + // Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled + try { + let chalTask = await Tasks.Task.findOne({ + _id: task.challenge.taskId, + }).exec(); + + chalTask.value += delta; + if (chalTask.type === 'habit' || chalTask.type === 'daily') { + chalTask.history.push({value: chalTask.value, date: Number(new Date())}); + // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? + chalTask.history = preenHistory(user, chalTask.history); + chalTask.markModified('history'); } - }); - }) - .catch(next); + + await chalTask.save(); + } catch (e) { + // TODO handle + } + } }, }; @@ -334,41 +331,39 @@ api.moveTask = { method: 'POST', url: '/tasks/move/:taskId/to/:position', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; let user = res.locals.user; let to = Number(req.params.position); - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo')); - let order = user.tasksOrder[`${task.type}s`]; - let currentIndex = order.indexOf(task._id); + }).exec(); - // If for some reason the task isn't ordered (should never happen) - // or if the task is moved to a non existing position - // or if the task is moved to postion -1 (push to bottom) - // -> push task at end of list - if (currentIndex === -1 || !order[to] || to === -1) { - order.push(task._id); - } else { - let taskToMove = order.splice(currentIndex, 1)[0]; - order.splice(to, 0, taskToMove); - } + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo')); + let order = user.tasksOrder[`${task.type}s`]; + let currentIndex = order.indexOf(task._id); - return user.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + // If for some reason the task isn't ordered (should never happen) + // or if the task is moved to a non existing position + // or if the task is moved to postion -1 (push to bottom) + // -> push task at end of list + if (currentIndex === -1 || !order[to] || to === -1) { + order.push(task._id); + } else { + let taskToMove = order.splice(currentIndex, 1)[0]; + order.splice(to, 0, taskToMove); + } + + await user.save(); + res.respond(200, {}); // TODO what to return }, }; @@ -386,28 +381,27 @@ api.addChecklistItem = { method: 'POST', url: '/tasks/:taskId/checklist', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty and is an array let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + + task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); + let savedTask = await task.save(); + + res.respond(200, savedTask); // TODO what to return }, }; @@ -426,31 +420,30 @@ api.scoreCheckListItem = { method: 'POST', url: '/tasks/:taskId/checklist/:itemId/score', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - let item = _.find(task.checklist, {_id: req.params.itemId}); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - if (!item) throw new NotFound(res.t('checklistItemNotFound')); - item.completed = !item.completed; - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + let item = _.find(task.checklist, {_id: req.params.itemId}); + + if (!item) throw new NotFound(res.t('checklistItemNotFound')); + item.completed = !item.completed; + let savedTask = await task.save(); + + res.respond(200, savedTask); // TODO what to return }, }; @@ -469,31 +462,30 @@ api.updateChecklistItem = { method: 'PUT', url: '/tasks/:taskId/checklist/:itemId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - let item = _.find(task.checklist, {_id: req.params.itemId}); - if (!item) throw new NotFound(res.t('checklistItemNotFound')); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + let item = _.find(task.checklist, {_id: req.params.itemId}); + if (!item) throw new NotFound(res.t('checklistItemNotFound')); + + _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); + let savedTask = await task.save(); + + res.respond(200, savedTask); // TODO what to return }, }; @@ -512,31 +504,30 @@ api.removeChecklistItem = { method: 'DELETE', url: '/tasks/:taskId/checklist/:itemId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); + }).exec(); - let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); - if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - task.checklist.splice(itemI, 1); - return task.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); + if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound')); + + task.checklist.splice(itemI, 1); + + await task.save(); + res.respond(200, {}); // TODO what to return }, }; @@ -555,7 +546,7 @@ api.addTagToTask = { method: 'POST', url: '/tasks/:taskId/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -563,24 +554,23 @@ api.addTagToTask = { req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - let tagId = req.params.tagId; + }).exec(); - let alreadyTagged = task.tags.indexOf(tagId) !== -1; - if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); + if (!task) throw new NotFound(res.t('taskNotFound')); + let tagId = req.params.tagId; - task.tags.push(tagId); - return task.save(); - }) - .then((savedTask) => res.respond(200, savedTask)) // TODO what to return - .catch(next); + let alreadyTagged = task.tags.indexOf(tagId) !== -1; + if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); + + task.tags.push(tagId); + + let savedTask = await task.save(); + res.respond(200, savedTask); // TODO what to return }, }; @@ -599,30 +589,29 @@ api.removeTagFromTask = { method: 'DELETE', url: '/tasks/:taskId/tags/:tagId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); + }).exec(); - let tagI = task.tags.indexOf(req.params.tagId); - if (tagI === -1) throw new NotFound(res.t('tagNotFound')); + if (!task) throw new NotFound(res.t('taskNotFound')); - task.tags.splice(tagI, 1); - return task.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + let tagI = task.tags.indexOf(req.params.tagId); + if (tagI === -1) throw new NotFound(res.t('tagNotFound')); + + task.tags.splice(tagI, 1); + + await task.save(); + res.respond(200, {}); // TODO what to return }, }; @@ -656,30 +645,26 @@ api.deleteTask = { method: 'DELETE', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - Tasks.Task.findOne({ + let task = await Tasks.Task.findOne({ _id: req.params.taskId, userId: user._id, - }).exec() - .then((task) => { - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + }).exec(); - _removeTaskTasksOrder(user, req.params.taskId); - return Q.all([ - user.save(), - task.remove(), - ]); - }) - .then(() => res.respond(200, {})) - .catch(next); + if (!task) throw new NotFound(res.t('taskNotFound')); + if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + + _removeTaskTasksOrder(user, req.params.taskId); + await Q.all([user.save(), task.remove()]); + + res.respond(200, {}); }, }; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index ef6749209c..39efc002f9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -16,7 +16,7 @@ api.getUser = { method: 'GET', middlewares: [authWithHeaders(), cron], url: '/user', - handler (req, res) { + async handler (req, res) { let user = res.locals.user.toJSON(); // Remove apiToken from resonse TODO make it priavte at the user level? returned in signup/login From bf0a5cba2b46a7b8e358b832ccf06bc14661e73d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 07:37:25 -0600 Subject: [PATCH 048/213] fix: Remove extranous comma in eslint confi --- .eslintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index c124bdda89..7613632120 100644 --- a/.eslintrc +++ b/.eslintrc @@ -112,7 +112,7 @@ "generator-star-spacing": 0, "babel/new-cap": 2, "babel/object-shorthand": 2, - "babel/no-await-in-loop": 2, + "babel/no-await-in-loop": 2 }, "env": { "es6": true, From 16e8104d351186ccbed06360b640b225a5eebedc Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 07:42:15 -0600 Subject: [PATCH 049/213] chore: Move eslint modules to devDependencies --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 434c63fde7..0090cb354d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "async": "^1.5.0", "aws-sdk": "^2.0.25", "babel-core": "^5.8.34", - "babel-eslint": "^4.1.6", "babelify": "^6.x.x", "body-parser": "^1.14.1", "bower": "~1.3.12", @@ -22,7 +21,6 @@ "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", "domain-middleware": "~0.1.0", - "eslint-plugin-babel": "^3.0.0", "estraverse": "^4.1.1", "express": "~4.13.3", "express-csv": "~0.6.0", @@ -108,11 +106,12 @@ "csv": "~0.3.6", "deep-diff": "~0.1.4", "eslint": "^1.9.0", + "babel-eslint": "^4.1.6", + "eslint-plugin-babel": "^3.0.0", "eslint-plugin-mocha": "^1.1.0", "event-stream": "^3.2.2", "expect.js": "~0.2.0", "istanbul": "^0.3.14", - "phantomjs": "^1.9.18", "karma": "~0.13.15", "karma-babel-preprocessor": "^5.0.0", "karma-chai-plugins": "~0.6.0", @@ -125,6 +124,7 @@ "mongodb": "^2.0.46", "mongoskin": "~0.6.1", "nock": "^2.17.0", + "phantomjs": "^1.9.18", "protractor": "~2.5.1", "rewire": "^2.3.3", "rimraf": "^2.4.3", From 6f220c686954bc0feeadfc58422afa6ac9f836ae Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 21:08:42 -0600 Subject: [PATCH 050/213] lint: Turn on linting for v3 group tests --- tasks/gulp-eslint.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 46aece05a6..5f4f28fd30 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -23,7 +23,7 @@ const TEST_FILES = [ './test/**/*.js', // @TODO remove these negations as the test files are cleaned up. '!./test/api-legacy/**/*', - '!./test/api/**/*', + '!./test/api/v2/**/*', '!./test/common/simulations/**/*', '!./test/content/**/*', '!./test/e2e/**/*', From 70151fd07390e8ca8bf07585375166900f96ef5c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 30 Dec 2015 21:16:26 -0600 Subject: [PATCH 051/213] lint: Fix tests that can be fixed with the eslint --fix flag --- test/api/v3/integration/groups/POST-groups.test.js | 10 +++++----- test/api/v3/integration/tags/GET-tags_id.test.js | 2 +- test/api/v3/integration/tasks/GET-tasks.test.js | 2 +- test/api/v3/integration/tasks/POST-tasks.test.js | 2 +- .../tasks/POST-tasks_id_score_direction.test.js | 10 +++++----- test/api/v3/integration/tasks/PUT-tasks_id.test.js | 6 +++--- test/api/v3/unit/libs/analyticsService.test.js | 12 ++++++------ test/api/v3/unit/libs/buildManifest.test.js | 2 +- test/api/v3/unit/libs/email.test.js | 14 +++++++------- test/api/v3/unit/libs/encryption.test.js | 2 +- test/api/v3/unit/libs/i18n.test.js | 2 +- test/api/v3/unit/middlewares/analytics.test.js | 6 +++--- .../v3/unit/middlewares/getUserLanguage.test.js | 2 +- test/api/v3/unit/middlewares/response.js | 10 +++++----- 14 files changed, 41 insertions(+), 41 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index f77e0fb5ae..f2ec45b06d 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -75,8 +75,8 @@ describe('POST /group', () => { context('Parties', () => { it('creates a party', () => { - let groupName = "Test Party"; - let groupType = "party"; + let groupName = 'Test Party'; + let groupType = 'party'; return user.post('/groups', { name: groupName, @@ -86,13 +86,13 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); - }) + }); }); it('prevents user in a party from creating another party', () => { let tmpUser; - let groupName = "Test Party"; - let groupType = "party"; + let groupName = 'Test Party'; + let groupType = 'party'; return generateUser().then((generatedUser) => { tmpUser = generatedUser; diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index f8180be5ca..fbdf96312f 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -17,7 +17,7 @@ describe('GET /tags/:tagId', () => { return user.post('/tags', {name: 'Tag 1'}) .then((tag) => { createdTag = tag; - return user.get(`/tags/${createdTag._id}`) + return user.get(`/tags/${createdTag._id}`); }) .then((tag) => { expect(tag).to.deep.equal(createdTag); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 25bf8b1308..8a191f17bb 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -41,5 +41,5 @@ describe('GET /tasks', () => { }); // 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 creation date if req.query.includeCompletedTodos is specified'); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 30e4d3ce37..776e69cad2 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -284,7 +284,7 @@ describe('POST /tasks', () => { return user.post('/tasks', { text: 'test reward', type: 'reward', - value: "10", + value: '10', }).then((task) => { expect(task.value).to.eql(10); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 4e21a31b20..2d7e10e745 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -54,7 +54,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('moves completed todos out of user.tasksOrder.todos', () => { return user.get('/user') .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { @@ -62,14 +62,14 @@ describe('POST /tasks/:id/score/:direction', () => { return user.get('/user'); }) .then((user) => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1) + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); }); it('moves un-completed todos back into user.tasksOrder.todos', () => { return user.get('/user') .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.post(`/tasks/${todo._id}/score/down`)) .then(() => user.get(`/tasks/${todo._id}`)) @@ -242,7 +242,7 @@ describe('POST /tasks/:id/score/:direction', () => { text: 'test plus habit', type: 'habit', down: false, - }) + }); }).then((task) => { plusHabit = task; user.post('/tasks', { @@ -250,7 +250,7 @@ describe('POST /tasks/:id/score/:direction', () => { type: 'habit', up: false, down: false, - }) + }); }).then((task) => { neitherHabit = task; }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 4e5ac308c5..99fdb83615 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -127,7 +127,7 @@ describe('PUT /tasks/:id', () => { }); }).then((savedTodo2) => { expect(savedTodo2.checklist.length).to.equal(1); - expect(savedTodo2.checklist[0].text).to.equal("789"); + expect(savedTodo2.checklist[0].text).to.equal('789'); expect(savedTodo2.checklist[0].completed).to.equal(false); }); }); @@ -190,7 +190,7 @@ describe('PUT /tasks/:id', () => { }); }).then((savedDaily2) => { expect(savedDaily2.checklist.length).to.equal(1); - expect(savedDaily2.checklist[0].text).to.equal("789"); + expect(savedDaily2.checklist[0].text).to.equal('789'); expect(savedDaily2.checklist[0].completed).to.equal(false); }); }); @@ -281,7 +281,7 @@ describe('PUT /tasks/:id', () => { it('requires value to be coerced into a number', () => { return user.put(`/tasks/${reward._id}`, { - value: "100", + value: '100', }).then((task) => { expect(task.value).to.eql(100); }); diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js index 99657b270a..c8536bc887 100644 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -68,7 +68,7 @@ describe('analyticsService', () => { }); it('sends english item name for gear if itemKey is provided', () => { - data.itemKey = 'headAccessory_special_foxEars' + data.itemKey = 'headAccessory_special_foxEars'; amplitudeNock .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); @@ -80,7 +80,7 @@ describe('analyticsService', () => { }); it('sends english item name for egg if itemKey is provided', () => { - data.itemKey = 'Wolf' + data.itemKey = 'Wolf'; amplitudeNock .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); @@ -92,7 +92,7 @@ describe('analyticsService', () => { }); it('sends english item name for food if itemKey is provided', () => { - data.itemKey = 'Cake_Skeleton' + data.itemKey = 'Cake_Skeleton'; amplitudeNock .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); @@ -104,7 +104,7 @@ describe('analyticsService', () => { }); it('sends english item name for hatching potion if itemKey is provided', () => { - data.itemKey = 'Golden' + data.itemKey = 'Golden'; amplitudeNock .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); @@ -116,7 +116,7 @@ describe('analyticsService', () => { }); it('sends english item name for quest if itemKey is provided', () => { - data.itemKey = 'atom1' + data.itemKey = 'atom1'; amplitudeNock .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); @@ -128,7 +128,7 @@ describe('analyticsService', () => { }); it('sends english item name for purchased spell if itemKey is provided', () => { - data.itemKey = 'seafoam' + data.itemKey = 'seafoam'; amplitudeNock .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index d03e19c9eb..e719d8c434 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -11,7 +11,7 @@ describe('Build Manifest', () => { }); it('throws an error in case the page does not exist', () => { - let getManifestFilesFn = () => { getManifestFiles('strange name here') }; + let getManifestFilesFn = () => { getManifestFiles('strange name here'); }; expect(getManifestFilesFn).to.throw(Error); }); }); diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 353ac0ad09..5cfd1b542f 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -28,7 +28,7 @@ function getUser () { }, }, }; -}; +} describe('emails', () => { let pathToEmailLib = '../../../../../website/src/libs/api-v3/email'; @@ -97,7 +97,7 @@ describe('emails', () => { delete user.auth['local']; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - + expect(data).to.have.property('name', user.auth.facebook.displayName); expect(data).to.have.property('email', user.auth.facebook.emails[0].value); expect(data).to.have.property('_id', user._id); @@ -109,11 +109,11 @@ describe('emails', () => { let getUserInfo = attachEmail.getUserInfo; let user = getUser(); delete user.profile['name']; - delete user.auth.local['email'] + delete user.auth.local['email']; delete user.auth['facebook']; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); - + expect(data).to.have.property('name', user.auth.local.username); expect(data).not.to.have.property('email'); expect(data).to.have.property('_id', user._id); @@ -194,7 +194,7 @@ describe('emails', () => { name: 'my name', email: 'my@email', }; - let variables = [1,2,3]; + let variables = [1, 2, 3]; sendTxnEmail(mailingInfo, emailType, variables); expect(request.post).to.be.calledWith(sinon.match({ @@ -204,8 +204,8 @@ describe('emails', () => { return value[0].name === 'BASE_URL'; }, 'matches variables'), personalVariables: sinon.match((value) => { - return (value[0].rcpt === mailingInfo.email - && value[0].vars[0].name === 'RECIPIENT_NAME' + return (value[0].rcpt === mailingInfo.email + && value[0].vars[0].name === 'RECIPIENT_NAME' && value[0].vars[1].name === 'RECIPIENT_UNSUB_URL' ); }, 'matches personal variables'), diff --git a/test/api/v3/unit/libs/encryption.test.js b/test/api/v3/unit/libs/encryption.test.js index dcab9bffd3..34c159ed02 100644 --- a/test/api/v3/unit/libs/encryption.test.js +++ b/test/api/v3/unit/libs/encryption.test.js @@ -1,4 +1,4 @@ -import { +import { encrypt, decrypt, } from '../../../../../website/src/libs/api-v3/encryption'; diff --git a/test/api/v3/unit/libs/i18n.test.js b/test/api/v3/unit/libs/i18n.test.js index 5bafc201dc..1136f255a6 100644 --- a/test/api/v3/unit/libs/i18n.test.js +++ b/test/api/v3/unit/libs/i18n.test.js @@ -34,7 +34,7 @@ describe('i18n', () => { describe('localePath', () => { it('is an absolute path to common/locales/', () => { expect(localePath).to.match(/.*\/common\/locales\//); - expect(localePath) + expect(localePath); }); }); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index e2808bc881..cc09c8f39a 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -3,10 +3,10 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService' +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; import nconf from 'nconf'; -describe('analytics middleware', function() { +describe('analytics middleware', function () { let res, req, next; let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; @@ -23,7 +23,7 @@ describe('analytics middleware', function() { delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; }); - it('attaches analytics object res.locals', function() { + it('attaches analytics object res.locals', function () { let attachAnalytics = require(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 1ee185915b..94693e6713 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -121,7 +121,7 @@ describe('getUserLanguage', () => { context('request with session', () => { it('uses the user preferred language if avalaible', (done) => { sandbox.stub(User, 'findOne').returns({ - exec() { + exec () { return Q.resolve({ preferences: { language: 'it', diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index ca3d160908..15bb9881a5 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -3,9 +3,9 @@ import { generateReq, generateNext, } from '../../../../helpers/api-unit.helper'; -import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response' +import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; -describe('response middleware', function() { +describe('response middleware', function () { let res, req, next; beforeEach(() => { @@ -15,13 +15,13 @@ describe('response middleware', function() { }); - it('attaches respond method to res', function() { + it('attaches respond method to res', function () { responseMiddleware(req, res, next); expect(res.respond).to.exist; }); - it('can be used to respond to requests', function() { + it('can be used to respond to requests', function () { responseMiddleware(req, res, next); res.respond(200, {field: 1}); @@ -34,7 +34,7 @@ describe('response middleware', function() { }); }); - it('treats status >= 400 as failures', function() { + it('treats status >= 400 as failures', function () { responseMiddleware(req, res, next); res.respond(403, {field: 1}); From 9428c6d997dce487f9a9d84c16e5d17441fc6040 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 08:50:02 -0600 Subject: [PATCH 052/213] lint: Correct linting errors in api v3 tests --- test/api/v3/integration/chat/GET-chat.test.js | 4 +- .../integration/chat/POST-chat.flag.test.js | 6 +- .../integration/chat/POST-chat.like.test.js | 6 +- .../v3/integration/groups/POST-groups.test.js | 11 ++- .../v3/integration/tags/PUT-tags_id.test.js | 4 +- .../integration/tasks/DELETE-tasks_id.test.js | 6 +- .../v3/integration/tasks/GET-tasks.test.js | 1 - .../v3/integration/tasks/GET-tasks_id.test.js | 8 ++- .../POST-tasks_id_score_direction.test.js | 70 +++++++++---------- .../v3/integration/tasks/PUT-tasks_id.test.js | 35 +++++----- ...LETE-tasks_taskId_checklist_itemId.test.js | 12 ++-- .../POST-tasks_taskId_checklist.test.js | 2 +- ...asks_taskId_checklist_itemId_score.test.js | 12 ++-- .../PUT-tasks_taskId_checklist_itemId.test.js | 24 +++---- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../user/auth/POST-register_local.test.js | 18 ++--- .../api/v3/unit/libs/analyticsService.test.js | 56 +++++++-------- test/api/v3/unit/libs/baseModel.test.js | 10 +-- test/api/v3/unit/libs/buildManifest.test.js | 5 +- test/api/v3/unit/libs/email.test.js | 40 +++++------ test/api/v3/unit/libs/webhooks.test.js | 43 ++++++------ .../api/v3/unit/middlewares/analytics.test.js | 5 +- .../v3/unit/middlewares/errorHandler.test.js | 6 +- .../unit/middlewares/getUserLanguage.test.js | 56 +++++++-------- test/api/v3/unit/middlewares/response.js | 8 +-- 25 files changed, 218 insertions(+), 234 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index 26ed88f70b..edaca2ca2d 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -36,7 +36,7 @@ describe('GET /groups/:groupId/chat', () => { }); it('returns Guild chat', () => { - return user.get('/groups/' + group._id + '/chat') + return user.get(`/groups/${group._id}/chat`) .then((getChat) => { expect(getChat).to.eql(group.chat); }); @@ -67,7 +67,7 @@ describe('GET /groups/:groupId/chat', () => { it('returns error if user is not member of requested private group', () => { return expect( - user.get('/groups/' + group._id + '/chat') + user.get(`/groups/${group._id}/chat`) ) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index c5178b7af8..3a0d494272 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -2,7 +2,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration.helper'; -import _ from 'lodash'; +import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { let user; @@ -66,7 +66,7 @@ describe('POST /chat/:chatId/flag', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.flags[user._id]).to.equal(true); }); }); @@ -89,7 +89,7 @@ describe('POST /chat/:chatId/flag', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.flags[secondUser._id]).to.equal(true); expect(messageToCheck.flagCount).to.equal(5); }); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 40f530174c..05d40f6e9f 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -2,7 +2,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration.helper'; -import _ from 'lodash'; +import { find } from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; @@ -65,7 +65,7 @@ describe('POST /chat/:chatId/like', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.likes[user._id]).to.equal(true); }); }); @@ -89,7 +89,7 @@ describe('POST /chat/:chatId/like', () => { return user.get(`/groups/${group._id}`); }) .then((updatedGroup) => { - let messageToCheck = _.find(updatedGroup.chat, {id: message.id}); + let messageToCheck = find(updatedGroup.chat, {id: message.id}); expect(messageToCheck.likes[user._id]).to.equal(false); }); }); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index f2ec45b06d..da532cc8d3 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -20,7 +20,7 @@ describe('POST /group', () => { return expect( user.post('/groups', { name: groupName, - type: groupType + type: groupType, }) ) .to.eventually.be.rejected.and.eql({ @@ -38,7 +38,7 @@ describe('POST /group', () => { return generateUser({balance: 1}).then((generatedUser) => { return generatedUser.post('/groups', { name: groupName, - type: groupType + type: groupType, }); }) .then((result) => { @@ -54,13 +54,12 @@ describe('POST /group', () => { let groupName = 'Test Private Guild'; let groupType = 'guild'; let groupPrivacy = 'private'; - let tmpUser; return generateUser({balance: 1}).then((generatedUser) => { return generatedUser.post('/groups', { name: groupName, type: groupType, - privacy: groupPrivacy + privacy: groupPrivacy, }); }) .then((result) => { @@ -80,7 +79,7 @@ describe('POST /group', () => { return user.post('/groups', { name: groupName, - type: groupType + type: groupType, }) .then((result) => { expect(result._id).to.exist; @@ -98,7 +97,7 @@ describe('POST /group', () => { tmpUser = generatedUser; return tmpUser.post('/groups', { name: groupName, - type: groupType + type: groupType, }); }) .then(() => { diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index e8a4335444..94a76ec39c 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -12,13 +12,11 @@ describe('PUT /tags/:tagId', () => { }); it('updates a tag given it\'s id', () => { - let length; - return user.post('/tags', {name: 'Tag 1'}) .then((createdTag) => { return user.put(`/tags/${createdTag._id}`, { name: 'Tag updated', - ignored: true + ignored: true, }); }) .then((updatedTag) => { diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index ad92739c9a..91f02b7844 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -25,9 +25,9 @@ describe('DELETE /tasks/:id', () => { }); it('deletes a user\'s task', () => { - return user.del('/tasks/' + task._id) + return user.del(`/tasks/${task._id}`) .then(() => { - return expect(user.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -54,7 +54,7 @@ describe('DELETE /tasks/:id', () => { }); }) .then((task2) => { - return expect(user.del('/tasks/' + task2._id)).to.eventually.be.rejected.and.eql({ + return expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 8a191f17bb..0772046f0b 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration.helper'; import Q from 'q'; diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 993e2c127f..a4ae455d6a 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -26,7 +26,7 @@ describe('GET /tasks/:id', () => { }); it('gets specified task', () => { - return user.get('/tasks/' + task._id) + return user.get(`/tasks/${task._id}`) .then((getTask) => { expect(getTask).to.eql(task); }); @@ -38,7 +38,9 @@ describe('GET /tasks/:id', () => { context('task cannot be accessed', () => { it('cannot get a non-existant task', () => { - return expect(user.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ + let dummyId = generateUUID(); + + return expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), @@ -57,7 +59,7 @@ describe('GET /tasks/:id', () => { type: 'habit', }); }).then((task) => { - return expect(anotherUser.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + return expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2d7e10e745..2978d93657 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -47,29 +47,29 @@ describe('POST /tasks/:id/score/:direction', () => { it('completes todo when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/tasks/${todo._id}`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('moves completed todos out of user.tasksOrder.todos', () => { return user.get('/user') - .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + .then(usr => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(true); return user.get('/user'); }) - .then((user) => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); + .then((usr) => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); }); it('moves un-completed todos back into user.tasksOrder.todos', () => { return user.get('/user') - .then(user => { - expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + .then(usr => { + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); }).then(() => user.post(`/tasks/${todo._id}/score/up`)) .then(() => user.post(`/tasks/${todo._id}/score/down`)) .then(() => user.get(`/tasks/${todo._id}`)) @@ -77,16 +77,16 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedTask.completed).to.equal(false); return user.get('/user'); }) - .then((user) => { - let l = user.tasksOrder.todos.length; - expect(user.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); - expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom + .then((usr) => { + let l = usr.tasksOrder.todos.length; + expect(usr.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom }); }); it('uncompletes todo when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/tasks/${todo._id}`)) + .then(() => user.get(`/tasks/${todo._id}`)) .then((updatedTask) => { expect(updatedTask.completed).to.equal(false); }); @@ -98,7 +98,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -106,7 +106,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -114,7 +114,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -122,7 +122,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s exp when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); @@ -130,7 +130,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${todo._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -138,7 +138,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s gold when direction is down', () => { return user.post(`/tasks/${todo._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -159,13 +159,13 @@ describe('POST /tasks/:id/score/:direction', () => { it('completes daily when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/tasks/${daily._id}`)) + .then(() => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(true)); }); it('uncompletes daily when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/tasks/${daily._id}`)) + .then(() => user.get(`/tasks/${daily._id}`)) .then((task) => expect(task.completed).to.equal(false)); }); @@ -175,7 +175,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -183,7 +183,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -191,7 +191,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -199,7 +199,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s exp when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); @@ -207,7 +207,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${daily._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -215,7 +215,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s gold when direction is down', () => { return user.post(`/tasks/${daily._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); @@ -223,7 +223,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); context('habits', () => { - let habit, minusHabit, plusHabit, neitherHabit; + let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(() => { return user.post('/tasks', { @@ -262,7 +262,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s mp when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); @@ -270,7 +270,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('decreases user\'s mp when direction is down', () => { return user.post(`/tasks/${habit._id}/score/down`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); @@ -278,7 +278,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s exp when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); @@ -286,7 +286,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('increases user\'s gold when direction is up', () => { return user.post(`/tasks/${habit._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); @@ -308,7 +308,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('purchases reward', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); @@ -316,7 +316,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not change user\'s mp', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); @@ -324,7 +324,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not change user\'s exp', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp).to.equal(updatedUser.stats.exp); }); @@ -332,7 +332,7 @@ describe('POST /tasks/:id/score/:direction', () => { it('does not allow a down direction', () => { return user.post(`/tasks/${reward._id}/score/up`) - .then((res) => user.get(`/user`)) + .then(() => user.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 99fdb83615..3092d3bd84 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -1,6 +1,5 @@ import { generateUser, - translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -28,7 +27,7 @@ describe('PUT /tasks/:id', () => { it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, () => { - user.put('/tasks/' + task._id, { + user.put(`/tasks/${task._id}`, { _id: 123, type: 'daily', userId: 123, @@ -54,7 +53,7 @@ describe('PUT /tasks/:id', () => { }); it('ignores invalid fields', () => { - user.put('/tasks/' + task._id, { + user.put(`/tasks/${task._id}`, { notValid: true, }).then((savedTask) => { expect(savedTask.notValid).to.be.a('undefined'); @@ -118,12 +117,12 @@ describe('PUT /tasks/:id', () => { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, - ] - }).then((savedTodo) => { + ], + }).then(() => { return user.put(`/tasks/${todo._id}`, { checklist: [ {text: 789, completed: false}, - ] + ], }); }).then((savedTodo2) => { expect(savedTodo2.checklist.length).to.equal(1); @@ -136,9 +135,9 @@ describe('PUT /tasks/:id', () => { let finalUUID = generateUUID(); return user.put(`/tasks/${todo._id}`, { tags: [generateUUID(), generateUUID()], - }).then((savedTodo) => { + }).then(() => { return user.put(`/tasks/${todo._id}`, { - tags: [finalUUID] + tags: [finalUUID], }); }).then((savedTodo2) => { expect(savedTodo2.tags.length).to.equal(1); @@ -161,8 +160,6 @@ describe('PUT /tasks/:id', () => { }); it('updates a daily', () => { - let now = new Date(); - return user.put(`/tasks/${daily._id}`, { text: 'some new text', notes: 'some new notes', @@ -181,12 +178,12 @@ describe('PUT /tasks/:id', () => { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, - ] - }).then((savedDaily) => { + ], + }).then(() => { return user.put(`/tasks/${daily._id}`, { checklist: [ {text: 789, completed: false}, - ] + ], }); }).then((savedDaily2) => { expect(savedDaily2.checklist.length).to.equal(1); @@ -199,9 +196,9 @@ describe('PUT /tasks/:id', () => { let finalUUID = generateUUID(); return user.put(`/tasks/${daily._id}`, { tags: [generateUUID(), generateUUID()], - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { - tags: [finalUUID] + tags: [finalUUID], }); }).then((savedDaily2) => { expect(savedDaily2.tags.length).to.equal(1); @@ -212,12 +209,12 @@ describe('PUT /tasks/:id', () => { it('updates repeat, even if frequency is set to daily', () => { return user.put(`/tasks/${daily._id}`, { frequency: 'daily', - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { repeat: { m: false, - su: false - } + su: false, + }, }); }).then((savedDaily2) => { expect(savedDaily2.repeat).to.eql({ @@ -235,7 +232,7 @@ describe('PUT /tasks/:id', () => { it('updates everyX, even if frequency is set to weekly', () => { return user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then((savedDaily) => { + }).then(() => { return user.put(`/tasks/${daily._id}`, { everyX: 5, }); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 094619e1fd..d013878a74 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -46,15 +46,13 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); }); - it('does not work with rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('does not work with rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index 4654871ac5..e9b695effd 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -66,7 +66,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('fails on task not found', () => { return expect(user.post(`/tasks/${generateUUID()}/checklist`, { - text: 'Checklist Item 1' + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 727aaea74c..667ef41446 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -45,15 +45,13 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); }); - it('fails on rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('fails on rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 149d1bedca..988574bbf8 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -32,30 +32,26 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on habits', () => { - let habit; - return expect(user.post('/tasks', { + it('fails on habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), }); }); - it('fails on rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('fails on rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); - }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${reward._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index ddb78b7262..c01b71fa2c 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -26,7 +26,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }).then(createdTag => { tag = createdTag; return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(savedTask => { + }).then(() => { return user.del(`/tasks/${task._id}/tags/${tag._id}`); }).then(() => user.get(`/tasks/${task._id}`)) .then(updatedTask => { @@ -35,8 +35,6 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', () => { - let task; - return expect(user.post('/tasks', { type: 'habit', text: 'Task with tag', diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 0e11e7b924..a0f83717ea 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -37,7 +37,7 @@ describe('POST /user/auth/local/register', () => { username, email, password, - confirmPassword: confirmPassword, + confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -104,8 +104,8 @@ describe('POST /user/auth/local/register', () => { return expect(api.post('/user/auth/local/register', { username, - email: email, - confirmPassword: confirmPassword, + email, + confirmPassword, })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -123,7 +123,7 @@ describe('POST /user/auth/local/register', () => { return generateUser({ 'auth.local.username': username, 'auth.local.lowerCaseUsername': username, - 'auth.local.email': email + 'auth.local.email': email, }); }); @@ -133,9 +133,9 @@ describe('POST /user/auth/local/register', () => { let password = 'password'; return expect(api.post('/user/auth/local/register', { - username: username, - email: uniqueEmail, - password: password, + username, + email: uniqueEmail, + password, confirmPassword: password, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -181,7 +181,7 @@ describe('POST /user/auth/local/register', () => { }).then((user) => { expect(user.flags.tour).to.not.be.empty; - each(user.flags.tour, (value, attribute) => { + each(user.flags.tour, (value) => { expect(value).to.eql(-2); }); }); @@ -232,7 +232,7 @@ describe('POST /user/auth/local/register', () => { }).then((user) => { expect(user.flags.tour).to.not.be.empty; - each(user.flags.tutorial.common, (value, attribute) => { + each(user.flags.tutorial.common, (value) => { expect(value).to.eql(true); }); }); diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js index c8536bc887..8ff14c2408 100644 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -6,12 +6,12 @@ describe('analyticsService', () => { let amplitudeNock, gaNock; beforeEach(() => { - amplitudeNock = nock( 'https://api.amplitude.com') + amplitudeNock = nock('https://api.amplitude.com') .filteringPath(/httpapi.*/g, '') .post('/') .reply(200, {status: 'OK'}); - gaNock = nock( 'http://www.google-analytics.com'); + gaNock = nock('http://www.google-analytics.com'); }); describe('#track', () => { @@ -23,14 +23,14 @@ describe('analyticsService', () => { category: 'behavior', uuid: 'unique-user-id', resting: true, - cronCount: 5 + cronCount: 5, }; }); context('Amplitude', () => { it('calls out to amplitude', () => { return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -42,7 +42,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -52,7 +52,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*platform.*server.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -62,7 +62,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*event_properties%22%3A%7B%22category%22%3A%22behavior%22%2C%22resting%22%3Atrue%2C%22cronCount%22%3A5%7D%2C%22.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -74,7 +74,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -86,7 +86,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -98,7 +98,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -110,7 +110,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -122,7 +122,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -134,7 +134,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -142,14 +142,14 @@ describe('analyticsService', () => { it('sends user data if provided', () => { let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; let user = { - stats: stats, + stats, contributor: { level: 1 }, purchased: { plan: { planId: 'foo-plan' } }, flags: {tour: {intro: -2}}, habits: [{_id: 'habit'}], dailys: [{_id: 'daily'}], todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}] + rewards: [{_id: 'reward'}], }; data.user = user; @@ -158,7 +158,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -171,7 +171,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -182,7 +182,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.track(eventType, data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -201,14 +201,14 @@ describe('analyticsService', () => { purchaseValue: 8, purchaseType: 'checkout', gift: false, - quantity: 1 + quantity: 1, }; }); context('Amplitude', () => { it('calls out to amplitude', () => { return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -220,7 +220,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -230,7 +230,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*platform.*server.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -240,7 +240,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*aypal-checkout%22%2C%22paymentMethod%22%3A%22PayPal%22%2C%22itemPurchased%22%3A%22Gems%22%2C%22purchaseType%22%3A%22checkout%22%2C%22gift%22%3Afalse%2C%22quantity%22%3A1%7D%2C%22event_type%22%3A%22purchase%22%2C%22revenue.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -248,14 +248,14 @@ describe('analyticsService', () => { it('sends user data if provided', () => { let stats = { class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30 }; let user = { - stats: stats, + stats, contributor: { level: 1 }, purchased: { plan: { planId: 'foo-plan' } }, flags: {tour: {intro: -2}}, habits: [{_id: 'habit'}], dailys: [{_id: 'daily'}], todos: [{_id: 'todo'}], - rewards: [{_id: 'reward'}] + rewards: [{_id: 'reward'}], }; data.user = user; @@ -264,7 +264,7 @@ describe('analyticsService', () => { .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { amplitudeNock.done(); }); }); @@ -277,7 +277,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { gaNock.done(); }); }); @@ -290,7 +290,7 @@ describe('analyticsService', () => { .reply(200, {status: 'OK'}); return analyticsService.trackPurchase(data) - .then((res) => { + .then(() => { gaNock.done(); }); }); diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 98835b1c29..95960c704d 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -32,7 +32,7 @@ describe('Base model plugin', () => { it('can sanitize input objects', () => { baseModel(schema, { - noSet: ['noUpdateForMe'] + noSet: ['noUpdateForMe'], }); expect(schema.statics.sanitize).to.exist; @@ -45,7 +45,7 @@ describe('Base model plugin', () => { it('accepts an array of additional fields to sanitize at runtime', () => { baseModel(schema, { - noSet: ['noUpdateForMe'] + noSet: ['noUpdateForMe'], }); expect(schema.statics.sanitize).to.exist; @@ -59,7 +59,7 @@ describe('Base model plugin', () => { it('can make fields private', () => { baseModel(schema, { - private: ['amPrivate'] + private: ['amPrivate'], }); expect(schema.options.toJSON.transform).to.exist; @@ -73,7 +73,7 @@ describe('Base model plugin', () => { it('accepts a further transform function for toJSON', () => { let options = { private: ['amPrivate'], - toJSONTransform: sandbox.stub().returns(true) + toJSONTransform: sandbox.stub().returns(true), }; baseModel(schema, options); @@ -88,7 +88,7 @@ describe('Base model plugin', () => { it('accepts a transform function for sanitize', () => { let options = { private: ['amPrivate'], - sanitizeTransform: sandbox.stub().returns(true) + sanitizeTransform: sandbox.stub().returns(true), }; baseModel(schema, options); diff --git a/test/api/v3/unit/libs/buildManifest.test.js b/test/api/v3/unit/libs/buildManifest.test.js index e719d8c434..0978d8fb41 100644 --- a/test/api/v3/unit/libs/buildManifest.test.js +++ b/test/api/v3/unit/libs/buildManifest.test.js @@ -11,8 +11,9 @@ describe('Build Manifest', () => { }); it('throws an error in case the page does not exist', () => { - let getManifestFilesFn = () => { getManifestFiles('strange name here'); }; - expect(getManifestFilesFn).to.throw(Error); + expect(() => { + getManifestFiles('strange name here'); + }).to.throw(Error); }); }); }); diff --git a/test/api/v3/unit/libs/email.test.js b/test/api/v3/unit/libs/email.test.js index 5cfd1b542f..bb7741dce7 100644 --- a/test/api/v3/unit/libs/email.test.js +++ b/test/api/v3/unit/libs/email.test.js @@ -1,3 +1,4 @@ +/* eslint-disable global-require */ import request from 'request'; import nconf from 'nconf'; import nodemailer from 'nodemailer'; @@ -14,17 +15,17 @@ function getUser () { }, facebook: { emails: [{ - value: 'email@facebook' + value: 'email@facebook', }], displayName: 'fb display name', - } + }, }, profile: { name: 'profile name', }, preferences: { emailNotifications: { - unsubscribeFromAll: false + unsubscribeFromAll: false, }, }, }; @@ -63,7 +64,7 @@ describe('emails', () => { attachEmail.send(); expect(sendMailSpy).to.be.calledOnce; deferred.reject(); - deferred.promise.catch((err) => { + deferred.promise.catch(() => { expect(logger.error).to.be.calledOnce; done(); }); @@ -93,8 +94,8 @@ describe('emails', () => { let attachEmail = require(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); - delete user.profile['name']; - delete user.auth['local']; + delete user.profile.name; + delete user.auth.local; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); @@ -108,9 +109,9 @@ describe('emails', () => { let attachEmail = require(pathToEmailLib); let getUserInfo = attachEmail.getUserInfo; let user = getUser(); - delete user.profile['name']; - delete user.auth.local['email']; - delete user.auth['facebook']; + delete user.profile.name; + delete user.auth.local.email; + delete user.auth.facebook; let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']); @@ -148,8 +149,8 @@ describe('emails', () => { to: sinon.match((value) => { return Array.isArray(value) && value[0].name === mailingInfo.name; }, 'matches mailing info array'), - } - } + }, + }, })); }); @@ -160,7 +161,7 @@ describe('emails', () => { let emailType = 'an email type'; let mailingInfo = { name: 'my name', - //email: 'my@email', + // email: 'my@email', }; sendTxnEmail(mailingInfo, emailType); @@ -180,8 +181,8 @@ describe('emails', () => { data: { emailType: sinon.match.same(emailType), to: sinon.match(val => val[0]._id === mailingInfo._id), - } - } + }, + }, })); }); @@ -204,13 +205,12 @@ describe('emails', () => { return value[0].name === 'BASE_URL'; }, 'matches variables'), personalVariables: sinon.match((value) => { - return (value[0].rcpt === mailingInfo.email - && value[0].vars[0].name === 'RECIPIENT_NAME' - && value[0].vars[1].name === 'RECIPIENT_UNSUB_URL' - ); + return value[0].rcpt === mailingInfo.email && + value[0].vars[0].name === 'RECIPIENT_NAME' && + value[0].vars[1].name === 'RECIPIENT_UNSUB_URL'; }, 'matches personal variables'), - } - } + }, + }, })); }); }); diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js index 62aeee3922..9bef501257 100644 --- a/test/api/v3/unit/libs/webhooks.test.js +++ b/test/api/v3/unit/libs/webhooks.test.js @@ -2,7 +2,6 @@ import request from 'request'; import { sendTaskWebhook } from '../../../../../website/src/libs/api-v3/webhook'; describe('webhooks', () => { - beforeEach(() => { sandbox.stub(request, 'post'); }); @@ -15,12 +14,12 @@ describe('webhooks', () => { let task = { details: { _id: 'task-id' }, delta: 1.4, - direction: 'up' + direction: 'up', }; let data = { - task: task, - user: { _id: 'user-id' } + task, + user: { _id: 'user-id' }, }; it('does not send if no webhook endpoints exist', () => { @@ -37,8 +36,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: false, - url: 'http://example.org/endpoint' - } + url: 'http://example.org/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -52,8 +51,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://malformedurl/endpoint' - } + url: 'http://malformedurl/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -67,8 +66,8 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://example.org/endpoint' - } + url: 'http://example.org/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -81,10 +80,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); }); @@ -94,14 +93,14 @@ describe('webhooks', () => { sort: 0, id: 'some-id', enabled: true, - url: 'http://example.org/endpoint' + url: 'http://example.org/endpoint', }, 'second-webhook': { sort: 1, id: 'second-webhook', enabled: true, - url: 'http://example.com/2/endpoint' - } + url: 'http://example.com/2/endpoint', + }, }; sendTaskWebhook(webhooks, data); @@ -114,10 +113,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); expect(request.post).to.be.calledWith({ url: 'http://example.com/2/endpoint', @@ -126,10 +125,10 @@ describe('webhooks', () => { task: { _id: 'task-id' }, delta: 1.4, user: { - _id: 'user-id' - } + _id: 'user-id', + }, }, - json: true + json: true, }); }); }); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index cc09c8f39a..bcaa7898e2 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -1,3 +1,4 @@ +/* eslint-disable global-require */ import { generateRes, generateReq, @@ -6,7 +7,7 @@ import { import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; import nconf from 'nconf'; -describe('analytics middleware', function () { +describe('analytics middleware', () => { let res, req, next; let pathToAnalyticsMiddleware = '../../../../../website/src/middlewares/api-v3/analytics'; @@ -23,7 +24,7 @@ describe('analytics middleware', function () { delete require.cache[require.resolve(pathToAnalyticsMiddleware)]; }); - it('attaches analytics object res.locals', function () { + it('attaches analytics object res.locals', () => { let attachAnalytics = require(pathToAnalyticsMiddleware); attachAnalytics(req, res, next); diff --git a/test/api/v3/unit/middlewares/errorHandler.test.js b/test/api/v3/unit/middlewares/errorHandler.test.js index 80e1ab08d1..269390b35b 100644 --- a/test/api/v3/unit/middlewares/errorHandler.test.js +++ b/test/api/v3/unit/middlewares/errorHandler.test.js @@ -115,7 +115,7 @@ describe('errorHandler', () => { error: 'BadRequest', message: 'Invalid request parameters.', errors: [ - {param: error[0].param, value: error[0].value, message: error[0].msg} + { param: error[0].param, value: error[0].value, message: error[0].msg }, ], }); }); @@ -142,8 +142,8 @@ describe('errorHandler', () => { error: 'BadRequest', message: 'User validation failed.', errors: [ - {path: 'auth.local.email', message: 'Invalid email.', value: 'not an email'} - ] + { path: 'auth.local.email', message: 'Invalid email.', value: 'not an email' }, + ], }); }); diff --git a/test/api/v3/unit/middlewares/getUserLanguage.test.js b/test/api/v3/unit/middlewares/getUserLanguage.test.js index 94693e6713..43b0cb0ab8 100644 --- a/test/api/v3/unit/middlewares/getUserLanguage.test.js +++ b/test/api/v3/unit/middlewares/getUserLanguage.test.js @@ -7,15 +7,13 @@ import getUserLanguage from '../../../../../website/src/middlewares/api-v3/getUs import { i18n } from '../../../../../common'; import Q from 'q'; import { model as User } from '../../../../../website/src/models/user'; -import { translations } from '../../../../../website/src/libs/api-v3/i18n'; -import accepts from 'accepts'; describe('getUserLanguage', () => { let res, req, next; - let checkResT = (req) => { - expect(res.t).to.be.a('function'); - expect(res.t('help')).to.equal(i18n.t('help', req.language)); + let checkResT = (resToCheck) => { + expect(resToCheck.t).to.be.a('function'); + expect(resToCheck.t('help')).to.equal(i18n.t('help', req.language)); }; beforeEach(() => { @@ -32,7 +30,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); }); it('falls back to english if the query parameter language does not exists', () => { @@ -42,7 +40,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); }); it('uses query even if the request includes a user and session', () => { @@ -59,12 +57,12 @@ describe('getUserLanguage', () => { }; req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, next); expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); }); }); @@ -80,7 +78,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); }); it('falls back to english if the user preferred language is not avalaible', (done) => { @@ -94,7 +92,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -109,12 +107,12 @@ describe('getUserLanguage', () => { }; req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, next); expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); }); }); @@ -125,18 +123,18 @@ describe('getUserLanguage', () => { return Q.resolve({ preferences: { language: 'it', - } + }, }); - } + }, }); req.session = { - userId: 123 + userId: 123, }; getUserLanguage(req, res, () => { expect(req.language).to.equal('it'); - checkResT(req); + checkResT(res); done(); }); }); @@ -148,7 +146,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('pt'); - checkResT(req); + checkResT(res); done(); }); }); @@ -158,7 +156,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkResT(req); + checkResT(res); done(); }); }); @@ -168,7 +166,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('he'); - checkResT(req); + checkResT(res); done(); }); }); @@ -178,7 +176,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkResT(req); + checkResT(res); done(); }); }); @@ -188,7 +186,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('fr'); - checkResT(req); + checkResT(res); done(); }); }); @@ -198,7 +196,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es'); - checkResT(req); + checkResT(res); done(); }); }); @@ -208,7 +206,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkResT(req); + checkResT(res); done(); }); }); @@ -218,7 +216,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('es_419'); - checkResT(req); + checkResT(res); done(); }); }); @@ -228,7 +226,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('zh_TW'); - checkResT(req); + checkResT(res); done(); }); }); @@ -238,7 +236,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -248,7 +246,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); @@ -258,7 +256,7 @@ describe('getUserLanguage', () => { getUserLanguage(req, res, () => { expect(req.language).to.equal('en'); - checkResT(req); + checkResT(res); done(); }); }); diff --git a/test/api/v3/unit/middlewares/response.js b/test/api/v3/unit/middlewares/response.js index 15bb9881a5..25916e1ef3 100644 --- a/test/api/v3/unit/middlewares/response.js +++ b/test/api/v3/unit/middlewares/response.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-unit.helper'; import responseMiddleware from '../../../../../website/src/middlewares/api-v3/response'; -describe('response middleware', function () { +describe('response middleware', () => { let res, req, next; beforeEach(() => { @@ -15,13 +15,13 @@ describe('response middleware', function () { }); - it('attaches respond method to res', function () { + it('attaches respond method to res', () => { responseMiddleware(req, res, next); expect(res.respond).to.exist; }); - it('can be used to respond to requests', function () { + it('can be used to respond to requests', () => { responseMiddleware(req, res, next); res.respond(200, {field: 1}); @@ -34,7 +34,7 @@ describe('response middleware', function () { }); }); - it('treats status >= 400 as failures', function () { + it('treats status >= 400 as failures', () => { responseMiddleware(req, res, next); res.respond(403, {field: 1}); From 7025bbfa3244dc1da9477513b6db68f4e436bf17 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Wed, 30 Dec 2015 01:27:38 +0200 Subject: [PATCH 053/213] Fix basilist scroll giving logic when joining party closes #6416 --- .../v3/integration/groups/POST-groups.test.js | 3 + .../groups/POST-groups_groupId_join.js | 63 +++++++++++++++++++ website/src/controllers/api-v3/groups.js | 14 +++-- website/src/models/group.js | 2 +- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_join.js diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index da532cc8d3..0d3a622581 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -45,6 +45,7 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); + expect(result.memberCount).to.equal(1); }); }); }); @@ -66,6 +67,7 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); + expect(result.memberCount).to.equal(1); expect(result.privacy).to.equal(groupPrivacy); }); }); @@ -85,6 +87,7 @@ describe('POST /group', () => { expect(result._id).to.exist; expect(result.name).to.equal(groupName); expect(result.type).to.equal(groupType); + expect(result.memberCount).to.equal(1); }); }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js new file mode 100644 index 0000000000..aa26c10289 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -0,0 +1,63 @@ +import { + generateUser, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /group/:groupId/join', () => { + context('Accepting invitation to a guild', () => { + let user, invitedUser, guild; + + beforeEach(async () => { + user = await generateUser({balance: 1}); + guild = await user.post('/groups', { + name: 'Test Guild', + type: 'guild', + }); + invitedUser = await generateUser({ + 'invitations.guilds': [{ id: guild._id}], + }); + }); + + it('does not give basilist quest to inviter when joining a guild', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist'); + }); + + it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => { + user.update({ 'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); + }); + + context('Accepting invitation to a party', () => { + let user, invitedUser, party; + + beforeEach(async () => { + user = await generateUser(); + party = await user.post('/groups', { + name: 'Test Party', + type: 'party', + }); + invitedUser = await generateUser({ + 'invitations.party': { id: party._id, inviter: user._id }, + }); + }); + + it('gives basilist quest item to the inviter when joining a party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); + + it('increments basilist quest item count to inviter when joining a party', async () => { + user.update({'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 1edf18b026..a61c56e26c 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -201,6 +201,7 @@ api.joinGroup = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let inviter; req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); @@ -213,6 +214,7 @@ api.joinGroup = { let isUserInvited = false; if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) { + inviter = user.invitations.party.inviter; user.invitations.party = {}; // Clear invite TODO mark modified? // invite new user to pending quest @@ -242,11 +244,13 @@ api.joinGroup = { if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader - await Q.all([ - group.save(), - user.save(), - User.update({_id: user.invitations.party.inviter}, {$inc: {'items.quests.basilist': 1}}).exec(), // Reward inviter - ]); + let promises = [group.save(), user.save()]; + + if (group.type === 'party' && inviter) { + promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter + } + + await Q.all(promises); firebase.addUserToGroup(group._id, user._id); res.respond(200, {}); // TODO what to return? diff --git a/website/src/models/group.js b/website/src/models/group.js index eb85e7662f..a45d2eaa60 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -35,7 +35,7 @@ export let schema = new Schema({ challenges: {type: Boolean, default: false, required: true}, // invites: {type:Boolean, 'default':false} // TODO ? }, - memberCount: {type: Number, default: 0}, + memberCount: {type: Number, default: 1}, challengeCount: {type: Number, default: 0}, balance: {type: Number, default: 0}, logo: String, From 91e7b9eb329f8277a471f7c4ebe03b3fc05688d9 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 31 Dec 2015 18:11:03 -0600 Subject: [PATCH 054/213] tests(api): Convert GET and flag chat posts to use async/await --- test/api/v3/integration/chat/GET-chat.test.js | 78 ++++++++----------- .../integration/chat/POST-chat.flag.test.js | 46 ++++------- 2 files changed, 49 insertions(+), 75 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index edaca2ca2d..cb1e0996e6 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -7,69 +7,55 @@ import { describe('GET /groups/:groupId/chat', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('public Guild', () => { let group; - before(() => { - return generateUser({balance: 2}) - .then((generatedLeader) => { - return generateGroup(generatedLeader, { - name: 'test group', - type: 'guild', - privacy: 'public', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], - }); - }) - .then((createdGroup) => { - group = createdGroup; + before(async () => { + let leader = await generateUser({balance: 2}); + + group = await generateGroup(leader, { + name: 'test group', + type: 'guild', + privacy: 'public', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], }); }); - it('returns Guild chat', () => { - return user.get(`/groups/${group._id}/chat`) - .then((getChat) => { - expect(getChat).to.eql(group.chat); - }); + it('returns Guild chat', async () => { + let chat = await user.get(`/groups/${group._id}/chat`); + + expect(chat).to.eql(group.chat); }); }); context('private Guild', () => { let group; - before(() => { - return generateUser({balance: 2}) - .then((generatedLeader) => { - return generateGroup(generatedLeader, { - name: 'test group', - type: 'guild', - privacy: 'private', - }, { - chat: [ - 'Hello', - 'Welcome to the Guild', - ], - }); - }) - .then((createdGroup) => { - group = createdGroup; + before(async () => { + let leader = await generateUser({balance: 2}); + + group = await generateGroup(leader, { + name: 'test group', + type: 'guild', + privacy: 'private', + }, { + chat: [ + 'Hello', + 'Welcome to the Guild', + ], }); }); - it('returns error if user is not member of requested private group', () => { - return expect( - user.get(`/groups/${group._id}/chat`) - ) - .to.eventually.be.rejected.and.eql({ + it('returns error if user is not member of requested private group', async () => { + await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 3a0d494272..b71d462e5a 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -5,31 +5,19 @@ import { import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { - let user; - let group; - let testMessage = 'Test Message'; + let user, group; + const TEST_MESSAGE = 'Test Message'; - 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 () => { + user = await generateUser({balance: 1}); + group = await user.post('/groups', { + name: 'Test Guild', + type: 'guild', + privacy: 'public', }); }); - it('Returns an error when chat message is not found', () => { + it('Returns an error when chat message is not found', async () => { return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`)) .to.eventually.be.rejected.and.eql({ code: 404, @@ -38,8 +26,8 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Returns an error when user tries to flag their own message', () => { - return user.post(`/groups/${group._id}/chat`, { message: testMessage}) + 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({ @@ -50,11 +38,11 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Flags a chat', () => { + it('Flags a chat', async () => { let message; return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); + return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); }) .then((result) => { message = result.message; @@ -71,13 +59,13 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Flags a chat with a higher flag acount when an admin flags the message', () => { + it('Flags a chat with a higher flag acount when an admin flags the message', async () => { let secondUser; let message; return generateUser({'contributor.admin': true}).then((generatedUser) => { secondUser = generatedUser; - return user.post(`/groups/${group._id}/chat`, { message: testMessage}); + return user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); }) .then((result) => { message = result.message; @@ -95,11 +83,11 @@ describe('POST /chat/:chatId/flag', () => { }); }); - it('Returns an error when user tries to flag a message that is already flagged', () => { + it('Returns an error when user tries to flag a message that is already flagged', async () => { let message; return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); + return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); }) .then((result) => { message = result.message; From 775766b30fb6c9a2e04bd5f64460890f8b4223e1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 15:43:33 +0100 Subject: [PATCH 055/213] allow multiple tasks to be created at once --- .../v3/integration/tasks/POST-tasks.test.js | 115 +++++++++++++++++- website/src/controllers/api-v3/tasks.js | 38 +++--- 2 files changed, 133 insertions(+), 20 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 776e69cad2..f4a7fed227 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -19,7 +19,7 @@ describe('POST /tasks', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('invalidReqParams'), + message: t('invalidTaskType'), }); }); @@ -29,7 +29,18 @@ describe('POST /tasks', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('invalidReqParams'), + message: t('invalidTaskType'), + }); + }); + + it('returns an error if one object inside an array is invalid', () => { + return expect(user.post('/tasks', [ + {type: 'habitF'}, + {type: 'habit'}, + ])).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidTaskType'), }); }); @@ -107,6 +118,36 @@ describe('POST /tasks', () => { }); }); + it('creates multiple habits', () => { + return user.post('/tasks', [{ + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }, { + text: 'test habit 2', + type: 'habit', + up: true, + down: false, + notes: 1977, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test habit 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('habit'); + expect(task2.up).to.eql(true); + expect(task2.down).to.eql(false); + }); + }); + it('defaults to setting up and down to true', () => { return user.post('/tasks', { text: 'test habit', @@ -145,6 +186,28 @@ describe('POST /tasks', () => { }); }); + it('creates multiple todos', () => { + return user.post('/tasks', [{ + text: 'test todo', + type: 'todo', + notes: 1976, + }, { + text: 'test todo 2', + type: 'todo', + notes: 1977, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test todo 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('todo'); + }); + }); + it('can create checklists', () => { return user.post('/tasks', { text: 'test todo', @@ -185,6 +248,28 @@ describe('POST /tasks', () => { }); }); + it('creates multiple dailys', () => { + return user.post('/tasks', [{ + text: 'test daily', + type: 'daily', + notes: 1976, + }, { + text: 'test daily 2', + type: 'daily', + notes: 1977, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test daily 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('daily'); + }); + }); + it('defaults to a weekly frequency, with every day set', () => { return user.post('/tasks', { text: 'test daily', @@ -271,6 +356,32 @@ describe('POST /tasks', () => { }); }); + it('creates multiple rewards', () => { + return user.post('/tasks', [{ + text: 'test reward', + type: 'reward', + notes: 1976, + value: 11, + }, { + text: 'test reward 2', + type: 'reward', + notes: 1977, + value: 12, + }]).then(([task, task2]) => { + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(11); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test reward 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('reward'); + expect(task2.value).to.eql(12); + }); + }); + it('defaults to a 0 value', () => { return user.post('/tasks', { text: 'test reward', diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f67a25b10f..25b5245a04 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -16,39 +16,41 @@ import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; let api = {}; /** - * @api {post} /tasks Create a new task + * @api {post} /tasks Create a new task. Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 * @apiName CreateTask * @apiGroup Task * - * @apiSuccess {Object} task The newly created task + * @apiSuccess {Object|Array} task The newly created task(s) */ -// TODO should allow to create multiple tasks at once -// TODO gives problems when creating tasks concurrently because of how mongoose treats arrays (VersionErrors - treated as 500s) api.createTask = { method: 'POST', url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - + let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; - let taskType = req.body.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(req.body)); - newTask.userId = user._id; + let toSave = tasksData.map(taskData => { + if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); - user.tasksOrder[`${taskType}s`].unshift(newTask._id); + let taskType = taskData.type; + let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); + newTask.userId = user._id; + user.tasksOrder[`${taskType}s`].unshift(newTask._id); - let results = await Q.all([ - newTask.save(), - user.save(), - ]); + return newTask.save(); + }); - res.respond(201, results[0]); + toSave.unshift(user.save()); + let results = await Q.all(toSave); + + if (results.length === 2) { // Just one task created + res.respond(201, results[1]); + } else { + results.splice(0, 1); // remove the user + res.respond(201, results); + } }, }; From 7490bfae87c27fef1c57dc4a49b30c3743fbeeb3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 16:04:39 +0100 Subject: [PATCH 056/213] update POST-tasks test to async/await syntax --- .../v3/integration/tasks/POST-tasks.test.js | 362 +++++++++--------- 1 file changed, 181 insertions(+), 181 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index f4a7fed227..c8b9e9a37a 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -6,14 +6,14 @@ import { describe('POST /tasks', () => { let user; - before(() => { + before(async () => { return generateUser().then((generatedUser) => { user = generatedUser; }); }); context('validates params', () => { - it('returns an error if req.body.type is absent', () => { + it('returns an error if req.body.type is absent', async () => { return expect(user.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ @@ -23,7 +23,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.type is not valid', () => { + it('returns an error if req.body.type is not valid', async () => { return expect(user.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ @@ -33,7 +33,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if one object inside an array is invalid', () => { + it('returns an error if one object inside an array is invalid', async () => { return expect(user.post('/tasks', [ {type: 'habitF'}, {type: 'habit'}, @@ -44,7 +44,7 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.text is absent', () => { + it('returns an error if req.body.text is absent', async () => { return expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ @@ -54,19 +54,19 @@ describe('POST /tasks', () => { }); }); - it('automatically sets "task.userId" to user\'s uuid', () => { - return user.post('/tasks', { + it('automatically sets "task.userId" to user\'s uuid', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((task) => { - expect(task.userId).to.equal(user._id); }); + + expect(task.userId).to.equal(user._id); }); it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`, () => { - return user.post('/tasks', { + dateCompleted fields`, async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', userId: 123, @@ -77,49 +77,49 @@ describe('POST /tasks', () => { completed: true, streak: 25, dateCompleted: 'never', - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.history).to.eql([]); - expect(task.createdAt).not.to.equal('yesterday'); - expect(task.updatedAt).not.to.equal('tomorrow'); - expect(task.challenge).not.to.equal('no'); - expect(task.completed).to.equal(false); - expect(task.streak).to.equal(0); - expect(task.streak).not.to.equal('never'); }); + + expect(task.userId).to.equal(user._id); + expect(task.history).to.eql([]); + expect(task.createdAt).not.to.equal('yesterday'); + expect(task.updatedAt).not.to.equal('tomorrow'); + expect(task.challenge).not.to.equal('no'); + expect(task.completed).to.equal(false); + expect(task.streak).to.equal(0); + expect(task.streak).not.to.equal('never'); }); - it('ignores invalid fields', () => { - return user.post('/tasks', { + it('ignores invalid fields', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', notValid: true, - }).then((task) => { - expect(task).not.to.have.property('notValid'); }); + + expect(task).not.to.have.property('notValid'); }); }); context('habits', () => { - it('creates a habit', () => { - return user.post('/tasks', { + it('creates a habit', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', up: false, down: true, notes: 1976, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); }); - it('creates multiple habits', () => { - return user.post('/tasks', [{ + it('creates multiple habits', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test habit', type: 'habit', up: false, @@ -131,63 +131,63 @@ describe('POST /tasks', () => { up: true, down: false, notes: 1977, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test habit'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('habit'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(true); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test habit 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('habit'); - expect(task2.up).to.eql(true); - expect(task2.down).to.eql(false); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test habit 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('habit'); + expect(task2.up).to.eql(true); + expect(task2.down).to.eql(false); }); - it('defaults to setting up and down to true', () => { - return user.post('/tasks', { + it('defaults to setting up and down to true', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, - }).then((task) => { - expect(task.up).to.eql(true); - expect(task.down).to.eql(true); }); + + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); }); - it('cannot create checklists', () => { - return user.post('/tasks', { + it('cannot create checklists', async () => { + let task = await user.post('/tasks', { text: 'test habit', type: 'habit', checklist: [ {_id: 123, completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task).not.to.have.property('checklist'); }); + + expect(task).not.to.have.property('checklist'); }); }); context('todos', () => { - it('creates a todo', () => { - return user.post('/tasks', { + it('creates a todo', async () => { + let task = await user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); }); - it('creates multiple todos', () => { - return user.post('/tasks', [{ + it('creates multiple todos', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test todo', type: 'todo', notes: 1976, @@ -195,61 +195,61 @@ describe('POST /tasks', () => { text: 'test todo 2', type: 'todo', notes: 1977, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test todo'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('todo'); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test todo 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('todo'); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test todo 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('todo'); }); - it('can create checklists', () => { - return user.post('/tasks', { + it('can create checklists', async () => { + let task = await user.post('/tasks', { text: 'test todo', type: 'todo', checklist: [ {completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); }); + + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); }); }); context('dailys', () => { - it('creates a daily', () => { + it('creates a daily', async () => { let now = new Date(); - return user.post('/tasks', { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, frequency: 'daily', everyX: 5, startDate: now, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); - expect(new Date(task.startDate)).to.eql(now); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + expect(new Date(task.startDate)).to.eql(now); }); - it('creates multiple dailys', () => { - return user.post('/tasks', [{ + it('creates multiple dailys', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test daily', type: 'daily', notes: 1976, @@ -257,40 +257,40 @@ describe('POST /tasks', () => { text: 'test daily 2', type: 'daily', notes: 1977, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test daily'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('daily'); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test daily 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('daily'); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test daily 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('daily'); }); - it('defaults to a weekly frequency, with every day set', () => { - return user.post('/tasks', { + it('defaults to a weekly frequency, with every day set', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - expect(task.frequency).to.eql('weekly'); - expect(task.everyX).to.eql(1); - expect(task.repeat).to.eql({ - m: true, - t: true, - w: true, - th: true, - f: true, - s: true, - su: true, - }); + }); + + expect(task.frequency).to.eql('weekly'); + expect(task.everyX).to.eql(1); + expect(task.repeat).to.eql({ + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + su: true, }); }); - it('allows repeat field to be configured', () => { - return user.post('/tasks', { + it('allows repeat field to be configured', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', repeat: { @@ -298,66 +298,66 @@ describe('POST /tasks', () => { w: false, su: false, }, - }).then((task) => { - expect(task.repeat).to.eql({ - m: false, - t: true, - w: false, - th: true, - f: true, - s: true, - su: false, - }); + }); + + expect(task.repeat).to.eql({ + m: false, + t: true, + w: false, + th: true, + f: true, + s: true, + su: false, }); }); - it('defaults startDate to today', () => { + it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - return user.post('/tasks', { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - expect((new Date(task.startDate)).getDay()).to.eql(today); }); + + expect((new Date(task.startDate)).getDay()).to.eql(today); }); - it('can create checklists', () => { - return user.post('/tasks', { + it('can create checklists', async () => { + let task = await user.post('/tasks', { text: 'test daily', type: 'daily', checklist: [ {completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task.checklist).to.be.an('array'); - expect(task.checklist.length).to.eql(1); - expect(task.checklist[0]).to.be.an('object'); - expect(task.checklist[0].text).to.eql('checklist'); - expect(task.checklist[0].completed).to.eql(false); - expect(task.checklist[0]._id).to.be.a('string'); }); + + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); }); }); context('rewards', () => { - it('creates a reward', () => { - return user.post('/tasks', { + it('creates a reward', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, value: 10, - }).then((task) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test reward'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('reward'); - expect(task.value).to.eql(10); }); + + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(10); }); - it('creates multiple rewards', () => { - return user.post('/tasks', [{ + it('creates multiple rewards', async () => { + let [task, task2] = await user.post('/tasks', [{ text: 'test reward', type: 'reward', notes: 1976, @@ -367,50 +367,50 @@ describe('POST /tasks', () => { type: 'reward', notes: 1977, value: 12, - }]).then(([task, task2]) => { - expect(task.userId).to.equal(user._id); - expect(task.text).to.eql('test reward'); - expect(task.notes).to.eql('1976'); - expect(task.type).to.eql('reward'); - expect(task.value).to.eql(11); + }]); - expect(task2.userId).to.equal(user._id); - expect(task2.text).to.eql('test reward 2'); - expect(task2.notes).to.eql('1977'); - expect(task2.type).to.eql('reward'); - expect(task2.value).to.eql(12); - }); + expect(task.userId).to.equal(user._id); + expect(task.text).to.eql('test reward'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('reward'); + expect(task.value).to.eql(11); + + expect(task2.userId).to.equal(user._id); + expect(task2.text).to.eql('test reward 2'); + expect(task2.notes).to.eql('1977'); + expect(task2.type).to.eql('reward'); + expect(task2.value).to.eql(12); }); - it('defaults to a 0 value', () => { - return user.post('/tasks', { + it('defaults to a 0 value', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', - }).then((task) => { - expect(task.value).to.eql(0); }); + + expect(task.value).to.eql(0); }); - it('requires value to be coerced into a number', () => { - return user.post('/tasks', { + it('requires value to be coerced into a number', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', value: '10', - }).then((task) => { - expect(task.value).to.eql(10); }); + + expect(task.value).to.eql(10); }); - it('cannot create checklists', () => { - return user.post('/tasks', { + it('cannot create checklists', async () => { + let task = await user.post('/tasks', { text: 'test reward', type: 'reward', checklist: [ {_id: 123, completed: false, text: 'checklist'}, ], - }).then((task) => { - expect(task).not.to.have.property('checklist'); }); + + expect(task).not.to.have.property('checklist'); }); }); }); From 743f98d67d2b31087d8fd5b4ceb0ab5cc3143a78 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 17:46:31 +0100 Subject: [PATCH 057/213] make sure user.tasksOrder is update only when necessary --- test/api/v3/integration/tasks/POST-tasks.test.js | 15 +++++++++++++++ website/src/controllers/api-v3/tasks.js | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index c8b9e9a37a..51999bc8a2 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -54,6 +54,21 @@ describe('POST /tasks', () => { }); }); + it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { + let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; + return expect(user.post('/tasks', { + type: 'habit', + })).to.eventually.be.rejected.and.eql({ // this block is necessary + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }).then(async () => { + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); + }); + }); + it('automatically sets "task.userId" to user\'s uuid', async () => { let task = await user.post('/tasks', { text: 'test habit', diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 25b5245a04..84ccf0a221 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -32,16 +32,28 @@ api.createTask = { let user = res.locals.user; let toSave = tasksData.map(taskData => { + // Validate that task.type is valid if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); let taskType = taskData.type; let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); newTask.userId = user._id; + + // Validate that the task is valid and throw if it isn't + // otherwise since we're saving user and task in parallel it could save the user with a tasksOrder that doens't match reality + let validationErrors = newTask.validateSync(); + if (validationErrors) throw validationErrors; + + // Otherwise update the user user.tasksOrder[`${taskType}s`].unshift(newTask._id); - return newTask.save(); + return newTask; }); + // If all tasks are valid, save everything, withough running validation again + toSave = toSave.map(task => task.save({ + validateBeforeSave: false, + })); toSave.unshift(user.save()); let results = await Q.all(toSave); From 27065b3f4dcf94eba758ce1c49c6bff20a12aba3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 18:21:06 +0100 Subject: [PATCH 058/213] more tests for user.tasksOrder --- .../v3/integration/tasks/POST-tasks.test.js | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 51999bc8a2..cf9eeea123 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -69,6 +69,38 @@ describe('POST /tasks', () => { }); }); + it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { + let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; + return expect(user.post('/tasks', [ + {type: 'habit'}, // Missing text + {type: 'habit', text: 'valid'}, // Valid + ])).to.eventually.be.rejected.and.eql({ // this block is necessary + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }).then(async () => { + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); + }); + }); + + it('does not save any task sent in an array when 1 is invalid', async () => { + let originalTasks = await user.get('/tasks'); + return expect(user.post('/tasks', [ + {type: 'habit'}, // Missing text + {type: 'habit', text: 'valid'}, // Valid + ])).to.eventually.be.rejected.and.eql({ // this block is necessary + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }).then(async () => { + let updatedTasks = await user.get('/tasks'); + + expect(updatedTasks).to.eql(originalTasks); + }); + }); + it('automatically sets "task.userId" to user\'s uuid', async () => { let task = await user.post('/tasks', { text: 'test habit', @@ -133,6 +165,34 @@ describe('POST /tasks', () => { expect(task.down).to.eql(true); }); + it('updates user.tasksOrder.habits when a new habit is created', async () => { + let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; + let task = await user.post('/tasks', { + type: 'habit', + text: 'an habit', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.habits[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 1); + }); + + it('updates user.tasksOrder.habits when multiple habits are created', async () => { + let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'habit', + text: 'an habit', + }, { + type: 'habit', + text: 'another habit' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.habits[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.habits[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 2); + }); + it('creates multiple habits', async () => { let [task, task2] = await user.post('/tasks', [{ text: 'test habit', @@ -223,6 +283,34 @@ describe('POST /tasks', () => { expect(task2.type).to.eql('todo'); }); + it('updates user.tasksOrder.todos when a new todo is created', async () => { + let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; + let task = await user.post('/tasks', { + type: 'todo', + text: 'a todo', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 1); + }); + + it('updates user.tasksOrder.todos when multiple todos are created', async () => { + let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'todo', + text: 'a todo', + }, { + type: 'todo', + text: 'another todo' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.todos[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 2); + }); + it('can create checklists', async () => { let task = await user.post('/tasks', { text: 'test todo', @@ -285,6 +373,34 @@ describe('POST /tasks', () => { expect(task2.type).to.eql('daily'); }); + it('updates user.tasksOrder.dailys when a new daily is created', async () => { + let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; + let task = await user.post('/tasks', { + type: 'daily', + text: 'a daily', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.dailys[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 1); + }); + + it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { + let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'daily', + text: 'a daily', + }, { + type: 'daily', + text: 'another daily' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.dailys[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.dailys[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 2); + }); + it('defaults to a weekly frequency, with every day set', async () => { let task = await user.post('/tasks', { text: 'test daily', @@ -397,6 +513,34 @@ describe('POST /tasks', () => { expect(task2.value).to.eql(12); }); + it('updates user.tasksOrder.rewards when a new reward is created', async () => { + let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; + let task = await user.post('/tasks', { + type: 'reward', + text: 'a reward', + }); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.rewards[0]).to.eql(task._id); + expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 1); + }); + + it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { + let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; + let [task, task2] = await user.post('/tasks', [{ + type: 'reward', + text: 'a reward', + }, { + type: 'reward', + text: 'another reward' + }]); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.rewards[0]).to.eql(task2._id); + expect(updatedUser.tasksOrder.rewards[1]).to.eql(task._id); + expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 2); + }); + it('defaults to a 0 value', async () => { let task = await user.post('/tasks', { text: 'test reward', From a775c992ab7f588a9a5b6ddbd9019285fbc657ef Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 2 Jan 2016 18:23:01 +0100 Subject: [PATCH 059/213] fix linting and add pending test --- test/api/v3/integration/tasks/DELETE-tasks_id.test.js | 1 + test/api/v3/integration/tasks/POST-tasks.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 91f02b7844..1d3e59ef15 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -63,5 +63,6 @@ describe('DELETE /tasks/:id', () => { }); it('cannot delete active challenge tasks'); // TODO after challenges are implemented + it('remove a task from user.tasksOrder'); // TODO }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index cf9eeea123..245cea7d28 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -184,7 +184,7 @@ describe('POST /tasks', () => { text: 'an habit', }, { type: 'habit', - text: 'another habit' + text: 'another habit', }]); let updatedUser = await user.get('/user'); @@ -302,7 +302,7 @@ describe('POST /tasks', () => { text: 'a todo', }, { type: 'todo', - text: 'another todo' + text: 'another todo', }]); let updatedUser = await user.get('/user'); @@ -392,7 +392,7 @@ describe('POST /tasks', () => { text: 'a daily', }, { type: 'daily', - text: 'another daily' + text: 'another daily', }]); let updatedUser = await user.get('/user'); @@ -532,7 +532,7 @@ describe('POST /tasks', () => { text: 'a reward', }, { type: 'reward', - text: 'another reward' + text: 'another reward', }]); let updatedUser = await user.get('/user'); From 5bcce0b86bce8d0a3e2e77789c2815d907733159 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Sat, 2 Jan 2016 16:10:37 +0200 Subject: [PATCH 060/213] Add tests for group join route --- .../groups/POST-groups_groupId_join.js | 123 +++++++++++++++--- website/src/controllers/api-v3/groups.js | 2 +- 2 files changed, 104 insertions(+), 21 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js index aa26c10289..2c36d12f19 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -1,8 +1,20 @@ import { generateUser, + translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('POST /group/:groupId/join', () => { + it('returns error when groupId is not for a valid group', async () => { + let joiningUser = await generateUser(); + + await expect(joiningUser.post(`/groups/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + context('Accepting invitation to a guild', () => { let user, invitedUser, guild; @@ -11,24 +23,68 @@ describe('POST /group/:groupId/join', () => { guild = await user.post('/groups', { name: 'Test Guild', type: 'guild', - }); - invitedUser = await generateUser({ - 'invitations.guilds': [{ id: guild._id}], + privacy: 'private', }); }); - it('does not give basilist quest to inviter when joining a guild', async () => { - await invitedUser.post(`/groups/${guild._id}/join`); + it('returns error when user is not invited to private guild', async () => { + let joiningUser = await generateUser(); - await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist'); + await expect(joiningUser.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), + }); }); - it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => { - user.update({ 'items.quests.basilist': 1 }); + it('allows non-invited users to join public guilds', async () => { + await user.update({balance: 1}); + guild = await user.post('/groups', { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }); - await invitedUser.post(`/groups/${guild._id}/join`); + let joiningUser = await generateUser(); + await joiningUser.post(`/groups/${guild._id}/join`); - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id); + }); + + context('User is invited', () => { + beforeEach(async () => { + invitedUser = await generateUser({ + 'invitations.guilds': [{ id: guild._id}], + }); + }); + + it('allows invited user to join private guilds', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(invitedUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id); + }); + + it('clears invitation from user when joining guilds', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(invitedUser.get('/user')) + .to.eventually.have.deep.property('invitations.guilds') + .to.not.include({id: guild._id}); + }); + + it('does not give basilist quest to inviter when joining a guild', async () => { + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist'); + }); + + it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => { + await user.update({ 'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${guild._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); }); }); @@ -41,23 +97,50 @@ describe('POST /group/:groupId/join', () => { name: 'Test Party', type: 'party', }); - invitedUser = await generateUser({ - 'invitations.party': { id: party._id, inviter: user._id }, + }); + + it('returns error when user is not invited to party', async () => { + let joiningUser = await generateUser(); + + await expect(joiningUser.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), }); }); - it('gives basilist quest item to the inviter when joining a party', async () => { - await invitedUser.post(`/groups/${party._id}/join`); + context('User is invited', () => { + beforeEach(async () => { + invitedUser = await generateUser({ + 'invitations.party': { id: party._id, inviter: user._id }, + }); + }); - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); - }); + it('allows invited user to join party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); - it('increments basilist quest item count to inviter when joining a party', async () => { - user.update({'items.quests.basilist': 1 }); + await expect(invitedUser.get('/user')).to.eventually.have.deep.property('party._id', party._id); + }); - await invitedUser.post(`/groups/${party._id}/join`); + it('clears invitation from user when joining party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); - await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); + await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id'); + }); + + it('gives basilist quest item to the inviter when joining a party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1); + }); + + it('increments basilist quest item count to inviter when joining a party', async () => { + await user.update({'items.quests.basilist': 1 }); + + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); + }); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index a61c56e26c..80b2972217 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -228,7 +228,7 @@ api.joinGroup = { user.party._id = group._id; // Set group as user's party isUserInvited = true; - } else if (group.type === 'guild' && user.invitations.guilds) { + } else if (group.type === 'guild') { let i = _.findIndex(user.invitations.guilds, {id: group._id}); if (i !== -1) { From d25cb70f66f8bc6874c7e9b37afba158930619d1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 19:44:39 +0100 Subject: [PATCH 061/213] wip get and create tasks plus initial user syncing --- common/locales/en/api-v3.json | 4 +- .../v3/integration/tasks/GET-tasks.test.js | 39 ++++------ website/src/controllers/api-v3/tasks.js | 76 ++++++++++++++----- website/src/models/challenge.js | 73 ++++++++++++------ 4 files changed, 124 insertions(+), 68 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2ace1f7773..a7c62e3744 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -46,5 +46,7 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", - "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." + "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." } diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 0772046f0b..3f1c50448d 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -1,42 +1,31 @@ import { generateUser, } from '../../../../helpers/api-integration.helper'; -import Q from 'q'; describe('GET /tasks', () => { let user; - before(() => { + beforeEach(async () => { + user = await generateUser(); + }); + + before(async () => { return generateUser().then((generatedUser) => { user = generatedUser; }); }); - it('returns all user\'s tasks', () => { - let length; - return Q.all([ - user.post('/tasks', {text: 'test habit', type: 'habit'}), - ]) - .then((createdTasks) => { - length = createdTasks.length; - return user.get('/tasks'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task - }); + it('returns all user\'s tasks', async () => { + let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user'); + expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task }); - it('returns only a type of user\'s tasks if req.query.type is specified', () => { - let habitId; - user.post('/tasks', {text: 'test habit', type: 'habit'}) - .then((task) => { - habitId = task._id; - return user.get('/tasks?type=habit'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(1); - expect(tasks[0]._id).to.equal(habitId); - }); + it('returns only a type of user\'s tasks if req.query.type is specified', async () => { + let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user?type=habit'); + expect(tasks.length).to.equal(1); + expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); // TODO complete after task scoring is done diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 84ccf0a221..676be05bf5 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -2,6 +2,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; +import { model as Challenge } from '../../models/challenge'; import { NotFound, NotAuthorized, @@ -21,15 +22,30 @@ let api = {}; * @apiName CreateTask * @apiGroup Task * + * @apiParam {string="user","challenge"} tasksOwner Define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). + * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. + * * @apiSuccess {Object|Array} task The newly created task(s) */ api.createTask = { method: 'POST', - url: '/tasks', + url: '/tasks/:tasksOwner/:challengeId?', middlewares: [authWithHeaders(), cron], async handler (req, res) { let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; + let tasksOwner = req.params.tasksOwner; + let challengeId = req.params.challengeId; + let challenge; + + if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); + if (tasksOwner === 'challenge') { + if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); + challenge = await Challenge.findOne({_id: challengeId}).exec(); + + // If the challenge does not exist, or if it exists but user is not the leader -> throw error + if (!challenge || challenge.leader !== user._id) throw new NotFound(res.t('challengeNotFound')); + } let toSave = tasksData.map(taskData => { // Validate that task.type is valid @@ -37,15 +53,20 @@ api.createTask = { let taskType = taskData.type; let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); - newTask.userId = user._id; + + if (challenge) { + newTask.challenge.id = challengeId; + } else { + newTask.userId = user._id; + } // Validate that the task is valid and throw if it isn't - // otherwise since we're saving user and task in parallel it could save the user with a tasksOrder that doens't match reality + // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality let validationErrors = newTask.validateSync(); if (validationErrors) throw validationErrors; - // Otherwise update the user - user.tasksOrder[`${taskType}s`].unshift(newTask._id); + // Otherwise update the user/challenge + (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); return newTask; }); @@ -54,41 +75,60 @@ api.createTask = { toSave = toSave.map(task => task.save({ validateBeforeSave: false, })); - toSave.unshift(user.save()); - let results = await Q.all(toSave); + toSave.unshift((challenge || user).save()); - if (results.length === 2) { // Just one task created - res.respond(201, results[1]); - } else { - results.splice(0, 1); // remove the user - res.respond(201, results); - } + let tasks = await Q.all(toSave); + tasks.splice(0, 1); // remove the user/challenge + res.respond(201, tasks); + + // If adding tasks to a challenge -> sync users + if (challenge) challenge.addTasksToMembers(tasks); // TODO catch/log }, }; /** - * @api {get} /tasks Get an user's tasks + * @api {get} /tasks/:tasksOwner/:challengeId Get an user's tasks * @apiVersion 3.0.0 * @apiName GetTasks * @apiGroup Task * + * @apiParam {string="user","challenge"} tasksOwner Url parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. + * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo" + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". * * @apiSuccess {Array} tasks An array of task objects */ api.getTasks = { method: 'GET', - url: '/tasks', + url: '/tasks/:tasksOwner/:challengeId?', middlewares: [authWithHeaders(), cron], async handler (req, res) { + req.checkParams('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); + req.checkParams('challengeId', res.t('challengeIdRequired')).optional().isUUID(); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; let user = res.locals.user; - let query = {userId: user._id}; + let tasksOwner = req.params.tasksOwner; + let challengeId = req.params.challengeId; + let challenge; + + if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); + if (tasksOwner === 'challenge') { + if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); + challenge = await Challenge.findOne({_id: challengeId}).exec(); + + // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('challengeNotFound')); + } + } + + let query = challenge ? {'challenge.id': challengeId, userId: {$exists: false}} : {userId: user._id}; let type = req.query.type; if (type) { @@ -102,6 +142,8 @@ api.getTasks = { } if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { + if (challengeId) throw new BadRequest(res.t('noCompletedTodosChallenge')); + let queryCompleted = Tasks.Task.find({ type: 'todo', completed: true, diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d5b3bf1a1b..d3bf4550a3 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -4,6 +4,7 @@ import validator from 'validator'; import baseModel from '../libs/api-v3/baseModel'; import _ from 'lodash'; import * as Tasks from './task'; +import { model as User } from './user'; let Schema = mongoose.Schema; @@ -29,37 +30,15 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'tasksOrder'], }); - -// Syncing logic - function _syncableAttrs (task) { - let t = task.toObject(); // lodash doesn't seem to like _.omit on EmbeddedDocument + let t = task.toObject(); // lodash doesn't seem to like _.omit on Document // only sync/compare important attrs let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO use whitelist instead of blacklist? if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } -// TODO redo -// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers -/* function comparableData(obj) { - return JSON.stringify( - _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) - .sortBy('id') // we don't want to update if they're sort-order is different - .transform(function(result, task){ - result.push(syncableAttrs(task)); - }) - .value()) -} - -ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { - return comparableData(this) !== comparableData(newData); -}*/ - -// Syncs all new tasks, deleted tasks, etc to the user object schema.methods.syncToUser = function syncChallengeToUser (user) { - if (!user) throw new Error('User required.'); - let challenge = this; challenge.shortName = challenge.shortName || challenge.name; @@ -83,8 +62,12 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { }); } + return user.save(); + + // Old logic used to sync tasks + // TODO might keep it around for when normal syncing doesn't succeed? or for first time syncing? // Sync new tasks and updated tasks - return Q.all([ + /* return Q.all([ // Find original challenge tasks Tasks.Task.find({ userId: {$exists: false}, @@ -131,7 +114,47 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { toSave.push(user.save()); return Q.all(toSave); - }); + });*/ }; +schema.methods.addTasksToMembers = async function addTasksToMembers (tasks) { + let challenge = this; + + let membersIds = (await User.find({challenges: {$in: [challenge._id]}}).select('_id').exec()).map(member => member._id); + + // Add tasks to users sequentially so that we don't kill the server (hopefully); + // using a for...of loop allows each op to be run in sequence + for (let memberId of membersIds) { + let update = User.update + await db.post(doc); + } + + tasks.forEach(chalTask => { + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; + matchingTask.userId = user._id; + + }) + +}; + +// Old Syncing logic, kept for reference and maybe will be needed to adapt v2 +/* + +// TODO redo +// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers +function comparableData(obj) { + return JSON.stringify( + _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) + .sortBy('id') // we don't want to update if they're sort-order is different + .transform(function(result, task){ + result.push(syncableAttrs(task)); + }) + .value()) +} + +ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { + return comparableData(this) !== comparableData(newData); +}*/ + export let model = mongoose.model('Challenge', schema); From b73f5a8f402d8dd70e5afe18b7d2b6b893b10336 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 19:57:20 +0100 Subject: [PATCH 062/213] add support for getting single challenges tasks --- website/src/controllers/api-v3/tasks.js | 14 ++++++++++++-- website/src/models/challenge.js | 5 ++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 676be05bf5..45339dc49a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -120,7 +120,7 @@ api.getTasks = { if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); if (tasksOwner === 'challenge') { if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); - challenge = await Challenge.findOne({_id: challengeId}).exec(); + challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens @@ -188,10 +188,20 @@ api.getTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); if (!task) throw new NotFound(res.t('taskNotFound')); + + // If the task belongs to a challenge make sure the user has rights + if (!task.userId) { + let challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin) { + throw new NotFound(res.t('taskNotFound')); + } + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } + res.respond(200, task); }, }; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d3bf4550a3..6e7e179047 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -125,7 +125,10 @@ schema.methods.addTasksToMembers = async function addTasksToMembers (tasks) { // Add tasks to users sequentially so that we don't kill the server (hopefully); // using a for...of loop allows each op to be run in sequence for (let memberId of membersIds) { - let update = User.update + let updateQ = {$push: {}}; + tasks.forEach(chalTask => { + + }) await db.post(doc); } From 6680853078aa8a4811d7a430513c894e6e66f0d8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 20:48:48 +0100 Subject: [PATCH 063/213] finish adding challenges support for tasks (except syncing) --- common/locales/en/api-v3.json | 3 +- website/src/controllers/api-v3/tasks.js | 94 +++++++++++++++++++------ 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a7c62e3744..77930e4583 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -48,5 +48,6 @@ "onlyLeaderDeleteChal": "Only the challenge leader can delete 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." + "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", + "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader." } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 45339dc49a..40425c27a3 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -44,7 +44,8 @@ api.createTask = { challenge = await Challenge.findOne({_id: challengeId}).exec(); // If the challenge does not exist, or if it exists but user is not the leader -> throw error - if (!challenge || challenge.leader !== user._id) throw new NotFound(res.t('challengeNotFound')); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); } let toSave = tasksData.map(taskData => { @@ -82,7 +83,7 @@ api.createTask = { res.respond(201, tasks); // If adding tasks to a challenge -> sync users - if (challenge) challenge.addTasksToMembers(tasks); // TODO catch/log + if (challenge) challenge.addTasks(tasks); // TODO catch/log }, }; @@ -190,12 +191,11 @@ api.getTask = { _id: req.params.taskId, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); - - // If the task belongs to a challenge make sure the user has rights - if (!task.userId) { + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights let challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); - if (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin) { + if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens throw new NotFound(res.t('taskNotFound')); } } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one @@ -222,6 +222,7 @@ api.updateTask = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty @@ -232,10 +233,17 @@ api.updateTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } // If checklist is updated -> replace the original one if (req.body.checklist) { @@ -258,6 +266,7 @@ api.updateTask = { let savedTask = await task.save(); res.respond(200, savedTask); + if (challenge) challenge.updateTask(savedTask); // TODO catch/log }, }; @@ -382,6 +391,7 @@ api.scoreTask = { // completed todos cannot be moved, they'll be returned ordered by date of completion // TODO check that it works when a tag is selected or todos are split between dated and due +// TODO support challenges? /** * @api {post} /tasks/move/:taskId/to/:position Move a task to a new position * @apiVersion 3.0.0 @@ -449,6 +459,7 @@ api.addChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty and is an array @@ -458,16 +469,25 @@ api.addChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } + if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); let savedTask = await task.save(); res.respond(200, savedTask); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -530,6 +550,7 @@ api.updateChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -539,10 +560,17 @@ api.updateChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let item = _.find(task.checklist, {_id: req.params.itemId}); @@ -552,6 +580,7 @@ api.updateChecklistItem = { let savedTask = await task.save(); res.respond(200, savedTask); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -572,6 +601,7 @@ api.removeChecklistItem = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); @@ -581,10 +611,17 @@ api.removeChecklistItem = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); let itemI = _.findIndex(task.checklist, {_id: req.params.itemId}); @@ -592,8 +629,9 @@ api.removeChecklistItem = { task.checklist.splice(itemI, 1); - await task.save(); + let savedTask = await task.save(); res.respond(200, {}); // TODO what to return + if (challenge) challenge.updateTask(savedTask); }, }; @@ -681,11 +719,11 @@ api.removeTagFromTask = { }, }; -// Remove a task from user.tasksOrder -function _removeTaskTasksOrder (user, taskId) { +// Remove a task from (user|challenge).tasksOrder +function _removeTaskTasksOrder (userOrChallenge, taskId) { // Loop through all lists and when the task is found, remove it and return for (let i = 0; i < Tasks.tasksTypes.length; i++) { - let list = user.tasksOrder[`${Tasks.tasksTypes[i]}s`]; + let list = userOrChallenge.tasksOrder[`${Tasks.tasksTypes[i]}s`]; let index = list.indexOf(taskId); if (index !== -1) { @@ -713,6 +751,7 @@ api.deleteTask = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -721,16 +760,25 @@ api.deleteTask = { let task = await Tasks.Task.findOne({ _id: req.params.taskId, - userId: user._id, }).exec(); - if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + if (!task) { + throw new NotFound(res.t('taskNotFound')); + } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights + challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); + } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one + throw new NotFound(res.t('taskNotFound')); + } else if (task.userId && task.challenge.id) { + throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); + } - _removeTaskTasksOrder(user, req.params.taskId); + _removeTaskTasksOrder(challenge || user, req.params.taskId); await Q.all([user.save(), task.remove()]); res.respond(200, {}); + if (challenge) challenge.removeTask(task); }, }; From 3bc8945bcc652a83bc0308dc40c0435ee85f63f9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 4 Jan 2016 22:03:21 +0100 Subject: [PATCH 064/213] adapt current tests and change urls to avoid conflicts --- common/locales/en/api-v3.json | 3 +- .../integration/tasks/DELETE-tasks_id.test.js | 4 +- .../v3/integration/tasks/GET-tasks.test.js | 8 +- .../v3/integration/tasks/GET-tasks_id.test.js | 4 +- .../v3/integration/tasks/POST-tasks.test.js | 76 +++++++++---------- .../POST-tasks_id_score_direction.test.js | 14 ++-- .../v3/integration/tasks/PUT-tasks_id.test.js | 10 +-- ...LETE-tasks_taskId_checklist_itemId.test.js | 8 +- .../POST-tasks_taskId_checklist.test.js | 6 +- ...asks_taskId_checklist_itemId_score.test.js | 8 +- .../PUT-tasks_taskId_checklist_itemId.test.js | 8 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 6 +- website/src/controllers/api-v3/tasks.js | 40 ++++++---- 14 files changed, 105 insertions(+), 94 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 77930e4583..a97308e064 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -49,5 +49,6 @@ "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.", - "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader." + "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", + "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\"." } diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 1d3e59ef15..503e484e66 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -48,7 +48,7 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', () => { return generateUser() .then((anotherUser) => { - return anotherUser.post('/tasks', { + return anotherUser.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 3f1c50448d..bf77442ea4 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -16,14 +16,14 @@ describe('GET /tasks', () => { }); it('returns all user\'s tasks', async () => { - let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user'); + let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks?tasksOwner=user'); expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task }); it('returns only a type of user\'s tasks if req.query.type is specified', async () => { - let createdTasks = await user.post('/tasks', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user?type=habit'); + let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks?tasksOwner=user&type=habit'); expect(tasks.length).to.equal(1); expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index a4ae455d6a..b88d8a95a6 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -17,7 +17,7 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -54,7 +54,7 @@ describe('GET /tasks/:id', () => { .then((user2) => { anotherUser = user2; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 245cea7d28..a002119634 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -14,7 +14,7 @@ describe('POST /tasks', () => { context('validates params', () => { it('returns an error if req.body.type is absent', async () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -24,7 +24,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +34,7 @@ describe('POST /tasks', () => { }); it('returns an error if one object inside an array is invalid', async () => { - return expect(user.post('/tasks', [ + return expect(user.post('/tasks?tasksOwner=user', [ {type: 'habitF'}, {type: 'habit'}, ])).to.eventually.be.rejected.and.eql({ @@ -45,7 +45,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -56,7 +56,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, @@ -71,7 +71,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', [ + return expect(user.post('/tasks?tasksOwner=user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -86,8 +86,8 @@ describe('POST /tasks', () => { }); it('does not save any task sent in an array when 1 is invalid', async () => { - let originalTasks = await user.get('/tasks'); - return expect(user.post('/tasks', [ + let originalTasks = await user.get('/tasks?tasksOwner=user'); + return expect(user.post('/tasks?tasksOwner=user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -95,14 +95,14 @@ describe('POST /tasks', () => { error: 'BadRequest', message: 'habit validation failed', }).then(async () => { - let updatedTasks = await user.get('/tasks'); + let updatedTasks = await user.get('/tasks?tasksOwner=user'); expect(updatedTasks).to.eql(originalTasks); }); }); it('automatically sets "task.userId" to user\'s uuid', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }); @@ -113,7 +113,7 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', userId: 123, @@ -137,7 +137,7 @@ describe('POST /tasks', () => { }); it('ignores invalid fields', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', notValid: true, @@ -149,7 +149,7 @@ describe('POST /tasks', () => { context('habits', () => { it('creates a habit', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', up: false, @@ -167,7 +167,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when a new habit is created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'an habit', }); @@ -179,7 +179,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when multiple habits are created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'habit', text: 'an habit', }, { @@ -194,7 +194,7 @@ describe('POST /tasks', () => { }); it('creates multiple habits', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test habit', type: 'habit', up: false, @@ -224,7 +224,7 @@ describe('POST /tasks', () => { }); it('defaults to setting up and down to true', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', notes: 1976, @@ -235,7 +235,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', checklist: [ @@ -249,7 +249,7 @@ describe('POST /tasks', () => { context('todos', () => { it('creates a todo', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', notes: 1976, @@ -262,7 +262,7 @@ describe('POST /tasks', () => { }); it('creates multiple todos', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test todo', type: 'todo', notes: 1976, @@ -285,7 +285,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when a new todo is created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'todo', text: 'a todo', }); @@ -297,7 +297,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when multiple todos are created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'todo', text: 'a todo', }, { @@ -312,7 +312,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', checklist: [ @@ -333,7 +333,7 @@ describe('POST /tasks', () => { it('creates a daily', async () => { let now = new Date(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', notes: 1976, @@ -352,7 +352,7 @@ describe('POST /tasks', () => { }); it('creates multiple dailys', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test daily', type: 'daily', notes: 1976, @@ -375,7 +375,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when a new daily is created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'a daily', }); @@ -387,7 +387,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'daily', text: 'a daily', }, { @@ -402,7 +402,7 @@ describe('POST /tasks', () => { }); it('defaults to a weekly frequency, with every day set', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', }); @@ -421,7 +421,7 @@ describe('POST /tasks', () => { }); it('allows repeat field to be configured', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', repeat: { @@ -445,7 +445,7 @@ describe('POST /tasks', () => { it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', }); @@ -454,7 +454,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', checklist: [ @@ -473,7 +473,7 @@ describe('POST /tasks', () => { context('rewards', () => { it('creates a reward', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', notes: 1976, @@ -488,7 +488,7 @@ describe('POST /tasks', () => { }); it('creates multiple rewards', async () => { - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ text: 'test reward', type: 'reward', notes: 1976, @@ -515,7 +515,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.rewards when a new reward is created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'a reward', }); @@ -527,7 +527,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let [task, task2] = await user.post('/tasks', [{ + let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ type: 'reward', text: 'a reward', }, { @@ -542,7 +542,7 @@ describe('POST /tasks', () => { }); it('defaults to a 0 value', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', }); @@ -551,7 +551,7 @@ describe('POST /tasks', () => { }); it('requires value to be coerced into a number', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', value: '10', @@ -561,7 +561,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', checklist: [ diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2978d93657..1c552613b4 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -37,7 +37,7 @@ describe('POST /tasks/:id/score/:direction', () => { let todo; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', }).then((task) => { @@ -149,7 +149,7 @@ describe('POST /tasks/:id/score/:direction', () => { let daily; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', }).then((task) => { @@ -226,26 +226,26 @@ describe('POST /tasks/:id/score/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((task) => { habit = task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test min habit', type: 'habit', up: false, }); }).then((task) => { minusHabit = task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test plus habit', type: 'habit', down: false, }); }).then((task) => { plusHabit = task; - user.post('/tasks', { + user.post('/tasks?tasksOwner=user', { text: 'test neither habit', type: 'habit', up: false, @@ -297,7 +297,7 @@ describe('POST /tasks/:id/score/:direction', () => { let reward; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', value: 5, diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 3092d3bd84..408ef92e99 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -65,7 +65,7 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test habit', type: 'habit', notes: 1976, @@ -93,7 +93,7 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test todo', type: 'todo', notes: 1976, @@ -150,7 +150,7 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test daily', type: 'daily', notes: 1976, @@ -254,7 +254,7 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(() => { - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { text: 'test reward', type: 'reward', notes: 1976, diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index d013878a74..738255996a 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('deletes a checklist item', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('does not work with habits', () => { let habit; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -47,7 +47,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('does not work with rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }); @@ -68,7 +68,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e9b695effd..1ad4f7f58c 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('adds a checklist item to a task', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -36,7 +36,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to habits', () => { let habit; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -51,7 +51,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to rewards', () => { let reward; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 667ef41446..862b67b638 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('scores a checklist item', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -32,7 +32,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('fails on habits', () => { let habit; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -46,7 +46,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 988574bbf8..ae6babf778 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { it('updates a checklist item', () => { let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'habit with checklist', }); @@ -46,7 +46,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks?tasksOwner=user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index c01b71fa2c..27d57bb2f0 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 6e4c2ba510..9887a5bedb 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks', { + return user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -54,7 +54,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a non existing tag to a task', () => { - return expect(user.post('/tasks', { + return expect(user.post('/tasks?tasksOwner=user', { type: 'habit', text: 'Task with tag', }).then((task) => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 40425c27a3..6ea130e843 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -22,20 +22,26 @@ let api = {}; * @apiName CreateTask * @apiGroup Task * - * @apiParam {string="user","challenge"} tasksOwner Define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). - * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. + * @apiParam {string="user","challenge"} tasksOwner Query parameter to define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). + * @apiParam {UUID} challengeId Optional. Query parameter. If "tasksOwner" is "challenge" then specify the challenge id. * * @apiSuccess {Object|Array} task The newly created task(s) */ api.createTask = { method: 'POST', - url: '/tasks/:tasksOwner/:challengeId?', + url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { + req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); + req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; - let tasksOwner = req.params.tasksOwner; - let challengeId = req.params.challengeId; + let tasksOwner = req.query.tasksOwner; + let challengeId = req.query.challengeId; let challenge; if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); @@ -79,8 +85,13 @@ api.createTask = { toSave.unshift((challenge || user).save()); let tasks = await Q.all(toSave); - tasks.splice(0, 1); // remove the user/challenge - res.respond(201, tasks); + + if (tasks.length === 2) { + res.respond(201, tasks[1]); + } else { + tasks.splice(0, 1); // remove the user/challenge + res.respond(201, tasks); + } // If adding tasks to a challenge -> sync users if (challenge) challenge.addTasks(tasks); // TODO catch/log @@ -93,8 +104,8 @@ api.createTask = { * @apiName GetTasks * @apiGroup Task * - * @apiParam {string="user","challenge"} tasksOwner Url parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. - * @apiParam {UUID} challengeId Optional. If "tasksOwner" is "challenge" then specify the challenge id. + * @apiParam {string="user","challenge"} tasksOwner Query parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. + * @apiParam {UUID} challengeId Optional query parameter. If "tasksOwner" is "challenge" then required to specify the challenge id. * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". * @@ -102,20 +113,19 @@ api.createTask = { */ api.getTasks = { method: 'GET', - url: '/tasks/:tasksOwner/:challengeId?', + url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkParams('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); - req.checkParams('challengeId', res.t('challengeIdRequired')).optional().isUUID(); - + req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); + req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; let user = res.locals.user; - let tasksOwner = req.params.tasksOwner; - let challengeId = req.params.challengeId; + let tasksOwner = req.query.tasksOwner; + let challengeId = req.query.challengeId; let challenge; if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); From a5b1dfd32d7b14371082541bc9544bb91b048f7f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 5 Jan 2016 20:19:55 +0100 Subject: [PATCH 065/213] finish implementing tasks syncing for challenges --- website/src/controllers/api-v3/groups.js | 11 +- website/src/controllers/api-v3/tasks.js | 4 +- website/src/models/challenge.js | 194 +++++++++++++---------- website/src/models/task.js | 2 +- 4 files changed, 123 insertions(+), 88 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 80b2972217..169512b0da 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -112,12 +112,13 @@ api.getGroups = { // If no valid value for type was supplied, return an error if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - let results = await Q.all(queries); // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 + let results = _.reduce(await Q.all(queries), (previousValue, currentValue) => { + if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array + return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue + }, []); - res.respond(200, _.reduce(results, (m, v) => { - if (_.isEmpty(v)) return m; - return m.concat(Array.isArray(v) ? v : [v]); - }, [])); + res.respond(200, results); }, }; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 6ea130e843..0f5ecc314c 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -35,8 +35,8 @@ api.createTask = { req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; let tasksData = Array.isArray(req.body) ? req.body : [req.body]; let user = res.locals.user; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 6e7e179047..bf009314a9 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -30,15 +30,18 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'tasksOrder'], }); +// 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 // only sync/compare important attrs - let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO use whitelist instead of blacklist? + let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } -schema.methods.syncToUser = function syncChallengeToUser (user) { +// 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) { let challenge = this; challenge.shortName = challenge.shortName || challenge.name; @@ -62,12 +65,7 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { }); } - return user.save(); - - // Old logic used to sync tasks - // TODO might keep it around for when normal syncing doesn't succeed? or for first time syncing? - // Sync new tasks and updated tasks - /* return Q.all([ + let [challengeTasks, userTasks] = await Q.all([ // Find original challenge tasks Tasks.Task.find({ userId: {$exists: false}, @@ -78,86 +76,122 @@ schema.methods.syncToUser = function syncChallengeToUser (user) { userId: user._id, 'challenge.id': challenge._id, }).exec(), - ]) - .then(results => { - let challengeTasks = results[0]; - let userTasks = results[1]; - let toSave = []; // An array of things to save + ]); - challengeTasks.forEach(chalTask => { - let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); + let toSave = []; // An array of things to save - if (!matchingTask) { // If the task is new, create it - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); - matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; - matchingTask.userId = user._id; - user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); - } else { - _.merge(matchingTask, _syncableAttrs(chalTask)); - // Make sure the task is in user.tasksOrder TODO necessary? - let orderList = user.tasksOrder[`${chalTask.type}s`]; - if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); - } + challengeTasks.forEach(chalTask => { + let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); - if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided - if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing - toSave.push(matchingTask.save()); - }); + if (!matchingTask) { // If the task is new, create it + matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; + matchingTask.userId = user._id; + user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id); + } else { + _.merge(matchingTask, _syncableAttrs(chalTask)); + // Make sure the task is in user.tasksOrder TODO necessary? + let orderList = user.tasksOrder[`${chalTask.type}s`]; + if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id); + } - // Flag deleted tasks as "broken" - userTasks.forEach(userTask => { - if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { - userTask.challenge.broken = 'TASK_DELETED'; - toSave.push(userTask.save()); - } - }); + if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided + if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing + toSave.push(matchingTask.save()); + }); - toSave.push(user.save()); - return Q.all(toSave); - });*/ + // Flag deleted tasks as "broken" + userTasks.forEach(userTask => { + if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { + userTask.challenge.broken = 'TASK_DELETED'; + toSave.push(userTask.save()); + } + }); + + toSave.push(user.save()); + return Q.all(toSave); }; -schema.methods.addTasksToMembers = async function addTasksToMembers (tasks) { - let challenge = this; - - let membersIds = (await User.find({challenges: {$in: [challenge._id]}}).select('_id').exec()).map(member => member._id); - - // Add tasks to users sequentially so that we don't kill the server (hopefully); - // using a for...of loop allows each op to be run in sequence - for (let memberId of membersIds) { - let updateQ = {$push: {}}; - tasks.forEach(chalTask => { - - }) - await db.post(doc); - } - - tasks.forEach(chalTask => { - matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); - matchingTask.challenge = {taskId: chalTask._id, id: challenge._id}; - matchingTask.userId = user._id; - - }) - -}; - -// Old Syncing logic, kept for reference and maybe will be needed to adapt v2 -/* - -// TODO redo -// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers -function comparableData(obj) { - return JSON.stringify( - _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) - .sortBy('id') // we don't want to update if they're sort-order is different - .transform(function(result, task){ - result.push(syncableAttrs(task)); - }) - .value()) +async function _fetchMembersIds (challengeId) { + return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id); } -ChallengeSchema.methods.isOutdated = function isChallengeOutdated (newData) { - return comparableData(this) !== comparableData(newData); -}*/ +// Add a new task to challenge members +schema.methods.addTasks = async function challengeAddTasks (tasks) { + let challenge = this; + let membersIds = await _fetchMembersIds(challenge._id); + + // Sync each user sequentially + for (let memberId of membersIds) { + let updateTasksOrderQ = {$push: {}}; + let toSave = []; + + // TODO eslint complaints about ahving a function inside a loop -> make sure it works + tasks.forEach(chalTask => { // eslint-disable-line no-loop-func + let userTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask))); + userTask.challenge = {taskId: chalTask._id, id: challenge._id}; + userTask.userId = memberId; + + let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`]; + if (!tasksOrderList) { + updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = { + $position: 0, // unshift + $each: [userTask._id], + }; + } else { + tasksOrderList.$each.unshift(userTask._id); + } + + toSave.push(userTask); + }); + + // Update the user + toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec()); + await Q.all(toSave); // eslint-disable-line babel/no-await-in-loop + } +}; + +// Sync updated task to challenge members +schema.methods.updateTask = async function challengeUpdateTask (task) { + let challenge = this; + + let updateCmd = {$set: {}}; + + _syncableAttrs(task).forEach((value, key) => { + updateCmd.$set[key] = value; + }); + + // TODO reveiw + // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks + await Tasks.Task.update({ + userId: {$exists: true}, + 'challenge.id': challenge.id, + 'challenge.taskId': task._id, + }, updateCmd, {multi: true}).exec(); +}; + +// Remove a task from challenge members +schema.methods.removeTask = async function challengeRemoveTask (task) { + let challenge = this; + + // Remove the tasks from users' and map each of them to an update query to remove the task from tasksOrder + let updateQueries = (await Tasks.Task.findOneAndRemove({ + userId: {$exists: true}, + 'challenge.id': challenge.id, + 'challenge.taskId': task._id, + }, { + fields: {userId: 1, type: 1}, // fetch only what's necessary + }).lean().exec()) + .map(removedTask => { + return User.update({_id: removedTask.userId}, { + $pull: {[`tasksOrder${removedTask.type}s`]: removedTask._id}, + }); + }); + + // Execute each update sequentially + for (let query of updateQueries) { + await query.exec(); // eslint-disable-line babel/no-await-in-loop + } +}; export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/task.js b/website/src/models/task.js index b31676da10..3fd574af93 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -28,7 +28,7 @@ export let TaskSchema = new Schema({ challenge: { id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task - taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task + taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task TODO unique index? broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? }, From 3a6d7bd466f2f5d8b6cfeae4881d5007aef27fc7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 12:16:41 +0100 Subject: [PATCH 066/213] fix sync of remove challenge task --- website/src/models/challenge.js | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index bf009314a9..430a7e0443 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -174,24 +174,14 @@ schema.methods.updateTask = async function challengeUpdateTask (task) { schema.methods.removeTask = async function challengeRemoveTask (task) { let challenge = this; - // Remove the tasks from users' and map each of them to an update query to remove the task from tasksOrder - let updateQueries = (await Tasks.Task.findOneAndRemove({ + // Set the task as broken + await Tasks.Task.update({ userId: {$exists: true}, 'challenge.id': challenge.id, 'challenge.taskId': task._id, }, { - fields: {userId: 1, type: 1}, // fetch only what's necessary - }).lean().exec()) - .map(removedTask => { - return User.update({_id: removedTask.userId}, { - $pull: {[`tasksOrder${removedTask.type}s`]: removedTask._id}, - }); - }); - - // Execute each update sequentially - for (let query of updateQueries) { - await query.exec(); // eslint-disable-line babel/no-await-in-loop - } + $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt? + }).lean().exec(); }; export let model = mongoose.model('Challenge', schema); From 2b2dcfe7ce66fafb53410e45ba13a6b343223ff6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 12:25:53 +0100 Subject: [PATCH 067/213] do not delete completed todos that belongs to a challenge --- website/src/middlewares/api-v3/cron.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 0d6d46b900..f606a72aea 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -34,6 +34,7 @@ export default function cronMiddleware (req, res, next) { cron({user, tasksByType, now, daysMissed, analytics}); // Clean 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, type: 'todo', @@ -41,6 +42,7 @@ export default function cronMiddleware (req, res, next) { dateCompleted: { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), }, + 'challenge.id': {$exists: false}, }).exec(); // TODO catch error or at least log it let ranCron = user.isModified(); From 8ba486ec12ee83350c759fdf2c048760189e613a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 18:40:11 +0100 Subject: [PATCH 068/213] change tasks routes to /tasks/(user|challenge) --- .../integration/tasks/DELETE-tasks_id.test.js | 4 +- .../v3/integration/tasks/GET-tasks.test.js | 10 +- .../v3/integration/tasks/GET-tasks_id.test.js | 4 +- .../v3/integration/tasks/POST-tasks.test.js | 76 +++--- .../POST-tasks_id_score_direction.test.js | 14 +- .../v3/integration/tasks/PUT-tasks_id.test.js | 10 +- ...LETE-tasks_taskId_checklist_itemId.test.js | 8 +- .../POST-tasks_taskId_checklist.test.js | 6 +- ...asks_taskId_checklist_itemId_score.test.js | 8 +- .../PUT-tasks_taskId_checklist_itemId.test.js | 8 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 4 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 6 +- website/src/controllers/api-v3/tasks.js | 248 ++++++++++++------ 13 files changed, 248 insertions(+), 158 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 503e484e66..053176a9ee 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -48,7 +48,7 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', () => { return generateUser() .then((anotherUser) => { - return anotherUser.post('/tasks?tasksOwner=user', { + return anotherUser.post('/tasks/user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index bf77442ea4..bf652777e4 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../../helpers/api-integration.helper'; -describe('GET /tasks', () => { +describe('GET /tasks/user', () => { let user; beforeEach(async () => { @@ -16,14 +16,14 @@ describe('GET /tasks', () => { }); it('returns all user\'s tasks', async () => { - let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks?tasksOwner=user'); + let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user'); expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task }); it('returns only a type of user\'s tasks if req.query.type is specified', async () => { - let createdTasks = await user.post('/tasks?tasksOwner=user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks?tasksOwner=user&type=habit'); + let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + let tasks = await user.get('/tasks/user?type=habit'); expect(tasks.length).to.equal(1); expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index b88d8a95a6..d3fe449b8d 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -17,7 +17,7 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -54,7 +54,7 @@ describe('GET /tasks/:id', () => { .then((user2) => { anotherUser = user2; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index a002119634..3fd81aa7cd 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -14,7 +14,7 @@ describe('POST /tasks', () => { context('validates params', () => { it('returns an error if req.body.type is absent', async () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -24,7 +24,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +34,7 @@ describe('POST /tasks', () => { }); it('returns an error if one object inside an array is invalid', async () => { - return expect(user.post('/tasks?tasksOwner=user', [ + return expect(user.post('/tasks/user', [ {type: 'habitF'}, {type: 'habit'}, ])).to.eventually.be.rejected.and.eql({ @@ -45,7 +45,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -56,7 +56,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', })).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, @@ -71,7 +71,7 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks?tasksOwner=user', [ + return expect(user.post('/tasks/user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -86,8 +86,8 @@ describe('POST /tasks', () => { }); it('does not save any task sent in an array when 1 is invalid', async () => { - let originalTasks = await user.get('/tasks?tasksOwner=user'); - return expect(user.post('/tasks?tasksOwner=user', [ + let originalTasks = await user.get('/tasks/user'); + return expect(user.post('/tasks/user', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary @@ -95,14 +95,14 @@ describe('POST /tasks', () => { error: 'BadRequest', message: 'habit validation failed', }).then(async () => { - let updatedTasks = await user.get('/tasks?tasksOwner=user'); + let updatedTasks = await user.get('/tasks/user'); expect(updatedTasks).to.eql(originalTasks); }); }); it('automatically sets "task.userId" to user\'s uuid', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -113,7 +113,7 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, dateCompleted fields`, async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', userId: 123, @@ -137,7 +137,7 @@ describe('POST /tasks', () => { }); it('ignores invalid fields', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notValid: true, @@ -149,7 +149,7 @@ describe('POST /tasks', () => { context('habits', () => { it('creates a habit', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', up: false, @@ -167,7 +167,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when a new habit is created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'an habit', }); @@ -179,7 +179,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.habits when multiple habits are created', async () => { let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'habit', text: 'an habit', }, { @@ -194,7 +194,7 @@ describe('POST /tasks', () => { }); it('creates multiple habits', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test habit', type: 'habit', up: false, @@ -224,7 +224,7 @@ describe('POST /tasks', () => { }); it('defaults to setting up and down to true', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', notes: 1976, @@ -235,7 +235,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', checklist: [ @@ -249,7 +249,7 @@ describe('POST /tasks', () => { context('todos', () => { it('creates a todo', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test todo', type: 'todo', notes: 1976, @@ -262,7 +262,7 @@ describe('POST /tasks', () => { }); it('creates multiple todos', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test todo', type: 'todo', notes: 1976, @@ -285,7 +285,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when a new todo is created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'todo', text: 'a todo', }); @@ -297,7 +297,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.todos when multiple todos are created', async () => { let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'todo', text: 'a todo', }, { @@ -312,7 +312,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test todo', type: 'todo', checklist: [ @@ -333,7 +333,7 @@ describe('POST /tasks', () => { it('creates a daily', async () => { let now = new Date(); - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notes: 1976, @@ -352,7 +352,7 @@ describe('POST /tasks', () => { }); it('creates multiple dailys', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test daily', type: 'daily', notes: 1976, @@ -375,7 +375,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when a new daily is created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'a daily', }); @@ -387,7 +387,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dailys when multiple dailys are created', async () => { let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'daily', text: 'a daily', }, { @@ -402,7 +402,7 @@ describe('POST /tasks', () => { }); it('defaults to a weekly frequency, with every day set', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -421,7 +421,7 @@ describe('POST /tasks', () => { }); it('allows repeat field to be configured', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', repeat: { @@ -445,7 +445,7 @@ describe('POST /tasks', () => { it('defaults startDate to today', async () => { let today = (new Date()).getDay(); - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -454,7 +454,7 @@ describe('POST /tasks', () => { }); it('can create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test daily', type: 'daily', checklist: [ @@ -473,7 +473,7 @@ describe('POST /tasks', () => { context('rewards', () => { it('creates a reward', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', notes: 1976, @@ -488,7 +488,7 @@ describe('POST /tasks', () => { }); it('creates multiple rewards', async () => { - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ text: 'test reward', type: 'reward', notes: 1976, @@ -515,7 +515,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.rewards when a new reward is created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { type: 'reward', text: 'a reward', }); @@ -527,7 +527,7 @@ describe('POST /tasks', () => { it('updates user.tasksOrder.dreward when multiple rewards are created', async () => { let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length; - let [task, task2] = await user.post('/tasks?tasksOwner=user', [{ + let [task, task2] = await user.post('/tasks/user', [{ type: 'reward', text: 'a reward', }, { @@ -542,7 +542,7 @@ describe('POST /tasks', () => { }); it('defaults to a 0 value', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', }); @@ -551,7 +551,7 @@ describe('POST /tasks', () => { }); it('requires value to be coerced into a number', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', value: '10', @@ -561,7 +561,7 @@ describe('POST /tasks', () => { }); it('cannot create checklists', async () => { - let task = await user.post('/tasks?tasksOwner=user', { + let task = await user.post('/tasks/user', { text: 'test reward', type: 'reward', checklist: [ diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 1c552613b4..139db4310f 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -37,7 +37,7 @@ describe('POST /tasks/:id/score/:direction', () => { let todo; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test todo', type: 'todo', }).then((task) => { @@ -149,7 +149,7 @@ describe('POST /tasks/:id/score/:direction', () => { let daily; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test daily', type: 'daily', }).then((task) => { @@ -226,26 +226,26 @@ describe('POST /tasks/:id/score/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((task) => { habit = task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test min habit', type: 'habit', up: false, }); }).then((task) => { minusHabit = task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test plus habit', type: 'habit', down: false, }); }).then((task) => { plusHabit = task; - user.post('/tasks?tasksOwner=user', { + user.post('/tasks/user', { text: 'test neither habit', type: 'habit', up: false, @@ -297,7 +297,7 @@ describe('POST /tasks/:id/score/:direction', () => { let reward; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test reward', type: 'reward', value: 5, diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 408ef92e99..453fb03cea 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', }).then((createdTask) => { @@ -65,7 +65,7 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test habit', type: 'habit', notes: 1976, @@ -93,7 +93,7 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test todo', type: 'todo', notes: 1976, @@ -150,7 +150,7 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test daily', type: 'daily', notes: 1976, @@ -254,7 +254,7 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(() => { - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { text: 'test reward', type: 'reward', notes: 1976, diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index 738255996a..2c6de8e570 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('deletes a checklist item', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { it('does not work with habits', () => { let habit; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -47,7 +47,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('does not work with rewards', async () => { - let reward = await user.post('/tasks?tasksOwner=user', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -68,7 +68,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index 1ad4f7f58c..fd79b89ae9 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('adds a checklist item to a task', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -36,7 +36,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to habits', () => { let habit; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -51,7 +51,7 @@ describe('POST /tasks/:taskId/checklist/', () => { it('does not add a checklist to rewards', () => { let reward; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 862b67b638..cc386341b7 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -16,7 +16,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('scores a checklist item', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -32,7 +32,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { it('fails on habits', () => { let habit; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }).then(createdTask => { @@ -46,7 +46,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks?tasksOwner=user', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index ae6babf778..2df3213f2b 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -16,7 +16,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { it('updates a checklist item', () => { let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }).then(createdTask => { @@ -33,7 +33,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks?tasksOwner=user', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -46,7 +46,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks?tasksOwner=user', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -67,7 +67,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index 27d57bb2f0..8a9617c803 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 9887a5bedb..ddc0c0fe6b 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -17,7 +17,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return user.post('/tasks?tasksOwner=user', { + return user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -35,7 +35,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { let tag; let task; - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then(createdTask => { @@ -54,7 +54,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a non existing tag to a task', () => { - return expect(user.post('/tasks?tasksOwner=user', { + return expect(user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }).then((task) => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0f5ecc314c..a061ac28e5 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -16,88 +16,197 @@ import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; let api = {}; +// challenge must be passed only when a challenge task is being created +async function _createTasks (req, res, user, challenge) { + let toSave = Array.isArray(req.body) ? req.body : [req.body]; + + toSave = toSave.map(taskData => { + // Validate that task.type is valid + if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); + + let taskType = taskData.type; + let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); + + if (challenge) { + newTask.challenge.id = challenge.id; + } else { + newTask.userId = user._id; + } + + // Validate that the task is valid and throw if it isn't + // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality + let validationErrors = newTask.validateSync(); + if (validationErrors) throw validationErrors; + + // Otherwise update the user/challenge + (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); + + return newTask; + }).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again + validateBeforeSave: false, + })); + + toSave.unshift((challenge || user).save()); + + let tasks = await Q.all(toSave); + tasks.splice(0, 1); // Remove user or challenge + return tasks; +} + /** - * @api {post} /tasks Create a new task. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @api {post} /tasks/user Create a new task belonging to the autheticated user. Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiVersion 3.0.0 - * @apiName CreateTask + * @apiName CreateUserTasks * @apiGroup Task * - * @apiParam {string="user","challenge"} tasksOwner Query parameter to define if tasks will belong to the auhenticated user or to a challenge (specifying the "challengeId" parameter). - * @apiParam {UUID} challengeId Optional. Query parameter. If "tasksOwner" is "challenge" then specify the challenge id. - * * @apiSuccess {Object|Array} task The newly created task(s) */ -api.createTask = { +api.createUserTasks = { method: 'POST', - url: '/tasks', + url: '/tasks/user', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); - req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); + let tasks = await _createTasks(req, res, res.locals.user); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); + }, +}; + +/** + * @api {post} /tasks/challenge/:challengeId Create a new task belonging to the challenge. Can be passed an object to create a single task or an array of objects to create multiple tasks. + * @apiVersion 3.0.0 + * @apiName CreateChallengeTasks + * @apiGroup Task + * + * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to. + * + * @apiSuccess {Object|Array} task The newly created task(s) + */ +api.createChallengeTasks = { + method: 'POST', + url: '/tasks/challenge/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let reqValidationErrors = req.validationErrors(); if (reqValidationErrors) throw reqValidationErrors; - let tasksData = Array.isArray(req.body) ? req.body : [req.body]; - let user = res.locals.user; - let tasksOwner = req.query.tasksOwner; - let challengeId = req.query.challengeId; - let challenge; + let user = res.local.user; + let challengeId = req.params.challengeId; - if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); - if (tasksOwner === 'challenge') { - if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); - challenge = await Challenge.findOne({_id: challengeId}).exec(); + let challenge = await Challenge.findOne({_id: challengeId}).exec(); - // If the challenge does not exist, or if it exists but user is not the leader -> throw error - if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - } + // If the challenge does not exist, or if it exists but user is not the leader -> throw error + if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); - let toSave = tasksData.map(taskData => { - // Validate that task.type is valid - if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType')); - - let taskType = taskData.type; - let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData)); - - if (challenge) { - newTask.challenge.id = challengeId; - } else { - newTask.userId = user._id; - } - - // Validate that the task is valid and throw if it isn't - // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality - let validationErrors = newTask.validateSync(); - if (validationErrors) throw validationErrors; - - // Otherwise update the user/challenge - (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); - - return newTask; - }); - - // If all tasks are valid, save everything, withough running validation again - toSave = toSave.map(task => task.save({ - validateBeforeSave: false, - })); - toSave.unshift((challenge || user).save()); - - let tasks = await Q.all(toSave); - - if (tasks.length === 2) { - res.respond(201, tasks[1]); - } else { - tasks.splice(0, 1); // remove the user/challenge - res.respond(201, tasks); - } + let tasks = await _createTasks(req, res, user, challenge); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); // If adding tasks to a challenge -> sync users if (challenge) challenge.addTasks(tasks); // TODO catch/log }, }; +// challenge must be passed only when a challenge task is being created +async function _getTasks (req, res, user, challenge) { + let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id}; + let type = req.query.type; + + if (type) { + query.type = type; + if (type === 'todo') query.completed = false; // Exclude completed todos + } else { + query.$or = [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ]; + } + + if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { + if (challenge) throw new BadRequest(res.t('noCompletedTodosChallenge')); // no completed todos for challenges + + let queryCompleted = Tasks.Task.find({ + type: 'todo', + completed: true, + }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos + dateCompleted: 1, + }); + + let results = await Q.all([ + queryCompleted.exec(), + Tasks.Task.find(query).exec(), + ]); + + res.respond(200, results[1].concat(results[0])); + } else { + let tasks = await Tasks.Task.find(query).exec(); + res.respond(200, tasks); + } +} + +/** + * @api {get} /tasks/user Get an user's tasks + * @apiVersion 3.0.0 + * @apiName GetUserTasks + * @apiGroup Task + * + * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". + * + * @apiSuccess {Array} tasks An array of task objects + */ +api.getUserTasks = { + method: 'GET', + url: '/tasks/user', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + await _getTasks(req, res, res.locals.user); + }, +}; + +/** + * @api {get} /tasks/challenge/:challengeId Get a challenge's tasks + * @apiVersion 3.0.0 + * @apiName GetChallengeTasks + * @apiGroup Task + * + * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. + * + * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * + * @apiSuccess {Array} tasks An array of task objects + */ +api.getChallengeTasks = { + method: 'GET', + url: '/tasks/challenge/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.local.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); + + // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('challengeNotFound')); + } + + await _getTasks(req, res, res.locals.user, challenge); + }, +}; + /** * @api {get} /tasks/:tasksOwner/:challengeId Get an user's tasks * @apiVersion 3.0.0 @@ -116,29 +225,10 @@ api.getTasks = { url: '/tasks', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('tasksOwner', res.t('invalidTasksOwner')).isIn(['user', 'challenge']); - req.checkQuery('challengeId', res.t('challengeIdRequired')).optional().isUUID(); - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - let user = res.locals.user; - let tasksOwner = req.query.tasksOwner; let challengeId = req.query.challengeId; let challenge; - if (tasksOwner === 'user' && challengeId) throw new BadRequest(res.t('userTasksNoChallengeId')); - if (tasksOwner === 'challenge') { - if (!challengeId) throw new BadRequest(res.t('challengeIdRequired')); - challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); - - // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error - if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens - throw new NotFound(res.t('challengeNotFound')); - } - } - let query = challenge ? {'challenge.id': challengeId, userId: {$exists: false}} : {userId: user._id}; let type = req.query.type; From 9dfcad238cf7ad0b4d4afa9448bf001a02d39ccc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 18:41:25 +0100 Subject: [PATCH 069/213] rename tasks tests to match new routes --- .../tasks/{GET-tasks.test.js => GET-tasks_user.test.js} | 0 .../tasks/{POST-tasks.test.js => POST-tasks_user.test.js} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename test/api/v3/integration/tasks/{GET-tasks.test.js => GET-tasks_user.test.js} (100%) rename test/api/v3/integration/tasks/{POST-tasks.test.js => POST-tasks_user.test.js} (99%) diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js similarity index 100% rename from test/api/v3/integration/tasks/GET-tasks.test.js rename to test/api/v3/integration/tasks/GET-tasks_user.test.js diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js similarity index 99% rename from test/api/v3/integration/tasks/POST-tasks.test.js rename to test/api/v3/integration/tasks/POST-tasks_user.test.js index 3fd81aa7cd..27da94bb50 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('POST /tasks', () => { +describe('POST /tasks/user', () => { let user; before(async () => { From f2350105368c25b3819aed021973b4dbd1c32c44 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 6 Jan 2016 22:29:47 +0100 Subject: [PATCH 070/213] add getChallenge route and remove old code --- website/src/controllers/api-v3/challenges.js | 45 ++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 5a19663f74..fa3a71b818 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -73,22 +73,11 @@ api.createChallenge = { group.challengeCount += 1; - let tasks = req.body.tasks || []; // TODO validate req.body.leader = user._id; req.body.official = user.contributor.admin && req.body.official; let challenge = new Challenge(Challenge.sanitize(req.body)); - let toSave = tasks.map(tasks, taskToCreate => { - // TODO validate type - let task = new Tasks[taskToCreate.type](Tasks.Task.sanitizeCreate(taskToCreate)); - task.challenge.id = challenge._id; - challenge.tasksOrder[`${task.type}s`].push(task._id); - return task.save(); - }); - - toSave.unshift(challenge, group); - - let results = await Q.all(toSave); + let results = await Q.all(challenge.save(), group.save()); let savedChal = results[0]; await savedChal.syncToUser(user); // (it also saves the user) @@ -133,6 +122,38 @@ api.getChallenges = { }, }; +/** + * @api {get} /challenges/:challengeId Get a challenge given its id + * @apiVersion 3.0.0 + * @apiName GetChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} challenge The challenge object + */ +api.getChallenge = { + method: 'GET', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.local.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findOne({_id: challengeId}).exec(); // TODO populate + + // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + throw new NotFound(res.t('challengeNotFound')); + } + + res.respond(200, challenge); + }, +}; + // 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 = {}) { From ffbd4696e32b6ba813e11f54cd7108457462a0a8 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 11 Jan 2016 12:35:46 -0600 Subject: [PATCH 071/213] Converted posts tests to async/await syntax and updated tests --- common/locales/en/api-v3.json | 3 +- .../v3/integration/groups/POST-groups.test.js | 167 ++++++++++-------- website/src/controllers/api-v3/groups.js | 1 + 3 files changed, 101 insertions(+), 70 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2ace1f7773..1f77e456aa 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -46,5 +46,6 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", - "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", + "partyMustbePrivate": "Parties must be private" } diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 0d3a622581..f0ac8b9aba 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -6,21 +6,34 @@ import { describe('POST /group', () => { let user; - beforeEach(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; + beforeEach(async () => { + user = await generateUser(); + }); + + context('All Groups', () => { + it('it returns validation error when type is not provided', async () => { + let userToCreateGroup = await generateUser({balance: 1}); + await expect(userToCreateGroup.post('/groups', { name: 'Test Group Without Type' })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Group validation failed', + }); }); }); context('Guilds', () => { - it('returns an error when a user with insufficient funds attempts to create a guild', () => { - let groupName = 'Test Public Guild'; - let groupType = 'guild'; + let userToCreateGuild; - return expect( + beforeEach(async () => { + userToCreateGuild = await generateUser({balance: 1}); + }); + + it('returns an error when a user with insufficient funds attempts to create a guild', async () => { + await expect( user.post('/groups', { - name: groupName, - type: groupType, + name: 'Test Public Guild', + type: 'guild', }) ) .to.eventually.be.rejected.and.eql({ @@ -31,84 +44,100 @@ describe('POST /group', () => { }); context('public guild', () => { - it('creates a group', () => { + it('creates a group', async () => { let groupName = 'Test Public Guild'; let groupType = 'guild'; - - return generateUser({balance: 1}).then((generatedUser) => { - return generatedUser.post('/groups', { - name: groupName, - type: groupType, - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.memberCount).to.equal(1); + let groupPrivacy = 'public'; + let publicGuild = await userToCreateGuild.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, }); + + expect(publicGuild._id).to.exist; + expect(publicGuild.name).to.equal(groupName); + expect(publicGuild.type).to.equal(groupType); + expect(publicGuild.memberCount).to.equal(1); + expect(publicGuild.privacy).to.equal(groupPrivacy); }); }); context('private guild', () => { - it('creates a group', () => { - let groupName = 'Test Private Guild'; - let groupType = 'guild'; - let groupPrivacy = 'private'; + let groupName = 'Test Private Guild'; + let groupType = 'guild'; + let groupPrivacy = 'private'; - return generateUser({balance: 1}).then((generatedUser) => { - return generatedUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.memberCount).to.equal(1); - expect(result.privacy).to.equal(groupPrivacy); + it('creates a group', async () => { + let privateGuild = await userToCreateGuild.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, }); + + expect(privateGuild._id).to.exist; + expect(privateGuild.name).to.equal(groupName); + expect(privateGuild.type).to.equal(groupType); + expect(privateGuild.memberCount).to.equal(1); + expect(privateGuild.privacy).to.equal(groupPrivacy); + }); + + it('deducts gems from user and adds them to guild bank', async () => { + let privateGuild = await userToCreateGuild.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + + expect(privateGuild.balance).to.eql(1); + + let updatedUser = await userToCreateGuild.get('/user'); + + expect(updatedUser.balance).to.eql(0); }); }); }); context('Parties', () => { - it('creates a party', () => { - let groupName = 'Test Party'; - let groupType = 'party'; + let partyName = 'Test Party'; + let partyType = 'party'; - return user.post('/groups', { - name: groupName, - type: groupType, - }) - .then((result) => { - expect(result._id).to.exist; - expect(result.name).to.equal(groupName); - expect(result.type).to.equal(groupType); - expect(result.memberCount).to.equal(1); + it('creates a party', async () => { + let party = await user.post('/groups', { + name: partyName, + type: partyType, + }); + + expect(party._id).to.exist; + expect(party.name).to.equal(partyName); + expect(party.type).to.equal(partyType); + expect(party.memberCount).to.equal(1); + }); + + it('prevents user in a party from creating another party', async () => { + let userToCreateTwoParties = await generateUser(); + + await userToCreateTwoParties.post('/groups', { + name: partyName, + type: partyType, + }); + + await expect(userToCreateTwoParties.post('/groups')) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupAlreadyInParty'), }); }); - it('prevents user in a party from creating another party', () => { - let tmpUser; - let groupName = 'Test Party'; - let groupType = 'party'; - - return generateUser().then((generatedUser) => { - tmpUser = generatedUser; - return tmpUser.post('/groups', { - name: groupName, - type: groupType, - }); - }) - .then(() => { - return expect(tmpUser.post('/groups')).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('messageGroupAlreadyInParty'), - }); + it('prevents creating a public party', async () => { + await expect(user.post('/groups', { + name: partyName, + type: partyType, + privacy: 'public', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('partyMustbePrivate'), }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 80b2972217..8604337648 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -43,6 +43,7 @@ api.createGroup = { user.balance--; user.guilds.push(group._id); } else { + if (group.privacy === 'public') throw new NotAuthorized(res.t('partyMustbePrivate')); if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty')); user.party._id = group._id; From fffa95ca09748a7b6a8dde86e9a9267e1ee87068 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 08:11:19 -0600 Subject: [PATCH 072/213] refactor: Test against party not being private, instead of public --- website/src/controllers/api-v3/groups.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8604337648..13ddf76d64 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -31,7 +31,6 @@ api.createGroup = { middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; - let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body group.leader = user._id; @@ -43,7 +42,7 @@ api.createGroup = { user.balance--; user.guilds.push(group._id); } else { - if (group.privacy === 'public') throw new NotAuthorized(res.t('partyMustbePrivate')); + if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate')); if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty')); user.party._id = group._id; @@ -54,6 +53,7 @@ api.createGroup = { firebase.updateGroupData(savedGroup); firebase.addUserToGroup(savedGroup._id, user._id); + return res.respond(201, savedGroup); // TODO populate }, }; From ddd0709b12a00399ff3a2e1bf1dfd50d23cb9519 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 15:21:24 +0200 Subject: [PATCH 073/213] Refactor Tags tests to use await syntax --- .../integration/tags/DELETE-tags_id.test.js | 35 ++++++++---------- test/api/v3/integration/tags/GET-tags.test.js | 26 ++++++------- .../v3/integration/tags/GET-tags_id.test.js | 20 +++------- .../api/v3/integration/tags/POST-tags.test.js | 31 ++++++---------- .../v3/integration/tags/PUT-tags_id.test.js | 37 ++++++++----------- 5 files changed, 62 insertions(+), 87 deletions(-) diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index 82911ddda7..b83521613b 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -4,30 +4,25 @@ import { describe('DELETE /tags/:tagId', () => { let user; + let tagName = 'Tag 1'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('deletes a tag given it\'s id', () => { - let length; - let tag; + it('deletes a tag given it\'s id', async () => { + let tag = await user.post('/tags', {name: tagName}); + let tags = await user.get('/tags'); + let length = tags.length; - return user.post('/tags', {name: 'Tag 1'}) - .then((createdTag) => { - tag = createdTag; - return user.get(`/tags`); - }) - .then((tags) => { - length = tags.length; - return user.del(`/tags/${tag._id}`); - }) - .then(() => user.get(`/tags`)) - .then((tags) => { - expect(tags.length).to.equal(length - 1); - expect(tags[tags.length - 1].name).to.not.equal('Tag 1'); + await user.del(`/tags/${tag._id}`); + + tags = await user.get('/tags'); + let tagNames = tags.map((t) => { + return t.name; }); + + expect(tags.length).to.equal(length - 1); + expect(tagNames).to.not.include(tagName); }); }); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js index 3669fc37e0..2cb30be885 100644 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -4,21 +4,21 @@ import { describe('GET /tags', () => { let user; + let tagName1 = 'Tag 1'; + let tagName2 = 'Tag 2'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns all user\'s tags', () => { - return user.post('/tags', {name: 'Tag 1'}) - .then(() => user.post('/tags', {name: 'Tag 2'})) - .then(() => user.get('/tags')) - .then((tags) => { - expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task - expect(tags[tags.length - 2].name).to.equal('Tag 1'); - expect(tags[tags.length - 1].name).to.equal('Tag 2'); - }); + it('returns all user\'s tags', async () => { + await user.post('/tags', {name: tagName1}); + await user.post('/tags', {name: tagName2}); + + let tags = await user.get('/tags'); + + expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task + expect(tags[tags.length - 2].name).to.equal(tagName1); + expect(tags[tags.length - 1].name).to.equal(tagName2); }); }); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index fbdf96312f..adccff6504 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -5,22 +5,14 @@ import { describe('GET /tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns a tag given it\'s id', () => { - let createdTag; + it('returns a tag given it\'s id', async () => { + let createdTag = await user.post('/tags', {name: 'Tag 1'}); + let tag = await user.get(`/tags/${createdTag._id}`); - return user.post('/tags', {name: 'Tag 1'}) - .then((tag) => { - createdTag = tag; - return user.get(`/tags/${createdTag._id}`); - }) - .then((tag) => { - expect(tag).to.deep.equal(createdTag); - }); + expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 351d14e9fb..78a73c578a 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -4,29 +4,22 @@ import { describe('POST /tags', () => { let user; + let tagName = 'Tag 1'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('creates a tag correctly', () => { - let createdTag; - - return user.post('/tags', { - name: 'Tag 1', + it('creates a tag correctly', async () => { + let createdTag = await user.post('/tags', { + name: tagName, ignored: false, - }).then((tag) => { - createdTag = tag; - - expect(tag.name).to.equal('Tag 1'); - expect(tag.ignored).to.be.a('undefined'); - - return user.get(`/tags/${createdTag._id}`); - }) - .then((tag) => { - expect(tag).to.deep.equal(createdTag); }); + + let tag = await user.get(`/tags/${createdTag._id}`); + + expect(tag.name).to.equal(tagName); + expect(tag.ignored).to.be.undefined; + expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index 94a76ec39c..71fbc2ef58 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -4,30 +4,25 @@ import { describe('PUT /tags/:tagId', () => { let user; + let updatedTagName = 'Tag updated'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('updates a tag given it\'s id', () => { - return user.post('/tags', {name: 'Tag 1'}) - .then((createdTag) => { - return user.put(`/tags/${createdTag._id}`, { - name: 'Tag updated', - ignored: true, - }); - }) - .then((updatedTag) => { - expect(updatedTag.name).to.equal('Tag updated'); - expect(updatedTag.ignored).to.be.a('undefined'); - - return user.get(`/tags/${updatedTag._id}`); - }) - .then((tag) => { - expect(tag.name).to.equal('Tag updated'); - expect(tag.ignored).to.be.a('undefined'); + it('updates a tag given it\'s id', async () => { + let createdTag = await user.post('/tags', {name: 'Tag 1'}); + let updatedTag = await user.put(`/tags/${createdTag._id}`, { + name: updatedTagName, + ignored: true, }); + + createdTag = await user.get(`/tags/${updatedTag._id}`); + + expect(updatedTag.name).to.equal(updatedTagName); + expect(updatedTag.ignored).to.be.undefined; + + expect(createdTag.name).to.equal(updatedTagName); + expect(createdTag.ignored).to.be.undefined; }); }); From 88755e69ae4f85f88b68973bb51231808e70a44b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 08:14:12 -0600 Subject: [PATCH 074/213] tests(api): Simplify tests --- .../v3/integration/groups/POST-groups.test.js | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index f0ac8b9aba..99b263353f 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -7,36 +7,30 @@ describe('POST /group', () => { let user; beforeEach(async () => { - user = await generateUser(); + user = await generateUser({ balance: 10 }); }); context('All Groups', () => { it('it returns validation error when type is not provided', async () => { - let userToCreateGroup = await generateUser({balance: 1}); - await expect(userToCreateGroup.post('/groups', { name: 'Test Group Without Type' })) - .to.eventually.be.rejected.and.eql({ + await expect( + user.post('/groups', { name: 'Test Group Without Type' }) + ).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: 'Group validation failed', }); }); - }); context('Guilds', () => { - let userToCreateGuild; - - beforeEach(async () => { - userToCreateGuild = await generateUser({balance: 1}); - }); - it('returns an error when a user with insufficient funds attempts to create a guild', async () => { + await user.update({ balance: 0 }); + await expect( user.post('/groups', { name: 'Test Public Guild', type: 'guild', }) - ) - .to.eventually.be.rejected.and.eql({ + ).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageInsufficientGems'), @@ -48,7 +42,8 @@ describe('POST /group', () => { let groupName = 'Test Public Guild'; let groupType = 'guild'; let groupPrivacy = 'public'; - let publicGuild = await userToCreateGuild.post('/groups', { + + let publicGuild = await user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -68,7 +63,7 @@ describe('POST /group', () => { let groupPrivacy = 'private'; it('creates a group', async () => { - let privateGuild = await userToCreateGuild.post('/groups', { + let privateGuild = await user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -82,7 +77,7 @@ describe('POST /group', () => { }); it('deducts gems from user and adds them to guild bank', async () => { - let privateGuild = await userToCreateGuild.post('/groups', { + let privateGuild = await user.post('/groups', { name: groupName, type: groupType, privacy: groupPrivacy, @@ -90,9 +85,9 @@ describe('POST /group', () => { expect(privateGuild.balance).to.eql(1); - let updatedUser = await userToCreateGuild.get('/user'); + let updatedUser = await user.get('/user'); - expect(updatedUser.balance).to.eql(0); + expect(updatedUser.balance).to.eql(user.balance - 1); }); }); }); @@ -114,15 +109,12 @@ describe('POST /group', () => { }); it('prevents user in a party from creating another party', async () => { - let userToCreateTwoParties = await generateUser(); - - await userToCreateTwoParties.post('/groups', { + await user.post('/groups', { name: partyName, type: partyType, }); - await expect(userToCreateTwoParties.post('/groups')) - .to.eventually.be.rejected.and.eql({ + await expect(user.post('/groups')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupAlreadyInParty'), From 36cd9e94306e35339c06be07e433e24a77dabf5d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 08:14:50 -0600 Subject: [PATCH 075/213] tests(api): Increase test coverage for group post tests --- .../v3/integration/groups/POST-groups.test.js | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 99b263353f..3515f07cec 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -21,6 +21,26 @@ describe('POST /group', () => { }); }); + it('it returns validation error when type is not supported', async () => { + await expect( + user.post('/groups', { name: 'Group with unsupported type', type: 'foo' }) + ).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Group validation failed', + }); + }); + + it('sets the group leader to the user who created the group', async () => { + await expect( + user.post('/groups', { + name: 'Test Public Guild', + type: 'guild', + }) + ).to.eventually.have.property('leader', user._id); + }); + }); + context('Guilds', () => { it('returns an error when a user with insufficient funds attempts to create a guild', async () => { await user.update({ balance: 0 }); @@ -37,6 +57,18 @@ describe('POST /group', () => { }); }); + it('adds guild to user\'s list of guilds', async () => { + let guild = await user.post('/groups', { + name: 'some guild', + type: 'guild', + privacy: 'public', + }); + + let updatedUser = await user.get('/user'); + + expect(updatedUser.guilds).to.include(guild._id); + }); + context('public guild', () => { it('creates a group', async () => { let groupName = 'Test Public Guild'; @@ -108,6 +140,32 @@ describe('POST /group', () => { expect(party.memberCount).to.equal(1); }); + it('does not require gems to create a party', async () => { + await user.update({ balance: 0 }); + + let party = await user.post('/groups', { + name: partyName, + type: partyType, + }); + + expect(party._id).to.exist; + + let updatedUser = await user.get('/user'); + + expect(updatedUser.balance).to.eql(user.balance); + }); + + it('sets party id on user object', async () => { + let party = await user.post('/groups', { + name: partyName, + type: partyType, + }); + + let updatedUser = await user.get('/user'); + + expect(updatedUser.party._id).to.eql(party._id); + }); + it('prevents user in a party from creating another party', async () => { await user.post('/groups', { name: partyName, From 9141598a3478e2d06613fe566b03db27f2ef1667 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 7 Jan 2016 12:32:59 -0600 Subject: [PATCH 076/213] Updated invite route and function for new standards, and added tests. --- common/locales/en/api-v3.json | 11 +- .../groups/POST-groups_invite.test.js | 236 +++++++++++++++ website/src/controllers/api-v3/groups.js | 268 ++++++++---------- 3 files changed, 370 insertions(+), 145 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_invite.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2ace1f7773..cd3133ffd9 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,7 +37,7 @@ "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", - "canOnlyInviteEmailUuid": "Can only invite using uuids or emails but not both at the same time.", + "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", @@ -46,5 +46,12 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", - "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", + "userAlreadyInGroup": "User already in that group.", + "userAlreadyInvitedToGroup": "User already invited to that group.", + "userAlreadyPendingInvitation": "User already pending invitation.", + "userAlreadyInAParty": "User already in a party.", + "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", + "emailsMustBeAnArray": "Email invites must be a an Array." } diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js new file mode 100644 index 0000000000..1bb604bd19 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -0,0 +1,236 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('Post /groups/:groupId/invite', () => { + let inviter; + let group; + let groupName = 'Test Public Guild'; + + beforeEach(async () => { + inviter = await generateUser({balance: 1}); + group = await inviter.post('/groups', { + name: groupName, + type: 'guild', + }); + }); + + describe('user id invites', () => { + it('returns an error when invited user is not found', async () => { + let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: fakeID}), + }); + }); + + it('returns an error when uuids is not an array', async () => { + let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: {fakeID}, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('uuidsMustBeAnArray'), + }); + }); + + it('returns empty when uuids is empty', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [], + })) + .to.eventually.be.empty; + }); + + it('invites a user to a group by uuid', async () => { + let userToInvite = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + })).to.eventually.deep.equal([{ + id: group._id, + name: groupName, + inviter: inviter._id, + }]); + await expect(userToInvite.get('/user')) + .to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + }); + + it('invites multiple users to a group by uuid', async () => { + let userToInvite = await generateUser(); + let userToInvite2 = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id, userToInvite2._id], + })).to.eventually.deep.equal([ + { + id: group._id, + name: groupName, + inviter: inviter._id, + }, + { + id: group._id, + name: groupName, + inviter: inviter._id, + }, + ]); + await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); + }); + }); + + describe('email invites', () => { + let testInvite = {name: 'test', email: 'test@habitca.com'}; + + it('returns an error when invite is missing an email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [{name: 'test'}], + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('inviteMissingEmail'), + }); + }); + + it('returns an error when emails is not an array', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: {testInvite}, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('emailsMustBeAnArray'), + }); + }); + + it('returns empty when emails is an empty array', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [], + })) + .to.eventually.be.empty; + }); + + it('invites a user to a group by email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [testInvite], + })).to.exist; + }); + + it('invites multiple users to a group by email', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails: [testInvite, {name: 'test2', email: 'test2@habitca.com'}], + })).to.exist; + }); + }); + + describe('user and email invites', () => { + it('returns an error when emails and uuids are not provided', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteEmailUuid'), + }); + }); + + it('invites users to a group by uuid and email', async () => { + let newUser = await generateUser(); + let invite = await inviter.post(`/groups/${group._id}/invite`, { + uuids: [newUser._id], + emails: [{name: 'test', email: 'test@habitca.com'}], + }); + let invitedUser = await newUser.get('/user'); + + expect(invite).to.exist; + expect(invitedUser.invitations.guilds[0].id).to.equal(group._id); + }); + }); + + describe('guild invites', () => { + it('returns an error when invited user is already invited to the group', async () => { + let userToInivite = await generateUser(); + await inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInivite._id], + }); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInivite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInvitedToGroup'), + }); + }); + + it('returns an error when invited user is already in the group', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${group._id}/join`); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInGroup'), + }); + }); + }); + + describe('party invites', () => { + let party; + + beforeEach(async () => { + party = await inviter.post('/groups', { + name: 'Test Party', + type: 'party', + }); + }); + + it('returns an error when invited user has a pending invitation to the party', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + + await expect(inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyPendingInvitation'), + }); + }); + + it('returns an error when invited user is already in the party', async () => { + let userToInvite = await generateUser(); + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${party._id}/join`); + + await expect(inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInAParty'), + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 80b2972217..0114240481 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -4,14 +4,15 @@ import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { NotFound, BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; import * as firebase from '../../libs/api-v3/firebase'; -import { txnEmail } from '../../libs/api-v3/email'; -// import { encrypt } from '../../libs/api-v3/encryption'; +import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; +import { encrypt } from '../../libs/api-v3/encryption'; let api = {}; @@ -304,7 +305,7 @@ api.leaveGroup = { // Send an email to the removed user with an optional message from the leader function _sendMessageToRemoved (group, removedUser, message) { if (removedUser.preferences.emailNotifications.kickedGroup !== false) { - txnEmail(removedUser, `kicked-from-${group.type}`, [ + sendTxnEmail(removedUser, `kicked-from-${group.type}`, [ {name: 'GROUP_NAME', content: group.name}, {name: 'MESSAGE', content: message}, {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'}, @@ -391,145 +392,105 @@ api.removeGroupMember = { }, }; -/* function _inviteByUUIDs (uuids, group, inviter, req, res, next) { - async.each(uuids, function(uuid, cb){ - User.findById(uuid, function(err,invite){ - if (err) return cb(err); - if (!invite) - return cb({code:400,err:'User with id "' + uuid + '" not found'}); - if (group.type == 'guild') { - if (_.contains(group.members,uuid)) - return cb({code:400, err: "User already in that group"}); - if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) - return cb({code:400, err:"User already invited to that group"}); - sendInvite(); - } else if (group.type == 'party') { - if (invite.invitations && !_.isEmpty(invite.invitations.party)) - return cb({code: 400,err:"User already pending invitation."}); - Group.find({type: 'party', members: {$in: [uuid]}}, function(err, groups){ - if (err) return cb(err); - if (!_.isEmpty(groups) && groups[0].members.length > 1) { - return cb({code: 400, err: "User already in a party."}) - } - sendInvite(); - }); - } +async function _inviteByUUID (uuid, group, inviter, req, res) { + // @TODO: Add Push Notifications + let userToInvite = await User.findById(uuid).exec(); - function sendInvite (){ - if(group.type === 'guild'){ - invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); + if (!userToInvite) { + throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); + } - pushNotify.sendNotify(invite, shared.i18n.t('invitedGuild'), group.name); - }else{ - //req.body.type in 'guild', 'party' - invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; + if (group.type === 'guild') { + if (_.contains(userToInvite.guilds, group._id)) { + throw new NotAuthorized(res.t('userAlreadyInGroup')); + } + if (_.find(userToInvite.invitations.guilds, {id: group._id})) { + throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); + } + userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: res.locals.user._id}); + } else if (group.type === 'party') { + if (!_.isEmpty(userToInvite.invitations.party)) { + throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); + } + if (userToInvite.party._id) { + 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: res.locals.user._id}; + } - pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); - } + let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; + if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { + let emailVars = [ + {name: 'INVITER', content: inviter.profile.name}, + {name: 'REPLY_TO_ADDRESS', content: inviter.email}, + ]; - group.invites.push(invite._id); - - async.series([ - function(cb){ - invite.save(cb); - } - ], function(err, results){ - if (err) return cb(err); - - if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ - var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); - var emailVars = [ - {name: 'INVITER', content: inviterVars.name}, - {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} - ]; - - if(group.type == 'guild'){ - emailVars.push( - {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} - ); - }else{ - emailVars.push( - {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: '/#/options/groups/party'} - ) - } - - utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); - } - - cb(); - }); - } - }); - }, function(err){ - if(err) return err.code ? res.json(err.code, {err: err.err}) : next(err); - - async.series([ - function(cb) { - group.save(cb); - }, - function(cb) { - // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? - populateQuery(group.type, Group.findById(group._id)).exec(function(err, populatedGroup){ - if(err) return next(err); - - res.json(populatedGroup); - }); - } - ]); - }); -}; - -function _inviteByEmails (emails, group, inviter, req, res, next) { - let usersAlreadyRegistered = []; - let invitesToSend = []; - - return Q.all(emails.forEach(invite => { - if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); - - return User.findOne({$or: [ - {'auth.local.email': invite.email}, - {'auth.facebook.emails.value': invite.email} - ]}) - .select({_id: true, 'preferences.emailNotifications': true}) - .exec() - .then(userToContact => { - if(userToContact){ - usersAlreadyRegistered.push(userToContact._id); // TODO does it work not returning - } else { - // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query - // TODO absolutely refactor this horrible code - let link = `?partyInvite=${utils.encrypt(JSON.stringify({id: group._id, inviter: inviter, name: group.name}))}`; - - let inviterVars = getUserInfo(inviter, ['name', 'email']); - let variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || inviterVars.name}, - {name: 'REPLY_TO_ADDRESS', content: inviterVars.email} - ]; - - if(group.type == 'guild'){ - variables.push({name: 'GUILD_NAME', content: group.name}); - } - - // TODO implement "users can only be invited once" - // Check for the email address not to be unsubscribed - return EmailUnsubscription.findOne({email: invite.email}).exec() - .then(unsubscribed => { - if (!unsubscribed) utils.txnEmail(invite, ('invite-friend' + (group.type == 'guild' ? '-guild' : '')), variables); - }); - } - }); - })) - .then(() => { - if (usersAlreadyRegistered.length > 0){ - return _inviteByUUIDs(usersAlreadyRegistered, group, inviter, req, res, next); + if (group.type === 'guild') { + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'}, + ); + } else { + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: '/#/options/groups/party'}, + ); } - res.respond(200, {}); // TODO what to return? - }); -}; */ + sendTxnEmail(userToInvite, `invited-${groupLabel}`, emailVars); + } + + let userInvited = await userToInvite.save(); + if (group.type === 'guild') { + return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; + } else if (group.type === 'party') { + return userInvited.invitations.party; + } +} + +async function _inviteByEmail (invite, group, inviter, req, res) { + let userReturnInfo; + + if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); + + let userToContact = await User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email}, + ]}) + .select({_id: true, 'preferences.emailNotifications': true}) + .exec(); + + if (userToContact) { + userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res); + } else { + userReturnInfo = invite.email; + // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query + // TODO absolutely refactor this horrible code + const partyQueryString = JSON.stringify({id: group._id, inviter, name: group.name}); + const encryptedPartyqueryString = encrypt(partyQueryString); + let link = `?partyInvite=${encryptedPartyqueryString}`; + + let variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: inviter || inviter.profile.name}, + {name: 'REPLY_TO_ADDRESS', content: inviter.email}, + ]; + + if (group.type === 'guild') { + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // TODO implement "users can only be invited once" + // Check for the email address not to be unsubscribed + let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec(); + let groupLabel = group.type === 'guild' ? '-guild' : ''; + if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables); + } + + return userReturnInfo; +} /** * @api {post} /groups/:groupId/invite Invite users to a group using their UUIDs or email addresses @@ -563,15 +524,36 @@ api.inviteToGroup = { let uuids = req.body.uuids; let emails = req.body.emails; - if (uuids && emails) { // TODO fix this, low priority, allow for inviting by both at the same time - throw new BadRequest(res.t('canOnlyInviteEmailUuid')); - } else if (Array.isArray(uuids)) { - // return _inviteByUUIDs(uuids, group, user, req, res, next); - } else if (Array.isArray(emails)) { - // return _inviteByEmails(emails, group, user, req, res, next); - } else { + let uuidsIsArray = Array.isArray(uuids); + let emailsIsArray = Array.isArray(emails); + + if (!uuids && !emails) { throw new BadRequest(res.t('canOnlyInviteEmailUuid')); } + + let results = []; + + if (uuids && !uuidsIsArray) { + throw new BadRequest(res.t('uuidsMustBeAnArray')); + } + + if (emails && !emailsIsArray) { + throw new BadRequest(res.t('emailsMustBeAnArray')); + } + + if (uuids) { + let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); + let uuidResults = await Q.all(uuidInvites); + results.push(...uuidResults); + } + + if (emails) { + let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); + let emailResults = await Q.all(emailInvites); + results.push(...emailResults); + } + + res.respond(200, results); }, }; From 1b395b39f849402c7fbf9332bb0de8b5fec48d15 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 08:33:02 -0600 Subject: [PATCH 077/213] Added invite limit and tests for when multiple users with an error --- common/locales/en/api-v3.json | 3 +- .../groups/POST-groups_invite.test.js | 84 +++++++++++++++++-- website/src/controllers/api-v3/groups.js | 30 +++++-- website/src/models/group.js | 2 + 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cd3133ffd9..e82133c009 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -53,5 +53,6 @@ "userAlreadyInAParty": "User already in a party.", "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", - "emailsMustBeAnArray": "Email invites must be a an Array." + "emailsMustBeAnArray": "Email invites must be a an Array.", + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time" } diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 1bb604bd19..2466bdf649 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -2,6 +2,9 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +const INVITES_LIMIT = 100; describe('Post /groups/:groupId/invite', () => { let inviter; @@ -18,7 +21,7 @@ describe('Post /groups/:groupId/invite', () => { describe('user id invites', () => { it('returns an error when invited user is not found', async () => { - let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + let fakeID = generateUUID(); await expect(inviter.post(`/groups/${group._id}/invite`, { uuids: [fakeID], @@ -31,7 +34,7 @@ describe('Post /groups/:groupId/invite', () => { }); it('returns an error when uuids is not an array', async () => { - let fakeID = '206039c6-24e4-4b9f-8a31-61cbb9aa3f66'; + let fakeID = generateUUID(); await expect(inviter.post(`/groups/${group._id}/invite`, { uuids: {fakeID}, @@ -50,6 +53,23 @@ describe('Post /groups/:groupId/invite', () => { .to.eventually.be.empty; }); + it('returns an error when there are more than INVITES_LIMIT uuids', async () => { + let uuids = []; + + for (let i = 0; i < 101; i += 1) { + uuids.push(generateUUID()); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + it('invites a user to a group by uuid', async () => { let userToInvite = await generateUser(); @@ -85,10 +105,24 @@ describe('Post /groups/:groupId/invite', () => { await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id); }); + + it('returns an error when inviting multiple users and a user is not found', async () => { + let userToInvite = await generateUser(); + let fakeID = generateUUID(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + uuids: [userToInvite._id, fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: fakeID}), + }); + }); }); describe('email invites', () => { - let testInvite = {name: 'test', email: 'test@habitca.com'}; + let testInvite = {name: 'test', email: 'test@habitica.com'}; it('returns an error when invite is missing an email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { @@ -119,6 +153,23 @@ describe('Post /groups/:groupId/invite', () => { .to.eventually.be.empty; }); + it('returns an error when there are more than INVITES_LIMIT emails', async () => { + let emails = []; + + for (let i = 0; i < 101; i += 1) { + emails.push(`${generateUUID()}@habitica.com`); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + it('invites a user to a group by email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { emails: [testInvite], @@ -127,7 +178,7 @@ describe('Post /groups/:groupId/invite', () => { it('invites multiple users to a group by email', async () => { await expect(inviter.post(`/groups/${group._id}/invite`, { - emails: [testInvite, {name: 'test2', email: 'test2@habitca.com'}], + emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}], })).to.exist; }); }); @@ -142,11 +193,34 @@ describe('Post /groups/:groupId/invite', () => { }); }); + it('returns an error when there are more than INVITES_LIMIT uuids and emails', async () => { + let emails = []; + let uuids = []; + + for (let i = 0; i < 50; i += 1) { + emails.push(`${generateUUID()}@habitica.com`); + } + + for (let i = 0; i < 51; i += 1) { + uuids.push(generateUUID()); + } + + await expect(inviter.post(`/groups/${group._id}/invite`, { + emails, + uuids, + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}), + }); + }); + it('invites users to a group by uuid and email', async () => { let newUser = await generateUser(); let invite = await inviter.post(`/groups/${group._id}/invite`, { uuids: [newUser._id], - emails: [{name: 'test', email: 'test@habitca.com'}], + emails: [{name: 'test', email: 'test@habitica.com'}], }); let invitedUser = await newUser.get('/user'); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 0114240481..d1ca3f6e13 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -2,7 +2,10 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import Q from 'q'; import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; -import { model as Group } from '../../models/group'; +import { + INVITES_LIMIT, + model as Group, +} from '../../models/group'; import { model as User } from '../../models/user'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { @@ -407,7 +410,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (_.find(userToInvite.invitations.guilds, {id: group._id})) { throw new NotAuthorized(res.t('userAlreadyInvitedToGroup')); } - userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: res.locals.user._id}); + userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id}); } else if (group.type === 'party') { if (!_.isEmpty(userToInvite.invitations.party)) { throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); @@ -417,7 +420,7 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { } // @TODO: Why was this here? // req.body.type in 'guild', 'party' - userToInvite.invitations.party = {id: group._id, name: group.name, inviter: res.locals.user._id}; + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; } let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; @@ -532,13 +535,26 @@ api.inviteToGroup = { } let results = []; + let totalInvites = 0; - if (uuids && !uuidsIsArray) { - throw new BadRequest(res.t('uuidsMustBeAnArray')); + if (uuids) { + if (!uuidsIsArray) { + throw new BadRequest(res.t('uuidsMustBeAnArray')); + } else { + totalInvites += uuids.length; + } } - if (emails && !emailsIsArray) { - throw new BadRequest(res.t('emailsMustBeAnArray')); + if (emails) { + if (!emailsIsArray) { + throw new BadRequest(res.t('emailsMustBeAnArray')); + } else { + totalInvites += emails.length; + } + } + + if (totalInvites > INVITES_LIMIT) { + throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT})); } if (uuids) { diff --git a/website/src/models/group.js b/website/src/models/group.js index a45d2eaa60..27ca80e73f 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -520,3 +520,5 @@ model.count({_id: 'habitrpg'}, (err, ct) => { privacy: 'public', }).save(); }); + +export const INVITES_LIMIT = 100; From 98b0db749bd9ec97b433158e369c3b60fd0fbb53 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 18:02:09 +0200 Subject: [PATCH 078/213] Refactor User tests to use await syntax --- test/api/v3/integration/user/GET-user.test.js | 27 ++--- .../user/auth/POST-register_local.test.js | 104 +++++++++--------- 2 files changed, 63 insertions(+), 68 deletions(-) diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js index 099526e9e6..8fbedca8cc 100644 --- a/test/api/v3/integration/user/GET-user.test.js +++ b/test/api/v3/integration/user/GET-user.test.js @@ -5,25 +5,20 @@ import { describe('GET /user', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('returns the authenticated user', () => { - return user.get('/user') - .then(returnedUser => { - expect(returnedUser._id).to.equal(user._id); - }); + it('returns the authenticated user', async () => { + let returnedUser = await user.get('/user'); + expect(returnedUser._id).to.equal(user._id); }); - it('does not return private paths (and apiToken)', () => { - return user.get('/user') - .then(returnedUser => { - expect(returnedUser.auth.local.hashed_password).to.not.exist; - expect(returnedUser.auth.local.salt).to.not.exist; - expect(returnedUser.apiToken).to.not.exist; - }); + it('does not return private paths (and apiToken)', async () => { + let returnedUser = await user.get('/user'); + + expect(returnedUser.auth.local.hashed_password).to.not.exist; + expect(returnedUser.auth.local.salt).to.not.exist; + expect(returnedUser.apiToken).to.not.exist; }); }); diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index a0f83717ea..862ee056c2 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -8,26 +8,30 @@ import { each } from 'lodash'; describe('POST /user/auth/local/register', () => { context('username and email are free', () => { - it('registers a new user', () => { - let api = requester(); + let api; + + beforeEach(async () => { + api = requester(); + }); + + it('registers a new user', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; - return api.post('/user/auth/local/register', { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user._id).to.exist; - expect(user.apiToken).to.exist; - expect(user.auth.local.username).to.eql(username); }); + + expect(user._id).to.exist; + expect(user.apiToken).to.exist; + expect(user.auth.local.username).to.eql(username); }); it('requires password and confirmPassword to match', () => { - let api = requester(); let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; @@ -46,7 +50,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires a username', () => { - let api = requester(); let email = `${generateRandomUserName()}@example.com`; let password = 'password'; let confirmPassword = 'password'; @@ -63,7 +66,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires an email', () => { - let api = requester(); let username = generateRandomUserName(); let password = 'password'; @@ -79,7 +81,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires a valid email', () => { - let api = requester(); let username = generateRandomUserName(); let email = 'notanemail@sdf'; let password = 'password'; @@ -97,7 +98,6 @@ describe('POST /user/auth/local/register', () => { }); it('requires a password', () => { - let api = requester(); let username = generateRandomUserName(); let email = `${username}@example.com`; let confirmPassword = 'password'; @@ -115,11 +115,13 @@ describe('POST /user/auth/local/register', () => { }); context('login is already taken', () => { - let username, email; + let username, email, api; - beforeEach(() => { + beforeEach(async () => { + api = requester(); username = generateRandomUserName(); email = `${username}@example.com`; + return generateUser({ 'auth.local.username': username, 'auth.local.lowerCaseUsername': username, @@ -128,7 +130,6 @@ describe('POST /user/auth/local/register', () => { }); it('rejects if username is already taken', () => { - let api = requester(); let uniqueEmail = `${generateRandomUserName()}@exampe.com`; let password = 'password'; @@ -145,7 +146,6 @@ describe('POST /user/auth/local/register', () => { }); it('rejects if email is already taken', () => { - let api = requester(); let uniqueUsername = generateRandomUserName(); let password = 'password'; @@ -172,44 +172,44 @@ describe('POST /user/auth/local/register', () => { password = 'password'; }); - it('sets all site tour values to -2 (already seen)', () => { - return api.post('/user/auth/local/register', { + it('sets all site tour values to -2 (already seen)', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.flags.tour).to.not.be.empty; + }); - each(user.flags.tour, (value) => { - expect(value).to.eql(-2); - }); + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tour, (value) => { + expect(value).to.eql(-2); }); }); - it('populates user with default todos, not no other task types', () => { - return api.post('/user/auth/local/register', { + it('populates user with default todos, not no other task types', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.be.empty; - expect(user.tasksOrder.rewards).to.be.empty; }); + + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.be.empty; + expect(user.tasksOrder.rewards).to.be.empty; }); - it('populates user with default tags', () => { - return api.post('/user/auth/local/register', { + it('populates user with default tags', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tags).to.not.be.empty; }); + + expect(user.tags).to.not.be.empty; }); }); @@ -223,44 +223,44 @@ describe('POST /user/auth/local/register', () => { password = 'password'; }); - it('sets all common tutorial flags to true', () => { - return api.post('/user/auth/local/register', { + it('sets all common tutorial flags to true', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.flags.tour).to.not.be.empty; + }); - each(user.flags.tutorial.common, (value) => { - expect(value).to.eql(true); - }); + expect(user.flags.tour).to.not.be.empty; + + each(user.flags.tutorial.common, (value) => { + expect(value).to.eql(true); }); }); - it('populates user with default todos, habits, and rewards', () => { - return api.post('/user/auth/local/register', { + it('populates user with default todos, habits, and rewards', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tasksOrder.todos).to.not.be.empty; - expect(user.tasksOrder.dailys).to.be.empty; - expect(user.tasksOrder.habits).to.not.be.empty; - expect(user.tasksOrder.rewards).to.not.be.empty; }); + + expect(user.tasksOrder.todos).to.not.be.empty; + expect(user.tasksOrder.dailys).to.be.empty; + expect(user.tasksOrder.habits).to.not.be.empty; + expect(user.tasksOrder.rewards).to.not.be.empty; }); - it('populates user with default tags', () => { - return api.post('/user/auth/local/register', { + it('populates user with default tags', async () => { + let user = await api.post('/user/auth/local/register', { username, email, password, confirmPassword: password, - }).then((user) => { - expect(user.tags).to.not.be.empty; }); + + expect(user.tags).to.not.be.empty; }); }); }); From 55db0a4a4b066b5d103f0a8433de9f84e1c1b6db Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Jan 2016 17:27:06 +0100 Subject: [PATCH 079/213] add routes to get a single user, get members for a group, get members invited to a group --- common/locales/en/api-v3.json | 1 + website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/chat.js | 2 +- website/src/controllers/api-v3/members.js | 157 +++++++++++++++++++ website/src/models/group.js | 1 - website/src/models/user.js | 10 +- 6 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 website/src/controllers/api-v3/members.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c934ad7446..c87946e717 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -15,6 +15,7 @@ "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", "onlySocialAttachLocal": "Local auth can only be added to a social account.", "invalidReqParams": "Invalid request parameters.", + "memberIdRequired": "\"member\" must be a valid UUID.", "taskIdRequired": "\"taskId\" must be a valid UUID.", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index fa3a71b818..7eaeb940d5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -167,7 +167,7 @@ function _closeChal (challenge, broken = {}) { 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 User.update({ - challenges: {$in: [challenge._id]}, + challenges: challenge._id, 'tags._id': challenge._id, }, { $set: {'tags.$.challenge': false}, diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 48dc2e440d..7c2d42bf62 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -7,7 +7,7 @@ import { } from '../../libs/api-v3/errors'; import _ from 'lodash'; import { sendTxn } from '../../libs/api-v3/email'; -import nconf from 'nconf'; +import nconf from 'nconf'; let api = {}; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js new file mode 100644 index 0000000000..7bc719d7d4 --- /dev/null +++ b/website/src/controllers/api-v3/members.js @@ -0,0 +1,157 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { + model as User, + publicFields as memberFields, + nameFields, +} from '../../models/user'; +import { model as Group } from '../../models/group'; +import { + NotFound, +} from '../../libs/api-v3/errors'; + +let api = {}; + +// TODO allow only to select nameFields instead of all publicFields? +/** + * @api {get} /members/:memberId Get a member profile + * @apiVersion 3.0.0 + * @apiName GetMember + * @apiGroup Member + * + * @apiParam {UUID} memberId The member's id + * + * @apiSuccess {object} member The member object + */ +api.getMember = { + method: 'GET', + url: '/members/:memberId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let memberId = req.params.memberId; + + let member = await User + .findById(memberId) + .select(memberFields) + .exec(); + + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + + res.respond(200, member); + }, +}; + +// TODO allow to get more members' fields (the same as in api.getMember) for parties? +/** + * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetMembersForGroup + * @apiGroup Member + * + * @apiParam {UUID} groupId The group id + * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results + * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) + * + * @apiSuccess {array} members An array of members, sorted by _id + */ +api.getMembersForGroup = { + method: 'GET', + url: '/groups/:groupId/members', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkQuery('lastId').optional().notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let groupId = req.params.groupId; + let lastId = req.query.lastId; + let user = res.locals.user; + + let group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let query = {}; + let fields = nameFields; + + if (group.type === 'guild') { + query.guilds = group._id; + } else { + query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' + + if (req.query.includeAllPublicFields === 'true') { + fields = memberFields; + } + } + + if (lastId) query._id = {$gt: lastId}; + + let members = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(fields) + .exec(); + + res.respond(200, members); + }, +}; + +// TODO very similar to getInvitesForGroup might be worth abstracting some logic +/** + * @api {get} /groups/:groupId/invites Get invites for a groups with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetInvitesForGroup + * @apiGroup Member + * + * @apiParam {UUID} groupId The group id + * @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results + * + * @apiSuccess {array} invites An array of invites, sorted by _id + */ +api.getInvitesForGroup = { + method: 'GET', + url: '/groups/:groupId/invites', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkQuery('lastId').optional().notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let groupId = req.params.groupId; + let lastId = req.query.lastId; + let user = res.locals.user; + + let group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let query = {}; + + if (group.type === 'guild') { + query['invitations.guilds.id'] = group._id; + } else { + query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' + } + + if (lastId) query._id = {$gt: lastId}; + + let invites = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(nameFields) + .exec(); + + res.respond(200, invites); + }, +}; + +export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 27ca80e73f..4770c5803d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -125,7 +125,6 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -// TODO populate (invites too), isMember? schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) { let query; diff --git a/website/src/models/user.js b/website/src/models/user.js index 8dff4bcafc..0738d48e02 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -338,7 +338,6 @@ export let schema = new Schema({ orderAscending: {type: String, default: 'ascending'}, quest: { key: String, - // TODO why are we storing quest progress here too and not only on party object? progress: { up: {type: Number, default: 0}, down: {type: Number, default: 0}, @@ -489,6 +488,15 @@ schema.plugin(baseModel, { }, }); +// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) +// TODO is all party data meant to be public? +export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt + preferences.costume preferences.sleep preferences.background profile stats achievements party + backer contributor auth.timestamps items`; + +// The minimum amount of data needed when populating multiple users +export let nameFields = `profile.name`; + schema.post('init', function postInitUser (doc) { shared.wrap(doc); }); From eaddf5a393c1a14e415fe2cac4a0d21fcb96ead3 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 18:59:53 +0200 Subject: [PATCH 080/213] Refactor notFound test to use await expect syntax --- test/api/v3/integration/notFound.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/notFound.test.js b/test/api/v3/integration/notFound.test.js index e86b6b35e8..6539a0037d 100644 --- a/test/api/v3/integration/notFound.test.js +++ b/test/api/v3/integration/notFound.test.js @@ -1,10 +1,10 @@ import { requester } from '../../../helpers/api-integration.helper'; describe('notFound Middleware', () => { - it('returns a 404 error when the resource is not found', () => { + it('returns a 404 error when the resource is not found', async () => { let request = requester().get('/api/v3/dummy-url'); - return expect(request).to.eventually.be.rejected.and.eql({ + await expect(request).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: 'Not found.', From 12705932e3b591a2d4afd1ff1f14fd63399b473a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Jan 2016 18:00:03 +0100 Subject: [PATCH 081/213] abstract common logic in members controller --- website/src/controllers/api-v3/members.js | 107 +++++++++------------- 1 file changed, 45 insertions(+), 62 deletions(-) diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 7bc719d7d4..3d36a63cda 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -12,7 +12,6 @@ import { let api = {}; -// TODO allow only to select nameFields instead of all publicFields? /** * @api {get} /members/:memberId Get a member profile * @apiVersion 3.0.0 @@ -46,24 +45,14 @@ api.getMember = { }, }; -// TODO allow to get more members' fields (the same as in api.getMember) for parties? -/** - * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. - * @apiVersion 3.0.0 - * @apiName GetMembersForGroup - * @apiGroup Member - * - * @apiParam {UUID} groupId The group id - * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results - * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) - * - * @apiSuccess {array} members An array of members, sorted by _id - */ -api.getMembersForGroup = { - method: 'GET', - url: '/groups/:groupId/members', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { +// Return a request handler for getMembersForGroup / getInvitesForGroup +// type is `invites` or `members` +function handleGetMembersInvitesForGroup (type) { + if (type !== 'members' && type !== 'invites') { + throw new Error('Type must be "invites" or "members"'); + } + + return async function getMembersOrInvitesForGroup (req, res) { req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); req.checkQuery('lastId').optional().notEmpty().isUUID(); @@ -80,30 +69,56 @@ api.getMembersForGroup = { let query = {}; let fields = nameFields; - if (group.type === 'guild') { - query.guilds = group._id; - } else { - query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' + if (type === 'members') { + if (group.type === 'guild') { + query.guilds = group._id; + } else { + query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' - if (req.query.includeAllPublicFields === 'true') { - fields = memberFields; + if (req.query.includeAllPublicFields === 'true') { + fields = memberFields; + } + } + } else { + if (group.type === 'guild') { // eslint-disable-line no-lonely-if + query['invitations.guilds.id'] = group._id; + } else { + query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' } } if (lastId) query._id = {$gt: lastId}; - let members = await User + let users = await User .find(query) .sortBy({_id: 1}) .limit(30) .select(fields) .exec(); - res.respond(200, members); - }, + res.respond(200, users); + }; +} + +/** + * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetMembersForGroup + * @apiGroup Member + * + * @apiParam {UUID} groupId The group id + * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results + * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member) + * + * @apiSuccess {array} members An array of members, sorted by _id + */ +api.getMembersForGroup = { + method: 'GET', + url: '/groups/:groupId/members', + middlewares: [authWithHeaders(), cron], + handler: handleGetMembersInvitesForGroup('members'), }; -// TODO very similar to getInvitesForGroup might be worth abstracting some logic /** * @api {get} /groups/:groupId/invites Get invites for a groups with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 @@ -119,39 +134,7 @@ api.getInvitesForGroup = { method: 'GET', url: '/groups/:groupId/invites', middlewares: [authWithHeaders(), cron], - async handler (req, res) { - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - req.checkQuery('lastId').optional().notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let groupId = req.params.groupId; - let lastId = req.query.lastId; - let user = res.locals.user; - - let group = await Group.getGroup(user, groupId, '_id type'); - if (!group) throw new NotFound(res.t('groupNotFound')); - - let query = {}; - - if (group.type === 'guild') { - query['invitations.guilds.id'] = group._id; - } else { - query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' - } - - if (lastId) query._id = {$gt: lastId}; - - let invites = await User - .find(query) - .sortBy({_id: 1}) - .limit(30) - .select(nameFields) - .exec(); - - res.respond(200, invites); - }, + handler: handleGetMembersInvitesForGroup('invites'), }; export default api; From 2e3bee08d8cb4867addbb41f733fca41f841201a Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 19:00:16 +0200 Subject: [PATCH 082/213] Refactor User tests to use await expect instead of return expect --- .../user/auth/POST-register_local.test.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 862ee056c2..68ff003645 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -31,13 +31,13 @@ describe('POST /user/auth/local/register', () => { expect(user.auth.local.username).to.eql(username); }); - it('requires password and confirmPassword to match', () => { + it('requires password and confirmPassword to match', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let password = 'password'; let confirmPassword = 'not password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, password, @@ -49,12 +49,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a username', () => { + it('requires a username', async () => { let email = `${generateRandomUserName()}@example.com`; let password = 'password'; let confirmPassword = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { email, password, confirmPassword, @@ -65,11 +65,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires an email', () => { + it('requires an email', async () => { let username = generateRandomUserName(); let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, password, confirmPassword: password, @@ -80,12 +80,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a valid email', () => { + it('requires a valid email', async () => { let username = generateRandomUserName(); let email = 'notanemail@sdf'; let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, password, @@ -97,12 +97,12 @@ describe('POST /user/auth/local/register', () => { }); }); - it('requires a password', () => { + it('requires a password', async () => { let username = generateRandomUserName(); let email = `${username}@example.com`; let confirmPassword = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email, confirmPassword, @@ -129,11 +129,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('rejects if username is already taken', () => { + it('rejects if username is already taken', async () => { let uniqueEmail = `${generateRandomUserName()}@exampe.com`; let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username, email: uniqueEmail, password, @@ -145,11 +145,11 @@ describe('POST /user/auth/local/register', () => { }); }); - it('rejects if email is already taken', () => { + it('rejects if email is already taken', async () => { let uniqueUsername = generateRandomUserName(); let password = 'password'; - return expect(api.post('/user/auth/local/register', { + await expect(api.post('/user/auth/local/register', { username: uniqueUsername, email, password, From ac6a0276abaaf46d4587c5431b23ed28698d2adf Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 12 Jan 2016 19:11:59 +0200 Subject: [PATCH 083/213] Refactor Tasks tests to use await syntax --- .../integration/tasks/DELETE-tasks_id.test.js | 60 ++- .../v3/integration/tasks/GET-tasks.test.js | 42 +- .../v3/integration/tasks/GET-tasks_id.test.js | 54 +-- .../v3/integration/tasks/POST-tasks.test.js | 41 +- .../POST-tasks_id_score_direction.test.js | 367 ++++++++---------- .../v3/integration/tasks/PUT-tasks_id.test.js | 244 ++++++------ ...LETE-tasks_taskId_checklist_itemId.test.js | 54 ++- .../POST-tasks_taskId_checklist.test.js | 65 ++-- ...asks_taskId_checklist_itemId_score.test.js | 56 ++- .../PUT-tasks_taskId_checklist_itemId.test.js | 52 +-- .../DELETE-tasks_taskId_tags_tagId.test.js | 43 +- .../tags/POST-tasks_taskId_tags_tagId.test.js | 59 ++- 12 files changed, 507 insertions(+), 630 deletions(-) diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 1d3e59ef15..a8c804b715 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -6,60 +6,52 @@ import { describe('DELETE /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('task can be deleted', () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); - it('deletes a user\'s task', () => { - return user.del(`/tasks/${task._id}`) - .then(() => { - return expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + it('deletes a user\'s task', async () => { + await user.del(`/tasks/${task._id}`); + + await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); }); context('task cannot be deleted', () => { - it('cannot delete a non-existant task', () => { - return expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ + it('cannot delete a non-existant task', async () => { + await expect(user.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('cannot delete a task owned by someone else', () => { - return generateUser() - .then((anotherUser) => { - return anotherUser.post('/tasks', { - text: 'test habit', - type: 'habit', - }); - }) - .then((task2) => { - return expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + it('cannot delete a task owned by someone else', async () => { + let anotherUser = await generateUser(); + let task2 = await anotherUser.post('/tasks', { + text: 'test habit', + type: 'habit', + }); + + await expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); it('cannot delete active challenge tasks'); // TODO after challenges are implemented diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js index 0772046f0b..4c169b3f6c 100644 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ b/test/api/v3/integration/tasks/GET-tasks.test.js @@ -6,37 +6,27 @@ import Q from 'q'; describe('GET /tasks', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + beforeEach(async () => { + user = await generateUser(); }); - it('returns all user\'s tasks', () => { - let length; - return Q.all([ + it('returns all user\'s tasks', async () => { + let createdTasks = await Q.all([ user.post('/tasks', {text: 'test habit', type: 'habit'}), - ]) - .then((createdTasks) => { - length = createdTasks.length; - return user.get('/tasks'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task - }); + ]); + + let length = createdTasks.length; + let tasks = await user.get('/tasks'); + + expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task }); - it('returns only a type of user\'s tasks if req.query.type is specified', () => { - let habitId; - user.post('/tasks', {text: 'test habit', type: 'habit'}) - .then((task) => { - habitId = task._id; - return user.get('/tasks?type=habit'); - }) - .then((tasks) => { - expect(tasks.length).to.equal(1); - expect(tasks[0]._id).to.equal(habitId); - }); + it('returns only a type of user\'s tasks if req.query.type is specified', async () => { + let task = await user.post('/tasks', {text: 'test habit', type: 'habit'}); + let tasks = await user.get('/tasks?type=habit'); + + expect(tasks.length).to.equal(1); + expect(tasks[0]._id).to.equal(task._id); }); // TODO complete after task scoring is done diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index a4ae455d6a..84df18187a 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -7,29 +7,23 @@ import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - context('task can be accessed', () => { + context('task can be accessed', async () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); - it('gets specified task', () => { - return user.get(`/tasks/${task._id}`) - .then((getTask) => { - expect(getTask).to.eql(task); - }); + it('gets specified task', async () => { + let getTask = await user.get(`/tasks/${task._id}`); + expect(getTask).to.eql(task); }); // TODO after challenges are implemented @@ -37,34 +31,28 @@ describe('GET /tasks/:id', () => { }); context('task cannot be accessed', () => { - it('cannot get a non-existant task', () => { + it('cannot get a non-existant task', async () => { let dummyId = generateUUID(); - return expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ + await expect(user.get(`/tasks/${dummyId}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('cannot get a task owned by someone else', () => { - let anotherUser; + it('cannot get a task owned by someone else', async () => { + let anotherUser = await generateUser(); + let task = await user.post('/tasks', { + text: 'test habit', + type: 'habit', + }); - return generateUser() - .then((user2) => { - anotherUser = user2; - - return user.post('/tasks', { - text: 'test habit', - type: 'habit', - }); - }).then((task) => { - return expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('taskNotFound'), - }); - }); + await expect(anotherUser.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); }); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 245cea7d28..7ce45a9d71 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -7,14 +7,12 @@ describe('POST /tasks', () => { let user; before(async () => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + user = await generateUser(); }); - context('validates params', () => { + context('validates params', async () => { it('returns an error if req.body.type is absent', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { notType: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -24,7 +22,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.type is not valid', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habitF', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -34,7 +32,7 @@ describe('POST /tasks', () => { }); it('returns an error if one object inside an array is invalid', async () => { - return expect(user.post('/tasks', [ + await expect(user.post('/tasks', [ {type: 'habitF'}, {type: 'habit'}, ])).to.eventually.be.rejected.and.eql({ @@ -45,7 +43,7 @@ describe('POST /tasks', () => { }); it('returns an error if req.body.text is absent', async () => { - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ code: 400, @@ -56,49 +54,46 @@ describe('POST /tasks', () => { it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', { + await expect(user.post('/tasks', { type: 'habit', })).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, error: 'BadRequest', message: 'habit validation failed', - }).then(async () => { - let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; - - expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); + + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => { let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; - return expect(user.post('/tasks', [ + await expect(user.post('/tasks', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, error: 'BadRequest', message: 'habit validation failed', - }).then(async () => { - let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; - - expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); + + let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits; + expect(updatedHabitsOrder).to.eql(originalHabitsOrder); }); it('does not save any task sent in an array when 1 is invalid', async () => { let originalTasks = await user.get('/tasks'); - return expect(user.post('/tasks', [ + await expect(user.post('/tasks', [ {type: 'habit'}, // Missing text {type: 'habit', text: 'valid'}, // Valid ])).to.eventually.be.rejected.and.eql({ // this block is necessary code: 400, error: 'BadRequest', message: 'habit validation failed', - }).then(async () => { - let updatedTasks = await user.get('/tasks'); - - expect(updatedTasks).to.eql(originalTasks); }); + + let updatedTasks = await user.get('/tasks'); + expect(updatedTasks).to.eql(originalTasks); }); it('automatically sets "task.userId" to user\'s uuid', async () => { diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2978d93657..b01a54775c 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -7,25 +7,23 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:id/score/:direction', () => { let user; - beforeEach(() => { - return generateUser({ + beforeEach(async () => { + user = await generateUser({ 'stats.gp': 100, - }).then((generatedUser) => { - user = generatedUser; }); }); context('all', () => { - it('requires a task id', () => { - return expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ + it('requires a task id', async () => { + await expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), }); }); - it('requires a task direction', () => { - return expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ + it('requires a task direction', async () => { + await expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -36,110 +34,97 @@ describe('POST /tasks/:id/score/:direction', () => { context('todos', () => { let todo; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + todo = await user.post('/tasks', { text: 'test todo', type: 'todo', - }).then((task) => { - todo = task; }); }); - it('completes todo when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((task) => expect(task.completed).to.equal(true)); + it('completes todo when direction is up', async () => { + await user.post(`/tasks/${todo._id}/score/up`); + let task = await user.get(`/tasks/${todo._id}`); + + expect(task.completed).to.equal(true); }); - it('moves completed todos out of user.tasksOrder.todos', () => { - return user.get('/user') - .then(usr => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - }).then(() => user.post(`/tasks/${todo._id}/score/up`)) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(true); - return user.get('/user'); - }) - .then((usr) => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); - }); + it('moves completed todos out of user.tasksOrder.todos', async () => { + let getUser = await user.get('/user'); + expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + + await user.post(`/tasks/${todo._id}/score/up`); + let updatedTask = await user.get(`/tasks/${todo._id}`); + expect(updatedTask.completed).to.equal(true); + + let updatedUser = await user.get('/user'); + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(-1); }); - it('moves un-completed todos back into user.tasksOrder.todos', () => { - return user.get('/user') - .then(usr => { - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); - }).then(() => user.post(`/tasks/${todo._id}/score/up`)) - .then(() => user.post(`/tasks/${todo._id}/score/down`)) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(false); - return user.get('/user'); - }) - .then((usr) => { - let l = usr.tasksOrder.todos.length; - expect(usr.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); - expect(usr.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom - }); + it('moves un-completed todos back into user.tasksOrder.todos', async () => { + let getUser = await user.get('/user'); + expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1); + + await user.post(`/tasks/${todo._id}/score/up`); + await user.post(`/tasks/${todo._id}/score/down`); + + let updatedTask = await user.get(`/tasks/${todo._id}`); + expect(updatedTask.completed).to.equal(false); + + let updatedUser = await user.get('/user'); + let l = updatedUser.tasksOrder.todos.length; + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom }); - it('uncompletes todo when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/tasks/${todo._id}`)) - .then((updatedTask) => { - expect(updatedTask.completed).to.equal(false); - }); + it('uncompletes todo when direction is down', async () => { + await user.post(`/tasks/${todo._id}/score/down`); + let updatedTask = await user.get(`/tasks/${todo._id}`); + + expect(updatedTask.completed).to.equal(false); }); it('scores up todo even if it is already completed'); // Yes? it('scores down todo even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is up', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${todo._id}/score/up`); + updatedUser = await user.get(`/user`); + }); + + it('increases user\'s mp', () => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - }); - - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s exp', () => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - }); - it('decreases user\'s exp when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${todo._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - it('decreases user\'s gold when direction is down', () => { - return user.post(`/tasks/${todo._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is down', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${todo._id}/score/down`); + updatedUser = await user.get(`/user`); + }); + + it('decreases user\'s mp', () => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + + it('decreases user\'s exp', () => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + + it('decreases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); @@ -148,75 +133,69 @@ describe('POST /tasks/:id/score/:direction', () => { context('dailys', () => { let daily; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + daily = await user.post('/tasks', { text: 'test daily', type: 'daily', - }).then((task) => { - daily = task; }); }); - it('completes daily when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/tasks/${daily._id}`)) - .then((task) => expect(task.completed).to.equal(true)); + it('completes daily when direction is up', async () => { + await user.post(`/tasks/${daily._id}/score/up`); + let task = await user.get(`/tasks/${daily._id}`); + + expect(task.completed).to.equal(true); }); - it('uncompletes daily when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/tasks/${daily._id}`)) - .then((task) => expect(task.completed).to.equal(false)); + it('uncompletes daily when direction is down', async () => { + await user.post(`/tasks/${daily._id}/score/down`); + let task = await user.get(`/tasks/${daily._id}`); + + expect(task.completed).to.equal(false); }); it('scores up daily even if it is already completed'); // Yes? it('scores down daily even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is up', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${daily._id}/score/up`); + updatedUser = await user.get(`/user`); + }); + + it('increases user\'s mp', () => { expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); - }); - - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s exp', () => { expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - }); - it('decreases user\'s exp when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${daily._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + it('increases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - it('decreases user\'s gold when direction is down', () => { - return user.post(`/tasks/${daily._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { + context('user stats when direction is down', () => { + let updatedUser; + + beforeEach(async () => { + await user.post(`/tasks/${daily._id}/score/down`); + updatedUser = await user.get(`/user`); + }); + + it('decreases user\'s mp', () => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + + it('decreases user\'s exp', () => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + + it('decreases user\'s gold', () => { expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); @@ -225,34 +204,29 @@ describe('POST /tasks/:id/score/:direction', () => { context('habits', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + habit = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((task) => { - habit = task; - return user.post('/tasks', { - text: 'test min habit', - type: 'habit', - up: false, - }); - }).then((task) => { - minusHabit = task; - return user.post('/tasks', { - text: 'test plus habit', - type: 'habit', - down: false, - }); - }).then((task) => { - plusHabit = task; - user.post('/tasks', { - text: 'test neither habit', - type: 'habit', - up: false, - down: false, - }); - }).then((task) => { - neitherHabit = task; + }); + + minusHabit = await user.post('/tasks', { + text: 'test min habit', + type: 'habit', + up: false, + }); + + plusHabit = await user.post('/tasks', { + text: 'test plus habit', + type: 'habit', + down: false, + }); + + neitherHabit = await user.post('/tasks', { + text: 'test neither habit', + type: 'habit', + up: false, + down: false, }); }); @@ -260,82 +234,63 @@ describe('POST /tasks/:id/score/:direction', () => { it('prevents minus only habit from scoring up'); // Yes? - it('increases user\'s mp when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); - }); + it('increases user\'s mp when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); - it('decreases user\'s mp when direction is down', () => { - return user.post(`/tasks/${habit._id}/score/down`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); - }); + it('decreases user\'s mp when direction is down', async () => { + await user.post(`/tasks/${habit._id}/score/down`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); - it('increases user\'s exp when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); - }); + it('increases user\'s exp when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); - it('increases user\'s gold when direction is up', () => { - return user.post(`/tasks/${habit._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); - }); + it('increases user\'s gold when direction is up', async () => { + await user.post(`/tasks/${habit._id}/score/up`); + let updatedUser = await user.get(`/user`); + + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); context('reward', () => { - let reward; + let reward, updatedUser; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + reward = await user.post('/tasks', { text: 'test reward', type: 'reward', value: 5, - }).then((task) => { - reward = task; }); + + await user.post(`/tasks/${reward._id}/score/up`); + updatedUser = await user.get(`/user`); }); it('purchases reward', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); - }); + expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); it('does not change user\'s mp', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); + expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); it('does not change user\'s exp', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.exp).to.equal(updatedUser.stats.exp); - }); + expect(user.stats.exp).to.equal(updatedUser.stats.exp); }); it('does not allow a down direction', () => { - return user.post(`/tasks/${reward._id}/score/up`) - .then(() => user.get(`/user`)) - .then((updatedUser) => { - expect(user.stats.mp).to.equal(updatedUser.stats.mp); - }); + expect(user.stats.mp).to.equal(updatedUser.stats.mp); }); }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 3092d3bd84..5f7132136d 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -6,21 +6,17 @@ import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); context('validates params', () => { let task; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + task = await user.post('/tasks', { text: 'test habit', type: 'habit', - }).then((createdTask) => { - task = createdTask; }); }); @@ -52,236 +48,228 @@ describe('PUT /tasks/:id', () => { }); }); - it('ignores invalid fields', () => { - user.put(`/tasks/${task._id}`, { + it('ignores invalid fields', async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { notValid: true, - }).then((savedTask) => { - expect(savedTask.notValid).to.be.a('undefined'); }); + + expect(savedTask.notValid).to.be.undefined; }); }); context('habits', () => { let habit; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + habit = await user.post('/tasks', { text: 'test habit', type: 'habit', notes: 1976, - }).then((createdHabit) => { - habit = createdHabit; }); }); - it('updates a habit', () => { - return user.put(`/tasks/${habit._id}`, { + it('updates a habit', async () => { + let savedHabit = await user.put(`/tasks/${habit._id}`, { text: 'some new text', up: false, down: false, notes: 'some new notes', - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.up).to.eql(false); - expect(task.down).to.eql(false); }); + + expect(savedHabit.text).to.eql('some new text'); + expect(savedHabit.notes).to.eql('some new notes'); + expect(savedHabit.up).to.eql(false); + expect(savedHabit.down).to.eql(false); }); }); context('todos', () => { let todo; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + todo = await user.post('/tasks', { text: 'test todo', type: 'todo', notes: 1976, - }).then((createdTodo) => { - todo = createdTodo; }); }); - it('updates a todo', () => { - return user.put(`/tasks/${todo._id}`, { + it('updates a todo', async () => { + let savedTodo = await user.put(`/tasks/${todo._id}`, { text: 'some new text', notes: 'some new notes', - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); }); + + expect(savedTodo.text).to.eql('some new text'); + expect(savedTodo.notes).to.eql('some new notes'); }); - it('can update checklists (replace it)', () => { - return user.put(`/tasks/${todo._id}`, { + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${todo._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ], - }).then(() => { - return user.put(`/tasks/${todo._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - }).then((savedTodo2) => { - expect(savedTodo2.checklist.length).to.equal(1); - expect(savedTodo2.checklist[0].text).to.equal('789'); - expect(savedTodo2.checklist[0].completed).to.equal(false); }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedTodo.checklist.length).to.equal(1); + expect(savedTodo.checklist[0].text).to.equal('789'); + expect(savedTodo.checklist[0].completed).to.equal(false); }); - it('can update tags (replace them)', () => { + it('can update tags (replace them)', async () => { let finalUUID = generateUUID(); - return user.put(`/tasks/${todo._id}`, { + await user.put(`/tasks/${todo._id}`, { tags: [generateUUID(), generateUUID()], - }).then(() => { - return user.put(`/tasks/${todo._id}`, { - tags: [finalUUID], - }); - }).then((savedTodo2) => { - expect(savedTodo2.tags.length).to.equal(1); - expect(savedTodo2.tags[0]).to.equal(finalUUID); }); + + let savedTodo = await user.put(`/tasks/${todo._id}`, { + tags: [finalUUID], + }); + + expect(savedTodo.tags.length).to.equal(1); + expect(savedTodo.tags[0]).to.equal(finalUUID); }); }); context('dailys', () => { let daily; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + daily = await user.post('/tasks', { text: 'test daily', type: 'daily', notes: 1976, - }).then((createdDaily) => { - daily = createdDaily; }); }); - it('updates a daily', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates a daily', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { text: 'some new text', notes: 'some new notes', frequency: 'daily', everyX: 5, - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.frequency).to.eql('daily'); - expect(task.everyX).to.eql(5); }); + + expect(savedDaily.text).to.eql('some new text'); + expect(savedDaily.notes).to.eql('some new notes'); + expect(savedDaily.frequency).to.eql('daily'); + expect(savedDaily.everyX).to.eql(5); }); - it('can update checklists (replace it)', () => { - return user.put(`/tasks/${daily._id}`, { + it('can update checklists (replace it)', async () => { + await user.put(`/tasks/${daily._id}`, { checklist: [ {text: 123, completed: false}, {text: 456, completed: true}, ], - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - checklist: [ - {text: 789, completed: false}, - ], - }); - }).then((savedDaily2) => { - expect(savedDaily2.checklist.length).to.equal(1); - expect(savedDaily2.checklist[0].text).to.equal('789'); - expect(savedDaily2.checklist[0].completed).to.equal(false); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 789, completed: false}, + ], + }); + + expect(savedDaily.checklist.length).to.equal(1); + expect(savedDaily.checklist[0].text).to.equal('789'); + expect(savedDaily.checklist[0].completed).to.equal(false); }); - it('can update tags (replace them)', () => { + it('can update tags (replace them)', async () => { let finalUUID = generateUUID(); - return user.put(`/tasks/${daily._id}`, { + await user.put(`/tasks/${daily._id}`, { tags: [generateUUID(), generateUUID()], - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - tags: [finalUUID], - }); - }).then((savedDaily2) => { - expect(savedDaily2.tags.length).to.equal(1); - expect(savedDaily2.tags[0]).to.equal(finalUUID); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + tags: [finalUUID], + }); + + expect(savedDaily.tags.length).to.equal(1); + expect(savedDaily.tags[0]).to.equal(finalUUID); }); - it('updates repeat, even if frequency is set to daily', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates repeat, even if frequency is set to daily', async () => { + await user.put(`/tasks/${daily._id}`, { frequency: 'daily', - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - repeat: { - m: false, - su: false, - }, - }); - }).then((savedDaily2) => { - expect(savedDaily2.repeat).to.eql({ + }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + repeat: { m: false, - t: true, - w: true, - th: true, - f: true, - s: true, su: false, - }); + }, + }); + + expect(savedDaily.repeat).to.eql({ + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + su: false, }); }); - it('updates everyX, even if frequency is set to weekly', () => { - return user.put(`/tasks/${daily._id}`, { + it('updates everyX, even if frequency is set to weekly', async () => { + await user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then(() => { - return user.put(`/tasks/${daily._id}`, { - everyX: 5, - }); - }).then((savedDaily2) => { - expect(savedDaily2.everyX).to.eql(5); }); + + let savedDaily = await user.put(`/tasks/${daily._id}`, { + everyX: 5, + }); + + expect(savedDaily.everyX).to.eql(5); }); - it('defaults startDate to today if none date object is passed in', () => { - return user.put(`/tasks/${daily._id}`, { + it('defaults startDate to today if none date object is passed in', async () => { + let savedDaily = await user.put(`/tasks/${daily._id}`, { frequency: 'weekly', - }).then((savedDaily2) => { - expect((new Date(savedDaily2.startDate)).getDay()).to.eql((new Date()).getDay()); }); + + expect((new Date(savedDaily.startDate)).getDay()).to.eql((new Date()).getDay()); }); }); context('rewards', () => { let reward; - beforeEach(() => { - return user.post('/tasks', { + beforeEach(async () => { + reward = await user.post('/tasks', { text: 'test reward', type: 'reward', notes: 1976, value: 10, - }).then((createdReward) => { - reward = createdReward; }); }); - it('updates a reward', () => { - return user.put(`/tasks/${reward._id}`, { + it('updates a reward', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { text: 'some new text', notes: 'some new notes', value: 10, - }).then((task) => { - expect(task.text).to.eql('some new text'); - expect(task.notes).to.eql('some new notes'); - expect(task.value).to.eql(10); }); + + expect(savedReward.text).to.eql('some new text'); + expect(savedReward.notes).to.eql('some new notes'); + expect(savedReward.value).to.eql(10); }); - it('requires value to be coerced into a number', () => { - return user.put(`/tasks/${reward._id}`, { + it('requires value to be coerced into a number', async () => { + let savedReward = await user.put(`/tasks/${reward._id}`, { value: '100', - }).then((task) => { - expect(task.value).to.eql(100); }); + + expect(savedReward.value).to.eql(100); }); }); }); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index d013878a74..6c690c6eb9 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -7,39 +7,31 @@ import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/checklist/:itemId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('deletes a checklist item', () => { - let task; - - return user.post('/tasks', { + it('deletes a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); - }).then(() => { - return user.get(`/tasks/${task._id}`); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(0); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + + await user.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + savedTask = await user.get(`/tasks/${task._id}`); + + expect(savedTask.checklist.length).to.equal(0); }); - it('does not work with habits', () => { - let habit; - return expect(user.post('/tasks', { + it('does not work with habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${habit._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('checklistOnlyDailyTodo'), @@ -59,21 +51,21 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on task not found', () => { - return expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e9b695effd..3e1dfb8494 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -7,41 +7,38 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('adds a checklist item to a task', () => { - let task; - - return user.post('/tasks', { + it('adds a checklist item to a task', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); - expect(savedTask.checklist[0].completed).to.equal(false); - expect(savedTask.checklist[0]._id).to.be.a('string'); - expect(savedTask.checklist[0]._id).to.not.equal('123'); - expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + ignored: false, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); }); - it('does not add a checklist to habits', () => { - let habit; - - return expect(user.post('/tasks', { + it('does not add a checklist to habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${habit._id}/checklist`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -49,14 +46,14 @@ describe('POST /tasks/:taskId/checklist/', () => { }); }); - it('does not add a checklist to rewards', () => { - let reward; - return expect(user.post('/tasks', { + it('does not add a checklist to rewards', async () => { + let reward = await user.post('/tasks', { type: 'reward', text: 'reward with checklist', - }).then(createdTask => { - reward = createdTask; - return user.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${reward._id}/checklist`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -64,8 +61,8 @@ describe('POST /tasks/:taskId/checklist/', () => { }); }); - it('fails on task not found', () => { - return expect(user.post(`/tasks/${generateUUID()}/checklist`, { + it('fails on task not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/checklist`, { text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 667ef41446..345e1c400e 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -7,37 +7,35 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/checklist/:itemId/score', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('scores a checklist item', () => { - let task; - - return user.post('/tasks', { + it('scores a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].completed).to.equal(true); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].completed).to.equal(true); }); - it('fails on habits', () => { - let habit; - return expect(user.post('/tasks', { + it('fails on habits', async () => { + let habit = await user.post('/tasks', { type: 'habit', text: 'habit with checklist', - }).then(createdTask => { - habit = createdTask; - return user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); + }); + + await expect(user.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, { + text: 'Checklist Item 1', })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', @@ -58,21 +56,21 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); }); - it('fails on task not found', () => { - return expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index 988574bbf8..0d93f6cc64 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -7,29 +7,31 @@ import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:taskId/checklist/:itemId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('updates a checklist item', () => { - let task; - - return user.post('/tasks', { + it('updates a checklist item', async () => { + let task = await user.post('/tasks', { type: 'daily', text: 'Daily with checklist', - }).then(createdTask => { - task = createdTask; - return user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); - }).then((savedTask) => { - return user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); - }).then((savedTask) => { - expect(savedTask.checklist.length).to.equal(1); - expect(savedTask.checklist[0].text).to.equal('updated'); - expect(savedTask.checklist[0].completed).to.equal(true); - expect(savedTask.checklist[0]._id).to.not.equal('123'); }); + + let savedTask = await user.post(`/tasks/${task._id}/checklist`, { + text: 'Checklist Item 1', + completed: false, + }); + + savedTask = await user.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, { + text: 'updated', + completed: true, + _id: 123, + }); + + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); }); it('fails on habits', async () => { @@ -58,21 +60,21 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); }); - it('fails on task not found', () => { - return expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + it('fails on task not found', async () => { + await expect(user.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), }); }); - it('fails on checklist item not found', () => { - return expect(user.post('/tasks', { + it('fails on checklist item not found', async () => { + let createdTask = await user.post('/tasks', { type: 'daily', text: 'daily with checklist', - }).then(createdTask => { - return user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('checklistItemNotFound'), diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js index c01b71fa2c..7ddeb6fe14 100644 --- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -7,40 +7,33 @@ import { v4 as generateUUID } from 'uuid'; describe('DELETE /tasks/:taskId/tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('removes a tag from a task', () => { - let tag; - let task; - - return user.post('/tasks', { + it('removes a tag from a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => { - return user.del(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => user.get(`/tasks/${task._id}`)) - .then(updatedTask => { - expect(updatedTask.tags.length).to.equal(0); }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + + await user.post(`/tasks/${task._id}/tags/${tag._id}`); + await user.del(`/tasks/${task._id}/tags/${tag._id}`); + + let updatedTask = await user.get(`/tasks/${task._id}`); + + expect(updatedTask.tags.length).to.equal(0); }); - it('only deletes existing tags', () => { - return expect(user.post('/tasks', { + it('only deletes existing tags', async () => { + let createdTask = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - return user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('tagNotFound'), diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js index 6e4c2ba510..8377d5a012 100644 --- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -7,59 +7,46 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:taskId/tags/:tagId', () => { let user; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - }); + before(async () => { + user = await generateUser(); }); - it('adds a tag to a task', () => { - let tag; - let task; - - return user.post('/tasks', { + it('adds a tag to a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(savedTask => { - expect(savedTask.tags[0]).to.equal(tag._id); }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + let savedTask = await user.post(`/tasks/${task._id}/tags/${tag._id}`); + + expect(savedTask.tags[0]).to.equal(tag._id); }); - it('does not add a tag to a task twice', () => { - let tag; - let task; - - return expect(user.post('/tasks', { + it('does not add a tag to a task twice', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then(createdTask => { - task = createdTask; - return user.post('/tags', {name: 'Tag 1'}); - }).then(createdTag => { - tag = createdTag; - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - }).then(() => { - return user.post(`/tasks/${task._id}/tags/${tag._id}`); - })).to.eventually.be.rejected.and.eql({ + }); + + let tag = await user.post('/tags', {name: 'Tag 1'}); + + await user.post(`/tasks/${task._id}/tags/${tag._id}`); + + await expect(user.post(`/tasks/${task._id}/tags/${tag._id}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('alreadyTagged'), }); }); - it('does not add a non existing tag to a task', () => { - return expect(user.post('/tasks', { + it('does not add a non existing tag to a task', async () => { + let task = await user.post('/tasks', { type: 'habit', text: 'Task with tag', - }).then((task) => { - return user.post(`/tasks/${task._id}/tags/${generateUUID()}`); - })).to.eventually.be.rejected.and.eql({ + }); + + await expect(user.post(`/tasks/${task._id}/tags/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), From 99217e865cb4589b3aac5320d62c53555c21685f Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 11 Jan 2016 12:53:03 -0600 Subject: [PATCH 084/213] Added initial group update tests --- .../v3/integration/groups/PUT-groups.test.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/api/v3/integration/groups/PUT-groups.test.js diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js new file mode 100644 index 0000000000..5a7abf3467 --- /dev/null +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -0,0 +1,50 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration.helper'; + +describe('Put /group', () => { + let groupLeader; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; + + beforeEach(async () => { + groupLeader = await generateUser({balance: 1}); + }); + + xit('returns an error when a non group leader tries to update', async () => { + let groupToUpdate = await groupLeader.post('/groups', { + name: groupName, + type: groupType, + }); + let groupUpdatedName = 'Test Public Guild Updated'; + let memberToAttemptUpdate = await generateUser(); + + await groupLeader.post(`/groups/${groupToUpdate._id}/invite`, { + uuids: [memberToAttemptUpdate._id], + }); + await memberToAttemptUpdate.post(`/groups/${groupToUpdate._id}/join`); + + await expect(memberToAttemptUpdate.put(`/groups/${groupToUpdate._id}`, { + name: groupUpdatedName, + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupOnlyLeaderCanUpdate'), + }); + }); + + it('updates a group', async () => { + let groupUpdatedName = 'Test Public Guild Updated'; + let group = await groupLeader.post('/groups', { + name: groupName, + type: groupType, + }); + let updatedGroup = await groupLeader.put(`/groups/${group._id}`, { + name: groupUpdatedName, + }); + + expect(updatedGroup.name).to.equal(groupUpdatedName); + }); +}); From da02f8ca0f333df518108be475feedbba94ec904 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 11:48:07 -0600 Subject: [PATCH 085/213] Removed pending status on test --- test/api/v3/integration/groups/PUT-groups.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 5a7abf3467..38c17df823 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -12,7 +12,7 @@ describe('Put /group', () => { groupLeader = await generateUser({balance: 1}); }); - xit('returns an error when a non group leader tries to update', async () => { + it('returns an error when a non group leader tries to update', async () => { let groupToUpdate = await groupLeader.post('/groups', { name: groupName, type: groupType, From 45aacc1e6f758c622ad2bbffd90e80108a43d5ad Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 12 Jan 2016 19:43:41 +0100 Subject: [PATCH 086/213] challenge.group -> challenge.groupId --- website/src/controllers/api-v3/challenges.js | 10 +++++----- website/src/models/challenge.js | 11 ++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index fa3a71b818..252ffe6c7e 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -30,12 +30,12 @@ api.createChallenge = { async handler (req, res) { let user = res.locals.user; - req.checkBody('group', res.t('groupIdRequired')).notEmpty(); + req.checkBody('groupId', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let groupId = req.body.group; + let groupId = req.body.groupId; let prize = req.body.prize; let group = await Group.getGroup(user, groupId, '-chat'); @@ -107,7 +107,7 @@ api.getChallenges = { let challenges = await Challenge.find({ $or: [ {_id: {$in: user.challenges}}, // Challenges where the user is participating - {group: {$in: groups}}, // Challenges in groups where I'm a member + {groupId: {$in: groups}}, // Challenges in groups where I'm a member {leader: user._id}, // Challenges where I'm the leader ], _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit @@ -183,11 +183,11 @@ function _closeChal (challenge, broken = {}) { }, }, {multi: true}).exec(), // Update the challengeCount on the group - Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(), + 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.group !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + if (challenge.groupId !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { tasks.push(User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec()); } diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index d5b3bf1a1b..00ab26a491 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -11,7 +11,7 @@ let schema = new Schema({ name: {type: String, required: true}, shortName: {type: String, required: true}, // TODO what is it? description: String, - official: {type: Boolean, default: false}, + official: {type: Boolean, default: false}, // TODO only settable by admin tasksOrder: { habits: [{type: String, ref: 'Task'}], dailys: [{type: String, ref: 'Task'}], @@ -19,14 +19,15 @@ let schema = new Schema({ rewards: [{type: String, ref: 'Task'}], }, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, - timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? + groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, + timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? memberCount: {type: Number, default: 0}, - prize: {type: Number, default: 0, min: 0}, + challengeCount: {type: Number, default: 0}, + prize: {type: Number, default: 0, min: 0}, // TODO no update? }); schema.plugin(baseModel, { - noSet: ['_id', 'memberCount', 'tasksOrder'], + noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); From e4fd37f3d0f450f11e199b73aa20f2f3c3337527 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 13:08:13 -0600 Subject: [PATCH 087/213] Cleaned up code style and abstracted variables that are reused. --- .../v3/integration/groups/PUT-groups.test.js | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 38c17df823..6700374697 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -3,21 +3,22 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; -describe('Put /group', () => { +describe('PUT /group', () => { let groupLeader; let groupName = 'Test Public Guild'; let groupType = 'guild'; + let groupToUpdate; + let groupUpdatedName = 'Test Public Guild Updated'; beforeEach(async () => { groupLeader = await generateUser({balance: 1}); - }); - - it('returns an error when a non group leader tries to update', async () => { - let groupToUpdate = await groupLeader.post('/groups', { + groupToUpdate = await groupLeader.post('/groups', { name: groupName, type: groupType, }); - let groupUpdatedName = 'Test Public Guild Updated'; + }); + + it('returns an error when a non group leader tries to update', async () => { let memberToAttemptUpdate = await generateUser(); await groupLeader.post(`/groups/${groupToUpdate._id}/invite`, { @@ -27,8 +28,7 @@ describe('Put /group', () => { await expect(memberToAttemptUpdate.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, - })) - .to.eventually.be.rejected.and.eql({ + })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupOnlyLeaderCanUpdate'), @@ -36,12 +36,7 @@ describe('Put /group', () => { }); it('updates a group', async () => { - let groupUpdatedName = 'Test Public Guild Updated'; - let group = await groupLeader.post('/groups', { - name: groupName, - type: groupType, - }); - let updatedGroup = await groupLeader.put(`/groups/${group._id}`, { + let updatedGroup = await groupLeader.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, }); From 336d8c9916ed79d0c800a535003b053c9c452147 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 17:36:14 -0600 Subject: [PATCH 088/213] tests(api): Clean up misc test styles --- test/api/v3/integration/tags/DELETE-tags_id.test.js | 9 ++++----- test/api/v3/integration/tags/GET-tags.test.js | 10 ++++------ test/api/v3/integration/tags/POST-tags.test.js | 6 +++--- test/api/v3/integration/tags/PUT-tags_id.test.js | 6 +++--- test/api/v3/integration/tasks/DELETE-tasks_id.test.js | 4 ++-- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js index b83521613b..9d6b53c531 100644 --- a/test/api/v3/integration/tags/DELETE-tags_id.test.js +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -4,25 +4,24 @@ import { describe('DELETE /tags/:tagId', () => { let user; - let tagName = 'Tag 1'; before(async () => { user = await generateUser(); }); it('deletes a tag given it\'s id', async () => { + let tagName = 'Tag 1'; let tag = await user.post('/tags', {name: tagName}); - let tags = await user.get('/tags'); - let length = tags.length; + let numberOfTags = (await user.get('/tags')).length; await user.del(`/tags/${tag._id}`); - tags = await user.get('/tags'); + let tags = await user.get('/tags'); let tagNames = tags.map((t) => { return t.name; }); - expect(tags.length).to.equal(length - 1); + expect(tags.length).to.equal(numberOfTags - 1); expect(tagNames).to.not.include(tagName); }); }); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js index 2cb30be885..5281bd35af 100644 --- a/test/api/v3/integration/tags/GET-tags.test.js +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -4,21 +4,19 @@ import { describe('GET /tags', () => { let user; - let tagName1 = 'Tag 1'; - let tagName2 = 'Tag 2'; before(async () => { user = await generateUser(); }); it('returns all user\'s tags', async () => { - await user.post('/tags', {name: tagName1}); - await user.post('/tags', {name: tagName2}); + let tag1 = await user.post('/tags', {name: 'Tag 1'}); + let tag2 = await user.post('/tags', {name: 'Tag 2'}); let tags = await user.get('/tags'); expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task - expect(tags[tags.length - 2].name).to.equal(tagName1); - expect(tags[tags.length - 1].name).to.equal(tagName2); + expect(tags[tags.length - 2].name).to.equal(tag1.name); + expect(tags[tags.length - 1].name).to.equal(tag2.name); }); }); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js index 78a73c578a..7b7b5c4a34 100644 --- a/test/api/v3/integration/tags/POST-tags.test.js +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -4,13 +4,13 @@ import { describe('POST /tags', () => { let user; - let tagName = 'Tag 1'; - before(async () => { + beforeEach(async () => { user = await generateUser(); }); it('creates a tag correctly', async () => { + let tagName = 'Tag 1'; let createdTag = await user.post('/tags', { name: tagName, ignored: false, @@ -19,7 +19,7 @@ describe('POST /tags', () => { let tag = await user.get(`/tags/${createdTag._id}`); expect(tag.name).to.equal(tagName); - expect(tag.ignored).to.be.undefined; + expect(tag.ignored).to.not.exist; expect(tag).to.deep.equal(createdTag); }); }); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js index 71fbc2ef58..c2576a3f0c 100644 --- a/test/api/v3/integration/tags/PUT-tags_id.test.js +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -4,13 +4,13 @@ import { describe('PUT /tags/:tagId', () => { let user; - let updatedTagName = 'Tag updated'; before(async () => { user = await generateUser(); }); it('updates a tag given it\'s id', async () => { + let updatedTagName = 'Tag updated'; let createdTag = await user.post('/tags', {name: 'Tag 1'}); let updatedTag = await user.put(`/tags/${createdTag._id}`, { name: updatedTagName, @@ -20,9 +20,9 @@ describe('PUT /tags/:tagId', () => { createdTag = await user.get(`/tags/${updatedTag._id}`); expect(updatedTag.name).to.equal(updatedTagName); - expect(updatedTag.ignored).to.be.undefined; + expect(updatedTag.ignored).to.not.exist; expect(createdTag.name).to.equal(updatedTagName); - expect(createdTag.ignored).to.be.undefined; + expect(createdTag.ignored).to.not.exist; }); }); diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index a8c804b715..59866dab3b 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -42,12 +42,12 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', async () => { let anotherUser = await generateUser(); - let task2 = await anotherUser.post('/tasks', { + let anotherUsersTask = await anotherUser.post('/tasks', { text: 'test habit', type: 'habit', }); - await expect(user.del(`/tasks/${task2._id}`)).to.eventually.be.rejected.and.eql({ + await expect(user.del(`/tasks/${anotherUsersTask._id}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('taskNotFound'), From a3fd838677271239687c069c1b71cd88982bc941 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Tue, 12 Jan 2016 18:01:47 -0600 Subject: [PATCH 089/213] tests(api): Fix PUT /task test to be valid --- .../v3/integration/tasks/PUT-tasks_id.test.js | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 5f7132136d..bbb42fa69b 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -22,8 +22,8 @@ describe('PUT /tasks/:id', () => { it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`, () => { - user.put(`/tasks/${task._id}`, { + dateCompleted fields`, async () => { + let savedTask = await user.put(`/tasks/${task._id}`, { _id: 123, type: 'daily', userId: 123, @@ -34,18 +34,18 @@ describe('PUT /tasks/:id', () => { completed: true, streak: 25, dateCompleted: 'never', - }).then((savedTask) => { - expect(savedTask._id).to.equal(task._id); - expect(savedTask.type).to.equal(task.type); - expect(savedTask.userId).to.equal(user._id); - expect(savedTask.history).to.eql([]); - expect(savedTask.createdAt).not.to.equal('yesterday'); - expect(savedTask.updatedAt).not.to.equal('tomorrow'); - expect(savedTask.challenge).not.to.equal('no'); - expect(savedTask.completed).to.equal(false); - expect(savedTask.streak).to.equal(0); - expect(savedTask.streak).not.to.equal('never'); }); + + expect(savedTask._id).to.equal(task._id); + expect(savedTask.type).to.equal(task.type); + expect(savedTask.userId).to.equal(task.userId); + expect(savedTask.history).to.eql(task.history); + expect(savedTask.createdAt).to.equal(task.createdAt); + expect(savedTask.updatedAt).to.be.greaterThan(task.updatedAt); + expect(savedTask.challenge).to.equal(task.challenge); + expect(savedTask.completed).to.equal(task.completed); + expect(savedTask.streak).to.equal(task.streak); + expect(savedTask.dateCompleted).to.equal(task.dateCompleted); }); it('ignores invalid fields', async () => { From 7c15472fab0d83ff10b8514e6ac6d21c895704cc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Jan 2016 18:29:02 +0100 Subject: [PATCH 090/213] wip on members controllers --- website/src/controllers/api-v3/members.js | 67 ++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 3d36a63cda..9ac5d826f3 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -101,7 +101,7 @@ function handleGetMembersInvitesForGroup (type) { } /** - * @api {get} /groups/:groupId/members Get members for a groups with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /groups/:groupId/members Get members for a group with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetMembersForGroup * @apiGroup Member @@ -120,7 +120,7 @@ api.getMembersForGroup = { }; /** - * @api {get} /groups/:groupId/invites Get invites for a groups with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @api {get} /groups/:groupId/invites Get invites for a group with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results. * @apiVersion 3.0.0 * @apiName GetInvitesForGroup * @apiGroup Member @@ -137,4 +137,67 @@ api.getInvitesForGroup = { handler: handleGetMembersInvitesForGroup('invites'), }; +/** + * @api {get} /challenges/:challengeId/members Get members for a challenge with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results. + * @apiVersion 3.0.0 + * @apiName GetMembersForChallenge + * @apiGroup Member + * + * @apiParam {UUID} challengeId The challenge id + * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results + * + * @apiSuccess {array} members An array of members, sorted by _id + */ +api.getMembersForChallenge = { + method: 'GET', + url: '/challenges/:challengeId/members', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty(); + req.checkQuery('lastId').optional().notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challengeId = req.params.challengeId; + let lastId = req.query.lastId; + let user = res.locals.user; + + let challenge = await Challenge.findById(challengeId).exec(); + + let query = {}; + let fields = nameFields; + + if (type === 'members') { + if (group.type === 'guild') { + query.guilds = group._id; + } else { + query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' + + if (req.query.includeAllPublicFields === 'true') { + fields = memberFields; + } + } + } else { + if (group.type === 'guild') { // eslint-disable-line no-lonely-if + query['invitations.guilds.id'] = group._id; + } else { + query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' + } + } + + if (lastId) query._id = {$gt: lastId}; + + let users = await User + .find(query) + .sortBy({_id: 1}) + .limit(30) + .select(fields) + .exec(); + + res.respond(200, users); + } +}; + + export default api; From 6088b6da42ef56fc6589a483b7b03c18fe01bd3d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Jan 2016 18:35:29 +0100 Subject: [PATCH 091/213] fix edge case where res.respond would not work, misc comments --- website/src/controllers/api-v3/challenges.js | 1 + website/src/controllers/api-v3/groups.js | 4 ++-- website/src/middlewares/api-v3/errorHandler.js | 4 +++- website/src/models/group.js | 1 - 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 252ffe6c7e..3b25cd359e 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -146,6 +146,7 @@ api.getChallenge = { let challenge = await Challenge.findOne({_id: challengeId}).exec(); // TODO populate // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error + // TODO support challenges in groups I'm a member of if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens throw new NotFound(res.t('challengeNotFound')); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 70da1221c0..85ba270935 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -100,13 +100,13 @@ api.getGroups = { type: 'guild', privacy: 'private', _id: {$in: user.guilds}, - }).select(groupFields).sort(sort).exec()); // TODO isMember + }).select(groupFields).sort(sort).exec()); break; case 'publicGuilds': queries.push(Group.find({ type: 'guild', privacy: 'public', - }).select(groupFields).sort(sort).exec()); // TODO use lean? isMember + }).select(groupFields).sort(sort).exec()); // TODO use lean? break; case 'tavern': queries.push(Group.getGroup(user, 'habitrpg', groupFields)); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index f3b4f65cdb..0590ad9f55 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -74,5 +74,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l if (responseErr.errors) jsonRes.errors = responseErr.errors; - return res.respond(responseErr.httpCode, jsonRes); + // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible, + // in this case we use the standard res.status(...).json(...) + return res.respond ? res.respond(responseErr.httpCode, jsonRes) : res.status(responseErr.httpCode).json(jsonRes); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 27ca80e73f..91ae59e714 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -146,7 +146,6 @@ schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMemb // TODO purge chat flags info? in tojson? }; -// TODO move to its own model export function chatDefaults (msg, user) { let message = { id: shared.uuid(), From 1f4e58e5cc44abbd3c866a4faefd8414f5a417f9 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 13 Jan 2016 12:39:23 -0600 Subject: [PATCH 092/213] tests(api): Port integration helper to v3 version for groups tests --- test/api/v3/integration/chat/GET-chat.test.js | 2 +- .../integration/chat/POST-chat.flag.test.js | 2 +- .../integration/chat/POST-chat.like.test.js | 2 +- .../api/v3/integration/chat/POST-chat.test.js | 2 +- .../v3/integration/groups/POST-groups.test.js | 2 +- .../groups/POST-groups_groupId_join.js | 2 +- .../groups/POST-groups_invite.test.js | 2 +- .../v3/integration/groups/PUT-groups.test.js | 2 +- test/helpers/api-v3-integration.helper.js | 40 ++++++++++++++----- 9 files changed, 37 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index cb1e0996e6..dbd631e362 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -2,7 +2,7 @@ import { generateUser, generateGroup, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('GET /groups/:groupId/chat', () => { let user; diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index b71d462e5a..47ba58b046 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index 05d40f6e9f..cf266431ba 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { find } from 'lodash'; describe('POST /chat/:chatId/like', () => { diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index 37403ed12d..fe943bfb1c 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('POST /chat', () => { let user; diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 3515f07cec..8a64ef4bb5 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('POST /group', () => { let user; diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js index 2c36d12f19..ca7c8317b5 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /group/:groupId/join', () => { diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 2466bdf649..42719c0fcd 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; const INVITES_LIMIT = 100; diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 6700374697..5c23668ddf 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -1,7 +1,7 @@ import { generateUser, translate as t, -} from '../../../../helpers/api-integration.helper'; +} from '../../../../helpers/api-v3-integration.helper'; describe('PUT /group', () => { let groupLeader; diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index dba72ca99d..30898513e2 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -14,6 +14,15 @@ import i18n from '../../common/script/src/i18n'; i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; const API_TEST_SERVER_PORT = 3003; +const API_V = process.env.API_VERSION || 'v2'; // eslint-disable-line no-process-env +const ROUTES = { + v2: { + register: '/register', + }, + v3: { + register: '/user/auth/local/register', + }, +}; class ApiUser { constructor (options) { @@ -96,7 +105,7 @@ export function generateUser (update = {}) { let request = _requestMaker({}, 'post'); return new Promise((resolve, reject) => { - request('/user/auth/local/register', { + request(ROUTES[API_V].register, { username, email, password, @@ -114,15 +123,15 @@ export function generateUser (update = {}) { // Generates a new group. Requires a user object, which // will will become the groups leader. Takes an update // argument which will update group -export function generateGroup (leader, update = {}) { +export function generateGroup (leader, details = {}, update = {}) { let request = _requestMaker(leader, 'post'); return new Promise((resolve, reject) => { - request('/groups').then((group) => { + request('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); - }).catch(reject); - }); + }); + }).catch(reject); }); } @@ -241,7 +250,7 @@ export function resetHabiticaDB () { function _requestMaker (user, method, additionalSets) { return (route, send, query) => { return new Promise((resolve, reject) => { - let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/v3${route}`) + let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${API_V}${route}`) .accept('application/json'); if (user && user._id && user.apiToken) { @@ -261,11 +270,20 @@ function _requestMaker (user, method, additionalSets) { if (err) { if (!err.response) return reject(err); - return reject({ - code: err.status, - error: err.response.body.error, - message: err.response.body.message, - }); + if (API_V === 'v3') { + return reject({ + code: err.status, + error: err.response.body.error, + message: err.response.body.message, + }); + } else if (API_V === 'v2') { + return reject({ + code: err.status, + text: err.response.body.err, + }); + } + + return reject(err); } resolve(response.body); From 9a908785c130c1c32a963b9055176955691f3ca5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 13 Jan 2016 23:07:08 +0100 Subject: [PATCH 093/213] new history preening, save tasks after cron --- common/script/api-v3/cron.js | 6 +- common/script/api-v3/preenHistory.js | 81 ---------------- common/script/api-v3/preening.js | 82 ++++++++++++++++ test/common/algos.mocha.js | 118 ------------------------ test/common/preening.test.js | 70 ++++++++++++++ website/src/controllers/api-v3/tasks.js | 27 +++++- website/src/middlewares/api-v3/cron.js | 15 ++- 7 files changed, 189 insertions(+), 210 deletions(-) delete mode 100644 common/script/api-v3/preenHistory.js create mode 100644 common/script/api-v3/preening.js create mode 100644 test/common/preening.test.js diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js index 604b6254f4..6f8626c7d8 100644 --- a/common/script/api-v3/cron.js +++ b/common/script/api-v3/cron.js @@ -1,7 +1,7 @@ import moment from 'moment'; import _ from 'lodash'; import scoreTask from './scoreTask'; -import preenUserHistory from './preenHistory'; +import { preenUserHistory } from './preening'; import common from '../../'; import { shouldDo, @@ -65,7 +65,7 @@ export default function cron (options = {}) { gemCapExtra: 0, }); - user.markModified('purchased.plan'); // TODO necessary? + user.markModified('purchased.plan'); } } @@ -197,7 +197,7 @@ export default function cron (options = {}) { // preen user history so that it doesn't become a performance problem // also for subscribed users but differentyly // premium subscribers can keep their full history. - preenUserHistory(user, tasksByType); + preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); if (perfect) { user.achievements.perfect++; diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js deleted file mode 100644 index 64ae4e4715..0000000000 --- a/common/script/api-v3/preenHistory.js +++ /dev/null @@ -1,81 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; - -function _preen (newHistory, history, amount, groupBy) { - _.chain(history) - .groupBy(h => moment(h.date).format(groupBy)) - .sortBy((h, k) => k) - .slice(-amount) - .pop() - .each((group) => { - newHistory.push({ - date: moment(group[0].date).toDate(), - value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, - }); - }) - .value(); -} - -// Free users: -// Preen history for users with > 7 history entries -// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array -// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week -// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] -// -// Subscribers: -// TODO implement - -// TODO Probably the description ^ is not too correct, this method actually takes 1 value each for the last 50 years, -// then the X last months, where X is the month we're in (september = 8 starting from 0) -// and all the days in this month -// Allowing for multiple values in a single day for habits we probably want something different: -// For free users: -// - At max 30 values for today (max 30) -// - 1 value each for the previous 61 days (2 months) -// - 1 value each for the previous 10 months (max 10) -// - 1 value each for the previous 50 years -// - Total: 30+61+10+ a few years ~= 105 -// -// For subscribed users -// - At max 30 values for today (max 30) -// - 1 value each for the previous 364 days (max 364) -// - 1 value each for the previous 12 months (max 12) -// - 1 value each for the previous 50 years -// - Total: 30+364+12+ a few years ~= 410 -// -export function preenHistory (history) { - // TODO remember to add this to migration - /* history = _.filter(history, function(h) { - return !!h; - }); */ - let newHistory = []; - - _preen(newHistory, history, 50, 'YYYY'); - _preen(newHistory, history, moment().format('MM'), 'YYYYMM'); - - let thisMonth = moment().format('YYYYMM'); - newHistory = newHistory.concat(history.filter(h => { - return moment(h.date).format('YYYYMM') === thisMonth; - })); - - return newHistory; -} - -export function preenUserHistory (user, tasksByType, minHistLen = 7) { - tasksByType.habits.concat(tasksByType.dailys).forEach((task) => { - if (task.history.length > minHistLen) { - task.history = preenHistory(user, task.history); - task.markModified('history'); - } - }); - - if (user.history.exp.length > minHistLen) { - user.history.exp = preenHistory(user, user.history.exp); - user.markModified('history.exp'); - } - - if (user.history.todos.length > minHistLen) { - user.history.todos = preenHistory(user, user.history.todos); - user.markModified('history.todos'); - } -} diff --git a/common/script/api-v3/preening.js b/common/script/api-v3/preening.js new file mode 100644 index 0000000000..ee6a201b3a --- /dev/null +++ b/common/script/api-v3/preening.js @@ -0,0 +1,82 @@ +import _ from 'lodash'; +import moment from 'moment'; + +// Aggregate entries +function _aggregate (history, aggregateBy) { + return _.chain(history) + .groupBy(entry => { // group entries by aggregateBy + return moment(entry.date).format(aggregateBy); + }) + .sortBy((entry, key) => key) // sort by date + .map(entries => { + return { + date: Number(entries[0].date), + value: _.reduce(entries, (previousValue, entry) => { + return previousValue + entry.value; + }, 0) / entries.length, + }; + }) + .value(); +} + +/* Preen an array of history entries +Free users: +- 1 value for each day of the past 60 days (no compression) +- 1 value each month for the previous 10 months +- 1 value each year for the previous years +Subscribers and challenges: +- 1 value for each day of the past 365 days (no compression) +- 1 value each month for the previous 12 months +- 1 value each year for the previous years + */ +export function preenHistory (history, isSubscribed, timezoneOffset) { + // history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries TODO add to migration + let now = timezoneOffset ? moment().zone(timezoneOffset) : moment(); + // Date after which to begin compressing data + let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day'); + + // Keep uncompressed entries (modifies history) + let newHistory = _.remove(history, entry => { + let date = moment(entry.date); + return date.isSame(cutOff) || date.isAfter(cutOff); + }); + + // Date after which to begin compressing data by year + let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day'); + let aggregateByMonth = _.remove(history, entry => { + let date = moment(entry.date); + return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff); + }); + // Aggregate remaining entries by month and year + if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM')); + if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY')); + + return newHistory; +} + +// Preen history for users and tasks. This code runs only on the server. +export function preenUserHistory (user, tasksByType) { + let isSubscribed = user.isSubscribed(); + let timezoneOffset = user.preferences.timezoneOffset; + let minHistoryLength = isSubscribed ? 365 : 60; + + function _processTask (task) { + if (task.history && task.history.length > minHistoryLength) { + task.history = preenHistory(task.history, isSubscribed, timezoneOffset); + task.markModified('history'); + } + } + + tasksByType.habits.forEach(_processTask); + tasksByType.dailys.forEach(_processTask); + + if (user.history.exp.length > minHistoryLength) { + user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset); + user.markModified('history.exp'); + } + + if (user.history.todos.length > minHistoryLength) { + user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset); + user.markModified('history.todos'); + } +} diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js index 8c18e3c92c..0528d62933 100644 --- a/test/common/algos.mocha.js +++ b/test/common/algos.mocha.js @@ -938,124 +938,6 @@ describe('Cron', () => { expect(beforeTasks).to.eql(afterTasks); }); - describe('preening', () => { - beforeEach(function () { - this.clock = sinon.useFakeTimers(Date.parse('2013-11-20'), 'Date'); - }); - afterEach(function () { - return this.clock.restore(); - }); - - it('should preen user history', function () { - let ref = beforeAfter({ - daysAgo: 1, - }); - let after = ref.after; - - let history = [ - { - date: '09/01/2012', - value: 0, - }, { - date: '10/01/2012', - value: 0, - }, { - date: '11/01/2012', - value: 2, - }, { - date: '12/01/2012', - value: 2, - }, { - date: '01/01/2013', - value: 1, - }, { - date: '01/15/2013', - value: 3, - }, { - date: '02/01/2013', - value: 2, - }, { - date: '02/15/2013', - value: 4, - }, { - date: '03/01/2013', - value: 3, - }, { - date: '03/15/2013', - value: 5, - }, { - date: '04/01/2013', - value: 4, - }, { - date: '04/15/2013', - value: 6, - }, { - date: '05/01/2013', - value: 5, - }, { - date: '05/15/2013', - value: 7, - }, { - date: '06/01/2013', - value: 6, - }, { - date: '06/15/2013', - value: 8, - }, { - date: '07/01/2013', - value: 7, - }, { - date: '07/15/2013', - value: 9, - }, { - date: '08/01/2013', - value: 8, - }, { - date: '08/15/2013', - value: 10, - }, { - date: '09/01/2013', - value: 9, - }, { - date: '09/15/2013', - value: 11, - }, { - date: '010/01/2013', - value: 10, - }, { - date: '010/15/2013', - value: 12, - }, { - date: '011/01/2013', - value: 12, - }, { - date: '011/02/2013', - value: 13, - }, { - date: '011/03/2013', - value: 14, - }, { - date: '011/04/2013', - value: 15, - }, - ]; - - after.history = { - exp: _.cloneDeep(history), - todos: _.cloneDeep(history), - }; - after.habits[0].history = _.cloneDeep(history); - after.fns.cron(); - after.history.exp.pop(); - after.history.todos.pop(); - _.each([after.history.exp, after.history.todos, after.habits[0].history], function (arr) { - expect(_.map(arr, (x) => { - return x.value; - })).to.eql([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); - }); - }); - }); - describe('Todos', () => { it('1 day missed', () => { let ref = beforeAfter({ diff --git a/test/common/preening.test.js b/test/common/preening.test.js new file mode 100644 index 0000000000..f19a3c26b5 --- /dev/null +++ b/test/common/preening.test.js @@ -0,0 +1,70 @@ +import { preenHistory } from '../../common/script/preening'; +import moment from 'moment'; +import sinon from 'sinon'; // eslint-disable-line no-shadow + +function generateHistory (days) { + let history = []; + let now = Number(moment().toDate()); + + while (days > 0) { + history.push({ + value: days, + date: Number(moment(now).subtract(days, 'days').toDate()), + }); + days--; + } + + return history; +} + +describe('preenHistory', () => { + let clock; + + beforeEach(() => { + // Replace system clocks so we can get predictable results + clock = sinon.useFakeTimers(Number(moment('2013-10-20').zone(0).startOf('day').toDate()), 'Date'); + }); + afterEach(() => { + return clock.restore(); + }); + + it('does not modify history if all entries are more recent than cutoff (free users)', () => { + let h = generateHistory(60); + expect(preenHistory(_.cloneDeep(h), false, 0)).to.eql(h); + }); + + it('does not modify history if all entries are more recent than cutoff (subscribers)', () => { + let h = generateHistory(365); + expect(preenHistory(_.cloneDeep(h), true, 0)).to.eql(h); + }); + + it('does aggregate data in monthly entries before cutoff (free users)', () => { + let h = generateHistory(81); // Jumps to July + let preened = preenHistory(_.cloneDeep(h), false, 0); + expect(preened.length).to.eql(62); // Keeps 60 days + 2 entries per august and july + }); + + it('does aggregate data in monthly entries before cutoff (subscribers)', () => { + let h = generateHistory(396); // Jumps to September 2012 + let preened = preenHistory(_.cloneDeep(h), true, 0); + expect(preened.length).to.eql(367); // Keeps 365 days + 2 entries per october and september + }); + + it('does aggregate data in monthly and yearly entries before cutoff (free users)', () => { + let h = generateHistory(731); // Jumps to October 21 2012 + let preened = preenHistory(_.cloneDeep(h), false, 0); + expect(preened.length).to.eql(73); // Keeps 60 days + 11 montly entries and 2 yearly entry for 2011 and 2012 + }); + + it('does aggregate data in monthly and yearly entries before cutoff (subscribers)', () => { + let h = generateHistory(1031); // Jumps to October 21 2012 + let preened = preenHistory(_.cloneDeep(h), true, 0); + expect(preened.length).to.eql(380); // Keeps 365 days + 13 montly entries and 2 yearly entries for 2011 and 2010 + }); + + it('correctly aggregates values', () => { + let h = generateHistory(63); // Compress last 3 days + let preened = preenHistory(_.cloneDeep(h), false, 0); + expect(preened[0].value).to.eql((61 + 62 + 63) / 3); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 84ccf0a221..316a4054aa 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -10,8 +10,9 @@ import { import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; +import moment from 'moment'; import scoreTask from '../../../../common/script/api-v3/scoreTask'; -import { preenHistory } from '../../../../common/script/api-v3/preenHistory'; +import { preenHistory } from '../../../../common/script/api-v3/preening'; let api = {}; @@ -313,11 +314,27 @@ api.scoreTask = { }).exec(); chalTask.value += delta; + if (chalTask.type === 'habit' || chalTask.type === 'daily') { - chalTask.history.push({value: chalTask.value, date: Number(new Date())}); - // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? - chalTask.history = preenHistory(user, chalTask.history); - chalTask.markModified('history'); + // Add only one history entry per day + if (moment(chalTask.history[chalTask.history.length - 1].date).isSame(new Date(), 'day')) { + chalTask.history[chalTask.history.length - 1] = { + date: Number(new Date()), + value: chalTask.value, + }; + chalTask.markModified(`history.${chalTask.history.length - 1}`); + } else { + chalTask.history.push({ + date: Number(new Date()), + value: chalTask.value, + }); + + // Only preen task history once a day when the task is scored first + if (chalTask.history.length > 365) { + chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user + chalTask.markModified(`history.${chalTask.history.length - 1}`); + } + } } await chalTask.save(); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 0d6d46b900..9457926233 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -6,9 +6,10 @@ import { import cron from '../../../../common/script/api-v3/cron'; import common from '../../../../common'; import Task from '../../models/task'; +import Q from 'q'; // import Group from '../../models/group'; -// TODO check that it's usef everywhere +// TODO check that it's used everywhere export default function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -26,7 +27,7 @@ export default function cronMiddleware (req, res, next) { {type: {$in: ['habit', 'daily', 'reward']}}, ], }).exec() - .then((tasks) => { + .then(tasks => { let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); @@ -49,7 +50,15 @@ export default function cronMiddleware (req, res, next) { // if (ranCron) res.locals.wasModified = true; // TODO remove? if (!ranCron) return next(); // TODO Group.tavernBoss(user, progress); - if (!quest || true /* TODO remove */) return user.save(next); + if (!quest || true /* TODO remove */) { + // Save user and tasks + let toSave = [user.save()]; + tasks.forEach(task => { + if (task.isModified) toSave.push(task.save()); + }); + + return Q.all(toSave).then(() => next()).catch(next); + } // If user is on a quest, roll for boss & player, or handle collections // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? From 2cc02ee1f160dfaf7933852c0ca8bd24e0f24de4 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 14 Jan 2016 08:55:44 -0600 Subject: [PATCH 094/213] refactor(test helper): Fix api v3 helper to be specific for v3 --- test/helpers/api-v3-integration.helper.js | 137 ++++++++-------------- 1 file changed, 48 insertions(+), 89 deletions(-) diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index 30898513e2..b0dbcdece8 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -7,6 +7,7 @@ import { set, times, } from 'lodash'; +import Q from 'q'; import { MongoClient as mongo } from 'mongodb'; import { v4 as generateUUID } from 'uuid'; import superagent from 'superagent'; @@ -14,15 +15,6 @@ import i18n from '../../common/script/src/i18n'; i18n.translations = require('../../website/src/libs/api-v3/i18n').translations; const API_TEST_SERVER_PORT = 3003; -const API_V = process.env.API_VERSION || 'v2'; // eslint-disable-line no-process-env -const ROUTES = { - v2: { - register: '/register', - }, - v3: { - register: '/user/auth/local/register', - }, -}; class ApiUser { constructor (options) { @@ -97,26 +89,26 @@ export function checkExistence (collectionName, id) { // paramter, such as the number of wolf eggs the user has, // , you can do so by passing in the full path as a string: // { 'items.eggs.Wolf': 10 } -export function generateUser (update = {}) { +export async function generateUser (update = {}) { let username = generateUUID(); let password = 'password'; let email = `${username}@example.com`; let request = _requestMaker({}, 'post'); - return new Promise((resolve, reject) => { - request(ROUTES[API_V].register, { - username, - email, - password, - confirmPassword: password, - }).then((user) => { - _updateDocument('users', user, update, () => { - let apiUser = new ApiUser(user); + let user = await request('/user/auth/local/register', { + username, + email, + password, + confirmPassword: password, + }); - resolve(apiUser); - }); - }).catch(reject); + return Q.promise((resolve) => { + _updateDocument('users', user, update, () => { + let apiUser = new ApiUser(user); + + resolve(apiUser); + }); }); } @@ -124,10 +116,8 @@ export function generateUser (update = {}) { // will will become the groups leader. Takes an update // argument which will update group export function generateGroup (leader, details = {}, update = {}) { - let request = _requestMaker(leader, 'post'); - return new Promise((resolve, reject) => { - request('/groups', details).then((group) => { + leader.post('/groups', details).then((group) => { _updateDocument('groups', group, update, () => { resolve(group); }); @@ -149,72 +139,50 @@ export function generateGroup (leader, details = {}, update = {}) { // invitees: an array of user objects that correspond to the invitees of the group // leader: the leader user object // group: the group object -export function createAndPopulateGroup (settings = {}) { - let request; - let leader; - let members; - let invitees; - let group; - +export async function createAndPopulateGroup (settings = {}) { let numberOfMembers = settings.members || 0; let numberOfInvites = settings.invites || 0; let groupDetails = settings.groupDetails; let leaderDetails = settings.leaderDetails || { balance: 10 }; - let leaderPromise = generateUser(leaderDetails); + let groupLeader = await generateUser(leaderDetails); + let group = await generateGroup(groupLeader, groupDetails); - let memberPromises = Promise.all( + let members = await Q.all( times(numberOfMembers, () => { return generateUser(); }) ); - let invitePromises = Promise.all( + let groupTypes = { + guild: { guilds: [group._id] }, + party: { 'party._id': group._id }, + }; + + each(members, (member) => { + member.update(groupTypes[group.type]); + }); + + let invitees = await Q.all( times(numberOfInvites, () => { return generateUser(); }) ); - return new Promise((resolve, reject) => { - return leaderPromise.then((user) => { - leader = user; - request = _requestMaker(leader, 'post'); - return memberPromises; - }).then((users) => { - members = users; - groupDetails.members = groupDetails.members || [leader._id]; - - each(members, (member) => { - groupDetails.members.push(member._id); - }); - - return generateGroup(leader, groupDetails); - }).then((createdGroup) => { - group = createdGroup; - return invitePromises; - }).then((users) => { - invitees = users; - - let invitationPromises = []; - - each(invitees, (invitee) => { - let invitePromise = request(`/groups/${group._id}/invite`, { - uuids: [invitee._id], - }); - - invitationPromises.push(invitePromise); - }); - - return Promise.all(invitationPromises); - }).then(() => { - resolve({ - leader, - group, - members, - invitees, - }); - }).catch(reject); + let invitationPromises = invitees.map((invitee) => { + return groupLeader.post(`/groups/${group._id}/invite`, { + uuids: [invitee._id], + }); }); + + await Q.all(invitationPromises); + + return { + groupLeader, + group, + members, + invitees, + }; } // Specifically helpful for the GET /groups tests, @@ -250,7 +218,7 @@ export function resetHabiticaDB () { function _requestMaker (user, method, additionalSets) { return (route, send, query) => { return new Promise((resolve, reject) => { - let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/${API_V}${route}`) + let request = superagent[method](`http://localhost:${API_TEST_SERVER_PORT}/api/v3${route}`) .accept('application/json'); if (user && user._id && user.apiToken) { @@ -270,20 +238,11 @@ function _requestMaker (user, method, additionalSets) { if (err) { if (!err.response) return reject(err); - if (API_V === 'v3') { - return reject({ - code: err.status, - error: err.response.body.error, - message: err.response.body.message, - }); - } else if (API_V === 'v2') { - return reject({ - code: err.status, - text: err.response.body.err, - }); - } - - return reject(err); + return reject({ + code: err.status, + error: err.response.body.error, + message: err.response.body.message, + }); } resolve(response.body); From 42530b9a5fa7f42cd2b866b4005f4c80c6f0cc2a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 14 Jan 2016 08:56:16 -0600 Subject: [PATCH 095/213] refactor(tests): Adjust tests to use createAndPopulateGroup --- .../groups/POST-groups_groupId_join.js | 70 ++++++++++--------- .../v3/integration/groups/PUT-groups.test.js | 30 ++++---- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.js index ca7c8317b5..b35409bb6c 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.js @@ -1,5 +1,6 @@ import { generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -15,22 +16,28 @@ describe('POST /group/:groupId/join', () => { }); }); - context('Accepting invitation to a guild', () => { + context('Accepting invitation to a private guild', () => { let user, invitedUser, guild; beforeEach(async () => { - user = await generateUser({balance: 1}); - guild = await user.post('/groups', { - name: 'Test Guild', - type: 'guild', - privacy: 'private', + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'private', + }, + invites: 1, }); + + guild = group; + user = groupLeader; + invitedUser = invitees[0]; }); it('returns error when user is not invited to private guild', async () => { - let joiningUser = await generateUser(); + let userWithoutInvite = await generateUser(); - await expect(joiningUser.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ + await expect(userWithoutInvite.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupRequiresInvite'), @@ -38,26 +45,21 @@ describe('POST /group/:groupId/join', () => { }); it('allows non-invited users to join public guilds', async () => { - await user.update({balance: 1}); - guild = await user.post('/groups', { - name: 'Test Guild', - type: 'guild', - privacy: 'public', - }); + let publicGuild = (await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + })).group; let joiningUser = await generateUser(); - await joiningUser.post(`/groups/${guild._id}/join`); + await joiningUser.post(`/groups/${publicGuild._id}/join`); - await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id); + await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').and.to.include(publicGuild._id); }); context('User is invited', () => { - beforeEach(async () => { - invitedUser = await generateUser({ - 'invitations.guilds': [{ id: guild._id}], - }); - }); - it('allows invited user to join private guilds', async () => { await invitedUser.post(`/groups/${guild._id}/join`); @@ -92,17 +94,23 @@ describe('POST /group/:groupId/join', () => { let user, invitedUser, party; beforeEach(async () => { - user = await generateUser(); - party = await user.post('/groups', { - name: 'Test Party', - type: 'party', + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + }, + invites: 1, }); + + party = group; + user = groupLeader; + invitedUser = invitees[0]; }); it('returns error when user is not invited to party', async () => { - let joiningUser = await generateUser(); + let userWithoutInvite = await generateUser(); - await expect(joiningUser.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ + await expect(userWithoutInvite.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('messageGroupRequiresInvite'), @@ -110,12 +118,6 @@ describe('POST /group/:groupId/join', () => { }); context('User is invited', () => { - beforeEach(async () => { - invitedUser = await generateUser({ - 'invitations.party': { id: party._id, inviter: user._id }, - }); - }); - it('allows invited user to join party', async () => { await invitedUser.post(`/groups/${party._id}/join`); diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 5c23668ddf..2b4a417090 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -1,32 +1,30 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; describe('PUT /group', () => { - let groupLeader; + let leader, nonLeader, groupToUpdate; let groupName = 'Test Public Guild'; let groupType = 'guild'; - let groupToUpdate; let groupUpdatedName = 'Test Public Guild Updated'; beforeEach(async () => { - groupLeader = await generateUser({balance: 1}); - groupToUpdate = await groupLeader.post('/groups', { - name: groupName, - type: groupType, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: groupName, + type: groupType, + }, + members: 1, }); + + groupToUpdate = group; + leader = groupLeader; + nonLeader = members[0]; }); it('returns an error when a non group leader tries to update', async () => { - let memberToAttemptUpdate = await generateUser(); - - await groupLeader.post(`/groups/${groupToUpdate._id}/invite`, { - uuids: [memberToAttemptUpdate._id], - }); - await memberToAttemptUpdate.post(`/groups/${groupToUpdate._id}/join`); - - await expect(memberToAttemptUpdate.put(`/groups/${groupToUpdate._id}`, { + await expect(nonLeader.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -36,7 +34,7 @@ describe('PUT /group', () => { }); it('updates a group', async () => { - let updatedGroup = await groupLeader.put(`/groups/${groupToUpdate._id}`, { + let updatedGroup = await leader.put(`/groups/${groupToUpdate._id}`, { name: groupUpdatedName, }); From 0f5cf318d92ba01a93a8af6c78e1bdeffad03c18 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 14 Jan 2016 12:22:56 -0600 Subject: [PATCH 096/213] fix: Update member objects with guild info in test helper --- test/api/v3/integration/groups/PUT-groups.test.js | 1 + test/helpers/api-v3-integration.helper.js | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 2b4a417090..414f495ff7 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -14,6 +14,7 @@ describe('PUT /group', () => { groupDetails: { name: groupName, type: groupType, + privacy: 'public', }, members: 1, }); diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index b0dbcdece8..530b275fb6 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -159,10 +159,12 @@ export async function createAndPopulateGroup (settings = {}) { party: { 'party._id': group._id }, }; - each(members, (member) => { - member.update(groupTypes[group.type]); + let memberPromises = members.map((member) => { + return member.update(groupTypes[group.type]); }); + await Q.all(memberPromises); + let invitees = await Q.all( times(numberOfInvites, () => { return generateUser(); From 5206469e909f656e9869c274eba83cccd45f9330 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Jan 2016 19:46:43 +0100 Subject: [PATCH 097/213] add unlinkTask route and refactor user.unlink (now user.unlinkChallengesTasks) --- common/locales/en/api-v3.json | 4 +- website/src/controllers/api-v3/tasks.js | 81 +++++++++++++++++++------ website/src/models/challenge.js | 2 +- website/src/models/group.js | 6 +- website/src/models/user.js | 56 ++++++++--------- 5 files changed, 95 insertions(+), 54 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 91711f15eb..dfdea49d6c 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,6 +37,7 @@ "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", + "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", @@ -59,5 +60,6 @@ "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", - "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time" + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked." } diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index ffa2d20bf5..a3610f7913 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -540,7 +540,7 @@ api.moveTask = { }).exec(); if (!task) throw new NotFound(res.t('taskNotFound')); - if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo')); + if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo')); let order = user.tasksOrder[`${task.type}s`]; let currentIndex = order.indexOf(task._id); @@ -837,21 +837,63 @@ api.removeTagFromTask = { }; // Remove a task from (user|challenge).tasksOrder -function _removeTaskTasksOrder (userOrChallenge, taskId) { - // Loop through all lists and when the task is found, remove it and return - for (let i = 0; i < Tasks.tasksTypes.length; i++) { - let list = userOrChallenge.tasksOrder[`${Tasks.tasksTypes[i]}s`]; - let index = list.indexOf(taskId); +function _removeTaskTasksOrder (userOrChallenge, taskId, taskType) { + let list = userOrChallenge.tasksOrder[taskType]; + let index = list.indexOf(taskId); - if (index !== -1) { - list.splice(index, 1); - break; - } - } - - return; + 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 + * @apiVersion 3.0.0 + * @apiName UnlinkTask + * @apiGroup Task + * + * @apiParam {UUID} taskId The task _id + * + * @apiSuccess {object} empty An empty object + */ +api.unlinkTask = { + method: 'POST', + url: '/tasks/unlink/:taskId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); + req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let keep = req.query.keep; + let taskId = req.params.taskId; + + let task = await Tasks.Task.findOne({ + _id: taskId, + userId: user._id, + }).exec(); + + if (!task) throw new NotFound(res.t('taskNotFound')); + if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); + + if (keep === 'keep') { + task.challenge = {}; + await task.save(); + } else { // remove + if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if + _removeTaskTasksOrder(user, taskId, task.type); + await Q.all([user.save(), task.remove()]); + } else { + await task.remove(); + } + } + + res.respond(200, {}); // TODO what to return + }, +}; + /** * @api {delete} /task/:taskId Delete a user task given its id * @apiVersion 3.0.0 @@ -875,9 +917,8 @@ api.deleteTask = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let task = await Tasks.Task.findOne({ - _id: req.params.taskId, - }).exec(); + let taskId = req.params.taskId; + let task = await Tasks.Task.findById(taskId).exec(); if (!task) { throw new NotFound(res.t('taskNotFound')); @@ -891,8 +932,12 @@ api.deleteTask = { throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); } - _removeTaskTasksOrder(challenge || user, req.params.taskId); - await Q.all([user.save(), task.remove()]); + if (task.type !== 'todo' || !task.completed) { + _removeTaskTasksOrder(challenge || user, taskId, task.type); + await Q.all([(challenge || user).save(), task.remove()]); + } else { + await task.remove(); + } res.respond(200, {}); if (challenge) challenge.removeTask(task); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 198ea2e476..b675a7833e 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -182,7 +182,7 @@ schema.methods.removeTask = async function challengeRemoveTask (task) { 'challenge.taskId': task._id, }, { $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt? - }).lean().exec(); + }, {multi: true}).exec(); }; export let model = mongoose.model('Challenge', schema); diff --git a/website/src/models/group.js b/website/src/models/group.js index 91ae59e714..61c324e6c1 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -439,7 +439,7 @@ schema.statics.bossQuest = function bossQuest (user, progress) { // Remove user from this group // TODO this is highly inefficient -schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { +schema.methods.leave = function leaveGroup (user, keep) { let group = this; return Q.all([ @@ -455,12 +455,12 @@ schema.methods.leave = function leaveGroup (user, keep = 'keep-all') { {_id: {$in: _.pluck(challenges, '_id')}}, {$pull: {members: user._id}}, {multi: true} - ).then(() => challenges); // pass `challenges` above to next promise TODO ok to return a non-promise? + ).then(() => challenges); // pass `challenges` above to next promise }).then(challenges => { return Q.all(challenges.map(chal => { let i = user.challenges.indexOf(chal._id); if (i !== -1) user.challenges.splice(i, 1); - return user.unlink({cid: chal._id, keep}); + return user.unlinkChallengeTasks(chal._id, keep); })); }), diff --git a/website/src/models/user.js b/website/src/models/user.js index a688453cfe..55fdd49bdb 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -643,40 +643,34 @@ schema.methods.isSubscribed = function isSubscribed () { return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion }; -schema.methods.unlink = function unlink (options, cb) { - let cid = options.cid; - let keep = options.keep; - let tid = options.tid; +// Unlink challenges tasks from user +schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { + let user = this; + let findQuery = { + userId: user._id, + 'challenge.id': challengeId, + }; - if (!cid) { - return cb('Could not remove challenge tasks. Please delete them manually.'); - } - - let self = this; - - if (keep === 'keep') { - self.tasks[tid].challenge = {}; - } else if (keep === 'remove') { - self.ops.deleteTask({params: {id: tid}}, () => {}); - } else if (keep === 'keep-all') { - _.each(self.tasks, (t) => { - if (t.challenge && t.challenge.id === cid) { - t.challenge = {}; + if (keep === 'keep-all') { + await Tasks.Task.update(findQuery, { + $set: {challenge: {}}, // TODO what about updatedAt? + }, {multi: true}).exec(); + } else { // keep = 'remove-all' + let tasks = Tasks.Task.find(findQuery).select('_id type completed').exec(); + tasks = tasks.map(task => { + // Remove task from user.tasksOrder and delete them + if (task.type !== 'todo' || !task.completed) { + let list = user.tasksOrder[task.type]; + let index = list.indexOf(task._id); + if (index !== -1) list.splice(index, 1); } - }); - } else if (keep === 'remove-all') { - _.each(self.tasks, (t) => { - if (t.challenge && t.challenge.id === cid) { - this.ops.deleteTask({params: {id: tid}}, () => {}); - } - }); - } - self.markModified('habits'); - self.markModified('dailys'); - self.markModified('todos'); - self.markModified('rewards'); - self.save(cb); + return task.remove(); + }); + + tasks.push(user.save()); + await Q.all(tasks); + } }; export let model = mongoose.model('User', schema); From 7d5a7503cba2e49ed3afb073a1b349ea9571dcfc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 14 Jan 2016 20:55:13 +0100 Subject: [PATCH 098/213] fix access to user.tasksOrder --- website/src/controllers/api-v3/tasks.js | 2 +- website/src/models/user.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index a3610f7913..cee1ba4527 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -838,7 +838,7 @@ api.removeTagFromTask = { // Remove a task from (user|challenge).tasksOrder function _removeTaskTasksOrder (userOrChallenge, taskId, taskType) { - let list = userOrChallenge.tasksOrder[taskType]; + let list = userOrChallenge.tasksOrder[`${taskType}s`]; let index = list.indexOf(taskId); if (index !== -1) list.splice(index, 1); diff --git a/website/src/models/user.js b/website/src/models/user.js index 55fdd49bdb..40181108d0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -660,7 +660,7 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall tasks = tasks.map(task => { // Remove task from user.tasksOrder and delete them if (task.type !== 'todo' || !task.completed) { - let list = user.tasksOrder[task.type]; + let list = user.tasksOrder[`${task.type}s`]; let index = list.indexOf(task._id); if (index !== -1) list.splice(index, 1); } From 50a85337a7fb9d6407e1efdad2ebc349834abc0a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 15:29:50 +0100 Subject: [PATCH 099/213] better access control for challenges --- website/src/controllers/api-v3/challenges.js | 14 ++++++-------- website/src/models/challenge.js | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 8bec5eae6e..321f3c66c5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -100,9 +100,9 @@ api.getChallenges = { async handler (req, res) { let user = res.locals.user; - let groups = user.guilds || []; + let groups = user.guilds.slice(0); // slice is used to clone the array so we don't modify it directly if (user.party._id) groups.push(user.party._id); - groups.push('habitrpg'); // Public challenges + groups.push('habitrpg'); // tavern challenges let challenges = await Challenge.find({ $or: [ @@ -143,11 +143,9 @@ api.getChallenge = { let user = res.local.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).exec(); // TODO populate + let challenge = await Challenge.findById(challengeId).exec(); - // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error - // TODO support challenges in groups I'm a member of - if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens + if (!challenge || !challenge.hasAccess(user)) { throw new NotFound(res.t('challengeNotFound')); } @@ -233,7 +231,7 @@ api.deleteChallenge = { let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); res.respond(200, {}); // Close channel in background @@ -264,7 +262,7 @@ api.selectChallengeWinner = { let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + 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})); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index b675a7833e..fcfb5cc1e3 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -31,6 +31,24 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); +// Return true if user has access to the challenge +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 || user.contributor.admin || userGroups.indexOf(this.groupId) !== -1; +}; + +// Return true if user is a member of the challenge +schema.methods.isMember = function isChallengeMember (user) { + return user.challenges.indexOf(this._id) !== -1; +}; + +// Return true if the user can modify (close, selectWinner, ...) the challenge +schema.methods.canModify = function canModifyChallenge (user) { + return user.contributor.admin || this.leader === user._id; +}; + // 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 From 3eb9d4b0989f9d18fedc9e6fafb252cef623c37a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 15:30:13 +0100 Subject: [PATCH 100/213] members controller: support challenges --- website/src/controllers/api-v3/members.js | 86 ++++++++--------------- 1 file changed, 28 insertions(+), 58 deletions(-) diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 9ac5d826f3..f464fe830c 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -6,6 +6,7 @@ import { nameFields, } from '../../models/user'; import { model as Group } from '../../models/group'; +import { model as Challenge } from '../../models/challenge'; import { NotFound, } from '../../libs/api-v3/errors'; @@ -45,31 +46,45 @@ api.getMember = { }, }; -// Return a request handler for getMembersForGroup / getInvitesForGroup +// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge // type is `invites` or `members` -function handleGetMembersInvitesForGroup (type) { - if (type !== 'members' && type !== 'invites') { - throw new Error('Type must be "invites" or "members"'); +function _getMembersForItem (type) { + if (['group-members', 'group-invites', 'challenge-members'].indexOf(type) === -1) { + throw new Error('Type must be one of "group-members", "group-invites", "challenge-members"'); } - return async function getMembersOrInvitesForGroup (req, res) { - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + return async function handleGetMembersForItem (req, res) { + if (type === 'challenge-members') { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + } else { + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + } req.checkQuery('lastId').optional().notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; let groupId = req.params.groupId; + let challengeId = req.params.challengeId; let lastId = req.query.lastId; let user = res.locals.user; + let challenge; + let group; - let group = await Group.getGroup(user, groupId, '_id type'); - if (!group) throw new NotFound(res.t('groupNotFound')); + if (type === 'challenge-members') { + challenge = await Challenge.findById(challengeId).select('_id type leader').exec(); + if (!challenge || !challenge.hasAccess(user)) throw new NotFound(res.t('groupNotFound')); + } else { + group = await Group.getGroup(user, groupId, '_id type'); + if (!group) throw new NotFound(res.t('groupNotFound')); + } let query = {}; let fields = nameFields; - if (type === 'members') { + if (type === 'challenge-members') { + query.challenges = challenge._id; + } else if (type === 'group-members') { if (group.type === 'guild') { query.guilds = group._id; } else { @@ -79,7 +94,7 @@ function handleGetMembersInvitesForGroup (type) { fields = memberFields; } } - } else { + } else if (type === 'group-invites') { if (group.type === 'guild') { // eslint-disable-line no-lonely-if query['invitations.guilds.id'] = group._id; } else { @@ -116,7 +131,7 @@ api.getMembersForGroup = { method: 'GET', url: '/groups/:groupId/members', middlewares: [authWithHeaders(), cron], - handler: handleGetMembersInvitesForGroup('members'), + handler: _getMembersForItem('group-members'), }; /** @@ -134,7 +149,7 @@ api.getInvitesForGroup = { method: 'GET', url: '/groups/:groupId/invites', middlewares: [authWithHeaders(), cron], - handler: handleGetMembersInvitesForGroup('invites'), + handler: _getMembersForItem('group-invites'), }; /** @@ -152,52 +167,7 @@ api.getMembersForChallenge = { method: 'GET', url: '/challenges/:challengeId/members', middlewares: [authWithHeaders(), cron], - async handler (req, res) { - req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty(); - req.checkQuery('lastId').optional().notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let challengeId = req.params.challengeId; - let lastId = req.query.lastId; - let user = res.locals.user; - - let challenge = await Challenge.findById(challengeId).exec(); - - let query = {}; - let fields = nameFields; - - if (type === 'members') { - if (group.type === 'guild') { - query.guilds = group._id; - } else { - query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party' - - if (req.query.includeAllPublicFields === 'true') { - fields = memberFields; - } - } - } else { - if (group.type === 'guild') { // eslint-disable-line no-lonely-if - query['invitations.guilds.id'] = group._id; - } else { - query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party' - } - } - - if (lastId) query._id = {$gt: lastId}; - - let users = await User - .find(query) - .sortBy({_id: 1}) - .limit(30) - .select(fields) - .exec(); - - res.respond(200, users); - } + handler: _getMembersForItem('challenge-members'), }; - export default api; From b0caf71641384ab9f771d69b501e2562d7387561 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 18:54:28 +0100 Subject: [PATCH 101/213] fix several busg with tasks and challenges --- website/src/controllers/api-v3/challenges.js | 12 +++++++++--- website/src/controllers/api-v3/tasks.js | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 321f3c66c5..f98cac0302 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -77,9 +77,15 @@ api.createChallenge = { req.body.official = user.contributor.admin && req.body.official; let challenge = new Challenge(Challenge.sanitize(req.body)); - let results = await Q.all(challenge.save(), group.save()); - let savedChal = results[0]; + // First validate challenge so we don't save group if it's invalid (only runs sync validators) + let challengeValidationErrors = challenge.validateSync(); + if (challengeValidationErrors) throw challengeValidationErrors; + let results = await Q.all([challenge.save({ + validateBeforeSave: false, // already validate + }), group.save()]); + + let savedChal = results[0]; await savedChal.syncToUser(user); // (it also saves the user) res.respond(201, savedChal); }, @@ -140,7 +146,7 @@ api.getChallenge = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let user = res.local.user; + let user = res.locals.user; let challengeId = req.params.challengeId; let challenge = await Challenge.findById(challengeId).exec(); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index cee1ba4527..c2498b2a94 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -92,7 +92,7 @@ api.createChallengeTasks = { let reqValidationErrors = req.validationErrors(); if (reqValidationErrors) throw reqValidationErrors; - let user = res.local.user; + let user = res.locals.user; let challengeId = req.params.challengeId; let challenge = await Challenge.findOne({_id: challengeId}).exec(); @@ -194,7 +194,7 @@ api.getChallengeTasks = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let user = res.local.user; + let user = res.locals.user; let challengeId = req.params.challengeId; let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); From 2ee75c1ad34f652e41dfd4ad1b7e180354c72bec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 19:44:45 +0100 Subject: [PATCH 102/213] add tests for getMember --- .../members/GET-members_id.test.js | 41 +++++++++++++++++++ .../v3/integration/tags/GET-tags_id.test.js | 2 + website/src/controllers/api-v3/members.js | 3 +- website/src/models/user.js | 12 +++--- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 test/api/v3/integration/members/GET-members_id.test.js diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js new file mode 100644 index 0000000000..25c7600c80 --- /dev/null +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -0,0 +1,41 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /members/:memberId', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + it('returns a member public data only', async () => { + let member = await generateUser({ // make sure user has all the fields that can be returned by the getMember call + contributor: {level: 1}, + backer: {tier: 3}, + preferences: { + costume: false, + background: 'volcano', + }, + }); + let memberRes = await user.get(`/members/${member._id}`); + expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'preferences', 'profile', 'stats', 'achievements', 'party', + 'backer', 'contributor', 'auth', 'items', + ]); + expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); + expect(Object.keys(memberRes.preferences).sort()).to.eql(['size', 'hair', 'skin', 'shirt', + 'costume', 'sleep', 'background'].sort()); + }); + + it('handles non-existing members', async () => { + let dummyId = generateUUID(); + await expect(user.get(`/members/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index 1189c2af24..c46b5b8801 100644 --- a/test/api/v3/integration/tags/GET-tags_id.test.js +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -15,4 +15,6 @@ describe('GET /tags/:tagId', () => { expect(tag).to.deep.equal(createdTag); }); + + it('handles non-existing tags'); }); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index f464fe830c..6eef598050 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -42,7 +42,8 @@ api.getMember = { if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); - res.respond(200, member); + // manually call toJSON with minimize: true so empty paths aren't returned + res.respond(200, member.toJSON({minimize: true})); }, }; diff --git a/website/src/models/user.js b/website/src/models/user.js index fcad88c5a6..8942a8afd2 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -472,18 +472,20 @@ export let schema = new Schema({ }, }, { strict: true, - minimize: false, // So empty objects are returned + minimize: false, // So empty objects are returned TODO make sure it's in every model }); schema.plugin(baseModel, { - // TODO revisit a lot of things are missing - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance'], + // TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', + 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', + 'invitations', 'balance', 'backer', 'contributor'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function userToJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? // TODO this works? - doc.filters = {}; - doc._tmp = this._tmp; // be sure to send down drop notifs + // doc.filters = {}; + // doc._tmp = this._tmp; // be sure to send down drop notifs return doc; }, From ad41037f597c296930ce8cce11e418f4b5f32799 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 21:47:20 +0100 Subject: [PATCH 103/213] finish tests for members controller --- .../GET-challenges_challengeId_members.test | 0 .../groups/GET-groups_groupId_invites.test.js | 0 .../groups/GET-groups_groupId_members.test.js | 95 +++++++++++++++++++ ...in.js => POST-groups_groupId_join.test.js} | 0 .../members/GET-members_id.test.js | 8 ++ website/src/controllers/api-v3/members.js | 7 +- 6 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members.test create mode 100644 test/api/v3/integration/groups/GET-groups_groupId_invites.test.js create mode 100644 test/api/v3/integration/groups/GET-groups_groupId_members.test.js rename test/api/v3/integration/groups/{POST-groups_groupId_join.js => POST-groups_groupId_join.test.js} (100%) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js new file mode 100644 index 0000000000..d7ad8ac769 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -0,0 +1,95 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /groups/:groupId/members', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates optional req.query.lastId to be an UUID', async () => { + await expect(user.get(`/groups/groupId/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if group doesn\'t exists', async () => { + await expect(user.get(`/groups/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the group', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let anotherUser = await generateUser(); + await expect(anotherUser.get(`/groups/${group._id}/members`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('works when passing party as req.params.groupId', async () => { + await generateGroup(user, {type: 'party', name: generateUUID()}); + let res = await user.get(`/groups/party/members`); + expect(res).to.be.an('array'); + expect(res.length).to.equal(1); + expect(res[0]).to.eql({ + _id: user._id, + profile: {name: user.profile.name}, + }); + }); + + it('populates only some fields', async () => { + await generateGroup(user, {type: 'party', name: generateUUID()}); + let res = await user.get(`/groups/party/members`); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('returns only first 30 members', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + + let usersToGenerate = []; + for (let i = 0; i < 31; i++) { + usersToGenerate.push(generateUser({party: {_id: group._id}})); + } + await Promise.all(usersToGenerate); + + let res = await user.get(`/groups/party/members`); + expect(res.length).to.equal(30); + res.forEach(member => { + expect(member).to.have.all.keys(['_id', 'profile']); + expect(member.profile).to.have.all.keys(['name']); + }); + }); + + it('supports using req.query.lastId to get more members', async () => { + let leader = await generateUser({balance: 4}); + let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); + + let usersToGenerate = []; + for (let i = 0; i < 57; i++) { + usersToGenerate.push(generateUser({guilds: [group._id]})); + } + let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader) + let expectedIds = [leader._id].concat(generatedUsers.map(generatedUser => generatedUser._id)); + + let res = await user.get(`/groups/${group._id}/members`); + expect(res.length).to.equal(30); + let res2 = await user.get(`/groups/${group._id}/members?lastId=${res[res.length - 1]._id}`); + expect(res2.length).to.equal(28); + + let resIds = res.concat(res2).map(member => member._id); + expect(resIds).to.eql(expectedIds.sort()) + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_join.js rename to test/api/v3/integration/groups/POST-groups_groupId_join.test.js diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js index 25c7600c80..9b4c734ea9 100644 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -11,6 +11,14 @@ describe('GET /members/:memberId', () => { user = await generateUser(); }); + it('validates req.params.memberId', async () => { + await expect(user.get(`/members/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + it('returns a member public data only', async () => { let member = await generateUser({ // make sure user has all the fields that can be returned by the getMember call contributor: {level: 1}, diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 6eef598050..3f5ca5ac9d 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -105,14 +105,15 @@ function _getMembersForItem (type) { if (lastId) query._id = {$gt: lastId}; - let users = await User + let members = await User .find(query) - .sortBy({_id: 1}) + .sort({_id: 1}) .limit(30) .select(fields) .exec(); - res.respond(200, users); + // manually call toJSON with minimize: true so empty paths aren't returned + res.respond(200, members.map(member => member.toJSON({minimize: true}))); }; } From 96c523493a4e9b03c1c684410bf347d691290b1b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 15 Jan 2016 22:08:51 +0100 Subject: [PATCH 104/213] fix missing semicolon --- .../v3/integration/groups/GET-groups_groupId_members.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js index d7ad8ac769..15193db012 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -90,6 +90,6 @@ describe('GET /groups/:groupId/members', () => { expect(res2.length).to.equal(28); let resIds = res.concat(res2).map(member => member._id); - expect(resIds).to.eql(expectedIds.sort()) + expect(resIds).to.eql(expectedIds.sort()); }); }); From d7d63ad229c01f04c38617125901ca102485770d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 15:10:18 +0100 Subject: [PATCH 105/213] populate group.leader --- test/helpers/api-v3-integration.helper.js | 3 +-- website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/chat.js | 8 +++---- website/src/controllers/api-v3/groups.js | 17 ++++++++------- website/src/controllers/api-v3/members.js | 2 +- website/src/models/group.js | 22 +++++++++++++------- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index 530b275fb6..a5d48478cd 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -201,11 +201,10 @@ export function resetHabiticaDB () { groups.insertOne({ _id: 'habitrpg', chat: [], - leader: '9', + leader: '9', // TODO change this name: 'HabitRPG', type: 'guild', privacy: 'public', - members: [], }, (insertErr) => { if (insertErr) return reject(insertErr); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index f98cac0302..ed8440ff1f 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -38,7 +38,7 @@ api.createChallenge = { let groupId = req.body.groupId; let prize = req.body.prize; - let group = await Group.getGroup(user, groupId, '-chat'); + let group = await Group.getGroup({user, groupId, fields: '-chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 7c2d42bf62..212e064441 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -33,7 +33,7 @@ api.getChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, 'chat'); + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); res.respond(200, group.chat); @@ -67,7 +67,7 @@ api.postChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, groupId); + let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party' && user.flags.chatRevoked) { @@ -118,7 +118,7 @@ api.likeChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, groupId); + let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); let message = _.find(group.chat, {id: req.params.chatId}); @@ -165,7 +165,7 @@ api.flagChat = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, groupId); + let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); let message = _.find(group.chat, {id: req.params.chatId}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8203e00b1b..d7d3eedbd3 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -93,7 +93,7 @@ api.getGroups = { types.forEach(type => { switch (type) { case 'party': - queries.push(Group.getGroup(user, 'party', groupFields)); + queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields, populateLeader: true})); break; case 'privateGuilds': queries.push(Group.find({ @@ -109,7 +109,7 @@ api.getGroups = { }).select(groupFields).sort(sort).exec()); // TODO use lean? break; case 'tavern': - queries.push(Group.getGroup(user, 'habitrpg', groupFields)); + queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields, populateLeader: true})); break; } }); @@ -149,7 +149,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId); + let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true}); if (!group) throw new NotFound(res.t('groupNotFound')); res.respond(200, group); @@ -178,7 +178,7 @@ api.updateGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId); + let group = await Group.getGroup({user, groupId: req.params.groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate')); @@ -214,7 +214,8 @@ api.joinGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat', true); // Do not fetch chat and work even if the user is not yet a member of the group + // Do not fetch chat and work even if the user is not yet a member of the group + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group if (!group) throw new NotFound(res.t('groupNotFound')); let isUserInvited = false; @@ -288,7 +289,7 @@ api.leaveGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat if (!group) throw new NotFound(res.t('groupNotFound')); // During quests, checke wheter user can leave @@ -344,7 +345,7 @@ api.removeGroupMember = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat + 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; @@ -523,7 +524,7 @@ api.inviteToGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup(user, req.params.groupId, '-chat'); // Do not fetch chat TODO other fields too? + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat TODO other fields too? if (!group) throw new NotFound(res.t('groupNotFound')); let uuids = req.body.uuids; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 3f5ca5ac9d..0b5638a95a 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -76,7 +76,7 @@ function _getMembersForItem (type) { challenge = await Challenge.findById(challengeId).select('_id type leader').exec(); if (!challenge || !challenge.hasAccess(user)) throw new NotFound(res.t('groupNotFound')); } else { - group = await Group.getGroup(user, groupId, '_id type'); + group = await Group.getGroup({user, groupId, fields: '_id type'}); if (!group) throw new NotFound(res.t('groupNotFound')); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 94072bfb79..169d977d08 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -1,5 +1,8 @@ import mongoose from 'mongoose'; -import { model as User} from './user'; +import { + model as User, + nameFields, +} from './user'; import shared from '../../../common'; import _ from 'lodash'; import { model as Challenge} from './challenge'; @@ -40,7 +43,6 @@ export let schema = new Schema({ balance: {type: Number, default: 0}, logo: String, leaderMessage: String, - // challenges: [{type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Challenge'}], // TODO do we need this? could depend on back-ref instead (Challenge.find({group:GID})) quest: { key: String, active: {type: Boolean, default: false}, @@ -57,6 +59,7 @@ export let schema = new Schema({ // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. // TODO when booting user, remove from .joined and check again if we can now start the quest // TODO as long as quests are party only we can keep it here + // TODO are we sure we need this type of default for this to work? members: {type: Schema.Types.Mixed, default: () => { return {}; }}, @@ -125,7 +128,8 @@ schema.post('remove', function postRemoveGroup (group) { firebase.deleteGroup(group._id); }); -schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMembership) { +schema.statics.getGroup = function getGroup (options = {}) { + let {user, groupId, fields, optionalMembership = false, populateLeader = false} = options; let query; // When optionalMembership is true it's not required for the user to be a member of the group @@ -141,7 +145,8 @@ schema.statics.getGroup = function getGroup (user, groupId, fields, optionalMemb let mQuery = this.findOne(query); if (fields) mQuery.select(fields); - return mQuery.exec(); // TODO catch errors here? + if (populateLeader === true) mQuery.populate('leader', nameFields); + return mQuery.exec(); // TODO purge chat flags info? in tojson? }; @@ -503,6 +508,7 @@ schema.methods.leave = function leaveGroup (user, keep) { }); }; +export const INVITES_LIMIT = 100; export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) @@ -511,12 +517,12 @@ model.count({_id: 'habitrpg'}, (err, ct) => { if (ct > 0) return; new model({ // eslint-disable-line babel/new-cap - _id: 'habitrpg', // TODO hmm this will probably break everything + _id: 'habitrpg', leader: '9', // TODO change this user id name: 'HabitRPG', type: 'guild', privacy: 'public', - }).save(); + }).save({ + validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise + }); // TODO catch/log? }); - -export const INVITES_LIMIT = 100; From f447af19aeef7915adb141e6a5d5a88b1411e3de Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:18:06 +0100 Subject: [PATCH 106/213] add tests for getting challenge members and fix a lot of bugs --- common/locales/en/api-v3.json | 1 + .../GET-challenges_challengeId_members.test | 0 website/src/controllers/api-v3/challenges.js | 9 +++++---- website/src/controllers/api-v3/members.js | 6 ++++-- website/src/models/challenge.js | 15 +++++++++++---- website/src/models/group.js | 11 +++++++++++ 6 files changed, 32 insertions(+), 10 deletions(-) delete mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members.test diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d7239af5c4..2d00b19192 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,6 +37,7 @@ "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", + "mustBeGroupMember": "Must be member of the group.", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index ed8440ff1f..da7a7e8afe 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -38,8 +38,9 @@ api.createChallenge = { let groupId = req.body.groupId; let prize = req.body.prize; - let group = await Group.getGroup({user, groupId, fields: '-chat'}); + let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember')); if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) { throw new NotAuthorized(res.t('onlyGroupLeaderChal')); @@ -150,10 +151,10 @@ api.getChallenge = { let challengeId = req.params.challengeId; let challenge = await Challenge.findById(challengeId).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge || !challenge.hasAccess(user)) { - 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')); res.respond(200, challenge); }, diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 0b5638a95a..5e2cb33d35 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -73,8 +73,10 @@ function _getMembersForItem (type) { let group; if (type === 'challenge-members') { - challenge = await Challenge.findById(challengeId).select('_id type leader').exec(); - if (!challenge || !challenge.hasAccess(user)) throw new NotFound(res.t('groupNotFound')); + 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'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); } else { group = await Group.getGroup({user, groupId, fields: '_id type'}); if (!group) throw new NotFound(res.t('groupNotFound')); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index fcfb5cc1e3..a19ecd3b60 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -20,7 +20,7 @@ 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}, + groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? memberCount: {type: Number, default: 0}, challengeCount: {type: Number, default: 0}, @@ -31,7 +31,7 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); -// Return true if user has access to the challenge +// 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); @@ -39,12 +39,19 @@ schema.methods.hasAccess = function hasAccessToChallenge (user) { return this.leader === user._id || user.contributor.admin || userGroups.indexOf(this.groupId) !== -1; }; -// Return true if user is a member of the challenge +// 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 (group.type === 'guild' && group.privacy === 'public') return true; + return this.hasAccess(user); +}; + +// Returns true if user is a member of the challenge schema.methods.isMember = function isChallengeMember (user) { return user.challenges.indexOf(this._id) !== -1; }; -// Return true if the user can modify (close, selectWinner, ...) the challenge +// Returns true if the user can modify (close, selectWinner, ...) the challenge schema.methods.canModify = function canModifyChallenge (user) { return user.contributor.admin || this.leader === user._id; }; diff --git a/website/src/models/group.js b/website/src/models/group.js index 169d977d08..85396fc693 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -150,6 +150,17 @@ schema.statics.getGroup = function getGroup (options = {}) { // TODO purge chat flags info? in tojson? }; +// Return true if user is a member of the group +schema.methods.isMember = function isGroupMember (user) { + if (this._id === 'habitrpg') { + return true; // everyone is considered part of the tavern + } else if (this.type === 'party') { + return user.party._id === this._id ? true : false; + } else { // guilds + return user.guilds.indexOf(this._id) !== -1; + } +}; + export function chatDefaults (msg, user) { let message = { id: shared.uuid(), From 4e5c4e99531eb102d00ff26f0aec2f968d144c57 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:18:29 +0100 Subject: [PATCH 107/213] add missing test file --- ...GET-challenges_challengeId_members.test.js | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js new file mode 100644 index 0000000000..318181b66e --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js @@ -0,0 +1,125 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId/members', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates optional req.query.lastId to be an UUID', async () => { + await expect(user.get(`/challenges/${generateUUID()}/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if challenge doesn\'t exists', async () => { + await expect(user.get(`/challenges/${generateUUID()}/members`)).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 () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let anotherUser = await generateUser(); + await expect(anotherUser.get(`/challenges/${challenge._id}/members`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('works with challenges belonging to public guild', async () => { + let leader = await generateUser({balance: 4}); + let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); + let challenge = await leader.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res[0]).to.eql({ + _id: leader._id, + profile: {name: leader.profile.name}, + }); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('populates only some fields', async () => { + let anotherUser = await generateUser({balance: 3}); + let group = await generateGroup(anotherUser, {type: 'guild', privacy: 'public', name: generateUUID()}); + let challenge = await anotherUser.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res[0]).to.eql({ + _id: anotherUser._id, + profile: {name: anotherUser.profile.name}, + }); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('returns only first 30 members', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + + let usersToGenerate = []; + for (let i = 0; i < 31; i++) { + usersToGenerate.push(generateUser({challenges: [challenge._id]})); + } + await Promise.all(usersToGenerate); + + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res.length).to.equal(30); + res.forEach(member => { + expect(member).to.have.all.keys(['_id', 'profile']); + expect(member.profile).to.have.all.keys(['name']); + }); + }); + + it('supports using req.query.lastId to get more members', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + + let usersToGenerate = []; + for (let i = 0; i < 57; i++) { + usersToGenerate.push(generateUser({challenges: [challenge._id]})); + } + let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader) + let expectedIds = [user._id].concat(generatedUsers.map(generatedUser => generatedUser._id)); + + let res = await user.get(`/challenges/${challenge._id}/members`); + expect(res.length).to.equal(30); + let res2 = await user.get(`/challenges/${challenge._id}/members?lastId=${res[res.length - 1]._id}`); + expect(res2.length).to.equal(28); + + let resIds = res.concat(res2).map(member => member._id); + expect(resIds).to.eql(expectedIds.sort()); + }); +}); From a59da8607baa095d5659c0df7972d24818142617 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:32:46 +0100 Subject: [PATCH 108/213] add tests for getting invites to a group --- .../groups/GET-groups_groupId_invites.test.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js index e69de29bb2..480808521e 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js @@ -0,0 +1,101 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /groups/:groupId/invites', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates optional req.query.lastId to be an UUID', async () => { + await expect(user.get(`/groups/groupId/invites?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if group doesn\'t exists', async () => { + await expect(user.get(`/groups/${generateUUID()}/invites`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the group', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let anotherUser = await generateUser(); + await expect(anotherUser.get(`/groups/${group._id}/invites`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('works when passing party as req.params.groupId', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let invited = await generateUser(); + await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + let res = await user.get(`/groups/party/invites`); + + expect(res).to.be.an('array'); + expect(res.length).to.equal(1); + expect(res[0]).to.eql({ + _id: invited._id, + profile: {name: invited.profile.name}, + }); + }); + + it('populates only some fields', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let invited = await generateUser(); + await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + let res = await user.get(`/groups/party/invites`); + expect(res[0]).to.have.all.keys(['_id', 'profile']); + expect(res[0].profile).to.have.all.keys(['name']); + }); + + it('returns only first 30 invites', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let invitesToGenerate = []; + for (let i = 0; i < 31; i++) { + let invited = await generateUser(); + invitesToGenerate.push(invited); + await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + } + await Promise.all(invitesToGenerate); + let res = await user.get(`/groups/party/invites`); + expect(res.length).to.equal(30); + res.forEach(member => { + expect(member).to.have.all.keys(['_id', 'profile']); + expect(member.profile).to.have.all.keys(['name']); + }); + }); + + it('supports using req.query.lastId to get more invites', async () => { + let leader = await generateUser({balance: 4}); + let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); + + let invitesToGenerate = []; + for (let i = 0; i < 32; i++) { + invitesToGenerate.push(await generateUser()); + } + let generatedInvites = await Promise.all(invitesToGenerate); // Group has 32 invites + let expectedIds = generatedInvites.map(generatedInvite => generatedInvite._id); + await user.post(`/groups/${group._id}/invite`, {uuids: expectedIds}); + + let res = await user.get(`/groups/${group._id}/invites`); + expect(res.length).to.equal(30); + let res2 = await user.get(`/groups/${group._id}/invites?lastId=${res[res.length - 1]._id}`); + expect(res2.length).to.equal(2); + + let resIds = res.concat(res2).map(invite => invite._id); + expect(resIds).to.eql(expectedIds.sort()); + }); +}); From f8f591e521260751ace4dd3a327f2483d5b4f569 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 16 Jan 2016 16:37:21 +0100 Subject: [PATCH 109/213] do not use wait inside for loop --- .../groups/GET-groups_groupId_invites.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js index 480808521e..d1604a4d84 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js @@ -65,11 +65,11 @@ describe('GET /groups/:groupId/invites', () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); let invitesToGenerate = []; for (let i = 0; i < 31; i++) { - let invited = await generateUser(); - invitesToGenerate.push(invited); - await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); + invitesToGenerate.push(generateUser()); } - await Promise.all(invitesToGenerate); + let generatedInvites = await Promise.all(invitesToGenerate); + await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)}); + let res = await user.get(`/groups/party/invites`); expect(res.length).to.equal(30); res.forEach(member => { @@ -84,7 +84,7 @@ describe('GET /groups/:groupId/invites', () => { let invitesToGenerate = []; for (let i = 0; i < 32; i++) { - invitesToGenerate.push(await generateUser()); + invitesToGenerate.push(generateUser()); } let generatedInvites = await Promise.all(invitesToGenerate); // Group has 32 invites let expectedIds = generatedInvites.map(generatedInvite => generatedInvite._id); From 8de8ca7e18eb35150ee591935b70d8ae6a92ca76 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Jan 2016 12:01:57 +0100 Subject: [PATCH 110/213] add getChallengeMemberProgress route and misc fixes --- common/locales/en/api-v3.json | 1 + website/src/controllers/api-v3/challenges.js | 57 +++++++++++++++++++- website/src/models/challenge.js | 4 +- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 2d00b19192..d16b388769 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -37,6 +37,7 @@ "onlyLeaderCanRemoveMember": "Only group leader can remove a member!", "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", + "challengeMemberNotFound": "User not found among challenge's members", "mustBeGroupMember": "Must be member of the group.", "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"", "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index da7a7e8afe..d0e54667af 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,7 +2,10 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; 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, @@ -135,6 +138,8 @@ api.getChallenges = { * @apiName GetChallenge * @apiGroup Challenge * + * @apiParam {UUID} challengeId The challenge _id + * * @apiSuccess {object} challenge The challenge object */ api.getChallenge = { @@ -160,6 +165,56 @@ api.getChallenge = { }, }; +/** + * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress + * @apiVersion 3.0.0 + * @apiName GetChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * @apiParam {UUID} member The member _id + * + * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member + */ +api.getChallengeMemberProgress = { + method: 'GET', + url: '/challenges/:challengeId/members/:memberId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkQuery('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + let memberId = req.params.memberId; + + let member = await User.findById(memberId).select(`${nameFields} challenges`).exec(); + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + + 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'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); + + let chalTasks = Tasks.Task.find({ + userId: memberId, + 'challenge.id': challengeId, + }) + .select('-tags') // We don't want to return the tags publicly TODO same for other data? + .exec(); + + // manually call toJSON with minimize: true so empty paths aren't returned + let response = member.toJSON({minimize: true}); + response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true})); + res.respond(200, response); + }, +}; + // 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 = {}) { diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index a19ecd3b60..112926ef0c 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -23,7 +23,6 @@ let schema = new Schema({ groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? memberCount: {type: Number, default: 0}, - challengeCount: {type: Number, default: 0}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); @@ -36,12 +35,13 @@ 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 || user.contributor.admin || userGroups.indexOf(this.groupId) !== -1; + 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); }; From ec7ed9c90e0fcad36c59e35c4b54564fd04cafa0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 17 Jan 2016 18:31:03 +0100 Subject: [PATCH 111/213] add tests for getChallengeMemberProgress route and several bug fixes --- ...enges_challengeId_members_memberId.test.js | 126 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 55 +------- website/src/controllers/api-v3/members.js | 52 ++++++++ website/src/controllers/api-v3/tasks.js | 6 +- website/src/models/challenge.js | 4 +- 5 files changed, 184 insertions(+), 59 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js new file mode 100644 index 0000000000..e91fef8563 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js @@ -0,0 +1,126 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId/members/:memberId', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('validates req.params.memberId to be an UUID', async () => { + await expect(user.get(`/challenges/invalidUUID/members/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('validates req.params.memberId to be an UUID', async () => { + await expect(user.get(`/challenges/${generateUUID()}/members/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('fails if member doesn\'t exists', async () => { + let userId = generateUUID(); + await expect(user.get(`/challenges/${generateUUID()}/members/${userId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId}), + }); + }); + + it('fails if challenge doesn\'t exists', async () => { + let member = await generateUser(); + await expect(user.get(`/challenges/${generateUUID()}/members/${member._id}`)).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 () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let anotherUser = await generateUser(); + let member = await generateUser(); + await expect(anotherUser.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('fails if member is not part of the challenge', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let member = await generateUser(); + await expect(user.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeMemberNotFound'), + }); + }); + + it('works with challenges belonging to a public guild', async () => { + let groupLeader = await generateUser({balance: 4}); + let group = await generateGroup(groupLeader, {type: 'guild', privacy: 'public', name: generateUUID()}); + let challenge = await groupLeader.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let taskText = 'Test Text'; + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); + + let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`); + expect(memberProgress).to.have.all.keys(['_id', 'profile', 'tasks']); + expect(memberProgress.profile).to.have.all.keys(['name']); + expect(memberProgress.tasks.length).to.equal(1); + }); + + it('returns the member tasks for the challenges', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: 'Test Text'}]); + + let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); + let chalTasks = await user.get(`/tasks/challenge/${challenge._id}`); + expect(memberProgress.tasks.length).to.equal(chalTasks.length); + expect(memberProgress.tasks[0].challenge.id).to.equal(challenge._id); + expect(memberProgress.tasks[0].challenge.taskId).to.equal(chalTasks[0]._id); + }); + + it('returns the tasks without the tags', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + let challenge = await user.post('/challenges', { + name: 'test chal', + shortName: 'test-chal', + groupId: group._id, + }); + let taskText = 'Test Text'; + await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); + + let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); + expect(memberProgress.tasks[0]).not.to.have.key('tags'); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index d0e54667af..879b13b433 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,10 +2,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; 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, - nameFields, -} from '../../models/user'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, @@ -165,56 +162,6 @@ api.getChallenge = { }, }; -/** - * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress - * @apiVersion 3.0.0 - * @apiName GetChallenge - * @apiGroup Challenge - * - * @apiParam {UUID} challengeId The challenge _id - * @apiParam {UUID} member The member _id - * - * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member - */ -api.getChallengeMemberProgress = { - method: 'GET', - url: '/challenges/:challengeId/members/:memberId', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - req.checkQuery('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let user = res.locals.user; - let challengeId = req.params.challengeId; - let memberId = req.params.memberId; - - let member = await User.findById(memberId).select(`${nameFields} challenges`).exec(); - if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); - - 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'}); - if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); - - let chalTasks = Tasks.Task.find({ - userId: memberId, - 'challenge.id': challengeId, - }) - .select('-tags') // We don't want to return the tags publicly TODO same for other data? - .exec(); - - // manually call toJSON with minimize: true so empty paths aren't returned - let response = member.toJSON({minimize: true}); - response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true})); - res.respond(200, response); - }, -}; - // 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 = {}) { diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 5e2cb33d35..62df59b651 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -10,6 +10,7 @@ import { model as Challenge } from '../../models/challenge'; import { NotFound, } from '../../libs/api-v3/errors'; +import * as Tasks from '../../models/task'; let api = {}; @@ -174,4 +175,55 @@ api.getMembersForChallenge = { handler: _getMembersForItem('challenge-members'), }; +/** + * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress + * @apiVersion 3.0.0 + * @apiName GetChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * @apiParam {UUID} member The member _id + * + * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member + */ +api.getChallengeMemberProgress = { + method: 'GET', + url: '/challenges/:challengeId/members/:memberId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + let memberId = req.params.memberId; + + let member = await User.findById(memberId).select(`${nameFields} challenges`).exec(); + if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + + 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'}); + if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); + if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); + + let chalTasks = await Tasks.Task.find({ + userId: memberId, + 'challenge.id': challengeId, + }) + .select('-tags') // We don't want to return the tags publicly TODO same for other data? + .exec(); + + // manually call toJSON with minimize: true so empty paths aren't returned + let response = member.toJSON({minimize: true}); + delete response.challenges; + response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true})); + res.respond(200, response); + }, +}; + export default api; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index c2498b2a94..e6a420b91a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -84,10 +84,10 @@ api.createUserTasks = { */ api.createChallengeTasks = { method: 'POST', - url: '/tasks/challenge/:challengeId', + url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural? middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let reqValidationErrors = req.validationErrors(); if (reqValidationErrors) throw reqValidationErrors; @@ -188,7 +188,7 @@ api.getChallengeTasks = { url: '/tasks/challenge/:challengeId', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); let validationErrors = req.validationErrors(); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 112926ef0c..8234d0b7f3 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -60,7 +60,7 @@ schema.methods.canModify = function canModifyChallenge (user) { function _syncableAttrs (task) { let t = task.toObject(); // lodash doesn't seem to like _.omit on Document // only sync/compare important attrs - let omitAttrs = ['userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? + let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt? if (t.type !== 'reward') omitAttrs.push('value'); return _.omit(t, omitAttrs); } @@ -168,7 +168,7 @@ schema.methods.addTasks = async function challengeAddTasks (tasks) { tasksOrderList.$each.unshift(userTask._id); } - toSave.push(userTask); + toSave.push(userTask.save()); }); // Update the user From b75adb4f3b30638071a7e42c2fd2561826118b02 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 17 Jan 2016 13:22:39 -0600 Subject: [PATCH 112/213] tests(api): Pull in theunknown's changes to createAndPopulateGroup https://github.com/KristianTashkov/habitrpg/commit/bf6814265c34b52b6efa565c60f3bb43da814164 --- .../api-integration/v3/object-generators.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index f14ecdad67..81bb855d56 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -73,17 +73,20 @@ export async function createAndPopulateGroup (settings = {}) { let group = await generateGroup(groupLeader, groupDetails); let members = await Q.all( - times(numberOfMembers, () => { - return generateUser(); + times(numberOfMembers, async () => { + let user = await generateUser(); + + if (group.type === 'party') { + await user.update({ 'party._id': group._id}); + } else { + await user.update({ guilds: [group._id] }); + } + + return user; }) ); - let memberIds = members.map((member) => { - return member._id; - }); - memberIds.push(groupLeader._id); - - await group.update({ members: memberIds }); + group.update({ memberCount: numberOfMembers + 1}); let invitees = await Q.all( times(numberOfInvites, () => { From b60e0a4c64b22063990c3a5435929f4d8ff44c8c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 17 Jan 2016 13:32:31 -0600 Subject: [PATCH 113/213] Added initial get groups tests --- .../v3/integration/groups/GET-groups.test.js | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/api/v3/integration/groups/GET-groups.test.js diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js new file mode 100644 index 0000000000..7f86480187 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -0,0 +1,99 @@ +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]') + .and.to.have.property('leader', user._id); + }); + + 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 () => { + let groups = await user.get('/groups?type=privateGuilds,publicGuilds,party'); + + await expect(groups.length) + .to.eql(NUMBER_OF_GROUPS_USER_CAN_VIEW); + }); +}); From 81e6172fb1d24f59d87e439a18d74474201b711b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 17 Jan 2016 13:42:09 -0600 Subject: [PATCH 114/213] tests: Tighten up generateGroup members helper --- .../api-integration/v3/object-generators.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 81bb855d56..1758998788 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -72,17 +72,14 @@ export async function createAndPopulateGroup (settings = {}) { let groupLeader = await generateUser(leaderDetails); let group = await generateGroup(groupLeader, groupDetails); + const groupMembershipTypes = { + party: { 'party._id': group._id}, + guild: { guilds: [group._id] }, + }; + let members = await Q.all( - times(numberOfMembers, async () => { - let user = await generateUser(); - - if (group.type === 'party') { - await user.update({ 'party._id': group._id}); - } else { - await user.update({ guilds: [group._id] }); - } - - return user; + times(numberOfMembers, () => { + return generateUser(groupMembershipTypes[group.type]); }) ); From 7b5945525280479e6ec741d1928a326b7bdf5d45 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sun, 17 Jan 2016 13:58:46 -0600 Subject: [PATCH 115/213] tests(api): Add generateChallenge helper --- ...GET-challenges_challengeId_members.test.js | 34 +++++-------------- ...enges_challengeId_members_memberId.test.js | 31 ++++------------- .../api-integration/v3/object-generators.js | 22 +++++++++++- 3 files changed, 35 insertions(+), 52 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js index 318181b66e..ae037b53e4 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js @@ -1,6 +1,7 @@ import { generateUser, generateGroup, + generateChallenge, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -29,13 +30,10 @@ describe('GET /challenges/:challengeId/members', () => { }); it('fails if user doesn\'t have access to the challenge', async () => { - let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let group = await generateGroup(user); + let challenge = await generateChallenge(user, group); let anotherUser = await generateUser(); + await expect(anotherUser.get(`/challenges/${challenge._id}/members`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -46,11 +44,7 @@ describe('GET /challenges/:challengeId/members', () => { it('works with challenges belonging to public guild', async () => { let leader = await generateUser({balance: 4}); let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await leader.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(leader, group); let res = await user.get(`/challenges/${challenge._id}/members`); expect(res[0]).to.eql({ _id: leader._id, @@ -63,11 +57,7 @@ describe('GET /challenges/:challengeId/members', () => { it('populates only some fields', async () => { let anotherUser = await generateUser({balance: 3}); let group = await generateGroup(anotherUser, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await anotherUser.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(anotherUser, group); let res = await user.get(`/challenges/${challenge._id}/members`); expect(res[0]).to.eql({ _id: anotherUser._id, @@ -79,11 +69,7 @@ describe('GET /challenges/:challengeId/members', () => { it('returns only first 30 members', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let usersToGenerate = []; for (let i = 0; i < 31; i++) { @@ -101,11 +87,7 @@ describe('GET /challenges/:challengeId/members', () => { it('supports using req.query.lastId to get more members', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let usersToGenerate = []; for (let i = 0; i < 57; i++) { diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js index e91fef8563..315d7bef2c 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js @@ -1,5 +1,6 @@ import { generateUser, + generateChallenge, generateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; @@ -48,11 +49,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('fails if user doesn\'t have access to the challenge', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let anotherUser = await generateUser(); let member = await generateUser(); await expect(anotherUser.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ @@ -64,11 +61,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('fails if member is not part of the challenge', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let member = await generateUser(); await expect(user.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({ code: 404, @@ -80,11 +73,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('works with challenges belonging to a public guild', async () => { let groupLeader = await generateUser({balance: 4}); let group = await generateGroup(groupLeader, {type: 'guild', privacy: 'public', name: generateUUID()}); - let challenge = await groupLeader.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(groupLeader, group); let taskText = 'Test Text'; await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); @@ -96,11 +85,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('returns the member tasks for the challenges', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: 'Test Text'}]); let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); @@ -112,11 +97,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { it('returns the tasks without the tags', async () => { let group = await generateGroup(user, {type: 'party', name: generateUUID()}); - let challenge = await user.post('/challenges', { - name: 'test chal', - shortName: 'test-chal', - groupId: group._id, - }); + let challenge = await generateChallenge(user, group); let taskText = 'Test Text'; await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 1758998788..b7785e664f 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -3,7 +3,7 @@ import { } from 'lodash'; import Q from 'q'; import { v4 as generateUUID } from 'uuid'; -import { ApiUser, ApiGroup } from '../api-classes'; +import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes'; import { requester } from '../requester'; // Creates a new user and returns it @@ -106,3 +106,23 @@ export async function createAndPopulateGroup (settings = {}) { invitees, }; } + +// Generates a new challenge. Requires an ApiUser object and a +// group-like object (can just be {_id: 'your-group-id'}). The group +// will will become the group that owns the challenge. It takes an +// optional details argument for the initial challenge creation and an +// optional update argument which will update the challenge via the db +export async function generateChallenge (challengeCreator, group, details = {}, update = {}) { + details.groupId = group._id; + details.name = details.name || 'a challenge'; + details.shortName = details.shortName || 'aChallenge'; + details.prize = details.prize || 0; + details.official = details.official || false; + + let challenge = await challengeCreator.post('/challenges', details); + let apiChallenge = new ApiChallenge(challenge); + + await apiChallenge.update(update); + + return apiChallenge; +} From 417d754a0d3af0a24a65c2e371fad8a96ef76190 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 17 Jan 2016 14:02:17 -0600 Subject: [PATCH 116/213] Added intial group remove member tests --- .../POST-groups_id_removeMember.test.js | 119 ++++++++++++++++++ website/src/controllers/api-v3/groups.js | 22 +++- 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_id_removeMember.test.js diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js new file mode 100644 index 0000000000..c49cfe6694 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -0,0 +1,119 @@ +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; + + before(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(_.findIndex(memberRemoved.guilds, {id: guild._id})).eql(-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; + + before(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('can remove other invites', async () => { + await partyleader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`); + + let invitedUserWithoutInvite = await partyInvitedUser.get('/user'); + + expect(_.findIndex(invitedUserWithoutInvite.invitations.party, {id: party._id})).eql(-1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 8203e00b1b..7bbb2ed1ca 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -347,15 +347,27 @@ api.removeGroupMember = { let group = await Group.getGroup(user, req.params.groupId, '-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; @@ -371,7 +383,9 @@ api.removeGroupMember = { if (isInGroup === 'guild') _.pull(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) { + member.newMessages.group._id = undefined; + } if (group.quest && group.quest.active && group.quest.leader === member._id) { member.items.quests[group.quest.key] += 1; // TODO why this? From c5947ca9c4a719f133ab7213a45d4dc226d47d8c Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 17 Jan 2016 14:20:51 -0600 Subject: [PATCH 117/213] Updated party test to conform to new leader populate --- test/api/v3/integration/groups/GET-groups.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index 7f86480187..1994d31dd1 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -77,7 +77,7 @@ describe('GET /groups', () => { await expect(user.get('/groups?type=party')) .to.eventually.have.a.lengthOf(1) .and.to.have.deep.property('[0]') - .and.to.have.property('leader', user._id); + .and.to.have.property('leader._id', user._id); }); it('returns all public guilds when publicGuilds passed in as query', async () => { @@ -93,7 +93,7 @@ describe('GET /groups', () => { it('returns a list of groups user has access to', async () => { let groups = await user.get('/groups?type=privateGuilds,publicGuilds,party'); - await expect(groups.length) + expect(groups.length) .to.eql(NUMBER_OF_GROUPS_USER_CAN_VIEW); }); }); From f37b5a7fac74b3ddc0ca3bd22faaa65899ec1997 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Sun, 17 Jan 2016 17:06:10 +0200 Subject: [PATCH 118/213] Challenges join route and tests --- common/locales/en/api-v3.json | 1 + .../POST-challenges_challengeId_join.test.js | 110 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 39 ++++++- website/src/models/challenge.js | 9 +- 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index d16b388769..c10fd1647a 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -54,6 +54,7 @@ "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.", + "userAlreadyInChallenge": "User is already participating in this challenge.", "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\".", "partyMustbePrivate": "Parties must be private", "userAlreadyInGroup": "User already in that group.", diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js new file mode 100644 index 0000000000..3eda5716cd --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -0,0 +1,110 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/join', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser({ balance: 1}); + + await expect(user.post('/challenges/test/join')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser({ balance: 1}); + + await expect(user.post(`/challenges/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Joining a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let authorizedUser; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + authorizedUser = populatedGroup.members[0]; + + challenge = await generateChallenge(groupLeader, group); + }); + + it('returns an error when user doesn\'t have permissions to access the challenge', async () => { + let unauthorizedUser = await generateUser(); + + await expect(unauthorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('adds challenge to user challenges', async () => { + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await authorizedUser.sync(); + + expect(authorizedUser).to.have.property('challenges').to.include(challenge._id); + }); + + it('returns error when user has already joined the challenge', async () => { + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInChallenge'), + }); + }); + + it('increases memberCount of challenge', async () => { + let oldMemberCount = challenge.memberCount; + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + + expect(challenge).to.have.property('memberCount', oldMemberCount + 1); + }); + + it('syncs challenge tasks to joining user', async () => { + let taskText = 'A challenge task text'; + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + let tasks = await authorizedUser.get('/tasks/user'); + let tasksTexts = tasks.map((task) => { + return task.text; + }); + + expect(tasksTexts).to.include(taskText); + }); + + it('adds challenge tag to user tags', async () => { + let userTagsLength = (await authorizedUser.get('/tags')).length; + + await authorizedUser.post(`/challenges/${challenge._id}/join`); + + await expect(authorizedUser.get('/tags')).to.eventually.have.length(userTagsLength + 1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 879b13b433..f0c682cf05 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; @@ -92,6 +93,42 @@ api.createChallenge = { }, }; +/** + * @api {post} /challenges/:challengeId/join Joins a challenge + * @apiVersion 3.0.0 + * @apiName JoinChallenge + * @apiGroup Challenge + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge the user joined + */ +api.joinChallenge = { + method: 'POST', + url: '/challenges/:challengeId/join', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challenge = await Challenge.findOne({ _id: req.params.challengeId }); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + if (!challenge.hasAccess(user)) throw new NotFound(res.t('challengeNotFound')); + + if (_.contains(user.challenges, challenge._id)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); + + challenge.memberCount += 1; + + // Add all challenge's tasks to user's tasks and save the challenge + await Q.all([challenge.syncToUser(user), challenge.save()]); + res.respond(200, challenge); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 @@ -144,7 +181,7 @@ api.getChallenge = { url: '/challenges/:challengeId', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 8234d0b7f3..39a7caa21b 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -22,7 +22,7 @@ let schema = new Schema({ leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, groupId: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, // TODO no update, no set? timestamp: {type: Date, default: Date.now, required: true}, // TODO what is this? use timestamps from plugin? not settable? - memberCount: {type: Number, default: 0}, + memberCount: {type: Number, default: 1}, prize: {type: Number, default: 0, min: 0}, // TODO no update? }); @@ -65,6 +65,13 @@ function _syncableAttrs (task) { return _.omit(t, omitAttrs); } +schema.methods.hasAccess = function hasAccessToChallenge (user) { + let userGroups = user.guilds.slice(0); + if (user.party._id) userGroups.push(user.party._id); + userGroups.push('habitrpg'); // tavern challenges + return this.leader === user._id || userGroups.indexOf(this.groupId) !== -1; +}; + // Sync challenge to user, including tasks and tags. // Used when user joins the challenge or to force sync. schema.methods.syncToUser = async function syncChallengeToUser (user) { From d93cabafb950b0e2d2c1e548cfa45bcb322118ef Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 16 Jan 2016 14:06:16 -0600 Subject: [PATCH 119/213] Added initial tests for createChallengeTasks and fixed some issues in the challenge and challengeTask routes --- .../POST-tasks_challenge_id.test.js | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js new file mode 100644 index 0000000000..62899ffb59 --- /dev/null +++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js @@ -0,0 +1,125 @@ +import { + generateUser, + generateGroup, + generateChallenge, + translate as t, +} from '../../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/challenge/:challengeId', () => { + let user; + let guild; + let challenge; + + beforeEach(async () => { + user = await generateUser({balance: 1}); + guild = await generateGroup(user); + challenge = await generateChallenge(user, guild); + }); + + it('returns error when challenge is not found', async () => { + let fakeChallengeId = generateUUID(); + + await expect(user.post(`/tasks/challenge/${fakeChallengeId}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns error when user does not have the challenge', async () => { + let userWithoutChallenge = await generateUser(); + + await expect(userWithoutChallenge.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns error when non leader tries to edit challenge', async () => { + let userThatIsNotLeaderOfChallenge = await generateUser({ + challenges: [challenge._id], + }); + + await expect(userThatIsNotLeaderOfChallenge.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyChalLeaderEditTasks'), + }); + }); + + it('creates a habit', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + let challengeWithTask = await user.get(`/challenges/${challenge._id}`); + + expect(challengeWithTask.tasksOrder.habits.indexOf(task._id)).to.be.above(-1); + expect(task.challenge.id).to.equal(challenge._id); + expect(task.text).to.eql('test habit'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('habit'); + expect(task.up).to.eql(false); + expect(task.down).to.eql(true); + }); + + it('creates a todo', async () => { + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test todo', + type: 'todo', + notes: 1976, + }); + let challengeWithTask = await user.get(`/challenges/${challenge._id}`); + + expect(challengeWithTask.tasksOrder.todos.indexOf(task._id)).to.be.above(-1); + expect(task.challenge.id).to.equal(challenge._id); + expect(task.text).to.eql('test todo'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('todo'); + }); + + it('creates a daily', async () => { + let now = new Date(); + let task = await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'test daily', + type: 'daily', + notes: 1976, + frequency: 'daily', + everyX: 5, + startDate: now, + }); + let challengeWithTask = await user.get(`/challenges/${challenge._id}`); + + expect(challengeWithTask.tasksOrder.dailys.indexOf(task._id)).to.be.above(-1); + expect(task.challenge.id).to.equal(challenge._id); + expect(task.text).to.eql('test daily'); + expect(task.notes).to.eql('1976'); + expect(task.type).to.eql('daily'); + expect(task.frequency).to.eql('daily'); + expect(task.everyX).to.eql(5); + expect(new Date(task.startDate)).to.eql(now); + }); +}); From ee854229c7b794043749912cd2a305ae31f99003 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 18 Jan 2016 15:01:46 +0100 Subject: [PATCH 120/213] fix dateCompleted not remove from un-completed todos and fix tasks test --- common/script/api-v3/scoreTask.js | 2 +- .../POST-tasks_id_score_direction.test.js | 2 + .../integration/tasks/POST-tasks_user.test.js | 3 - website/src/controllers/api-v3/tasks.js | 61 +------------------ website/src/models/group.js | 2 +- 5 files changed, 5 insertions(+), 65 deletions(-) diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 25c59393b6..91db45ce53 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -224,7 +224,7 @@ export default function scoreTask (options = {}, req = {}) { if (cron) { // don't touch stats on cron delta += _changeTaskValue(user, task, direction, times, cron); } else { - if (direction === 'up') task.dateCompleted = new Date(); + task.dateCompleted = direction === 'up' ? new Date() : undefined; delta += _changeTaskValue(user, task, direction, times, cron); if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 228f8348f8..c1204a7341 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -46,6 +46,7 @@ describe('POST /tasks/:id/score/:direction', () => { let task = await user.get(`/tasks/${todo._id}`); expect(task.completed).to.equal(true); + expect(task.dateCompleted).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type }); it('moves completed todos out of user.tasksOrder.todos', async () => { @@ -81,6 +82,7 @@ describe('POST /tasks/:id/score/:direction', () => { let updatedTask = await user.get(`/tasks/${todo._id}`); expect(updatedTask.completed).to.equal(false); + expect(updatedTask.dateCompleted).to.be.a('undefined'); }); it('scores up todo even if it is already completed'); // Yes? diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index 906bc8feed..afae951fb1 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -95,9 +95,6 @@ describe('POST /tasks/user', () => { expect(updatedTasks).to.eql(originalTasks); }); - - let updatedTasks = await user.get('/tasks'); - expect(updatedTasks).to.eql(originalTasks); }); it('automatically sets "task.userId" to user\'s uuid', async () => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e6a420b91a..7f2a488735 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -153,7 +153,7 @@ async function _getTasks (req, res, user, challenge) { * @apiGroup Task * * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". + * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". * * @apiSuccess {Array} tasks An array of task objects */ @@ -178,7 +178,6 @@ api.getUserTasks = { * @apiGroup Task * * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. - * * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks * * @apiSuccess {Array} tasks An array of task objects @@ -208,64 +207,6 @@ api.getChallengeTasks = { }, }; -/** - * @api {get} /tasks/:tasksOwner/:challengeId Get an user's tasks - * @apiVersion 3.0.0 - * @apiName GetTasks - * @apiGroup Task - * - * @apiParam {string="user","challenge"} tasksOwner Query parameter to return tasks belonging to a challenge (specifying the "challengeId" parameter) or to the autheticated user. - * @apiParam {UUID} challengeId Optional query parameter. If "tasksOwner" is "challenge" then required to specify the challenge id. - * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". Only valid whe "tasksOwner" is "user". - * - * @apiSuccess {Array} tasks An array of task objects - */ -api.getTasks = { - method: 'GET', - url: '/tasks', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let challengeId = req.query.challengeId; - let challenge; - - let query = challenge ? {'challenge.id': challengeId, userId: {$exists: false}} : {userId: user._id}; - let type = req.query.type; - - if (type) { - query.type = type; - if (type === 'todo') query.completed = false; // Exclude completed todos - } else { - query.$or = [ // Exclude completed todos - {type: 'todo', completed: false}, - {type: {$in: ['habit', 'daily', 'reward']}}, - ]; - } - - if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { - if (challengeId) throw new BadRequest(res.t('noCompletedTodosChallenge')); - - let queryCompleted = Tasks.Task.find({ - type: 'todo', - completed: true, - }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos - dateCompleted: 1, - }); - - let results = await Q.all([ - queryCompleted.exec(), - Tasks.Task.find(query).exec(), - ]); - - res.respond(200, results[1].concat(results[0])); - } else { - let tasks = await Tasks.Task.find(query).exec(); - res.respond(200, tasks); - } - }, -}; - /** * @api {get} /task/:taskId Get a task given its id * @apiVersion 3.0.0 diff --git a/website/src/models/group.js b/website/src/models/group.js index 85396fc693..5ff1eda3dd 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -492,7 +492,7 @@ schema.methods.leave = function leaveGroup (user, keep) { if (group.type === 'guild') { _.pull(user.guilds, group._id); } else { - user.party._id = undefined; + user.party._id = undefined; // TODO remove quest information too? } // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) From be55176954a76d046e2e988d38c1be802f677022 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 19 Jan 2016 08:13:24 -0600 Subject: [PATCH 121/213] Ensured tavern is not returned twice and removed leader population --- test/api/v3/integration/groups/GET-groups.test.js | 9 +++------ website/src/controllers/api-v3/groups.js | 8 +++++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js index 1994d31dd1..b37b87f890 100644 --- a/test/api/v3/integration/groups/GET-groups.test.js +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -76,8 +76,7 @@ describe('GET /groups', () => { 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]') - .and.to.have.property('leader._id', user._id); + .and.to.have.deep.property('[0]'); }); it('returns all public guilds when publicGuilds passed in as query', async () => { @@ -91,9 +90,7 @@ describe('GET /groups', () => { }); it('returns a list of groups user has access to', async () => { - let groups = await user.get('/groups?type=privateGuilds,publicGuilds,party'); - - expect(groups.length) - .to.eql(NUMBER_OF_GROUPS_USER_CAN_VIEW); + await expect(user.get('/groups?type=privateGuilds,publicGuilds,party,tavern')) + .to.eventually.have.lengthOf(NUMBER_OF_GROUPS_USER_CAN_VIEW); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index d7d3eedbd3..14754ffaf6 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -86,14 +86,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 +109,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; } }); From ef6afaedb4b4d6839f1cd5fdcadeb35e0b287096 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 12 Jan 2016 18:00:37 -0600 Subject: [PATCH 122/213] Added intitial group leave tests Fixed test readability, updated party test, and updated challenge update code when leaving group Updated library, added group existance check, and reset full party Updated syntax, added new userUnlinkChallenges, and added some initial testing for challenges Added challenge tasks tests Added try/catch to group remove, add more party tests, fixed broken challenge test, removed useless return value Added public guild tests, added more tests to party, and abstracted remove invitations logic Closes #6506 --- .../groups/POST-groups_groupId_leave.js | 195 ++++++++++++++++++ test/helpers/api-integration/v3/index.js | 8 + website/src/controllers/api-v3/groups.js | 2 + website/src/controllers/api-v3/tasks.js | 1 + website/src/models/group.js | 149 ++++++------- website/src/models/user.js | 13 +- 6 files changed, 280 insertions(+), 88 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_leave.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_leave.js b/test/api/v3/integration/groups/POST-groups_groupId_leave.js new file mode 100644 index 0000000000..59031300ba --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_leave.js @@ -0,0 +1,195 @@ +import { + generateChallenge, + checkExistence, + createAndPopulateGroup, + sleep, +} from '../../../../helpers/api-v3-integration.helper'; +import { + each, +} from 'lodash'; + +describe('POST /groups/:groupId/leave', () => { + let typesOfGroups = { + 'public guild': { type: 'guild', privacy: 'public' }, + 'private guild': { type: 'guild', privacy: 'private' }, + party: { type: 'party', privacy: 'private' }, + }; + + each(typesOfGroups, (groupDetails, groupType) => { + context(`Leaving a ${groupType}`, () => { + let groupToLeave; + let leader; + let member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails, + members: 1, + }); + + groupToLeave = group; + leader = groupLeader; + member = members[0]; + }); + + it(`lets user leave a ${groupType}`, async () => { + await member.post(`/groups/${groupToLeave._id}/leave`); + + let userThatLeftGroup = await member.get('/user'); + + expect(userThatLeftGroup.guilds).to.be.empty; + expect(userThatLeftGroup.party._id).to.not.exist; + }); + + it(`sets a new group leader when leader leaves a ${groupType}`, async () => { + await leader.post(`/groups/${groupToLeave._id}/leave`); + + let groupToLeaveWithNewLeader = await member.get(`/groups/${groupToLeave._id}`); + + expect(groupToLeaveWithNewLeader.leader._id).to.equal(member._id); + }); + + context('With challenges', () => { + let challenge; + + beforeEach(async () => { + challenge = await generateChallenge(leader, groupToLeave); + + await leader.post(`/tasks/challenge/${challenge._id}`, { + text: 'test habit', + type: 'habit', + }); + + await sleep(0.5); + }); + + it('removes all challenge tasks when keep parameter is set to remove', async () => { + await leader.post(`/groups/${groupToLeave._id}/leave?keep=remove-all`); + + let userWithoutChallengeTasks = await leader.get('/user'); + + expect(userWithoutChallengeTasks.challenges).to.not.include(challenge._id); + expect(userWithoutChallengeTasks.tasksOrder.habits).to.be.empty; + }); + + it('keeps all challenge tasks when keep parameter is not set', async () => { + await leader.post(`/groups/${groupToLeave._id}/leave`); + + let userWithChallengeTasks = await leader.get('/user'); + + expect(userWithChallengeTasks.challenges).to.not.include(challenge._id); + // @TODO find elegant way to assert against the task existing + expect(userWithChallengeTasks.tasksOrder.habits).to.not.be.empty; + }); + }); + + it('prevents quest leader from leaving a groupToLeave'); + it('prevents a user from leaving during an active quest'); + }); + }); + + context('Leaving a group as the last member', () => { + context('private guild', () => { + let privateGuild; + let leader; + let invitedUser; + + beforeEach(async () => { + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Private Guild', + type: 'guild', + }, + invites: 1, + }); + + privateGuild = group; + leader = groupLeader; + invitedUser = invitees[0]; + }); + + it('removes a group when the last member leaves', async () => { + await leader.post(`/groups/${privateGuild._id}/leave`); + + await expect(checkExistence('groups', privateGuild._id)).to.eventually.equal(false); + }); + + it('removes invitations when the last member leaves', async () => { + await leader.post(`/groups/${privateGuild._id}/leave`); + + let userWithoutInvitation = await invitedUser.get('/user'); + + expect(userWithoutInvitation.invitations.guilds).to.be.empty; + }); + }); + + context('public guild', () => { + let publicGuild; + let leader; + let invitedUser; + + beforeEach(async () => { + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Public Guild', + type: 'guild', + privacy: 'public', + }, + invites: 1, + }); + + publicGuild = group; + leader = groupLeader; + invitedUser = invitees[0]; + }); + + it('keeps the group when the last member leaves', async () => { + await leader.post(`/groups/${publicGuild._id}/leave`); + + await expect(checkExistence('groups', publicGuild._id)).to.eventually.equal(true); + }); + + it('keeps the invitations when the last member leaves a public guild', async () => { + await leader.post(`/groups/${publicGuild._id}/leave`); + + let userWithoutInvitation = await invitedUser.get('/user'); + + expect(userWithoutInvitation.invitations.guilds).to.not.be.empty; + }); + }); + + context('party', () => { + let party; + let leader; + let invitedUser; + + beforeEach(async () => { + let { group, groupLeader, invitees } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + }, + invites: 1, + }); + + party = group; + leader = groupLeader; + invitedUser = invitees[0]; + }); + + it('removes a group when the last member leaves a party', async () => { + await leader.post(`/groups/${party._id}/leave`); + + await expect(checkExistence('party', party._id)).to.eventually.equal(false); + }); + + it('removes invitations when the last member leaves a party', async () => { + await leader.post(`/groups/${party._id}/leave`); + + let userWithoutInvitation = await invitedUser.get('/user'); + + expect(userWithoutInvitation.invitations.party).to.be.empty; + }); + }); + }); +}); diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 0c440ade26..880f88f41a 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -8,3 +8,11 @@ export { requester }; export { translate } from '../translate'; export { checkExistence, resetHabiticaDB } from '../mongo'; export * from './object-generators'; + +export async function sleep (seconds) { + let milliseconds = seconds * 1000; + + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index d7d3eedbd3..05a68831e9 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -251,6 +251,8 @@ api.joinGroup = { if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader + group.memberCount += 1; + let promises = [group.save(), user.save()]; if (group.type === 'party' && inviter) { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 7f2a488735..acdd456beb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -102,6 +102,7 @@ api.createChallengeTasks = { if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks')); let tasks = await _createTasks(req, res, user, challenge); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); // If adding tasks to a challenge -> sync users diff --git a/website/src/models/group.js b/website/src/models/group.js index 5ff1eda3dd..8e21623c7c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -98,30 +98,15 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { }*/ // TODO test -schema.pre('remove', true, function preRemoveGroup (next, done) { +schema.pre('remove', true, async function preRemoveGroup (next, done) { next(); let group = this; - - // Remove invitations when group is deleted - // TODO verify it works fir everything - User.find({ - // TODO id -> _id ? - [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, - }).exec() - .then(users => { - return Q.all(users.map(user => { - 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); - } - - return user.save(); - })); - }) - .then(done) - .catch(done); + try { + await group.removeGroupInvitations(); + done(); + } catch (err) { + done(err); + } }); schema.post('remove', function postRemoveGroup (group) { @@ -150,6 +135,27 @@ schema.statics.getGroup = function getGroup (options = {}) { // TODO purge chat flags info? in tojson? }; +schema.methods.removeGroupInvitations = async function removeGroupInvitations () { + let group = this; + + let usersToRemoveInvitationsFrom = await User.find({ + // TODO id -> _id ? + [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id, + }).exec(); + + let userUpdates = usersToRemoveInvitationsFrom.map(user => { + 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); + } + return user.save(); + }); + + return Q.all(userUpdates); +}; + // Return true if user is a member of the group schema.methods.isMember = function isGroupMember (user) { if (this._id === 'habitrpg') { @@ -452,71 +458,46 @@ schema.statics.bossQuest = function bossQuest (user, progress) { }); }; -// Remove user from this group -// TODO this is highly inefficient -schema.methods.leave = function leaveGroup (user, keep) { +schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let group = this; - return Q.all([ - // Remove user from group challenges - - // First find relevant Challenges - Challenge.find({ - _id: {$in: user.challenges}, // Challenges I am in - group: group._id, // that belong to the group I am leaving - }).then(challenges => { - // Update each challenge - return Challenge.update( - {_id: {$in: _.pluck(challenges, '_id')}}, - {$pull: {members: user._id}}, - {multi: true} - ).then(() => challenges); // pass `challenges` above to next promise - }).then(challenges => { - return Q.all(challenges.map(chal => { - let i = user.challenges.indexOf(chal._id); - if (i !== -1) user.challenges.splice(i, 1); - return user.unlinkChallengeTasks(chal._id, keep); - })); - }), - - // Update the group - (() => { - // If user is the last one in group and group is private, delete it - if (group.members.length === 1 && ( - group.type === 'party' || - group.type === 'guild' && group.privacy === 'private' - )) return group.remove(); - - let update = {}; - // otherwise just remove a member TODO create User.methods.removeFromGroup? - if (group.type === 'guild') { - _.pull(user.guilds, group._id); - } else { - user.party._id = undefined; // TODO remove quest information too? - } - - // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - let leader = group.leader; - - if (leader === user._id || group.members.indexOf(leader) === -1) { - let seniorMember = _.find(group.members, m => m !== user._id); - - // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving - if (seniorMember) update.$set = {leader: seniorMember}; - } - - update.$inc = {memberCount: -1}; - return Q.all([ - model.update({_id: group._id}, update).exec(), // eslint-disable-line no-use-before-define - user.save(), - ]); - })(), - ]).then(() => { - firebase.removeUserFromGroup(group._id, user._id); - return; // TODO ok not to return promise? - }).catch(err => { // TODO do we have to catch err if we return the promise? - throw err; + let challenges = await Challenge.find({ + _id: {$in: user.challenges}, + groupId: group._id, }); + + let challengesToRemoveUserFrom = challenges.map(chal => { + return user.unlinkChallengeTasks(chal._id, keep); + }); + await Q.all(challengesToRemoveUserFrom); + + let promises = []; + + // If user is the last one in group and group is private, delete it + if (group.memberCount <= 1 && group.privacy === 'private') { + return await group.remove(); + } + + // otherwise just remove a member TODO create User.methods.removeFromGroup? + if (group.type === 'guild') { + promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id } }).exec()); + } else { + promises.push(User.update({_id: user._id}, {$set: {party: {} } }).exec()); + } + + // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) + let update = { memberCount: group.memberCount - 1 }; + if (group.leader === user._id) { + let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; + let seniorMember = await User.findOne({query, _id: {$ne: user._id}}).exec(); + + // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving + if (seniorMember) update.$set = {leader: seniorMember._id}; + } + promises.push(group.update(update).exec()); + firebase.removeUserFromGroup(group._id, user._id); + + return Q.all(promises); }; export const INVITES_LIMIT = 100; diff --git a/website/src/models/user.js b/website/src/models/user.js index 8942a8afd2..6ea4ede281 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -661,13 +661,18 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall 'challenge.id': challengeId, }; + let challengeIndex = user.challenges.indexOf(challengeId); + if (challengeIndex !== -1) user.challenges.splice(challengeIndex, 1); + if (keep === 'keep-all') { await Tasks.Task.update(findQuery, { $set: {challenge: {}}, // TODO what about updatedAt? }, {multi: true}).exec(); + + await user.save(); } else { // keep = 'remove-all' - let tasks = Tasks.Task.find(findQuery).select('_id type completed').exec(); - tasks = tasks.map(task => { + let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec(); + 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`]; @@ -678,8 +683,8 @@ schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (chall return task.remove(); }); - tasks.push(user.save()); - await Q.all(tasks); + taskPromises.push(user.save()); + return Q.all(taskPromises); } }; From 926f22f272292ab1eee32794438dd966a4dde590 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Tue, 19 Jan 2016 20:17:25 +0200 Subject: [PATCH 123/213] Add missing tests in group join route --- .../groups/POST-groups_groupId_join.test.js | 81 +++++++++++++++---- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index b35409bb6c..eceb388ac4 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -16,7 +16,39 @@ 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); + }); + }); + + context('Joining a private guild', () => { let user, invitedUser, guild; beforeEach(async () => { @@ -44,21 +76,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 +91,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 +115,7 @@ describe('POST /group/:groupId/join', () => { }); }); - context('Accepting invitation to a party', () => { + context('Joining a party', () => { let user, invitedUser, party; beforeEach(async () => { @@ -130,6 +155,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 +176,20 @@ describe('POST /group/:groupId/join', () => { await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2); }); + + 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; + }); }); }); }); From 1a31f7323828968bf634442afe7042b5ebb1f81d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 20 Jan 2016 21:35:00 +0100 Subject: [PATCH 124/213] fix query when setting new group leader --- website/src/models/group.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index 8e21623c7c..af592bb332 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -489,7 +489,8 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let update = { memberCount: group.memberCount - 1 }; if (group.leader === user._id) { let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id}; - let seniorMember = await User.findOne({query, _id: {$ne: user._id}}).exec(); + query._id = {$ne: user._id}; + let seniorMember = await User.findOne(query).select('_id').exec(); // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving if (seniorMember) update.$set = {leader: seniorMember._id}; From 3bd806a0f0c3f6f80444fce3ef7ca66e184ab26e Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Thu, 21 Jan 2016 17:31:54 -0500 Subject: [PATCH 125/213] test(quests): accept route WIP --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupId_quests_accept.test.js | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c10fd1647a..bc71703017 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -65,5 +65,6 @@ "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked." + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "questInviteNotFound": "No quest invitation found." } diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js new file mode 100644 index 0000000000..44286e6e79 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -0,0 +1,51 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe.skip('POST /groups/:groupId/quests/accept', () => { + let questingGroup; + let leader; + let member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + leader = groupLeader; + member = members[0]; + }); + + context('failure conditions', () => { + it('does not accept quest without an invite', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInviteNotFound'), + }); + + )}; + + it('does not accept quest for a group in which user is not a member', () => { + + )}; + )}; + context('successfully accepting a quest invitation', () => { + it('joins a quest from an invitation', () => { + + )}; + + it('does not begin the quest if pending invitations remain', () => { + + )}; + + it('begins the quest if accepting the last pending invite', () => { + + )}; + )}; +)}; From 0ec63ca68d644a1cc68a1a9dc8e7712390370e75 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 22 Jan 2016 16:59:34 -0500 Subject: [PATCH 126/213] test(quests): invite and accept WIP --- common/locales/en/api-v3.json | 7 +- .../POST-groups_groupId_quests_accept.test.js | 2 +- .../POST-groups_groupId_quests_invite.test.js | 120 ++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index bc71703017..f030c020f7 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -66,5 +66,10 @@ "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", - "questInviteNotFound": "No quest invitation found." + "questInviteNotFound": "No quest invitation found.", + "guildQuestsNotSupported": "Guilds cannot be invited on quests.", + "questNotFound": "Quest \"<%= key %>\" not found.", + "questNotOwned": "You don't own that quest scroll.", + "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." } diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js index 44286e6e79..a50497555b 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -10,7 +10,7 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { let member; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ + let { group, groupLeader, members } = await createAndPopulateGroup( { type: 'party', privacy: 'private' }, members: 1, }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js new file mode 100644 index 0000000000..76ccf25e51 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -0,0 +1,120 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe.skip('POST /groups/:groupId/quests/invite', () => { + let questingGroup; + let leader; + let member; + const PET_QUEST = 'whale'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup( + { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + leader = groupLeader; + member = members[0]; + }); + + context('failure conditions', () => { + it('does not issue invites with an invalid group ID', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not issue invites for a group in which user is not a member', async () => { + let { alternateGroup } = await createAndPopulateGroup( + { type: 'party', privacy: 'private' }, + members: 1, + }); + + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + )}; + + it('does not issue invites for Guilds', async () => { + let { alternateGroup } = await createAndPopulateGroup( + { type: 'guild', privacy: 'public' }, + members: 1, + }); + + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + )}; + + it('does not issue invites with an invalid quest key', async () => { + const FAKE_QUEST = 'herkimer'; + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${FAKE_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotFound', {key: FAKE_QUEST}), + }); + )}; + + it('does not issue invites for a quest the user does not own', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questNotOwned'), + }); + )}; + + it('does not issue invites if the user is of insufficient Level', async () => { + const LEVELED_QUEST = 'atom1'; + const LEVELED_QUEST_REQ = 15; + leader.items.quests[LEVELED_QUEST] = 1; + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), + }); + )}; + + it('does not issue invites if a quest is already underway', async () => { + leader.items.quests[PET_QUEST] = 2; + + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + )}; + )}; + + context('successfully issuing a quest invitation', () => { + it('sends an invite to all party members', async () => { + leader.items.quests[PET_QUEST] = 1; + + await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ + + }]); + )}; + + it('allows non-leader party members to send invites', () => { + member.items.quests[PET_QUEST] = 1; + + await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ + + }]); + )}; + )}; +)}; From a414aeaf708b014834856648cacfa0e1f9a15662 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 22 Jan 2016 17:38:47 -0500 Subject: [PATCH 127/213] fix(test): linting --- .../POST-groups_groupId_quests_accept.test.js | 33 +++++++++-------- .../POST-groups_groupId_quests_invite.test.js | 37 +++++++++---------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js index a50497555b..e218d7e3d9 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js @@ -1,6 +1,5 @@ import { createAndPopulateGroup, - generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; @@ -10,8 +9,8 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { let member; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -22,30 +21,34 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { context('failure conditions', () => { it('does not accept quest without an invite', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`, {})) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInviteNotFound'), + }); + }); + + it('does not accept quest for a group in which user is not a member', async () => { await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('questInviteNotFound'), }); - - )}; - - it('does not accept quest for a group in which user is not a member', () => { - - )}; - )}; + }); + }); context('successfully accepting a quest invitation', () => { it('joins a quest from an invitation', () => { - )}; + }); it('does not begin the quest if pending invitations remain', () => { - )}; + }); it('begins the quest if accepting the last pending invite', () => { - )}; - )}; -)}; + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 76ccf25e51..e03043b09a 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -1,6 +1,5 @@ import { createAndPopulateGroup, - generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -12,8 +11,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { const PET_QUEST = 'whale'; beforeEach(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -32,8 +31,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for a group in which user is not a member', async () => { - let { alternateGroup } = await createAndPopulateGroup( - { type: 'party', privacy: 'private' }, + let { alternateGroup } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); @@ -42,11 +41,11 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotFound', message: t('groupNotFound'), }); - )}; + }); it('does not issue invites for Guilds', async () => { - let { alternateGroup } = await createAndPopulateGroup( - { type: 'guild', privacy: 'public' }, + let { alternateGroup } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'public' }, members: 1, }); @@ -55,7 +54,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('guildQuestsNotSupported'), }); - )}; + }); it('does not issue invites with an invalid quest key', async () => { const FAKE_QUEST = 'herkimer'; @@ -65,7 +64,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotFound', message: t('questNotFound', {key: FAKE_QUEST}), }); - )}; + }); it('does not issue invites for a quest the user does not own', async () => { await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ @@ -73,7 +72,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questNotOwned'), }); - )}; + }); it('does not issue invites if the user is of insufficient Level', async () => { const LEVELED_QUEST = 'atom1'; @@ -85,7 +84,7 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), }); - )}; + }); it('does not issue invites if a quest is already underway', async () => { leader.items.quests[PET_QUEST] = 2; @@ -97,8 +96,8 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { error: 'NotAuthorized', message: t('questAlreadyUnderway'), }); - )}; - )}; + }); + }); context('successfully issuing a quest invitation', () => { it('sends an invite to all party members', async () => { @@ -107,14 +106,14 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ }]); - )}; + }); - it('allows non-leader party members to send invites', () => { + it('allows non-leader party members to send invites', async () => { member.items.quests[PET_QUEST] = 1; await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ }]); - )}; - )}; -)}; + }); + }); +}); From cded1aa8abfd9154abc23bde89d5c2be3596286c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 23 Jan 2016 19:13:24 +0100 Subject: [PATCH 128/213] remove duplicate eslint rules --- .eslintrc | 2 -- 1 file changed, 2 deletions(-) diff --git a/.eslintrc b/.eslintrc index 7613632120..85937b0e6e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -23,7 +23,6 @@ "no-lone-blocks": 2, "no-loop-func": 2, "no-implicit-coercion": 2, - "no-implied-eval": 2, "no-native-reassign": 2, "no-new-func": 2, "no-new-wrappers": 2, @@ -59,7 +58,6 @@ "no-path-concat": 2, "arrow-spacing": 2, "constructor-super": 2, - "generator-star-spacing": 2, "no-arrow-condition": 2, "no-class-assign": 2, "no-const-assign": 2, From 13693a041a54a398d61d68a294fb92e7d6e21b39 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 23 Jan 2016 19:44:55 +0100 Subject: [PATCH 129/213] fix npm test so that it does not fail - temporary solution --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd5659a079..5fc1aed24d 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test", + "test": "gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;)", "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", From ab5fc1f526c3090fafb86dff5b2d69bde91cc785 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 21 Jan 2016 10:38:43 -0600 Subject: [PATCH 130/213] Added initial get group tests --- .../integration/groups/GET-groups_id.test.js | 302 ++++++++++++++++++ test/helpers/api-integration/api-classes.js | 25 ++ website/src/controllers/api-v3/groups.js | 7 + 3 files changed, 334 insertions(+) create mode 100644 test/api/v3/integration/groups/GET-groups_id.test.js diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js new file mode 100644 index 0000000000..9cc2fce766 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups_id.test.js @@ -0,0 +1,302 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +import { + each, +} from 'lodash'; + +describe('GET /groups/:id', () => { + let typesOfGroups = {}; + typesOfGroups['public guild'] = { type: 'guild', privacy: 'public' }; + typesOfGroups['private guild'] = { type: 'guild', privacy: 'private' }; + typesOfGroups.party = { type: 'party', privacy: 'private' }; + + each(typesOfGroups, (groupDetails, groupType) => { + context(`Member of a ${groupType}`, () => { + let leader, member, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 30, + groupDetails, + }); + + leader = groupData.groupLeader; + member = groupData.members[0]; + createdGroup = groupData.group; + }); + + it('returns the group object', async () => { + let group = await member.get(`/groups/${createdGroup._id}`); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + + it('transforms leader id to leader object', async () => { + let group = await member.get(`/groups/${createdGroup._id}`); + + expect(group.leader._id).to.eql(leader._id); + expect(group.leader.profile.name).to.eql(leader.profile.name); + expect(group.leader.items).to.exist; + expect(group.leader.stats).to.exist; + expect(group.leader.achievements).to.exist; + expect(group.leader.contributor).to.exist; + }); + }); + }); + + context('Non-member of a public guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('returns the group object for a non-member', async () => { + let group = await nonMember.get(`/groups/${createdGroup._id}`); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-member of a private guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Non-member of a party', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Member of a party', () => { + let member, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + member = groupData.members[0]; + }); + + it('returns the user\'s party if an id of "party" is passed in', async () => { + let group = await member.get('/groups/party'); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-existent group', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns error if group does not exist', async () => { + await expect(user.get('/groups/group-that-does-not-exist')) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Flagged messages', () => { + let group; + + let chat1 = { + id: 'chat1', + text: 'chat 1', + flags: {}, + }; + + let chat2 = { + id: 'chat2', + text: 'chat 2', + flags: {}, + flagCount: 0, + }; + + let chat3 = { + id: 'chat3', + text: 'chat 3', + flags: { + 'user-id': true, + }, + flagCount: 1, + }; + + let chat4 = { + id: 'chat4', + text: 'chat 4', + flags: { + 'user-id': true, + 'other-user-id': true, + }, + flagCount: 2, + }; + + let chat5 = { + id: 'chat5', + text: 'chat 5', + flags: { + 'user-id': true, + 'other-user-id': true, + 'yet-another-user-id': true, + }, + flagCount: 3, + }; + + beforeEach(async () => { + let groupData = await createAndPopulateGroup({ + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + chat: [ + chat1, + chat2, + chat3, + chat4, + chat5, + ], + }, + }); + + group = groupData.group; + + await group.addChat([chat1, chat2, chat3, chat4, chat5]); + }); + + context('non-admin', () => { + let nonAdmin; + + beforeEach(async () => { + nonAdmin = await generateUser(); + }); + + it('does not include messages with a flag count of 2 or greater', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(3); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + }); + + it('does not include user ids in flags object', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql({}); + }); + }); + + context('admin', () => { + let admin; + + beforeEach(async () => { + admin = await generateUser({ + 'contributor.admin': true, + }); + }); + + it('includes all messages', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(5); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + expect(fetchedGroup.chat[3].id).to.eql(chat4.id); + expect(fetchedGroup.chat[4].id).to.eql(chat5.id); + }); + + it('includes user ids in flags object', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql(chat3.flags); + }); + }); + }); +}); diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 3fe7adbca7..79cb3163bd 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -59,6 +59,31 @@ 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 }; + + this.update(update, '$push'); + + return this; + } } export class ApiChallenge extends ApiObject { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 05a68831e9..4782164d82 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -152,6 +152,13 @@ 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) { + _.remove(group.chat, function removeChat (chat) { + chat.flags = {}; + return chat.flagCount >= 2; + }); + } + res.respond(200, group); }, }; From febc48cd4100ed74bbfe9036269fc6516d00a120 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 21 Jan 2016 12:35:39 -0600 Subject: [PATCH 131/213] Fixed failing chat test by using and admin to view chat flags --- test/api/v3/integration/chat/POST-chat.flag.test.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 47ba58b046..16f91a60e8 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -5,11 +5,13 @@ import { import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { - let user, group; + let user, admin, group; const TEST_MESSAGE = 'Test Message'; before(async () => { user = await generateUser({balance: 1}); + admin = await generateUser({balance: 1, 'contributor.admin': true}); + group = await user.post('/groups', { name: 'Test Guild', type: 'guild', @@ -51,7 +53,7 @@ describe('POST /chat/:chatId/flag', () => { .then((result) => { expect(result.flags[user._id]).to.equal(true); expect(result.flagCount).to.equal(1); - return user.get(`/groups/${group._id}`); + return admin.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = find(updatedGroup.chat, {id: message.id}); @@ -74,7 +76,7 @@ describe('POST /chat/:chatId/flag', () => { .then((result) => { expect(result.flags[secondUser._id]).to.equal(true); expect(result.flagCount).to.equal(5); - return user.get(`/groups/${group._id}`); + return admin.get(`/groups/${group._id}`); }) .then((updatedGroup) => { let messageToCheck = find(updatedGroup.chat, {id: message.id}); From 2063070f182b2263ede55393aa48fb1c59fcd1ac Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 13:41:51 -0600 Subject: [PATCH 132/213] Added group toJSON conversion and fixed syntax errors --- test/helpers/api-integration/api-classes.js | 4 +--- website/src/controllers/api-v3/groups.js | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 79cb3163bd..097c359427 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -80,9 +80,7 @@ export class ApiGroup extends ApiObject { let update = { chat }; - this.update(update, '$push'); - - return this; + return await this.update(update); } } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 4782164d82..3457a390af 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -153,6 +153,7 @@ api.getGroup = { 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; From 9405d4b054cfcf7b852460a320fd2fbb19789689 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 14:32:24 -0600 Subject: [PATCH 133/213] Fixed send message and added intiial seen chat tests --- .../integration/chat/POST-chat_seen.test.js | 32 +++++++++++++++ website/src/controllers/api-v3/chat.js | 40 ++++++++++++++++++- website/src/models/group.js | 3 +- 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 test/api/v3/integration/chat/POST-chat_seen.test.js diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js new file mode 100644 index 0000000000..6073fc1204 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -0,0 +1,32 @@ +import { + createAndPopulateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:id/chat/seen', () => { + let groupWithChat, message, author, member; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + groupWithChat = group; + author = groupLeader; + member = members[0]; + + message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + }); + + it('clears new messages', async () => { + await member.post(`/groups/${groupWithChat._id}/chat/seen`); + + let userThatHasSeenChat = await member.get('/user'); + + expect(userThatHasSeenChat.newMessages).to.be.empty; + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 212e064441..bfca7eb3b7 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -105,7 +105,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 +152,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 +256,40 @@ api.flagChat = { }, }; +/** + * @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')); + + // Skip the auth step, we want this to be fast. If !found with uuid/token, then it just doesn't save + let update = { $unset: {} }; + + update.$unset[`newMessages.${groupId}`] = ''; + await User.update({_id: user._id}, update).exec(); + + res.respond(200); + }, +}; + export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index af592bb332..7e7e9c102b 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -205,7 +205,8 @@ schema.methods.sendChat = function sendChat (message, user) { // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); } else { User.update({ - _id: {$in: this.members, $ne: user ? user._id : ''}, + guilds: this._id, + _id: { $ne: user ? user._id : ''}, }, lastSeenUpdate, {multi: true}).exec(); } }; From ff034cf61b79c0c8223c3bea047dae3141dd5fd8 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 19:56:11 -0600 Subject: [PATCH 134/213] Fixed send message for parties and added party seen message test --- .../integration/chat/POST-chat_seen.test.js | 69 ++++++++++++++----- website/src/models/group.js | 15 ++-- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js index 6073fc1204..9f90ca3296 100644 --- a/test/api/v3/integration/chat/POST-chat_seen.test.js +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -3,30 +3,65 @@ import { } from '../../../../helpers/api-v3-integration.helper'; describe('POST /groups/:id/chat/seen', () => { - let groupWithChat, message, author, member; + context('Guild', () => { + let guild, guildLeader, guildMember, guildMessage; - before(async () => { - let { group, groupLeader, members } = await createAndPopulateGroup({ - groupDetails: { - type: 'guild', - privacy: 'public', - }, - members: 1, + 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; }); - groupWithChat = group; - author = groupLeader; - member = members[0]; + it('clears new messages for a guild', async () => { + let user = await guildMember.get('/user'); - message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); - message = message.message; + await guildMember.post(`/groups/${guild._id}/chat/seen`); + + let guildThatHasSeenChat = await guildMember.get('/user'); + + expect(guildThatHasSeenChat.newMessages).to.be.empty; + }); }); - it('clears new messages', async () => { - await member.post(`/groups/${groupWithChat._id}/chat/seen`); + context('Party', () => { + let party, partyLeader, partyMember, partyMessage; - let userThatHasSeenChat = await member.get('/user'); + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'party', + privacy: 'private', + }, + members: 1, + }); - expect(userThatHasSeenChat.newMessages).to.be.empty; + 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 () => { + let user = await partyMember.get('/user'); + + await partyMember.post(`/groups/${party._id}/chat/seen`); + + let partyMemberThatHasSeenChat = await partyMember.get('/user'); + + expect(partyMemberThatHasSeenChat.newMessages).to.be.empty; + }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index 7e7e9c102b..246a2f0625 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -204,10 +204,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({ - guilds: this._id, - _id: { $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(); } }; From c498eef21c9aadabc533ef01dd08bd107a62c948 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 23:09:22 -0600 Subject: [PATCH 135/213] Added initial chat delete tests --- common/locales/en/api-v3.json | 3 +- .../integration/chat/DELETE-chat_id.test.js | 81 +++++++++++++++++++ website/src/controllers/api-v3/chat.js | 58 +++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/chat/DELETE-chat_id.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index c10fd1647a..e6dc9bee55 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -65,5 +65,6 @@ "uuidsMustBeAnArray": "UUIDs invites must be a an Array.", "emailsMustBeAnArray": "Email invites must be a an Array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", - "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked." + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!" } diff --git a/test/api/v3/integration/chat/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js new file mode 100644 index 0000000000..5d1f73f867 --- /dev/null +++ b/test/api/v3/integration/chat/DELETE-chat_id.test.js @@ -0,0 +1,81 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /groups/:groupId/chat/:chatId', () => { + let groupWithChat, message, user, userThatDidNotCreateChat, admin; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + }); + + groupWithChat = group; + user = groupLeader; + message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + userThatDidNotCreateChat = await generateUser(); + admin = await generateUser({'contributor.admin': true}); + }); + + context('Chat errors', () => { + it('returns an error is message does not exist', async () => { + let fakeChatId = generateUUID(); + await expect(user.del(`/groups/${groupWithChat._id}/chat/${fakeChatId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('returns an error when user does not have permission to delete', async () => { + await expect(userThatDidNotCreateChat.del(`/groups/${groupWithChat._id}/chat/${message.id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyCreatorOrAdminCanDeleteChat'), + }); + }); + }); + + context('Chat success', () => { + let nextMessage; + + beforeEach(async () => { + nextMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some new message' }); + nextMessage = nextMessage.message; + }); + + it('allows creator to delete a their message', async () => { + await user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('allows admin to delete another user\'s message', async () => { + await admin.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('returns empty when previous message parameter is passed and the last message was deleted', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${nextMessage.id}`)) + .to.eventually.be.empty; + }); + + it('returns the update chat when previous message parameter is passed and the chat is updated', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${message.id}`)) + .eventually + .is.an('array') + .to.include(message) + .to.be.lengthOf(1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 212e064441..e37ecee445 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -4,6 +4,7 @@ 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 { sendTxn } from '../../libs/api-v3/email'; @@ -256,4 +257,61 @@ api.flagChat = { }, }; +/** + * @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(); + _.remove(group.chat, function removeChat (chat) { + return chat.id === chatId; + }); + res.json(group.chat); + } else { + res.send(200, {}); + } + }, +}; + export default api; From e8c13d311772ce90ce43668456cf869e7b0e9ed5 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sat, 23 Jan 2016 23:11:57 -0600 Subject: [PATCH 136/213] Added initial clear chat flags tests --- ...POST-groups_id_chat_id_clear_flags.test.js | 101 ++++++++++++++++++ website/src/controllers/api-v3/chat.js | 48 +++++++++ 2 files changed, 149 insertions(+) create mode 100644 test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js new file mode 100644 index 0000000000..87cad5d6f0 --- /dev/null +++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js @@ -0,0 +1,101 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:id/chat/:id/clearflags', () => { + let groupWithChat, message, author, nonAdmin, admin; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + groupWithChat = group; + author = groupLeader; + nonAdmin = members[0]; + admin = await generateUser({'contributor.admin': true}); + + message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/flag`); + }); + + context('Single Message', () => { + it('returns error when non-admin attempts to clear flags', async () => { + return expect(nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupChatAdminClearFlagCount'), + }); + }); + + it('returns error if message does not exist', async () => { + let fakeMessageID = generateUUID(); + + await expect(admin.post(`/groups/${groupWithChat._id}/chat/${fakeMessageID}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('clears flags and leaves old flags on the flag object', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + expect(messages[0].flagCount).to.eql(0); + expect(messages[0].flags).to.have.property(admin._id, true); + }); + }); + + context('admin user, group with multiple messages', () => { + let message2, message3, message4; + + before(async () => { + message2 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 2' }); + message2 = message2.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message2.id}/flag`); + + message3 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 3' }); + message3 = message3.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + await nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + + message4 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 4' }); + message4 = message4.message; + }); + + it('changes only the message that is flagged', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + + expect(messages).to.have.lengthOf(4); + + let messageThatWasUnflagged = messages[3]; + let messageWith1Flag = messages[2]; + let messageWith2Flag = messages[1]; + let messageWithoutFlags = messages[0]; + + expect(messageThatWasUnflagged.flagCount).to.eql(0); + expect(messageThatWasUnflagged.flags).to.have.property(admin._id, true); + + expect(messageWith1Flag.flagCount).to.eql(5); + expect(messageWith1Flag.flags).to.have.property(admin._id, true); + + expect(messageWith2Flag.flagCount).to.eql(6); + expect(messageWith2Flag.flags).to.have.property(admin._id, true); + expect(messageWith2Flag.flags).to.have.property(nonAdmin._id, true); + + expect(messageWithoutFlags.flagCount).to.eql(0); + expect(messageWithoutFlags.flags).to.eql({}); + }); + }); +}); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 212e064441..a5f208ab46 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -4,6 +4,7 @@ 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 { sendTxn } from '../../libs/api-v3/email'; @@ -256,4 +257,51 @@ 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, {}); + }, +}; + export default api; From 198d2e6ab5cf0177fb40cccdf8f7b0fd66fb5e07 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:38:20 +0100 Subject: [PATCH 137/213] misc fixes and run gulp lint when testing --- package.json | 2 +- .../v3/integration/chat/POST-chat_seen.test.js | 4 ---- website/src/controllers/api-v3/chat.js | 17 +++++++---------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 5fc1aed24d..0f9ee88db0 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;)", + "test": "gulp lint && (gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;))", "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", diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js index 9f90ca3296..8b22461a04 100644 --- a/test/api/v3/integration/chat/POST-chat_seen.test.js +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -24,8 +24,6 @@ describe('POST /groups/:id/chat/seen', () => { }); it('clears new messages for a guild', async () => { - let user = await guildMember.get('/user'); - await guildMember.post(`/groups/${guild._id}/chat/seen`); let guildThatHasSeenChat = await guildMember.get('/user'); @@ -55,8 +53,6 @@ describe('POST /groups/:id/chat/seen', () => { }); it('clears new messages for a party', async () => { - let user = await partyMember.get('/user'); - await partyMember.post(`/groups/${party._id}/chat/seen`); let partyMemberThatHasSeenChat = await partyMember.get('/user'); diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 33daf58a64..6c491c4e7c 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -330,12 +330,10 @@ api.seenChat = { let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); - // Skip the auth step, we want this to be fast. If !found with uuid/token, then it just doesn't save - let update = { $unset: {} }; + let update = {$unset: {}}; + update.$unset[`newMessages.${groupId}`] = true; - update.$unset[`newMessages.${groupId}`] = ''; await User.update({_id: user._id}, update).exec(); - res.respond(200); }, }; @@ -382,17 +380,16 @@ api.deleteChat = { await Group.update( {_id: group._id}, - {$pull: {chat: {id: chatId} } } + {$pull: {chat: {id: chatId}}} ); if (chatUpdated) { group = group.toJSON(); - _.remove(group.chat, function removeChat (chat) { - return chat.id === chatId; - }); - res.json(group.chat); + let i = _.findIndex(group.chat, {id: chatId}); + if (i !== -1) group.chat.splice(i, 1); + res.respond(200, group.chat); } else { - res.send(200, {}); + res.respond(200, {}); } }, }; From 59f5a80af72e0045e4af39de551959f226df78a3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:52:59 +0100 Subject: [PATCH 138/213] improve access control for challenges --- website/src/controllers/api-v3/challenges.js | 2 +- website/src/controllers/api-v3/members.js | 9 ++++- website/src/models/challenge.js | 40 +++++++++----------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index f0c682cf05..bd7f694cf6 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -192,7 +192,7 @@ 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); diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 62df59b651..142926ed64 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -76,7 +76,10 @@ function _getMembersForItem (type) { if (type === 'challenge-members') { challenge = await Challenge.findById(challengeId).select('_id type leader groupId').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + + // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge + // for example if you've been booted from it, are the leader or a site admin + group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); } else { group = await Group.getGroup({user, groupId, fields: '_id type'}); @@ -207,7 +210,9 @@ api.getChallengeMemberProgress = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy'}); + // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge + // for example if you've been booted from it, are the leader or a site admin + let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 39a7caa21b..ed9a88213e 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -30,22 +30,6 @@ schema.plugin(baseModel, { noSet: ['_id', 'memberCount', 'challengeCount', 'tasksOrder'], }); -// 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); -}; - // Returns true if user is a member of the challenge schema.methods.isMember = function isChallengeMember (user) { return user.challenges.indexOf(this._id) !== -1; @@ -56,6 +40,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); + if (user.party._id) userGroups.push(user.party._id); + userGroups.push('habitrpg'); // tavern challenges + 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 +66,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) { From df0b49f4f31f585c05a55f83472e07821710a135 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:59:04 +0100 Subject: [PATCH 139/213] tests: check that memberCount is increased for public guilds too --- .../integration/groups/POST-groups_groupId_join.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index eceb388ac4..6c5ee11b35 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -46,6 +46,14 @@ describe('POST /group/:groupId/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 = guild.memberCount; + + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); }); context('Joining a private guild', () => { From 490d92eb2f80eb8e4b34dcfc47179832984ff463 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 12:59:54 +0100 Subject: [PATCH 140/213] fix increments memberCount when joining guilds test --- .../v3/integration/groups/POST-groups_groupId_join.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index 6c5ee11b35..4c1e422ce6 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -48,11 +48,11 @@ describe('POST /group/:groupId/join', () => { }); it('increments memberCount when joining guilds', async () => { - let oldMemberCount = guild.memberCount; + let oldMemberCount = publicGuild.memberCount; await joiningUser.post(`/groups/${publicGuild._id}/join`); - await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + await expect(invitedUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); }); }); From 4149cbf381aa636ed0ebad9f17f8451c3138381a Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 24 Jan 2016 13:14:29 +0100 Subject: [PATCH 141/213] check for memberCount in join group and removeMemberFromGroup test --- .../groups/POST-groups_groupId_join.test.js | 2 +- .../groups/POST-groups_id_removeMember.test.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index 4c1e422ce6..d24a03cb7e 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -52,7 +52,7 @@ describe('POST /group/:groupId/join', () => { await joiningUser.post(`/groups/${publicGuild._id}/join`); - await expect(invitedUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); }); }); diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js index c49cfe6694..cf98902887 100644 --- a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -11,7 +11,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { let member; let member2; - before(async () => { + beforeEach(async () => { let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ groupDetails: { name: 'Test Guild', @@ -62,12 +62,17 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { context('Guilds', () => { it('can remove other members', async () => { await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); - let memberRemoved = await member.get('/user'); expect(_.findIndex(memberRemoved.guilds, {id: 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}`); @@ -83,7 +88,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { let partyInvitedUser; let partyMember; - before(async () => { + beforeEach(async () => { let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ groupDetails: { name: 'Test Party', @@ -108,6 +113,12 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { 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}`); From d5751837ed212c04cd6a2229ea4c76bcf5a78356 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Jan 2016 17:23:01 +0100 Subject: [PATCH 142/213] add clearCompletedTodos route with tests, add test for getting completed todos, misc fixes --- .../integration/tasks/GET-tasks_user.test.js | 19 ++++++++++-- website/src/controllers/api-v3/tasks.js | 31 ++++++++++++++++++- website/src/middlewares/api-v3/cron.js | 2 +- website/src/models/group.js | 2 +- website/src/models/task.js | 4 +-- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index fb6c7c84f5..a206d7b3ce 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -22,6 +22,21 @@ describe('GET /tasks/user', () => { expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); - // TODO complete after task scoring is done - it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); + it('returns completed todos sorted by completion date if req.query.includeCompletedTodos is specified', async () => { + let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'}); + let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'}); + + await user.sync(); + let initialTodoCount = user.tasksOrder.todos.length; + + await user.post(`/tasks/${todo2._id}/score/up`); + await user.post(`/tasks/${todo1._id}/score/up`); + await user.sync(); + + expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - 2); + + let allTodos = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); + expect(allTodos.length).to.equal(initialTodoCount); + expect(allTodos[allTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later + }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index acdd456beb..020ae5061b 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -129,6 +129,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 @@ -837,7 +838,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 diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index aaebc476dc..288452e670 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -34,7 +34,7 @@ export default function cronMiddleware (req, res, next) { // Run cron cron({user, tasksByType, now, daysMissed, analytics}); - // Clean completed todos - 30 days for free users, 90 for subscribers + // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? Task.remove({ userId: user._id, diff --git a/website/src/models/group.js b/website/src/models/group.js index 246a2f0625..d5d83b19b1 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -79,7 +79,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 diff --git a/website/src/models/task.js b/website/src/models/task.js index 3fd574af93..c6a326999a 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -48,13 +48,13 @@ TaskSchema.plugin(baseModel, { // A list of additional fields that cannot be set on creation (but can be set on updare) let noCreate = ['completed']; // TODO completed should be removed for updates too? TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { - return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define + return this.sanitize(createObj, noCreate); }; // A list of additional fields that cannot be updated (but can be set on creation) let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define + return this.sanitize(updateObj, noUpdate); }; // Sanitize checklist objects (disallowing _id) From 211a7bb46a8f661a44681dbe1337e6d1c89ccc91 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Jan 2016 17:23:22 +0100 Subject: [PATCH 143/213] add new files for clearCompletedTodos functionality --- .../POST-tasks_clearCompletedTodos.test.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js diff --git a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js new file mode 100644 index 0000000000..338808a4a1 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js @@ -0,0 +1,41 @@ +import { + generateUser, + generateGroup, + generateChallenge, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /tasks/clearCompletedTodos', () => { + it('deletes all completed todos except the ones from a challenge', async () => { + let user = await generateUser({balance: 1}); + let guild = await generateGroup(user); + let challenge = await generateChallenge(user, guild); + + let initialTodoCount = user.tasksOrder.todos.length; + await user.post('/tasks/user', [ + {text: 'todo 1', type: 'todo'}, + {text: 'todo 2', type: 'todo'}, + {text: 'todo 3', type: 'todo'}, + {text: 'todo 4', type: 'todo'}, + {text: 'todo 5', type: 'todo'}, + ]); + + await user.post(`/tasks/challenge/${challenge._id}`, { + text: 'todo 6', + type: 'todo', + }); + + let tasks = await user.get('/tasks/user?type=todo'); + expect(tasks.length).to.equal(initialTodoCount + 6); + + for (let task of tasks) { + if (['todo 2', 'todo 3', 'todo 6'].indexOf(task.text) !== -1) { + await user.post(`/tasks/${task._id}/score/up`); // eslint-disable-line babel/no-await-in-loop + } + } + + await user.post('/tasks/clearCompletedTodos'); + let tasksUpdated = await user.get('/tasks/user?type=todo&includeCompletedTodos=true'); + expect(tasksUpdated.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge) + expect(tasksUpdated[tasksUpdated.length - 1].text).to.equal('todo 6'); + }); +}); From b28e0629b1410ba95ce1935c238b46a007210cc5 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 25 Jan 2016 18:17:09 +0100 Subject: [PATCH 144/213] add feature to invite user if he is partying solo --- .../groups/POST-groups_groupId_join.test.js | 18 ++++++++++++++++++ .../groups/POST-groups_invite.test.js | 19 +++++++++++++++++-- website/src/controllers/api-v3/groups.js | 19 +++++++++++++++---- website/src/models/group.js | 6 +++--- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index d24a03cb7e..d20c8b830d 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -1,6 +1,7 @@ import { generateUser, createAndPopulateGroup, + checkExistence, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; @@ -185,6 +186,23 @@ 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 diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index 42719c0fcd..55c25d9153 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -290,12 +290,14 @@ describe('Post /groups/:groupId/invite', () => { }); }); - it('returns an error when invited user is already in the party', async () => { + it('returns an error when invited user is already in a party of more than 1 member', async () => { let userToInvite = await generateUser(); + let userToInvite2 = await generateUser(); await inviter.post(`/groups/${party._id}/invite`, { - uuids: [userToInvite._id], + uuids: [userToInvite._id, userToInvite2._id], }); await userToInvite.post(`/groups/${party._id}/join`); + await userToInvite2.post(`/groups/${party._id}/join`); await expect(inviter.post(`/groups/${party._id}/invite`, { uuids: [userToInvite._id], @@ -306,5 +308,18 @@ describe('Post /groups/:groupId/invite', () => { message: t('userAlreadyInAParty'), }); }); + + it('allow inviting an user to a party if he\'s partying solo', async () => { + let userToInvite = await generateUser(); + await userToInvite.post('/groups', { // add user to a party + name: 'Another Test Party', + type: 'party', + }); + + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id); + }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 48e06a752a..0c6a35330e 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -238,10 +238,18 @@ 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; @@ -425,7 +433,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) { @@ -444,11 +452,14 @@ async function _inviteByUUID (uuid, group, inviter, req, res) { if (!_.isEmpty(userToInvite.invitations.party)) { throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); } + if (userToInvite.party._id) { - throw new NotAuthorized(res.t('userAlreadyInAParty')); + let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); + + // Allow user to be invited to a new party when they're partying solo + if (userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty')); } - // @TODO: Why was this here? - // req.body.type in 'guild', 'party' + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; } diff --git a/website/src/models/group.js b/website/src/models/group.js index d5d83b19b1..8b11a57081 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -118,10 +118,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 { From bd87ada9029e226441ec920773081775bc61bee4 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 25 Jan 2016 15:22:54 -0600 Subject: [PATCH 145/213] Updated chat tests with new async/await syntax --- .../integration/chat/POST-chat.flag.test.js | 105 ++++++--------- .../integration/chat/POST-chat.like.test.js | 117 +++++++---------- .../api/v3/integration/chat/POST-chat.test.js | 124 ++++++++---------- 3 files changed, 144 insertions(+), 202 deletions(-) diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 16f91a60e8..51a3abb164 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -5,12 +5,13 @@ import { import { find } from 'lodash'; describe('POST /chat/:chatId/flag', () => { - let user, admin, 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', @@ -20,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', @@ -29,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 admin.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 admin.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.flags[secondUser._id]).to.equal(true); - expect(messageToCheck.flagCount).to.equal(5); - }); + let flagResult = await admin.post(`/groups/${group._id}/chat/${message.id}/flag`); + expect(flagResult.flags[admin._id]).to.equal(true); + expect(flagResult.flagCount).to.equal(5); + + let groupWithFlags = await admin.get(`/groups/${group._id}`); + + let messageToCheck = find(groupWithFlags.chat, {id: message.id}); + expect(messageToCheck.flags[admin._id]).to.equal(true); + expect(messageToCheck.flagCount).to.equal(5); }); it('Returns an error when user tries to flag a message that is already flagged', async () => { - let message; + let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/flag`); - }) - .then(() => { - return expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatFlagAlreadyReported'), - }); - }); + await user.post(`/groups/${group._id}/chat/${message.id}/flag`); + + await expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatFlagAlreadyReported'), + }); }); }); diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js index cf266431ba..d7ae6047df 100644 --- a/test/api/v3/integration/chat/POST-chat.like.test.js +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -1,36 +1,32 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { find } from 'lodash'; describe('POST /chat/:chatId/like', () => { let user; - let group; + let groupWithChat; let testMessage = 'Test Message'; + let anotherUser; - before(() => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - - return generateUser({balance: 1}).then((generatedUser) => { - user = generatedUser; - }) - .then(() => { - return user.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((generatedGroup) => { - group = generatedGroup; + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + members: 1, }); + + user = groupLeader; + groupWithChat = group; + anotherUser = members[0]; }); - it('Returns an error when chat message is not found', () => { - return expect(user.post(`/groups/${group._id}/chat/incorrectMessage/like`)) + it('Returns an error when chat message is not found', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat/incorrectMessage/like`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,59 +34,42 @@ describe('POST /chat/:chatId/like', () => { }); }); - it('Returns an error when user tries to like their own message', () => { - return user.post(`/groups/${group._id}/chat`, { message: testMessage}) - .then((result) => { - return expect(user.post(`/groups/${group._id}/chat/${result.message.id}/like`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('messageGroupChatLikeOwnMessage'), - }); - }); + it('Returns an error when user tries to like their own message', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + + await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatLikeOwnMessage'), + }); }); - it('Likes a chat', () => { - let message; + it('Likes a chat', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(true); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(true); - }); + let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + + expect(likeResult.likes[user._id]).to.equal(true); + + let groupWithChatLikes = await user.get(`/groups/${groupWithChat._id}`); + + let messageToCheck = find(groupWithChatLikes.chat, {id: message.message.id}); + expect(messageToCheck.likes[user._id]).to.equal(true); }); - it('Unlikes a chat', () => { - let message; + it('Unlikes a chat', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser().then((anotherUser) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - message = result.message; - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(true); - return user.post(`/groups/${group._id}/chat/${message.id}/like`); - }) - .then((result) => { - expect(result.likes[user._id]).to.equal(false); - return user.get(`/groups/${group._id}`); - }) - .then((updatedGroup) => { - let messageToCheck = find(updatedGroup.chat, {id: message.id}); - expect(messageToCheck.likes[user._id]).to.equal(false); - }); + let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + expect(likeResult.likes[user._id]).to.equal(true); + + let unlikeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`); + expect(unlikeResult.likes[user._id]).to.equal(false); + + let groupWithoutChatLikes = await user.get(`/groups/${groupWithChat._id}`); + + let messageToCheck = find(groupWithoutChatLikes.chat, {id: message.message.id}); + expect(messageToCheck.likes[user._id]).to.equal(false); }); }); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index fe943bfb1c..99d1469af8 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,95 +1,81 @@ import { - generateUser, + createAndPopulateGroup, translate as t, } from '../../../../helpers/api-v3-integration.helper'; describe('POST /chat', () => { - let user; + let user, groupWithChat, userWithChatRevoked, member; + let testMessage = 'Test Message'; - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }, + members: 2, }); + + user = groupLeader; + groupWithChat = group; + userWithChatRevoked = await members[0].update({'flags.chatRevoked': true}); + member = members[0]; }); - it('Returns an error when no message is provided', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = ''; - - return generateUser({balance: 1}).then((anotherUser) => { - return anotherUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, + it('Returns an error when no message is provided', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: ''})) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), }); - }) - .then((group) => { - return expect(user.post(`/groups/${group._id}/chat`, { message: testMessage})) - .to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); }); - it('Returns an error when group is not found', () => { - let testMessage = 'Test Message'; - return expect(user.post('/groups/nvalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ + it('Returns an error when group is not found', async () => { + await expect(user.post('/groups/invalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); }); - it('Returns an error when chat privileges are revoked', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = 'Test Message'; - let userWithoutChat; - - return generateUser({balance: 1, 'flags.chatRevoked': true}).then((generatedUser) => { - userWithoutChat = generatedUser; - - return userWithoutChat.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((group) => { - return expect(userWithoutChat.post(`/groups/${group._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: 'Your chat privileges have been revoked.', - }); + it('Returns an error when chat privileges are revoked', async () => { + await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: 'Your chat privileges have been revoked.', }); }); - it('creates a chat', () => { - let groupName = 'Test Guild'; - let groupType = 'guild'; - let groupPrivacy = 'public'; - let testMessage = 'Test Message'; - let anotherUser; + it('creates a chat', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); - return generateUser({balance: 1}).then((generatedUser) => { - anotherUser = generatedUser; + expect(message.message.id).to.exist; + }); - return anotherUser.post('/groups', { - name: groupName, - type: groupType, - privacy: groupPrivacy, - }); - }) - .then((group) => { - return anotherUser.post(`/groups/${group._id}/chat`, { message: testMessage}); - }) - .then((result) => { - expect(result.message.id).to.exist; + it('notifies other users of new messages for a guild', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + let memberWithNotification = await member.get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist; + }); + + it('notifies other users of new messages for a party', async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + members: 1, }); + + let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage}); + let memberWithNotification = await members[0].get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${group._id}`]).to.exist; }); }); From f12c9d23536e44be9b3d9b1a8de7d5388406824c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 26 Jan 2016 00:20:56 +0100 Subject: [PATCH 146/213] fix removeMember route and tests --- .../groups/POST-groups_id_removeMember.test.js | 2 +- website/src/controllers/api-v3/groups.js | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js index cf98902887..0ebde39361 100644 --- a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -64,7 +64,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); let memberRemoved = await member.get('/user'); - expect(_.findIndex(memberRemoved.guilds, {id: guild._id})).eql(-1); + expect(memberRemoved.guilds.indexOf(guild._id)).eql(-1); }); it('updates memberCount', async () => { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 0c6a35330e..dd298adbf3 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -394,18 +394,23 @@ api.removeGroupMember = { 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') { + let i = member.guilds.indexOf(group._id); + if (i !== -1) member.guilds.splice(i, 1); + } if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? - if (member.newMessages.group) { - 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) { From 95603b9d214d1f00bb0c23ea14d63fbd3f8f8026 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 21 Jan 2016 07:54:17 -0600 Subject: [PATCH 147/213] tests: Change baseModel test to more accurately reflect use case --- test/api/v3/unit/libs/baseModel.test.js | 27 +++++++++---------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 95960c704d..17f3f7834a 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -1,23 +1,16 @@ import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; +import { Schema } from 'mongoose'; describe('Base model plugin', () => { - let schema = { - add () { - return true; - }, - statics: {}, - options: {}, - pre () { - return true; - }, - }; + let schema; beforeEach(() => { + schema = new 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}; From e5f1c44a3e7aa24c3bddfa5ab37055bd546d9365 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 21 Jan 2016 08:05:51 -0600 Subject: [PATCH 148/213] feat: Add removeFromArray function --- test/api/v3/unit/libs/baseModel.test.js | 4 +- .../unit/libs/collectionManipulators.test.js | 88 +++++++++++++++++++ test/helpers/api-unit.helper.js | 19 +++- website/src/controllers/api-v3/chat.js | 4 +- website/src/controllers/api-v3/groups.js | 12 ++- website/src/controllers/api-v3/tasks.js | 33 +++---- .../src/libs/api-v3/collectionManipulators.js | 19 ++++ website/src/models/group.js | 4 +- website/src/models/user.js | 8 +- 9 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 test/api/v3/unit/libs/collectionManipulators.test.js create mode 100644 website/src/libs/api-v3/collectionManipulators.js diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 17f3f7834a..ebde4ee3ea 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -1,11 +1,11 @@ import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; -import { Schema } from 'mongoose'; +import mongoose from 'mongoose'; describe('Base model plugin', () => { let schema; beforeEach(() => { - schema = new Schema(); + schema = new mongoose.Schema(); sandbox.stub(schema, 'add'); }); diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js new file mode 100644 index 0000000000..fff818b32e --- /dev/null +++ b/test/api/v3/unit/libs/collectionManipulators.test.js @@ -0,0 +1,88 @@ +import mongoose from 'mongoose'; +import { + removeFromArray, +} from '../../../../../website/src/libs/api-v3/collectionManipulators'; + +describe('Collection Manipulators', () => { + describe('removeFromArray', () => { + it('removes item from specified array on document', () => { + 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('removal of element persists when mongoose document is saved', async () => { + let schema = new mongoose.Schema({ + array: Array, + }); + let Model = mongoose.model('ModelToTestRemoveFromArray', schema); + let model = await new Model({ + array: ['a', 'b', 'c'], + }).save(); // Initial creation + + removeFromArray(model.array, 'b'); + + let savedModel = await model.save(); + + expect(savedModel.array).to.not.include('b'); + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index cefd1eeeeb..0a4a22df79 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,10 +1,27 @@ 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'; -afterEach(() => { +mongoose.Promise = require('q').Promise; + +mongoose.connect('mongodb://localhost/habitica-unit-tests'); +let connection = mongoose.connection; + +before((done) => { + connection.on('open', () => { + connection.db.dropDatabase(done); + }); +}); + +after((done) => { + connection.close(done); +}); + +afterEach((done) => { sandbox.restore(); + connection.db.dropDatabase(done); }); export function generateUser (options = {}) { diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 6c491c4e7c..5c8d4fd3ed 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -7,6 +7,7 @@ import { 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'; @@ -385,8 +386,7 @@ api.deleteChat = { if (chatUpdated) { group = group.toJSON(); - let i = _.findIndex(group.chat, {id: chatId}); - if (i !== -1) group.chat.splice(i, 1); + removeFromArray(group.chat, {id: chatId}); res.respond(200, group.chat); } else { res.respond(200, {}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index dd298adbf3..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -13,6 +13,7 @@ import { BadRequest, NotAuthorized, } from '../../libs/api-v3/errors'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; @@ -254,11 +255,10 @@ api.joinGroup = { 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; } @@ -403,8 +403,7 @@ api.removeGroupMember = { } if (isInGroup === 'guild') { - let i = member.guilds.indexOf(group._id); - if (i !== -1) member.guilds.splice(i, 1); + removeFromArray(member.guilds, group._id); } if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? @@ -418,8 +417,7 @@ api.removeGroupMember = { } } 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 { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 020ae5061b..2e72953ad1 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,6 +1,7 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as Tasks from '../../models/task'; import { model as Challenge } from '../../models/challenge'; import { @@ -383,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); } } @@ -684,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 @@ -769,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 @@ -826,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(); @@ -904,7 +891,7 @@ api.deleteTask = { } if (task.type !== 'todo' || !task.completed) { - _removeTaskTasksOrder(challenge || user, taskId, task.type); + removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId); await Q.all([(challenge || user).save(), task.remove()]); } else { await task.remove(); diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js new file mode 100644 index 0000000000..ce40085552 --- /dev/null +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -0,0 +1,19 @@ +import { findIndex } from 'lodash'; + +export function removeFromArray (array, element) { + let elementIndex; + + if (typeof element === 'object') { + elementIndex = findIndex(array, element); + } else { + elementIndex = array.indexOf(element); + } + + if (elementIndex !== -1) { + let removedElement = array[elementIndex]; + array.splice(elementIndex, 1); + return removedElement; + } + + return false; +} diff --git a/website/src/models/group.js b/website/src/models/group.js index 8b11a57081..0acc03fe32 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -7,6 +7,7 @@ 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'; @@ -147,8 +148,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(); }); diff --git a/website/src/models/user.js b/website/src/models/user.js index 6ea4ede281..db466ce829 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -6,6 +6,7 @@ import moment from 'moment'; import * as Tasks from './task'; import Q from 'q'; import { schema as TagSchema } from './tag'; +import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import baseModel from '../libs/api-v3/baseModel'; // import {model as Challenge} from './challenge'; @@ -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(); From f430b6ff6fc17a08a5ebb11e6d7663a5ab3eed20 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 25 Jan 2016 21:46:53 -0600 Subject: [PATCH 149/213] tests: Correct phrasing of tests --- test/api/v3/unit/libs/collectionManipulators.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js index fff818b32e..e32e953d51 100644 --- a/test/api/v3/unit/libs/collectionManipulators.test.js +++ b/test/api/v3/unit/libs/collectionManipulators.test.js @@ -5,7 +5,7 @@ import { describe('Collection Manipulators', () => { describe('removeFromArray', () => { - it('removes item from specified array on document', () => { + it('removes element from array', () => { let array = ['a', 'b', 'c', 'd']; removeFromArray(array, 'c'); @@ -69,7 +69,7 @@ describe('Collection Manipulators', () => { expect(result).to.eql(false); }); - it('removal of element persists when mongoose document is saved', async () => { + it('persists removal of element when mongoose document is saved', async () => { let schema = new mongoose.Schema({ array: Array, }); From 13b12830dac74ca18853ff79ab631c223ba31194 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Wed, 27 Jan 2016 09:31:10 +0200 Subject: [PATCH 150/213] Challenge leave route and tests --- .../POST-challenges_challengeId_leave.test.js | 123 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 38 ++++++ 2 files changed, 161 insertions(+) create mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js new file mode 100644 index 0000000000..9694b263a9 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js @@ -0,0 +1,123 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/leave', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post('/challenges/test/leave')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Leaving a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let notInChallengeUser; + let leavingUser; + let taskText; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 2, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + leavingUser = populatedGroup.members[0]; + notInChallengeUser = populatedGroup.members[1]; + + challenge = await generateChallenge(groupLeader, group); + + taskText = 'A challenge task text'; + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await leavingUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to view the challenge', async () => { + let unauthorizedUser = await generateUser(); + + await expect(unauthorizedUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns an error when user isn\'t a member of the challenge', async () => { + await expect(notInChallengeUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('challengeMemberNotFound'), + }); + }); + + it('removes challenge from user challenges', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await leavingUser.sync(); + + expect(leavingUser).to.have.property('challenges').to.not.include(challenge._id); + }); + + it('decreases memberCount of challenge', async () => { + let oldMemberCount = challenge.memberCount; + + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await challenge.sync(); + + expect(challenge).to.have.property('memberCount', oldMemberCount - 1); + }); + + it('unlinks challenge tasks from leaving user when remove-all is passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'remove-all', + }); + let tasks = await leavingUser.get('/tasks/user'); + let tasksTexts = tasks.map((task) => { + return task.text; + }); + + expect(tasksTexts).to.not.include(taskText); + }); + + it('doesn\'t unlink challenge tasks from leaving user when remove-all isn\'t passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'test', + }); + + let tasks = await leavingUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask).to.not.be.undefined; + expect(testTask.challenge).to.be.undefined; + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index bd7f694cf6..858b26fc32 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -129,6 +129,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} challenge The challenge the user left + */ +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, challenge); + }, +}; + /** * @api {get} /challenges Get challenges for a user * @apiVersion 3.0.0 From 995148cbfb9673ca33217ff92c2b9eb31da2707a Mon Sep 17 00:00:00 2001 From: KristianTashkov Date: Wed, 27 Jan 2016 11:37:52 +0200 Subject: [PATCH 151/213] Make challenge leave route return empty object on success --- website/src/controllers/api-v3/challenges.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 858b26fc32..4ffd9e301c 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -136,7 +136,7 @@ api.joinChallenge = { * @apiGroup Challenge * @apiParam {UUID} challengeId The challenge _id * - * @apiSuccess {object} challenge The challenge the user left + * @apiSuccess {object} empty An empty object */ api.leaveChallenge = { method: 'POST', @@ -163,7 +163,7 @@ api.leaveChallenge = { // 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, challenge); + res.respond(200, {}); }, }; From f16b605c379daaf5b82bf59b32e9f7066720fe6c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Jan 2016 12:22:24 +0100 Subject: [PATCH 152/213] add update challenge with tests --- common/locales/en/api-v3.json | 1 + .../PUT-challenges_challengeId.test.js | 76 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 41 +++++++++- website/src/controllers/api-v3/tasks.js | 2 +- website/src/models/challenge.js | 16 ++-- website/src/models/user.js | 2 +- 6 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index e6dc9bee55..a435098a1d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -50,6 +50,7 @@ "winnerIdRequired": "\"winnerId\" must be a valid UUID.", "challengeNotFound": "Challenge not found.", "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", + "onlyLeaderUpdateChal": "Only the challenge leader can update it.", "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.", "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.", "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.", diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js new file mode 100644 index 0000000000..e916ab5b7a --- /dev/null +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -0,0 +1,76 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('PUT /challenges/:challengeId', () => { + let privateGuild, user, nonMember, challenge, member; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestPrivateGuild', + type: 'guild', + privacy: 'private', + }, + members: 1, + }); + + privateGuild = group; + user = groupLeader; + + nonMember = await generateUser(); + member = members[0]; + + challenge = await generateChallenge(user, group); + await member.post(`/challenges/${challenge._id}/join`); + }); + + it('fails if the user can\'t view the challenge', async () => { + await expect(nonMember.put(`/challenges/${challenge._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should only allow the leader or an admin to update the challenge', async () => { + await expect(member.put(`/challenges/${challenge._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderUpdateChal'), + }); + }); + + it('only updates allowed fields', async () => { + let res = await user.put(`/challenges/${challenge._id}`, { + // ignored + prize: 33, + groupId: 'blabla', + memberCount: 33, + tasksOrder: 'new order', + official: true, + shortName: 'new short name', + + // applied + name: 'New Challenge Name', + description: 'New challenge description.', + leader: member._id, + }); + + expect(res.prize).to.equal(0); + expect(res.groupId).to.equal(privateGuild._id); + expect(res.memberCount).to.equal(2); + expect(res.tasksOrder).not.to.equal('new order'); + expect(res.official).to.equal(false); + expect(res.shortName).not.to.equal('new short name'); + + expect(res.leader).to.equal(member._id); + expect(res.name).to.equal('New Challenge Name'); + expect(res.description).to.equal('New challenge description.'); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index bd7f694cf6..172ff5783b 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -3,7 +3,9 @@ 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, +} from '../../models/user'; import { NotFound, NotAuthorized, @@ -199,6 +201,43 @@ api.getChallenge = { }, }; +/** + * @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 = {}) { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 2e72953ad1..0197d38dbb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -302,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) diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index ed9a88213e..c044caaba6 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -10,9 +10,9 @@ let Schema = mongoose.Schema; let schema = new Schema({ name: {type: String, required: true}, - shortName: {type: String, required: true}, // TODO what is it? + shortName: {type: String, required: true}, description: String, - official: {type: Boolean, default: false}, // TODO only settable by admin + official: {type: Boolean, default: false}, tasksOrder: { habits: [{type: String, ref: 'Task'}], dailys: [{type: String, ref: 'Task'}], @@ -20,16 +20,22 @@ 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, }); +// 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 schema.methods.isMember = function isChallengeMember (user) { return user.challenges.indexOf(this._id) !== -1; diff --git a/website/src/models/user.js b/website/src/models/user.js index db466ce829..cc1ebccf54 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -654,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 = { From 4518d3693c2dd06f5f23ce5a71e85014533317f2 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Jan 2016 20:28:54 +0100 Subject: [PATCH 153/213] add exportChallengeCsv route (missing tests) --- package.json | 1 + website/src/controllers/api-v3/challenges.js | 74 ++++++++++++++++++++ website/src/libs/api-v3/csvStringify.js | 11 +++ website/src/models/challenge.js | 4 +- 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 website/src/libs/api-v3/csvStringify.js diff --git a/package.json b/package.json index 0f9ee88db0..b7de9c1f5a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cookie-parser": "^1.4.0", "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", + "csv-stringify": "^1.0.1", "domain-middleware": "~0.1.0", "estraverse": "^4.1.1", "express": "~4.13.3", diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3c45f0e167..ea3e787602 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -5,6 +5,7 @@ import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; import { model as User, + nameFields, } from '../../models/user'; import { NotFound, @@ -15,6 +16,7 @@ import * as Tasks from '../../models/task'; import { 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 = {}; @@ -239,6 +241,78 @@ api.getChallenge = { }, }; +/** + * @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').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 challengeTasks = _.reduce(challenge.tasksOrder, (result, array) => { + return result.concat(array); + }, []).sort(); + + let [members, tasks] = await Q.all([ + User.find({challenges: challengeId}) + .select(nameFields) + .sortBy({_id: 1}) + .lean() // so we don't involve mongoose + .exec(), + + Tasks.Task.find({'task.challenge.id': challengeId, userId: {$exists: true}}) + .sortBy({userId: 1, _id: 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 + 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`, + }); + res.status(200).send(await csvStringify(resArray)); + }, +}; + /** * @api {put} /challenges/:challengeId Update a challenge * @apiVersion 3.0.0 diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js new file mode 100644 index 0000000000..3a597ff55c --- /dev/null +++ b/website/src/libs/api-v3/csvStringify.js @@ -0,0 +1,11 @@ +import csvStringify from 'csv-stringify'; +import Q from 'q'; + +export default function (input) { + return Q.promise((resolve, reject) => { + csvStringify(input, (err, output) => { + if (err) return reject(err); + return resolve(output); + }); + }); +} diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index c044caaba6..040a6c503f 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -48,9 +48,9 @@ schema.methods.canModify = function canModifyChallenge (user) { // Returns true if user has access to the challenge (can join) schema.methods.hasAccess = function hasAccessToChallenge (user) { - let userGroups = user.guilds.slice(0); + 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 challenges + userGroups.push('habitrpg'); // tavern return this.canModify(user) || userGroups.indexOf(this.groupId) !== -1; }; From 6d38caf78bd32925b961cef140170d668d7eb735 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 27 Jan 2016 20:52:47 +0100 Subject: [PATCH 154/213] tests for challengeExportCsv --- ...-challenges_challengeId_export_csv.test.js | 59 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 19 +++--- 2 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js new file mode 100644 index 0000000000..cc7f408951 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -0,0 +1,59 @@ +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(1); + }); + + 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 () => { + await members[0].get(`/challenges/${challenge._id}/export/csv`); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index ea3e787602..3169650a0d 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -264,7 +264,7 @@ api.exportChallengeCsv = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findById(challengeId).select('_id groupId leader').exec(); + 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')); @@ -272,19 +272,15 @@ api.exportChallengeCsv = { // 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 challengeTasks = _.reduce(challenge.tasksOrder, (result, array) => { - return result.concat(array); - }, []).sort(); - let [members, tasks] = await Q.all([ User.find({challenges: challengeId}) .select(nameFields) - .sortBy({_id: 1}) + .sort({_id: 1}) .lean() // so we don't involve mongoose .exec(), - Tasks.Task.find({'task.challenge.id': challengeId, userId: {$exists: true}}) - .sortBy({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), + Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}}) + .sort({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), ]); let resArray = members.map(member => [member._id, member.profile.name]); @@ -302,6 +298,9 @@ api.exportChallengeCsv = { }); // 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')); @@ -309,7 +308,9 @@ api.exportChallengeCsv = { 'Content-Type': 'text/csv', 'Content-disposition': `attachment; filename=${challengeId}.csv`, }); - res.status(200).send(await csvStringify(resArray)); + + let csvRes = await csvStringify(resArray); + res.status(200).send(csvRes); }, }; From 6dce552140c0e0d69185dc97de27e0f3a55cf3a6 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Thu, 28 Jan 2016 00:01:13 +0200 Subject: [PATCH 155/213] Challenge select winner route tests --- ...lenges_challengeId_winner_winnerId.test.js | 114 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 8 +- 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js new file mode 100644 index 0000000000..c079086777 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -0,0 +1,114 @@ +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; + + 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: 'A challenge task text'}, + ]); + + 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); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3169650a0d..3c14fc69aa 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -438,9 +438,9 @@ api.deleteChallenge = { }; /** - * @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 @@ -452,7 +452,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(); @@ -463,7 +463,7 @@ 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})); res.respond(200, {}); // Close channel in background From 1369327c44a7e6698950de48efb21671b66c91cc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 28 Jan 2016 11:30:53 +0100 Subject: [PATCH 156/213] requester should resolve response.text if response is not json, better tests for csv export --- ...GET-challenges_challengeId_export_csv.test.js | 16 ++++++++++++++-- test/helpers/api-integration/requester.js | 3 ++- website/src/controllers/api-v3/challenges.js | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index cc7f408951..e4c1ceee34 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -34,7 +34,10 @@ describe('GET /challenges/:challengeId/export/csv', () => { {type: 'habit', text: 'Task 1'}, {type: 'todo', text: 'Task 2'}, ]); - await sleep(1); + 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 () => { @@ -54,6 +57,15 @@ describe('GET /challenges/:challengeId/export/csv', () => { }); it('should return a valid CSV file with export data', async () => { - await members[0].get(`/challenges/${challenge._id}/export/csv`); + let res = await members[0].get(`/challenges/${challenge._id}/export/csv`); + let sortedMembers = _.sortBy([members[0], members[1], members[2], groupLeader], '_id'); + let splitRes = res.split('\n'); + + expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Task,Value,Notes'); + expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[5]).to.equal(''); }); }); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index a55da4d3d8..de090f54aa 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -51,7 +51,8 @@ function _requestMaker (user, method, additionalSets) { reject(parsedError); } - resolve(response.body); + let contentType = response.headers['content-type'] || ''; + resolve(contentType.indexOf('json') !== -1 ? response.body : response.text); }); }); }; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3169650a0d..e5d9fd4424 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -280,7 +280,7 @@ api.exportChallengeCsv = { .exec(), Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}}) - .sort({userId: 1, _id: 1}).select('userId type text value notes').lean().exec(), + .sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(), ]); let resArray = members.map(member => [member._id, member.profile.name]); From c52064b3df02fd5308c9af2cf3ba75afe5c52ac8 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Thu, 28 Jan 2016 22:01:39 +0200 Subject: [PATCH 157/213] Add challenge select winner test for broken and winner flags of challenge tasks --- ...lenges_challengeId_winner_winnerId.test.js | 27 ++++++++++++++++++- website/src/controllers/api-v3/challenges.js | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js index c079086777..98dfc20926 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -44,6 +44,7 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { let group; let challenge; let winningUser; + let taskText = 'A challenge task text'; beforeEach(async () => { let populatedGroup = await createAndPopulateGroup({ @@ -59,7 +60,7 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { }); await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: 'A challenge task text'}, + {type: 'habit', text: taskText}, ]); await winningUser.post(`/challenges/${challenge._id}/join`); @@ -110,5 +111,29 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { await expect(winningUser.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); }); + + it('doesn\'t refund gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance); + }); + + it('sets broken and winner flags for user\'s challenge tasks', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + let tasks = await winningUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_CLOSED'); + expect(testTask.challenge.winner).to.eql(winningUser.profile.name); + }); }); }); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 3c14fc69aa..fb91ed4079 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -467,7 +467,7 @@ api.selectChallengeWinner = { res.respond(200, {}); // Close channel in background - _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); + _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner}); }, }; From 2c378bf2f23201fbbc659b74dd0e0f72adc96ee4 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Thu, 28 Jan 2016 23:21:07 +0200 Subject: [PATCH 158/213] Add challenge delete tests --- .../DELETE-challenges_challengeId.test.js | 95 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js new file mode 100644 index 0000000000..e8bd7a66d9 --- /dev/null +++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js @@ -0,0 +1,95 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + sleep, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /challenges/:challengeId', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/test`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Deleting a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let taskText = 'A challenge task text'; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup(); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + + challenge = await generateChallenge(groupLeader, group); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to delete the challenge', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderDeleteChal'), + }); + }); + + it('deletes challenge', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); + }); + + it('refunds gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); + }); + + it('sets broken and doesn\'t set winner flags for user\'s challenge tasks', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + let tasks = await groupLeader.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_DELETED'); + expect(testTask.challenge.winner).to.be.null; + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index e5d9fd4424..2c3bfd40e3 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -422,7 +422,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; From b960ecdd94e7d07742bb75a3359371f4c7c88113 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 11:13:39 +0100 Subject: [PATCH 159/213] first attempt to fix fialing api-v3 tests --- test/README.md | 5 ----- test/api/README.md | 8 +++++--- test/helpers/api-integration/mongo.js | 6 ++---- test/helpers/api-integration/requester.js | 4 +++- test/helpers/api-unit.helper.js | 3 --- test/helpers/common.helper.js | 1 - test/helpers/globals.helper.js | 1 - test/mocha.opts | 1 - 8 files changed, 10 insertions(+), 19 deletions(-) delete mode 100644 test/README.md diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 8115753727..0000000000 --- a/test/README.md +++ /dev/null @@ -1,5 +0,0 @@ -We need to clean up this directory. The *real* tests are in spec/ mock/ e2e/ and api.mocha.coffee. We want to: - -1. Move all old / deprecated tests from casper, test2, etc into spec, mock, e2e -1. Remove dependency of api.mocha.coffee on Derby, port it to Mongoose -1. Add better test-coverage diff --git a/test/api/README.md b/test/api/README.md index b5223cc0f4..ff3296721f 100644 --- a/test/api/README.md +++ b/test/api/README.md @@ -1,5 +1,7 @@ # So you want to write API integration tests? +@TODO rewrite + That's great! This README will serve as a quick primer for style conventions and practices for these tests. ## What is this? @@ -73,7 +75,7 @@ POST-groups_id_leave.test.js To mitigate [callback hell](http://callbackhell.com/) :imp:, we've written a helper method to generate a user object that can make http requests that [return promises](https://babeljs.io/docs/learn-es2015/#promises). This makes it very easy to chain together commands. All you need to do to make a subsequent request is return another promise and then call `.then((result) => {})` on the surrounding block, like so: ```js -it('does something', () => { +it('does something', () => { let user; return generateUser().then((_user) => { // We return the initial promise so this test can be run asyncronously @@ -97,7 +99,7 @@ it('does something', () => { If the test is simple, you can use the [chai-as-promised](http://chaijs.com/plugins/chai-as-promised) `return expect(somePromise).to.eventually` syntax to make your assertion. ```js -it('makes the party creator the leader automatically', () => { +it('makes the party creator the leader automatically', () => { return expect(user.post('/groups', { type: 'party', })).to.eventually.have.deep.property('leader._id', user._id); @@ -107,7 +109,7 @@ it('makes the party creator the leader automatically', () => { If the test is checking that the request returns an error, use the `.eventually.be.rejected.and.eql` syntax. ```js -it('returns an error', () => { +it('returns an error', () => { return expect(user.get('/groups/id-of-a-party-that-user-does-not-belong-to')) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/api-integration/mongo.js index bbe22496b6..e3db242fd3 100644 --- a/test/helpers/api-integration/mongo.js +++ b/test/helpers/api-integration/mongo.js @@ -1,9 +1,7 @@ /* eslint-disable no-use-before-define */ - +import nconf from 'nconf'; import { MongoClient as mongo } from 'mongodb'; -const DB_URI = 'mongodb://localhost/habitrpg_test'; - // Useful for checking things that have been deleted, // but you no longer have access to, // like private parties or users @@ -81,7 +79,7 @@ export async function getDocument (collectionName, doc) { export function connectToMongo () { return new Promise((resolve, reject) => { - mongo.connect(DB_URI, (err, db) => { + mongo.connect(nconf.get('NODE_DB_URI'), (err, db) => { if (err) return reject(err); resolve(db); diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index de090f54aa..be2ea5c817 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -1,8 +1,10 @@ /* eslint-disable no-use-before-define */ import superagent from 'superagent'; +import nconf from 'nconf'; +import app from '../../../website/src/server'; -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 diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 0a4a22df79..8988ca684a 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -4,9 +4,6 @@ import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; -mongoose.Promise = require('q').Promise; - -mongoose.connect('mongodb://localhost/habitica-unit-tests'); let connection = mongoose.connection; before((done) => { diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 5e526c1424..5e0660d3c4 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,6 +1,5 @@ import mongoose from 'mongoose'; import Q from 'q'; -mongoose.Promise = Q.Promise; import { wrap as wrapUser } from '../../common/script/index'; import { model as User } from '../../website/src/models/user'; diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 19f3ebfc71..f08df055fd 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,5 +1,4 @@ /* eslint-disable no-undef */ -require('babel-core/register'); //------------------------------ // Global modules //------------------------------ diff --git a/test/mocha.opts b/test/mocha.opts index bcdefd3e55..c55b351ceb 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -5,5 +5,4 @@ --growl --globals io --compilers js:babel-core/register ---require test/api-legacy/api-helper --require ./test/helpers/globals.helper From e345fa76f564411544b9a9c1ea7dae2965cc468e Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:07:46 +0100 Subject: [PATCH 160/213] failing unit test --- package.json | 2 +- tasks/gulp-tests.js | 2 + .../api/v3/unit/middlewares/analytics.test.js | 3 +- test/helpers/api-integration/api-classes.js | 2 +- test/helpers/api-integration/requester.js | 1 - test/helpers/api-integration/v3/index.js | 2 +- test/helpers/api-unit.helper.js | 14 +----- test/helpers/common.helper.js | 1 - test/helpers/globals.helper.js | 7 +++ test/helpers/{api-integration => }/mongo.js | 48 ++++++++----------- 10 files changed, 34 insertions(+), 48 deletions(-) rename test/helpers/{api-integration => }/mongo.js (67%) diff --git a/package.json b/package.json index b7de9c1f5a..11258f9f00 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp lint && (gulp test:nodemon & (sleep 20; mocha test/api/v3 --recursive; killall gulp; killall node;))", + "test": "gulp lint && npm run test:api-v3:unit && npm run test:api-v3:integration", "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", diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 5fbd32cac8..a963c6781d 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -13,6 +13,8 @@ import Q from 'q'; import runSequence from 'run-sequence'; import os from 'os'; +// TODO rewrite + const TEST_SERVER_PORT = 3003 const TEST_DB = 'habitrpg_test' let server; diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index bcaa7898e2..a56666b7a1 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -29,7 +29,7 @@ describe('analytics middleware', () => { attachAnalytics(req, res, next); - expect(res.analytics).to.exist; + expect(res.analytics).to.not.exist; }); it('attaches stubbed methods for non-prod environments', () => { @@ -53,4 +53,3 @@ describe('analytics middleware', () => { expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase); }); }); - diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 097c359427..f584826520 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -4,7 +4,7 @@ import { requester } from './requester'; import { getDocument as getDocumentFromMongo, updateDocument as updateDocumentInMongo, -} from './mongo'; +} from '../mongo'; import { assign, each, diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index be2ea5c817..bcf580e653 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -2,7 +2,6 @@ import superagent from 'superagent'; import nconf from 'nconf'; -import app from '../../../website/src/server'; const API_TEST_SERVER_PORT = nconf.get('PORT'); let apiVersion; diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 880f88f41a..4ee2e6cfbe 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -6,7 +6,7 @@ requester.setApiVersion('v3'); export { requester }; export { translate } from '../translate'; -export { checkExistence, resetHabiticaDB } from '../mongo'; +export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; export async function sleep (seconds) { diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 8988ca684a..ffef97ab82 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -4,21 +4,9 @@ import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; -let connection = mongoose.connection; - -before((done) => { - connection.on('open', () => { - connection.db.dropDatabase(done); - }); -}); - -after((done) => { - connection.close(done); -}); - afterEach((done) => { sandbox.restore(); - connection.db.dropDatabase(done); + mongoose.connection.db.dropDatabase(done); }); export function generateUser (options = {}) { diff --git a/test/helpers/common.helper.js b/test/helpers/common.helper.js index 5e0660d3c4..96064b8142 100644 --- a/test/helpers/common.helper.js +++ b/test/helpers/common.helper.js @@ -1,5 +1,4 @@ import mongoose from 'mongoose'; -import Q from 'q'; import { wrap as wrapUser } from '../../common/script/index'; import { model as User } from '../../website/src/models/user'; diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index f08df055fd..7b4a96070e 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -11,7 +11,14 @@ global.expect = chai.expect; global.sinon = require('sinon'); global.sandbox = sinon.sandbox.create(); +import nconf from 'nconf'; + //------------------------------ // Load nconf for unit tests //------------------------------ require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); +nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); +// We require src/server and npt src/index because +// 1. nconf is already setup +// 2. we don't need clustering +require('../../website/src/server'); diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/mongo.js similarity index 67% rename from test/helpers/api-integration/mongo.js rename to test/helpers/mongo.js index e3db242fd3..17d9c17a2a 100644 --- a/test/helpers/api-integration/mongo.js +++ b/test/helpers/mongo.js @@ -1,22 +1,17 @@ -/* eslint-disable no-use-before-define */ -import nconf from 'nconf'; -import { MongoClient as mongo } from 'mongodb'; +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); }); }); @@ -25,12 +20,10 @@ 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', @@ -42,7 +35,6 @@ export async function resetHabiticaDB () { }, (insertErr) => { if (insertErr) return reject(insertErr); - db.close(); resolve(); }); }); @@ -50,39 +42,39 @@ export async function resetHabiticaDB () { } 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(nconf.get('NODE_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); + }); +}); From bcbaa75aad5fa5bd97608b04f43743ad51087d86 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:08:30 +0100 Subject: [PATCH 161/213] failing integration test --- .../challenges/GET-challenges_challengeId_export_csv.test.js | 2 +- test/api/v3/unit/middlewares/analytics.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index e4c1ceee34..25e1adcf09 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -50,7 +50,7 @@ describe('GET /challenges/:challengeId/export/csv', () => { 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, + code: 409, error: 'NotFound', message: t('challengeNotFound'), }); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index a56666b7a1..eb238c6fa9 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -29,7 +29,7 @@ describe('analytics middleware', () => { attachAnalytics(req, res, next); - expect(res.analytics).to.not.exist; + expect(res.analytics).to.exist; }); it('attaches stubbed methods for non-prod environments', () => { From bcfb52a16e4927b853e720c98745fd2e52c1c0b3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:14:33 +0100 Subject: [PATCH 162/213] passing tests --- .../challenges/GET-challenges_challengeId_export_csv.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index 25e1adcf09..e4c1ceee34 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -50,7 +50,7 @@ describe('GET /challenges/:challengeId/export/csv', () => { 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: 409, + code: 404, error: 'NotFound', message: t('challengeNotFound'), }); From 80160597b0717bb9fc508a56212b601c7afba34c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:37:17 +0100 Subject: [PATCH 163/213] fix for travis? --- test/helpers/mongo.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index 17d9c17a2a..88535da38b 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -20,22 +20,29 @@ 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 () { + console.log('calling resetHabiticaDatabase'); // eslint-disable-line return new Promise((resolve, reject) => { mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); 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(); - resolve(); + groups.insertOne({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }, (insertErr) => { + if (insertErr) return reject(insertErr); + + resolve(); + }); }); }); }); From 361d0aa35cf760c01cac94c472837dda66504fed Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 12:52:57 +0100 Subject: [PATCH 164/213] finally fix tests --- test/helpers/api-unit.helper.js | 1 + test/helpers/globals.helper.js | 1 + test/helpers/mongo.js | 1 - website/src/models/group.js | 31 +++++++++++++++++-------------- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index ffef97ab82..3370bd0887 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -3,6 +3,7 @@ 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((done) => { sandbox.restore(); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 7b4a96070e..2bfe3c8a56 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -18,6 +18,7 @@ import nconf from 'nconf'; //------------------------------ require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); +nconf.set('NODE_ENV', 'test'); // We require src/server and npt src/index because // 1. nconf is already setup // 2. we don't need clustering diff --git a/test/helpers/mongo.js b/test/helpers/mongo.js index 88535da38b..d99c195c3c 100644 --- a/test/helpers/mongo.js +++ b/test/helpers/mongo.js @@ -20,7 +20,6 @@ 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 () { - console.log('calling resetHabiticaDatabase'); // eslint-disable-line return new Promise((resolve, reject) => { mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); diff --git a/website/src/models/group.js b/website/src/models/group.js index 0acc03fe32..2d2b4e3e71 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -11,6 +11,7 @@ import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import * 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; @@ -513,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('NODE_ENV') !== '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? + }); +} From b1848da8ded07a5b3dbc8f985e034360739066b1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 14:43:14 +0100 Subject: [PATCH 165/213] do not output server logs when testing, ability to run tests and server separately --- package.json | 3 ++- test/helpers/globals.helper.js | 26 +++++++++++++++++++------- website/src/libs/api-v3/logger.js | 3 +++ website/src/libs/api-v3/setupNconf.js | 1 + website/src/models/group.js | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 11258f9f00..6d787f5bc9 100644 --- a/package.json +++ b/package.json @@ -97,9 +97,10 @@ "test": "gulp lint && npm run test:api-v3:unit && npm run test:api-v3:integration", "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": "npm run test:api-v3:unit && npm run test:api-v3:integration", "test:api-v3:unit": "mocha test/api/v3/unit --recursive", "test:api-v3:integration": "mocha test/api/v3/integration --recursive", + "test:api-v3:integration:separate-server": "LOAD_SERVER=0 mocha test/api/v3/integration --recursive", "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", "test:common": "mocha test/common", "test:content": "mocha test/content", diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 2bfe3c8a56..9e92a85de9 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,4 +1,6 @@ /* eslint-disable no-undef */ +/* eslint-disable global-require */ +/* eslint-disable no-process-env */ //------------------------------ // Global modules //------------------------------ @@ -12,14 +14,24 @@ 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'); -nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); -nconf.set('NODE_ENV', 'test'); -// We require src/server and npt src/index because -// 1. nconf is already setup -// 2. we don't need clustering -require('../../website/src/server'); +if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose + require('../../website/src/libs/api-v3/setupNconf')('./config.json'); + // Use Q promises instead of mpromise in mongoose + mongoose.Promise = Q.Promise; + mongoose.connect(nconf.get('NODE_DB_URI')); +} else { // When running tests and the server in the same process + require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); + nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); + nconf.set('NODE_ENV', 'test'); + nconf.set('IS_TEST', true); + // We require src/server and npt src/index because + // 1. nconf is already setup + // 2. we don't need clustering + require('../../website/src/server'); +} diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 0d00ca7f4b..3b259eda6b 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -4,12 +4,15 @@ import winston from 'winston'; import nconf from 'nconf'; const IS_PROD = nconf.get('IS_PROD'); +const IS_TEST = nconf.get('IS_TEST'); let logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly // log errors to console too +} else if (IS_TEST) { + // Do not log anything when testing } else { logger .add(winston.transports.Console, { diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js index e5a49bc3e9..f55f593bad 100644 --- a/website/src/libs/api-v3/setupNconf.js +++ b/website/src/libs/api-v3/setupNconf.js @@ -13,4 +13,5 @@ export default function setupNconf (file) { nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); + nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test'); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 2d2b4e3e71..718df557c9 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -515,7 +515,7 @@ export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) // do not run when testing as it's handled by the tests and can easily cause a race condition -if (nconf.get('NODE_ENV') !== 'test') { +if (nconf.get('IS_TEST')) { model.count({_id: 'habitrpg'}, (err, ct) => { if (err) throw err; if (ct > 0) return; From 0084a0d057d705622743d0b22a7b36592d55c879 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 19:09:19 +0100 Subject: [PATCH 166/213] move tests to gulp tasks --- package.json | 10 ++++---- tasks/gulp-tests.js | 56 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 6d787f5bc9..a068f199e1 100644 --- a/package.json +++ b/package.json @@ -94,13 +94,13 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp lint && npm run test:api-v3:unit && npm run test:api-v3:integration", + "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": "npm run test:api-v3:unit && npm run test:api-v3:integration", - "test:api-v3:unit": "mocha test/api/v3/unit --recursive", - "test:api-v3:integration": "mocha test/api/v3/integration --recursive", - "test:api-v3:integration:separate-server": "LOAD_SERVER=0 mocha test/api/v3/integration --recursive", + "test:api-v3": "gulp test:api-v3", + "test:api-v3:unit": "gulp test:api-v3:unit", + "test:api-v3:integration": "gulp test:api-v3:integration", + "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server", "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", "test:common": "mocha test/common", "test:content": "mocha test/content", diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index a963c6781d..b8ccf23f68 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -44,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}`; } }; @@ -344,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']); @@ -358,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'], () => { @@ -432,4 +470,4 @@ gulp.task('test', ['test:all'], () => { console.log('\n\x1b[36mThanks for helping keep Habitica clean!\x1b[0m'); process.exit(); } -}); +});*/ From 0972d32d03f84c657f7e55ebad4504f714b99d29 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 29 Jan 2016 20:05:06 +0100 Subject: [PATCH 167/213] improve _closeChal to run some ops immediately --- website/src/controllers/api-v3/challenges.js | 68 ++++++++++---------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 9ac5c4bd51..94eb0330e5 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -13,7 +13,7 @@ import { } 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'; @@ -352,14 +352,38 @@ api.updateChallenge = { }; // 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 @@ -379,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 } @@ -431,9 +433,9 @@ 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'}); }, }; @@ -465,9 +467,9 @@ api.selectChallengeWinner = { 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.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_CLOSED', winner}); }, }; From 834cca0ddcede568bd7f1651e5cf05f9dafb7456 Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Fri, 29 Jan 2016 18:30:41 -0500 Subject: [PATCH 168/213] test(quests): finish invite route --- .../POST-groups_groupId_quests_invite.test.js | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index e03043b09a..7f43168b95 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -100,20 +100,28 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); context('successfully issuing a quest invitation', () => { + let inviteResponse = { + key: PET_QUEST, + active: false, + leader: leader._id, + members: {}, + progress: { + collect: {}, + }, + }; + inviteResponse.members[member._id] = null; + inviteResponse.members[leader._id] = null; + it('sends an invite to all party members', async () => { leader.items.quests[PET_QUEST] = 1; - await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ - - }]); + await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); }); it('allows non-leader party members to send invites', async () => { member.items.quests[PET_QUEST] = 1; - await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal([{ - - }]); + await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); }); }); }); From 69e24eafa3df58cf369a677472fa9a91d0c5bcec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sat, 30 Jan 2016 16:04:38 +0100 Subject: [PATCH 169/213] fix typo when creating tavern --- website/src/models/group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index 718df557c9..1bbb0145a5 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -515,7 +515,7 @@ export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) // do not run when testing as it's handled by the tests and can easily cause a race condition -if (nconf.get('IS_TEST')) { +if (!nconf.get('IS_TEST')) { model.count({_id: 'habitrpg'}, (err, ct) => { if (err) throw err; if (ct > 0) return; From a2f5c2a8424952e624fc18529ceb1948eb9bb77d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 30 Jan 2016 09:28:03 -0600 Subject: [PATCH 170/213] tests: Add readme for v3 tests --- test/api/v3/README.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 test/api/v3/README.md diff --git a/test/api/v3/README.md b/test/api/v3/README.md new file mode 100644 index 0000000000..ad9b55a32c --- /dev/null +++ b/test/api/v3/README.md @@ -0,0 +1,4 @@ +# How to run tests: + +1. `npm test` is equivalent to `gulp test:api-v3` which will run, in order, `gulp lint`, `gulp test:api-v3:unit` and `gulp test:api-v3:integration`. If one of these fails, the whole `npm test` command blocks and fails. Each of these commands can also be run as a standalone command. +2. To run the server and the integrations tests in two different terminals (to better inspect the output in the server) run `npm start` in one and `npm test:api-v3:integration:separate-server` in the other From 707170ec7e78141c35c5c7b2db91d61cebb62877 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 30 Jan 2016 13:35:05 -0600 Subject: [PATCH 171/213] feat(api-v3): Add failure conditions for quest invite route --- .../POST-groups_groupId_quests_invite.test.js | 54 ++++++++++++------- website/src/controllers/api-v3/groups.js | 39 ++++++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 7f43168b95..7fbbfa2ca6 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -3,8 +3,9 @@ import { translate as t, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; +import { quests as questScrolls } from '../../../../../common/script/content'; -describe.skip('POST /groups/:groupId/quests/invite', () => { +describe('POST /groups/:groupId/quests/invite/:questKey', () => { let questingGroup; let leader; let member; @@ -31,11 +32,13 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for a group in which user is not a member', async () => { - let { alternateGroup } = await createAndPopulateGroup({ + let { group } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, members: 1, }); + let alternateGroup = group; + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -44,11 +47,13 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites for Guilds', async () => { - let { alternateGroup } = await createAndPopulateGroup({ + let { group } = await createAndPopulateGroup({ groupDetails: { type: 'guild', privacy: 'public' }, members: 1, }); + let alternateGroup = group; + await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -76,8 +81,12 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { it('does not issue invites if the user is of insufficient Level', async () => { const LEVELED_QUEST = 'atom1'; - const LEVELED_QUEST_REQ = 15; - leader.items.quests[LEVELED_QUEST] = 1; + const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl; + const leaderUpdate = {}; + leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1; + leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1; + + await leader.update(leaderUpdate); await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, @@ -87,9 +96,12 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); it('does not issue invites if a quest is already underway', async () => { - leader.items.quests[PET_QUEST] = 2; + const QUEST_IN_PROGRESS = 'atom1'; + const leaderUpdate = {}; + leaderUpdate[`items.quests.${PET_QUEST}`] = 1; - await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await leader.update(leaderUpdate); + await questingGroup.update({ 'quest.key': QUEST_IN_PROGRESS }); await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({ code: 401, @@ -99,18 +111,22 @@ describe.skip('POST /groups/:groupId/quests/invite', () => { }); }); - context('successfully issuing a quest invitation', () => { - let inviteResponse = { - key: PET_QUEST, - active: false, - leader: leader._id, - members: {}, - progress: { - collect: {}, - }, - }; - inviteResponse.members[member._id] = null; - inviteResponse.members[leader._id] = null; + context.skip('successfully issuing a quest invitation', () => { + let inviteResponse; + + beforeEach(() => { + inviteResponse = { + key: PET_QUEST, + active: false, + leader: leader._id, + members: {}, + progress: { + collect: {}, + }, + }; + inviteResponse.members[member._id] = null; + inviteResponse.members[leader._id] = null; + }); it('sends an invite to all party members', async () => { leader.items.quests[PET_QUEST] = 1; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..628d31f1f2 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,6 +17,7 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; +import { quests as questScrolls } from '../../../../common/script/content'; let api = {}; @@ -616,4 +617,42 @@ api.inviteToGroup = { }, }; +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + // TODO Logic for quest invite and send back quest object + res.respond(200, {}); + }, +}; + export default api; From 461eb96d450eceb5c286c8ecf91e3f8057a231ac Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 26 Jan 2016 17:51:15 -0600 Subject: [PATCH 172/213] Added get group challenges route and initial tests --- .../GET-challenges_group_groupid.test.js | 82 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 37 +++++++++ 2 files changed, 119 insertions(+) create mode 100644 test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js new file mode 100644 index 0000000000..37347ffc9a --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -0,0 +1,82 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET challenges/group/:groupId', () => { + context('Public Guild', () => { + let publicGuild, user, nonMember, challenge, challenge2; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestGuild', + type: 'guild', + privacy: 'public', + }, + }); + + publicGuild = group; + user = groupLeader; + + nonMember = await generateUser(); + + challenge = await generateChallenge(user, group); + challenge2 = await generateChallenge(user, group); + }); + + it('should return group challenges for non member', async () => { + let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`); + + expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); + expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + }); + + it('should return group challenges for member', async () => { + let challenges = await user.get(`/challenges/groups/${publicGuild._id}`); + + expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); + expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + }); + }); + + context('Private Guild', () => { + let privateGuild, user, nonMember, challenge, challenge2; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestPrivateGuild', + type: 'guild', + privacy: 'private', + }, + }); + + privateGuild = group; + user = groupLeader; + + nonMember = await generateUser(); + + challenge = await generateChallenge(user, group); + challenge2 = await generateChallenge(user, group); + }); + + it('should prevent non-member from seeing challenges', async () => { + await expect(nonMember.get(`/challenges/groups/${privateGuild._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('should return group challenges for member', async () => { + let challenges = await user.get(`/challenges/groups/${privateGuild._id}`); + + expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); + expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 94eb0330e5..9f1e9e50f9 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -208,6 +208,43 @@ api.getChallenges = { }, }; +/** + * @api {get} /challenges/group/group:Id Get challenges for a group + * @apiVersion 3.0.0 + * @apiName GetGroupChallenges + * @apiGroup Challenge + * + * @apiParam {groupId} groupId The group _id + * + * @apiSuccess {Array} challenges An array of challenges + */ +api.getGroupChallenges = { + method: 'GET', + url: '/challenges/groups/:groupId', + 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 challenges = await Challenge.find({groupId}) + .sort('-official -timestamp') + // TODO populate + // .populate('group', '_id name type') + // .populate('leader', 'profile.name') + .exec(); + + res.respond(200, challenges); + }, +}; + /** * @api {get} /challenges/:challengeId Get a challenge given its id * @apiVersion 3.0.0 From c699874e36e00aedf4123340cc05d053d8a4dc8a Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 17:55:55 -0600 Subject: [PATCH 173/213] feat: Add startQuest method on group model --- test/api/v3/unit/models/group.test.js | 134 ++++++++++++++++++++++++++ website/src/models/group.js | 60 ++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 test/api/v3/unit/models/group.test.js diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js new file mode 100644 index 0000000000..bdc49355f7 --- /dev/null +++ b/test/api/v3/unit/models/group.test.js @@ -0,0 +1,134 @@ +import { model as Group } from '../../../../../website/src/models/group'; +import { model as User } from '../../../../../website/src/models/user'; +import { quests as questScrolls } from '../../../../../common/script/content'; + +describe('Group Model', () => { + context('Instance Methods', () => { + let party; + + beforeEach(() => { + party = new Group({ + type: 'party', + }); + }); + + describe('#startQuest', () => { + context('Failure Conditions', () => { + it('throws an error if group is not a party', () => { + let guild = new Group({ + type: 'guild', + }); + + expect(() => { + guild.startQuest(); + }).to.throw('Must be a party to use this method'); + }); + + it('throws an error if party is not on a quest', () => { + expect(() => { + party.startQuest(); + }).to.throw('Party does not have a pending quest'); + }); + + it('throws an error if quest is already active', () => { + party.quest.key = 'whale'; + party.quest.active = true; + + expect(() => { + party.startQuest(); + }).to.throw('Quest is already active'); + }); + }); + + context('Successes', () => { + beforeEach(() => { + party.quest.key = 'whale'; + party.quest.active = false; + party.quest.leader = 'quest-leader'; + party.quest.members = { + 'quest-leader': true, + 'participating-member': true, + 'non-participating-member': false, + 'undecided-member': null, + }; + + sandbox.stub(User, 'update').returns({ exec: sandbox.spy() }); + }); + + it('activates quest', () => { + party.startQuest(); + + expect(party.quest.active).to.eql(true); + }); + + it('sets up boss quest', () => { + let bossQuest = questScrolls.whale; + party.quest.key = bossQuest.key; + + party.startQuest(); + + expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); + }); + + it('sets up rage meter for rage boss quest', () => { + let rageBossQuest = questScrolls.trex_undead; + party.quest.key = rageBossQuest.key; + + party.startQuest(); + + expect(party.quest.progress.rage).to.eql(0); + }); + + it('sets up collection quest', () => { + let collectionQuest = questScrolls.vice2; + party.quest.key = collectionQuest.key; + party.startQuest(); + + expect(party.quest.progress.collect).to.eql({ + lightCrystal: 0, + }); + }); + + it('sets up collection quest with multiple items', () => { + let collectionQuest = questScrolls.evilsanta2; + party.quest.key = collectionQuest.key; + party.startQuest(); + + expect(party.quest.progress.collect).to.eql({ + tracks: 0, + branches: 0, + }); + }); + + it('updates quest object for participating members', () => { + party.startQuest(); + + expect(User.update).to.be.calledTwice; + expect(User.update).to.not.be.calledWith({ _id: 'non-participating-member' }); + expect(User.update).to.not.be.calledWith({ _id: 'undecided-member' }); + expect(User.update).to.be.calledWith( + { _id: 'participating-member' }, + sinon.match({ $set: { 'party.quest.key': 'whale' }}), + ); + expect(User.update).to.be.calledWith( + { _id: 'quest-leader' }, + sinon.match({ $set: { 'party.quest.key': 'whale' }}), + ); + }); + + it('removes quest scroll from quest leader', () => { + party.startQuest(); + + expect(User.update).to.be.calledWith( + { _id: 'quest-leader' }, + sinon.match({ $inc: { 'items.quests.whale': -1 }}), + ); + }); + + it('sends email to participating members that quest has started'); + + it('sends email only to members who have not opted out'); + }); + }); + }); +}); diff --git a/website/src/models/group.js b/website/src/models/group.js index 1bbb0145a5..e416317a47 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -8,8 +8,10 @@ import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; +import { BadRequest } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; +import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; @@ -168,6 +170,64 @@ schema.methods.isMember = function isGroupMember (user) { } }; +schema.methods.startQuest = function startQuest () { + if (this.type !== 'party') throw new BadRequest('Must be a party to use this method'); + if (!this.quest.key) throw new BadRequest('Party does not have a pending quest'); + if (this.quest.active) throw new BadRequest('Quest is already active'); + + let quest = questScrolls[this.quest.key]; + let collected = {}; + if (quest.collect) { + collected = _.transform(quest.collect, (result, n, itemToCollect) => { + result[itemToCollect] = 0; + }); + } + + let backgroundOperations = []; + + this.markModified('quest'); + this.quest.active = true; + if (quest.boss) { + this.quest.progress.hp = quest.boss.hp; + if (quest.boss.rage) this.quest.progress.rage = 0; + } else if (quest.collect) { + this.quest.progress.collect = collected; + } + + _.each(this.quest.members, (participating, memberId) => { + if (!participating) return; + + let update = { + $set: { + // Do *not* reset party.quest.progress.up + // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + $inc: { _v: 1 }, + }; + + if (this.quest.leader === memberId) { + update.$inc[`items.quests.${this.quest.key}`] = -1; + } + + backgroundOperations.push(User.update({ _id: memberId }, update).exec()); + }); + + // TODO Add emails to users that quest has started to background ops + + // These operations should run in the background + // and not hold up the quest routes from resolving + // TODO: What here? + // Q.all(backgroundOperations).then(() => { + // }).catch(err => { + // TODO: How to handle errors? + // IE, user deleted their account? + // }); +}; + export function chatDefaults (msg, user) { let message = { id: shared.uuid(), From a7486821e5e11774b61d90ee2ac0172745fd9930 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 18:02:32 -0600 Subject: [PATCH 174/213] feat: Add quest details to group in quest invite route --- .../POST-groups_groupId_quests_invite.test.js | 74 ++++++++++++++----- website/src/controllers/api-v3/groups.js | 16 ++++ 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 7fbbfa2ca6..4311cf7911 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -111,33 +111,67 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - context.skip('successfully issuing a quest invitation', () => { - let inviteResponse; + context('successfully issuing a quest invitation', () => { + beforeEach(async () => { + const memberUpdate = {}; + memberUpdate[`items.quests.${PET_QUEST}`] = 1; - beforeEach(() => { - inviteResponse = { - key: PET_QUEST, - active: false, - leader: leader._id, - members: {}, - progress: { - collect: {}, - }, - }; - inviteResponse.members[member._id] = null; - inviteResponse.members[leader._id] = null; + await Promise.all([ + leader.update(memberUpdate), + member.update(memberUpdate), + ]); }); - it('sends an invite to all party members', async () => { - leader.items.quests[PET_QUEST] = 1; + xit('adds quest details to group object', async () => { + await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await expect(leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); + await questingGroup.sync(); + + let quest = questingGroup.quest; + + expect(quest.key).to.eql(PET_QUEST); + expect(quest.active).to.eql(false); + expect(quest.leader).to.eql(false); + expect(quest.members).to.have.property(leader._id, null); + expect(quest.members).to.have.property(member._id, null); + expect(quest).to.have.property('progress'); }); - it('allows non-leader party members to send invites', async () => { - member.items.quests[PET_QUEST] = 1; + xit('adds quest details to user objects', async () => { + await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await expect(member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.deep.equal(inviteResponse); + await Promise.all([ + leader.sync(), + member.sync(), + ]); + + [leader, member].forEach((user) => { + let quest = user.party.quest; + + expect(quest.key).to.eql(PET_QUEST); + expect(quest.active).to.eql(false); + expect(quest.leader).to.eql(false); + expect(quest.members).to.have.property(leader._id, null); + expect(quest.members).to.have.property(member._id, null); + expect(quest).to.have.property('progress'); + }); + }); + + xit('sends back the quest object', async () => { + let inviteResponse = await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + expect(inviteResponse.key).to.eql(PET_QUEST); + expect(inviteResponse.active).to.eql(false); + expect(inviteResponse.leader).to.eql(false); + expect(inviteResponse.members).to.have.property(leader._id, null); + expect(inviteResponse.members).to.have.property(member._id, null); + expect(inviteResponse).to.have.property('progress'); + }); + + xit('allows non-leader party members to send invites', async () => { + let inviteResponse = await member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + expect(inviteResponse.key).to.eql(PET_QUEST); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 628d31f1f2..c663688163 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -650,7 +650,23 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + group.markModified('quest'); + group.quest.key = questKey; + group.quest.leader = user._id; + group.quest.members = {}; + + // let memberUpdate = { + // '$set': { + // 'party.quest.key': questKey, + // 'party.quest.progress.down': 0, + // 'party.quest.completed': null, + // }, + // }; + + // TODO collect members of party // TODO Logic for quest invite and send back quest object + + await group.save(); res.respond(200, {}); }, }; From e3c7d2834e6e5fa024afb71032c21177ca4124a7 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 1 Feb 2016 21:13:52 -0600 Subject: [PATCH 175/213] refactor(api-v3): Move quest routes to separate file --- website/src/controllers/api-v3/groups.js | 55 ------------------- website/src/controllers/api-v3/quests.js | 68 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 website/src/controllers/api-v3/quests.js diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index c663688163..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,7 +17,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; -import { quests as questScrolls } from '../../../../common/script/content'; let api = {}; @@ -617,58 +616,4 @@ api.inviteToGroup = { }, }; -/** - * @api {post} /groups/:groupId/quests/invite Invite users to a quest - * @apiVersion 3.0.0 - * @apiName InviteToQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} Quest Object - */ -api.inviteToQuest = { - method: 'POST', - url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); - if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - - group.markModified('quest'); - group.quest.key = questKey; - group.quest.leader = user._id; - group.quest.members = {}; - - // let memberUpdate = { - // '$set': { - // 'party.quest.key': questKey, - // 'party.quest.progress.down': 0, - // 'party.quest.completed': null, - // }, - // }; - - // TODO collect members of party - // TODO Logic for quest invite and send back quest object - - await group.save(); - res.respond(200, {}); - }, -}; - export default api; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js new file mode 100644 index 0000000000..38020fcae5 --- /dev/null +++ b/website/src/controllers/api-v3/quests.js @@ -0,0 +1,68 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { + model as Group, +} from '../../models/group'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import { quests as questScrolls } from '../../../../common/script/content'; + +let api = {}; + +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + group.markModified('quest'); + group.quest.key = questKey; + group.quest.leader = user._id; + group.quest.members = {}; + + // let memberUpdate = { + // '$set': { + // 'party.quest.key': questKey, + // 'party.quest.progress.down': 0, + // 'party.quest.completed': null, + // }, + // }; + + // TODO collect members of party + // TODO Logic for quest invite and send back quest object + + await group.save(); + res.respond(200, {}); + }, +}; + +export default api; From 824603bc8964184dbca0dc8c312d9d3d307651f8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 11:14:55 +0100 Subject: [PATCH 176/213] fix moveTask and add tests --- website/src/controllers/api-v3/tasks.js | 26 ++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0197d38dbb..e101bca590 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -452,19 +452,19 @@ api.scoreTask = { // TODO check that it works when a tag is selected or todos are split between dated and due // TODO support challenges? /** - * @api {post} /tasks/move/:taskId/to/:position Move a task to a new position + * @api {post} /tasks/:taskId/move/to/:position Move a task to a new position * @apiVersion 3.0.0 * @apiName MoveTask * @apiGroup Task * * @apiParam {UUID} taskId The task _id - * @apiParam {Number} position Where to move the task (-1 means push to bottom) + * @apiParam {Number} position Where to move the task (-1 means push to bottom). First position is 0 * - * @apiSuccess {object} empty An empty object + * @apiSuccess {object} tasksOrder The new tasks order (user.tasksOrder.{task.type}s) */ api.moveTask = { method: 'POST', - url: '/tasks/move/:taskId/to/:position', + url: '/tasks/:taskId/move/to/:position', middlewares: [authWithHeaders(), cron], async handler (req, res) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); @@ -486,19 +486,23 @@ api.moveTask = { let order = user.tasksOrder[`${task.type}s`]; let currentIndex = order.indexOf(task._id); - // If for some reason the task isn't ordered (should never happen) - // or if the task is moved to a non existing position - // or if the task is moved to postion -1 (push to bottom) + // If for some reason the task isn't ordered (should never happen), push it in the new position + // if the task is moved to a non existing position + // or if the task is moved to position -1 (push to bottom) // -> push task at end of list - if (currentIndex === -1 || !order[to] || to === -1) { + if (!order[to] && to !== -1) { order.push(task._id); } else { - let taskToMove = order.splice(currentIndex, 1)[0]; - order.splice(to, 0, taskToMove); + if (currentIndex !== -1) order.splice(currentIndex, 1); + if (to === -1) { + order.push(task._id); + } else { + order.splice(to, 0, task._id); + } } await user.save(); - res.respond(200, {}); // TODO what to return + res.respond(200, order); }, }; From 9854d1cf34db92d6661446cdd87baf540114d322 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 11:42:05 +0100 Subject: [PATCH 177/213] add missing test file and update parameters for GET tasks --- .../integration/tasks/GET-tasks_user.test.js | 10 +-- .../POST-tasks_clearCompletedTodos.test.js | 10 ++- ...POST-tasks_move_taskId_to_position.test.js | 84 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 59 ++++++------- 4 files changed, 121 insertions(+), 42 deletions(-) create mode 100644 test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index a206d7b3ce..cd11100599 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -17,12 +17,12 @@ describe('GET /tasks/user', () => { it('returns only a type of user\'s tasks if req.query.type is specified', async () => { let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); - let tasks = await user.get('/tasks/user?type=habit'); + let tasks = await user.get('/tasks/user?type=habits'); expect(tasks.length).to.equal(1); expect(tasks[0]._id).to.equal(createdTasks[0]._id); }); - it('returns completed todos sorted by completion date if req.query.includeCompletedTodos is specified', async () => { + it('returns completed todos sorted by completion date if req.query.type === "completeTodos"', 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'}); @@ -35,8 +35,8 @@ describe('GET /tasks/user', () => { 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 + let completedTodos = await user.get('/tasks/user?type=completedTodos'); + expect(completedTodos.length).to.equal(2); + expect(completedTodos[completedTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js index 338808a4a1..d6cdf21749 100644 --- a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js @@ -24,7 +24,7 @@ describe('POST /tasks/clearCompletedTodos', () => { type: 'todo', }); - let tasks = await user.get('/tasks/user?type=todo'); + let tasks = await user.get('/tasks/user?type=todos'); expect(tasks.length).to.equal(initialTodoCount + 6); for (let task of tasks) { @@ -34,8 +34,10 @@ describe('POST /tasks/clearCompletedTodos', () => { } 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'); + let completedTodos = await user.get('/tasks/user?type=completedTodos'); + let todos = await user.get('/tasks/user?type=todos'); + let allTodos = todos.concat(completedTodos); + expect(allTodos.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge) + expect(allTodos[allTodos.length - 1].text).to.equal('todo 6'); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js b/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js new file mode 100644 index 0000000000..211c7ea975 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js @@ -0,0 +1,84 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/move/to/:position', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('requires a valid taskId', async () => { + await expect(user.post('/tasks/123/move/to/1')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('requires a numeric position parameter', async () => { + await expect(user.post(`/tasks/${generateUUID()}/move/to/notANumber`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('taskId must match a valid task', async () => { + await expect(user.post(`/tasks/${generateUUID()}/move/to/1`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('can move task to new position', async () => { + let tasks = await user.post('/tasks/user', [ + {type: 'habit', text: 'habit 1'}, + {type: 'habit', text: 'habit 2'}, + {type: 'daily', text: 'daily 1'}, + {type: 'habit', text: 'habit 3'}, + {type: 'habit', text: 'habit 4'}, + {type: 'todo', text: 'todo 1'}, + {type: 'habit', text: 'habit 5'}, + ]); + + let taskToMove = tasks[1]; + expect(taskToMove.text).to.equal('habit 2'); + let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/3`); + expect(newOrder[3]).to.equal(taskToMove._id); + expect(newOrder.length).to.equal(5); + }); + + it('can\'t move completed todo', async () => { + let task = await user.post('/tasks/user', {type: 'todo', text: 'todo 1'}); + await user.post(`/tasks/${task._id}/score/up`); + + await expect(user.post(`/tasks/${task._id}/move/to/1`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('cantMoveCompletedTodo'), + }); + }); + + it('can push to bottom', async () => { + let tasks = await user.post('/tasks/user', [ + {type: 'habit', text: 'habit 1'}, + {type: 'habit', text: 'habit 2'}, + {type: 'daily', text: 'daily 1'}, + {type: 'habit', text: 'habit 3'}, + {type: 'habit', text: 'habit 4'}, + {type: 'todo', text: 'todo 1'}, + {type: 'habit', text: 'habit 5'}, + ]); + + let taskToMove = tasks[1]; + expect(taskToMove.text).to.equal('habit 2'); + let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/-1`); + expect(newOrder[4]).to.equal(taskToMove._id); + expect(newOrder.length).to.equal(5); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e101bca590..847a8bee19 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -4,6 +4,7 @@ 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 { model as Group } from '../../models/group'; import { NotFound, NotAuthorized, @@ -117,8 +118,19 @@ async function _getTasks (req, res, user, challenge) { let type = req.query.type; if (type) { - query.type = type; - if (type === 'todo') query.completed = false; // Exclude completed todos + if (type === 'todos') { + query.completed = false; // Exclude completed todos + } else if (type === 'completedTodos') { + query = Tasks.Task.find({ + userId: user._id, + type: 'todo', + completed: true, + }).limit(30).sort({ // TODO add ability to pick more than 30 completed todos + dateCompleted: 1, + }); + } else { + query.type = type.slice(0, -1); // removing the final "s" + } } else { query.$or = [ // Exclude completed todos {type: 'todo', completed: false}, @@ -126,27 +138,8 @@ async function _getTasks (req, res, user, challenge) { ]; } - if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { - 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 - dateCompleted: 1, - }); - - let results = await Q.all([ - queryCompleted.exec(), - Tasks.Task.find(query).exec(), - ]); - - res.respond(200, results[1].concat(results[0])); - } else { - let tasks = await Tasks.Task.find(query).exec(); - res.respond(200, tasks); - } + let tasks = await Tasks.Task.find(query).exec(); + res.respond(200, tasks); } /** @@ -155,8 +148,7 @@ async function _getTasks (req, res, user, challenge) { * @apiName GetUserTasks * @apiGroup Task * - * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks - * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo". + * @apiParam {string="habits","dailys","todos","rewards","completeTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. * * @apiSuccess {Array} tasks An array of task objects */ @@ -165,7 +157,9 @@ api.getUserTasks = { url: '/tasks/user', middlewares: [authWithHeaders(), cron], async handler (req, res) { - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + let types = Tasks.tasksTypes.map(type => `${type}s`); + types.push('completedTodos'); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -181,7 +175,7 @@ api.getUserTasks = { * @apiGroup Task * * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks. - * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks + * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks * * @apiSuccess {Array} tasks An array of task objects */ @@ -191,7 +185,8 @@ api.getChallengeTasks = { middlewares: [authWithHeaders(), cron], async handler (req, res) { req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + let types = Tasks.tasksTypes.map(type => `${type}s`); + req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; @@ -200,11 +195,9 @@ api.getChallengeTasks = { let challengeId = req.params.challengeId; let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); - - // If the challenge does not exist, or if it exists but user is not a member, not the leader and not an admin -> throw error - if (!challenge || (user.challenges.indexOf(challengeId) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens - throw new NotFound(res.t('challengeNotFound')); - } + 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')); await _getTasks(req, res, res.locals.user, challenge); }, From 4aa6545c797f3255dad90dda24a8f29fe26c8223 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 11:56:06 +0100 Subject: [PATCH 178/213] fix access control when getting tasks for a challenge --- website/src/controllers/api-v3/tasks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 847a8bee19..0526163ccb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -194,7 +194,7 @@ api.getChallengeTasks = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).select('leader').exec(); + let challenge = await Challenge.findOne({_id: challengeId}).select('groupId leader').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')); From 337b68ce43d3c7e6a7d0fa733b3241c1dd237a0f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 12:23:56 +0100 Subject: [PATCH 179/213] make sure that tasks are returned in the correct order --- website/src/controllers/api-v3/tasks.js | 28 ++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0526163ccb..df78ca3a4f 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -139,7 +139,29 @@ async function _getTasks (req, res, user, challenge) { } let tasks = await Tasks.Task.find(query).exec(); - res.respond(200, tasks); + + // Order tasks based on tasksOrder + if (type && type !== 'completedTodos') { + let order = (challenge || user).tasksOrder[type]; + let orderedTasks = new Array(tasks.length); + let unorderedTasks = []; // what we want to add later + + tasks.forEach((task, index) => { + let taskId = task._id; + let i = order[index] === taskId ? index : order.indexOf(taskId); + if (i === -1) { + unorderedTasks.push(task); + } else { + orderedTasks[i] = task; + } + }); + + // Remove empty values from the array and add any unordered task + orderedTasks = _.compact(orderedTasks).concat(unorderedTasks); + res.respond(200, orderedTasks); + } else { + res.respond(200, tasks); + } } /** @@ -148,7 +170,7 @@ async function _getTasks (req, res, user, challenge) { * @apiName GetUserTasks * @apiGroup Task * - * @apiParam {string="habits","dailys","todos","rewards","completeTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. + * @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately. * * @apiSuccess {Array} tasks An array of task objects */ @@ -194,7 +216,7 @@ api.getChallengeTasks = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).select('groupId leader').exec(); + let challenge = await Challenge.findOne({_id: challengeId}).select('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')); From 754befc5807bb469cb23f54256e8295e812620ba Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Tue, 2 Feb 2016 10:29:55 -0600 Subject: [PATCH 180/213] Updated test syntax and added get user challenges tests --- .../GET-challenges_group_groupid.test.js | 18 +++-- .../challenges/GET-challenges_user.test.js | 72 +++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 11 +-- 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_user.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js index 37347ffc9a..9dcbbdcd5d 100644 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -30,15 +30,19 @@ describe('GET challenges/group/:groupId', () => { it('should return group challenges for non member', async () => { let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`); - expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); - expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; }); it('should return group challenges for member', async () => { let challenges = await user.get(`/challenges/groups/${publicGuild._id}`); - expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); - expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; }); }); @@ -75,8 +79,10 @@ describe('GET challenges/group/:groupId', () => { it('should return group challenges for member', async () => { let challenges = await user.get(`/challenges/groups/${privateGuild._id}`); - expect(_.findIndex(challenges, {_id: challenge._id})).to.be.above(-1); - expect(_.findIndex(challenges, {_id: challenge2._id})).to.be.above(-1); + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; }); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js new file mode 100644 index 0000000000..8b6a279045 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -0,0 +1,72 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET challenges/user', () => { + let user, member, nonMember, challenge, challenge2; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestGuild', + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + user = groupLeader; + + member = members[0]; + nonMember = await generateUser(); + + challenge = await generateChallenge(user, group); + challenge2 = await generateChallenge(user, group); + }); + + it('should return challenges user has joined', async () => { + await nonMember.post(`/challenges/${challenge._id}/join`); + + let challenges = await nonMember.get(`/challenges/user`); + + let foundChallenge = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge).to.exist; + }); + + it('should return challenges user has created', async () => { + let challenges = await user.get(`/challenges/user`); + + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; + }); + + it('should return challenges in user\'s group', async () => { + let challenges = await member.get(`/challenges/user`); + + let foundChallenge1 = _.find(challenges, { _id: challenge._id }); + expect(foundChallenge1).to.exist; + let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); + expect(foundChallenge2).to.exist; + }); + + it('should not return challenges user doesn\'t have access to', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestPrivateGuild', + type: 'guild', + privacy: 'private', + }, + }); + + let privateChallenge = await generateChallenge(groupLeader, group); + + let challenges = await nonMember.get(`/challenges/user`); + + let foundChallenge = _.find(challenges, { _id: privateChallenge._id }); + expect(foundChallenge).to.not.exist; + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 9f1e9e50f9..6f386a8936 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -121,7 +121,8 @@ api.joinChallenge = { let challenge = await Challenge.findOne({ _id: req.params.challengeId }); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - if (!challenge.hasAccess(user)) throw new NotFound(res.t('challengeNotFound')); + 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 (_.contains(user.challenges, challenge._id)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); @@ -172,16 +173,16 @@ api.leaveChallenge = { }; /** - * @api {get} /challenges Get challenges for a user + * @api {get} /challenges/user Get challenges for a user * @apiVersion 3.0.0 - * @apiName GetChallenges + * @apiName GetUserChallenges * @apiGroup Challenge * * @apiSuccess {Array} challenges An array of challenges */ -api.getChallenges = { +api.getUserChallenges = { method: 'GET', - url: '/challenges', + url: '/challenges/user', middlewares: [authWithHeaders(), cron], async handler (req, res) { let user = res.locals.user; From 509dffd0c736e0a6e5609ecc79e79ac02ce40a32 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 19:13:29 +0100 Subject: [PATCH 181/213] update hasAccess to include public guilds --- website/src/controllers/api-v3/challenges.js | 5 ++--- website/src/models/challenge.js | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 6f386a8936..a75cc6cc58 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -120,11 +120,10 @@ api.joinChallenge = { let challenge = await Challenge.findOne({ _id: req.params.challengeId }); if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); 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 (_.contains(user.challenges, challenge._id)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); + if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound')); challenge.memberCount += 1; diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 040a6c503f..62307f6d8e 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -47,20 +47,19 @@ schema.methods.canModify = function canModifyChallenge (user) { }; // Returns true if user has access to the challenge (can join) -schema.methods.hasAccess = function hasAccessToChallenge (user) { +schema.methods.hasAccess = function hasAccessToChallenge (user, group) { + if (group.type === 'guild' && group.privacy === 'public') return true; 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; + return 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 +// Different from hasAccess because you can see challenges of groups you've been removed from if you're partecipating in them 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); + return this.hasAccess(user, group); }; // Takes a Task document and return a plain object of attributes that can be synced to the user From 0e5c3f6e829a170c35def54bdee604b27a7be806 Mon Sep 17 00:00:00 2001 From: Kristian Tashkov Date: Sun, 31 Jan 2016 00:51:16 +0200 Subject: [PATCH 182/213] Challenge create tests --- .../challenges/POST-challenges.test.js | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 test/api/v3/integration/challenges/POST-challenges.test.js diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js new file mode 100644 index 0000000000..8bddb016b9 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -0,0 +1,295 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges', () => { + it('returns error when groupId is empty', async () => { + let user = await generateUser(); + + await expect(user.post('/challenges')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when groupId is not for a valid group', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges`, { + groupId: generateUUID(), + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns error when creating a challenge in the tavern with no prize', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges`, { + groupId: 'habitrpg', + prize: 0, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('pubChalsMinPrize'), + }); + }); + + it('returns error when creating a challenge in a public guild and you are not a member of it', async () => { + let user = await generateUser(); + let { group } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + }); + + await expect(user.post(`/challenges`, { + groupId: group._id, + prize: 4, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('mustBeGroupMember'), + }); + }); + + context('Creating a challenge for a valid group', () => { + let groupLeader; + let group; + let groupMember; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + leaderDetails: { + balance: 3, + }, + groupDetails: { + type: 'guild', + leaderOnly: { + challenges: true, + }, + }, + }); + + groupLeader = await populatedGroup.groupLeader.sync(); + group = populatedGroup.group; + groupMember = populatedGroup.members[0]; + }); + + it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { + await expect(groupMember.post(`/challenges`, { + groupId: group._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderChal'), + }); + }); + + it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { + await expect(groupMember.post(`/challenges`, { + groupId: group._id, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderChal'), + }); + }); + + it('allows non-leader member to create a challenge', async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + group = populatedGroup.group; + groupMember = populatedGroup.members[0]; + + await expect(groupMember.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + })).to.eventually.have.property('leader', groupMember._id); + }); + + it('doesn\'t take gems from user or group when challenge has no prize', async () => { + let oldUserBalance = groupLeader.balance; + let oldGroupBalance = group.balance; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize: 0, + }); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance); + await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance); + }); + + it('returns error when user and group can\'t pay prize', async () => { + await expect(groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize: 20, + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cantAfford'), + }); + }); + + it('takes prize out of group if it has sufficient funds', async () => { + let oldUserBalance = groupLeader.balance; + let oldGroupBalance = group.balance; + let prize = 4; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize, + }); + + await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance - prize / 4); + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance); + }); + + it('takes prize out of both group and user if group doesn\'t have enough', async () => { + let oldUserBalance = groupLeader.balance; + let prize = 8; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize, + }); + + await expect(group.sync()).to.eventually.have.property('balance', 0); + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - (prize / 4 - 1)); + }); + + it('takes prize out of user if group has no balance', async () => { + let oldUserBalance = groupLeader.balance; + let prize = 8; + + await group.update({ balance: 0}); + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + prize, + }); + + await expect(group.sync()).to.eventually.have.property('balance', 0); + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - prize / 4); + }); + + it('increases challenge count of group', async () => { + let oldChallengeCount = group.challengeCount; + + await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + }); + + await expect(group.sync()).to.eventually.have.property('challengeCount', oldChallengeCount + 1); + }); + + it('sets challenge as official if created by admin and official flag is set', async () => { + await groupLeader.update({ + contributor: { + admin: true, + }, + }); + + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + official: true, + }); + + expect(challenge.official).to.eql(true); + }); + + it('doesn\'t set challenge as official if official flag is set by non-admin', async () => { + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + official: true, + }); + + expect(challenge.official).to.be.undefined; + }); + + it('returns an error when challenge validation fails; doesn\'s save user or group', async () => { + let oldChallengeCount = group.challengeCount; + let oldUserBalance = groupLeader.balance; + let oldUserChallenges = groupLeader.challenges; + let oldGroupBalance = group.balance; + + await expect(groupLeader.post(`/challenges`, { + groupId: group._id, + prize: 8, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Challenge validation failed', + }); + + group = await group.sync(); + groupLeader = await groupLeader.sync(); + + expect(group.challengeCount).to.eql(oldChallengeCount); + expect(group.balance).to.eql(oldGroupBalance); + expect(groupLeader.balance).to.eql(oldUserBalance); + expect(groupLeader.challenges).to.eql(oldUserChallenges); + }); + + it('sets all properites of the challenge as passed', async () => { + let name = 'Test Challenge'; + let shortName = 'TC'; + let description = 'Test Description'; + let prize = 4; + + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name, + shortName, + description, + prize, + }); + + expect(challenge.leader).to.eql(groupLeader._id); + expect(challenge.name).to.eql(name); + expect(challenge.shortName).to.eql(shortName); + expect(challenge.description).to.eql(description); + expect(challenge.official).to.be.undefined; + expect(challenge.groupId).to.eql(group._id); + expect(challenge.memberCount).to.eql(1); + expect(challenge.prize).to.eql(prize); + }); + + it('adds challenge to creator\'s challenges', async () => { + let challenge = await groupLeader.post(`/challenges`, { + groupId: group._id, + name: 'Test Challenge', + shortName: 'TC', + }); + + await expect(groupLeader.sync()).to.eventually.have.property('challenges').to.include(challenge._id); + }); + }); +}); From 7a5aa731db16bf27878cb62e2b04ab7282b64f95 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 21:47:12 +0100 Subject: [PATCH 183/213] add population to challenge.group, challenge.leader and group.leader --- .../GET-challenges_group_groupid.test.js | 30 ++++++- .../challenges/GET-challenges_user.test.js | 54 ++++++++++- .../PUT-challenges_challengeId.test.js | 4 +- .../integration/groups/GET-groups_id.test.js | 4 - .../v3/integration/groups/POST-groups.test.js | 35 ++++++-- .../api-integration/v3/object-generators.js | 2 +- website/src/controllers/api-v3/challenges.js | 89 +++++++++++++------ website/src/controllers/api-v3/groups.js | 19 ++-- website/src/controllers/api-v3/members.js | 6 +- website/src/controllers/api-v3/tasks.js | 4 +- website/src/models/challenge.js | 9 +- website/src/models/group.js | 5 +- website/src/models/user.js | 8 ++ 13 files changed, 207 insertions(+), 62 deletions(-) diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js index 9dcbbdcd5d..3f116d833d 100644 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -27,22 +27,38 @@ describe('GET challenges/group/:groupId', () => { challenge2 = await generateChallenge(user, group); }); - it('should return group challenges for non member', async () => { + it('should return group challenges for non member with populated leader', async () => { let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); }); - it('should return group challenges for member', async () => { + it('should return group challenges for member with populated leader', async () => { let challenges = await user.get(`/challenges/groups/${publicGuild._id}`); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); }); }); @@ -76,13 +92,21 @@ describe('GET challenges/group/:groupId', () => { }); }); - it('should return group challenges for member', async () => { + it('should return group challenges for member with populated leader', async () => { let challenges = await user.get(`/challenges/groups/${privateGuild._id}`); let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: privateGuild.leader._id, + profile: {name: user.profile.name}, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: privateGuild.leader._id, + profile: {name: user.profile.name}, + }); }); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js index 8b6a279045..21ae0aa96b 100644 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; describe('GET challenges/user', () => { - let user, member, nonMember, challenge, challenge2; + let user, member, nonMember, challenge, challenge2, publicGuild; before(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ @@ -18,7 +18,7 @@ describe('GET challenges/user', () => { }); user = groupLeader; - + publicGuild = group; member = members[0]; nonMember = await generateUser(); @@ -33,6 +33,16 @@ describe('GET challenges/user', () => { let foundChallenge = _.find(challenges, { _id: challenge._id }); expect(foundChallenge).to.exist; + expect(foundChallenge.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); }); it('should return challenges user has created', async () => { @@ -40,8 +50,28 @@ describe('GET challenges/user', () => { let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge1.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge2.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); }); it('should return challenges in user\'s group', async () => { @@ -49,8 +79,28 @@ describe('GET challenges/user', () => { let foundChallenge1 = _.find(challenges, { _id: challenge._id }); expect(foundChallenge1).to.exist; + expect(foundChallenge1.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge1.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; + expect(foundChallenge2.leader).to.eql({ + _id: publicGuild.leader._id, + profile: {name: user.profile.name}, + }); + expect(foundChallenge2.group).to.eql({ + _id: publicGuild._id, + type: publicGuild.type, + privacy: publicGuild.privacy, + name: publicGuild.name, + }); }); it('should not return challenges user doesn\'t have access to', async () => { diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js index e916ab5b7a..33cc2e5166 100644 --- a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -50,7 +50,7 @@ describe('PUT /challenges/:challengeId', () => { let res = await user.put(`/challenges/${challenge._id}`, { // ignored prize: 33, - groupId: 'blabla', + group: 'blabla', memberCount: 33, tasksOrder: 'new order', official: true, @@ -63,7 +63,7 @@ describe('PUT /challenges/:challengeId', () => { }); expect(res.prize).to.equal(0); - expect(res.groupId).to.equal(privateGuild._id); + expect(res.group).to.equal(privateGuild._id); expect(res.memberCount).to.equal(2); expect(res.tasksOrder).not.to.equal('new order'); expect(res.official).to.equal(false); diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js index 9cc2fce766..2ecb53a33f 100644 --- a/test/api/v3/integration/groups/GET-groups_id.test.js +++ b/test/api/v3/integration/groups/GET-groups_id.test.js @@ -43,10 +43,6 @@ describe('GET /groups/: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; }); }); }); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js index 8a64ef4bb5..8036d6b0d8 100644 --- a/test/api/v3/integration/groups/POST-groups.test.js +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -32,12 +32,17 @@ describe('POST /group', () => { }); it('sets the group leader to the user who created the group', async () => { - await expect( - user.post('/groups', { - name: 'Test Public Guild', - type: 'guild', - }) - ).to.eventually.have.property('leader', user._id); + let group = await user.post('/groups', { + name: 'Test Public Guild', + type: 'guild', + }); + + expect(group.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); }); @@ -86,6 +91,12 @@ describe('POST /group', () => { expect(publicGuild.type).to.equal(groupType); expect(publicGuild.memberCount).to.equal(1); expect(publicGuild.privacy).to.equal(groupPrivacy); + expect(publicGuild.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); }); @@ -106,6 +117,12 @@ describe('POST /group', () => { expect(privateGuild.type).to.equal(groupType); expect(privateGuild.memberCount).to.equal(1); expect(privateGuild.privacy).to.equal(groupPrivacy); + expect(privateGuild.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); it('deducts gems from user and adds them to guild bank', async () => { @@ -138,6 +155,12 @@ describe('POST /group', () => { expect(party.name).to.equal(partyName); expect(party.type).to.equal(partyType); expect(party.memberCount).to.equal(1); + expect(party.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); }); it('does not require gems to create a party', async () => { diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index b7785e664f..57f6e2163b 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -113,7 +113,7 @@ export async function createAndPopulateGroup (settings = {}) { // optional details argument for the initial challenge creation and an // optional update argument which will update the challenge via the db export async function generateChallenge (challengeCreator, group, details = {}, update = {}) { - details.groupId = group._id; + details.group = group._id; details.name = details.name || 'a challenge'; details.shortName = details.shortName || 'aChallenge'; details.prize = details.prize || 0; diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index a75cc6cc58..7eccbcb9bf 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,7 +2,10 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import _ from 'lodash'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; -import { model as Group } from '../../models/group'; +import { + model as Group, + basicFields as basicGroupFields, +} from '../../models/group'; import { model as User, nameFields, @@ -35,12 +38,12 @@ api.createChallenge = { async handler (req, res) { let user = res.locals.user; - req.checkBody('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkBody('group', res.t('groupIdRequired')).notEmpty(); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let groupId = req.body.groupId; + let groupId = req.body.group; let prize = req.body.prize; let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true}); @@ -92,6 +95,17 @@ api.createChallenge = { }), group.save()]); let savedChal = results[0]; + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // await Q.ninvoke(savedChal, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise + let response = savedChal.toJSON(); + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + response.group = { + _id: group._id, + name: group.name, + type: group.type, + privacy: group.privacy, + }; + await savedChal.syncToUser(user); // (it also saves the user) res.respond(201, savedChal); }, @@ -122,7 +136,7 @@ api.joinChallenge = { if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound')); challenge.memberCount += 1; @@ -158,7 +172,7 @@ api.leaveChallenge = { 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'}); + let group = await Group.getGroup({user, groupId: challenge.group, 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')); @@ -186,25 +200,32 @@ api.getUserChallenges = { async handler (req, res) { let user = res.locals.user; - let groups = user.guilds.slice(0); // slice is used to clone the array so we don't modify it directly - if (user.party._id) groups.push(user.party._id); - groups.push('habitrpg'); // tavern challenges - let challenges = await Challenge.find({ $or: [ {_id: {$in: user.challenges}}, // Challenges where the user is participating - {groupId: {$in: groups}}, // Challenges in groups where I'm a member + {group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member {leader: user._id}, // Challenges where I'm the leader ], _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit }) .sort('-official -timestamp') - // TODO populate - // .populate('group', '_id name type') - // .populate('leader', 'profile.name') + // .populate('group', basicGroupFields) + // .populate('leader', nameFields) .exec(); - res.respond(200, challenges); + let resChals = challenges.map(challenge => challenge.toJSON()); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + await Q.all(resChals.map((chal, index) => { + return Q.all([ + User.findById(chal.leader).select(nameFields).exec(), + Group.findById(chal.group).select(basicGroupFields).exec(), + ]).then(populatedData => { + resChals[index].leader = populatedData[0].toJSON({minimize: true}); + resChals[index].group = populatedData[1].toJSON({minimize: true}); + }); + })); + + res.respond(200, resChals); }, }; @@ -234,14 +255,20 @@ api.getGroupChallenges = { let group = await Group.getGroup({user, groupId}); if (!group) throw new NotFound(res.t('groupNotFound')); - let challenges = await Challenge.find({groupId}) + let challenges = await Challenge.find({group: groupId}) .sort('-official -timestamp') - // TODO populate - // .populate('group', '_id name type') - // .populate('leader', 'profile.name') + // .populate('leader', nameFields) // Only populate the leader as the group is implicit .exec(); - res.respond(200, challenges); + let resChals = challenges.map(challenge => challenge.toJSON()); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + await Q.all(resChals.map((chal, index) => { + return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => { + resChals[index].leader = populatedLeader.toJSON({minimize: true}); + }); + })); + + res.respond(200, resChals); }, }; @@ -268,13 +295,21 @@ api.getChallenge = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findById(challengeId).exec(); + let challenge = await Challenge.findById(challengeId) + // .populate('leader', nameFields) // don't populate the group as we'll fetch it manually later + .exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.groupId, fields: '_id type privacy', optionalMembership: true}); + // Fetching basicGroupFields + let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); - res.respond(200, challenge); + let chalRes = challenge.toJSON(); + chalRes.group = group.toJSON({minimize: true}); + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + chalRes.leader = (await User.findById(chalRes.leader).select(nameFields).exec()).toJSON({minimize: true}); + + res.respond(200, chalRes); }, }; @@ -301,9 +336,9 @@ api.exportChallengeCsv = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findById(challengeId).select('_id groupId leader tasksOrder').exec(); + let challenge = await Challenge.findById(challengeId).select('_id group 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}); + let group = await Group.getGroup({user, groupId: challenge.group, 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 @@ -377,7 +412,7 @@ api.updateChallenge = { 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}); + let group = await Group.getGroup({user, groupId: challenge.group, 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')); @@ -398,12 +433,12 @@ async function _closeChal (challenge, broken = {}) { 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') { + if (challenge.group !== '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(); + await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(); // Award prize to winner and notify if (winner) { diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..ff223bcef8 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -5,8 +5,12 @@ import cron from '../../middlewares/api-v3/cron'; import { INVITES_LIMIT, model as Group, + basicFields as basicGroupFields, } from '../../models/group'; -import { model as User } from '../../models/user'; +import { + model as User, + nameFields, +} from '../../models/user'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { NotFound, @@ -55,11 +59,14 @@ api.createGroup = { let results = await Q.all([user.save(), group.save()]); let savedGroup = results[1]; + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + // await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise + let response = savedGroup.toJSON(); + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(201, response); firebase.updateGroupData(savedGroup); firebase.addUserToGroup(savedGroup._id, user._id); - - return res.respond(201, savedGroup); // TODO populate }, }; @@ -87,7 +94,7 @@ api.getGroups = { // TODO validate types are acceptable? probably not necessary let types = req.query.type.split(','); - let groupFields = 'name description memberCount balance'; + let groupFields = basicGroupFields.concat('description memberCount balance'); let sort = '-memberCount'; let queries = []; @@ -152,7 +159,7 @@ api.getGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: true}); + let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false}); if (!group) throw new NotFound(res.t('groupNotFound')); if (!user.contributor.admin) { @@ -163,6 +170,8 @@ api.getGroup = { }); } + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 + group.leader = (await User.findById(group.leader).select(nameFields).exec()).toJSON({minimize: true}); res.respond(200, group); }, }; diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js index 142926ed64..de2213d3b6 100644 --- a/website/src/controllers/api-v3/members.js +++ b/website/src/controllers/api-v3/members.js @@ -74,12 +74,12 @@ function _getMembersForItem (type) { let group; if (type === 'challenge-members') { - challenge = await Challenge.findById(challengeId).select('_id type leader groupId').exec(); + challenge = await Challenge.findById(challengeId).select('_id type leader group').exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); // 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}); + group = await Group.getGroup({user, groupId: challenge.group, 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'}); @@ -212,7 +212,7 @@ api.getChallengeMemberProgress = { // 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}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound')); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index df78ca3a4f..2b711a5a2a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -216,9 +216,9 @@ api.getChallengeTasks = { let user = res.locals.user; let challengeId = req.params.challengeId; - let challenge = await Challenge.findOne({_id: challengeId}).select('groupId leader tasksOrder').exec(); + let challenge = await Challenge.findOne({_id: challengeId}).select('group 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}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); await _getTasks(req, res, res.locals.user, challenge); diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js index 62307f6d8e..b67e8b7832 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -20,7 +20,7 @@ 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}, + group: {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? }); @@ -31,7 +31,7 @@ schema.plugin(baseModel, { }); // A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['groupId', 'official', 'shortName', 'prize']; +let noUpdate = ['group', 'official', 'shortName', 'prize']; schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return this.sanitize(updateObj, noUpdate); }; @@ -49,10 +49,7 @@ schema.methods.canModify = function canModifyChallenge (user) { // Returns true if user has access to the challenge (can join) schema.methods.hasAccess = function hasAccessToChallenge (user, group) { if (group.type === 'guild' && group.privacy === 'public') return true; - 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 userGroups.indexOf(this.groupId) !== -1; + return user.getGroups().indexOf(this.group) !== -1; }; // Returns true if user can view the challenge diff --git a/website/src/models/group.js b/website/src/models/group.js index 1bbb0145a5..890662de4c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -84,6 +84,9 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return this.sanitize(updateObj, noUpdate); }; +// Basic fields to fetch for populating a group info +export let basicFields = 'name type privacy'; + // TODO migration /** * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration @@ -472,7 +475,7 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { let challenges = await Challenge.find({ _id: {$in: user.challenges}, - groupId: group._id, + group: group._id, }); let challengesToRemoveUserFrom = challenges.map(chal => { diff --git a/website/src/models/user.js b/website/src/models/user.js index cc1ebccf54..31272dc874 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -654,6 +654,14 @@ schema.methods.isSubscribed = function isSubscribed () { return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion }; +// Get an array of groups ids the user is member of +schema.methods.getGroups = function getUserGroups () { + let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original + if (this.party._id) userGroups.push(this.party._id); + userGroups.push('habitrpg'); // tavern + return userGroups; +}; + // Unlink challenges tasks (and the challenge itself) from user schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { let user = this; From a8dc5294495ddf1b6bc7dca9a572c3f74609dedc Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 2 Feb 2016 22:09:35 +0100 Subject: [PATCH 184/213] adapt create challenge tests to population --- .../challenges/POST-challenges.test.js | 57 ++++++++++++------- website/src/controllers/api-v3/challenges.js | 2 +- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index 8bddb016b9..2f4878d91a 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -6,7 +6,7 @@ import { import { v4 as generateUUID } from 'uuid'; describe('POST /challenges', () => { - it('returns error when groupId is empty', async () => { + it('returns error when group is empty', async () => { let user = await generateUser(); await expect(user.post('/challenges')).to.eventually.be.rejected.and.eql({ @@ -20,7 +20,7 @@ describe('POST /challenges', () => { let user = await generateUser(); await expect(user.post(`/challenges`, { - groupId: generateUUID(), + group: generateUUID(), })).to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -32,7 +32,7 @@ describe('POST /challenges', () => { let user = await generateUser(); await expect(user.post(`/challenges`, { - groupId: 'habitrpg', + group: 'habitrpg', prize: 0, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -51,7 +51,7 @@ describe('POST /challenges', () => { }); await expect(user.post(`/challenges`, { - groupId: group._id, + group: group._id, prize: 4, })).to.eventually.be.rejected.and.eql({ code: 401, @@ -86,7 +86,7 @@ describe('POST /challenges', () => { it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { await expect(groupMember.post(`/challenges`, { - groupId: group._id, + group: group._id, })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -96,7 +96,7 @@ describe('POST /challenges', () => { it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => { await expect(groupMember.post(`/challenges`, { - groupId: group._id, + group: group._id, })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', @@ -112,11 +112,16 @@ describe('POST /challenges', () => { group = populatedGroup.group; groupMember = populatedGroup.members[0]; - await expect(groupMember.post(`/challenges`, { - groupId: group._id, + let chal = await groupMember.post(`/challenges`, { + group: group._id, name: 'Test Challenge', shortName: 'TC', - })).to.eventually.have.property('leader', groupMember._id); + }); + + expect(chal.leader).to.eql({ + _id: groupMember._id, + profile: {name: groupMember.profile.name}, + }); }); it('doesn\'t take gems from user or group when challenge has no prize', async () => { @@ -124,7 +129,7 @@ describe('POST /challenges', () => { let oldGroupBalance = group.balance; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize: 0, @@ -136,7 +141,7 @@ describe('POST /challenges', () => { it('returns error when user and group can\'t pay prize', async () => { await expect(groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize: 20, @@ -153,7 +158,7 @@ describe('POST /challenges', () => { let prize = 4; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize, @@ -168,7 +173,7 @@ describe('POST /challenges', () => { let prize = 8; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize, @@ -184,7 +189,7 @@ describe('POST /challenges', () => { await group.update({ balance: 0}); await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', prize, @@ -198,7 +203,7 @@ describe('POST /challenges', () => { let oldChallengeCount = group.challengeCount; await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', }); @@ -214,7 +219,7 @@ describe('POST /challenges', () => { }); let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', official: true, @@ -225,7 +230,7 @@ describe('POST /challenges', () => { it('doesn\'t set challenge as official if official flag is set by non-admin', async () => { let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', official: true, @@ -241,7 +246,7 @@ describe('POST /challenges', () => { let oldGroupBalance = group.balance; await expect(groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, prize: 8, })).to.eventually.be.rejected.and.eql({ code: 400, @@ -265,26 +270,34 @@ describe('POST /challenges', () => { let prize = 4; let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name, shortName, description, prize, }); - expect(challenge.leader).to.eql(groupLeader._id); + expect(challenge.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); expect(challenge.name).to.eql(name); expect(challenge.shortName).to.eql(shortName); expect(challenge.description).to.eql(description); expect(challenge.official).to.be.undefined; - expect(challenge.groupId).to.eql(group._id); + expect(challenge.group).to.eql({ + _id: group._id, + privacy: group.privacy, + name: group.name, + type: group.type, + }); expect(challenge.memberCount).to.eql(1); expect(challenge.prize).to.eql(prize); }); it('adds challenge to creator\'s challenges', async () => { let challenge = await groupLeader.post(`/challenges`, { - groupId: group._id, + group: group._id, name: 'Test Challenge', shortName: 'TC', }); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 7eccbcb9bf..c14a12d643 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -107,7 +107,7 @@ api.createChallenge = { }; await savedChal.syncToUser(user); // (it also saves the user) - res.respond(201, savedChal); + res.respond(201, response); }, }; From d5148a7bf01f196d77c83a028190b277b51aaf47 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 1 Feb 2016 19:33:35 -0600 Subject: [PATCH 185/213] Added quest reject route and intial tests --- common/locales/en/quests.json | 3 +- .../POST-groups_groupid_quests_reject.test.js | 98 +++++++++++++++++++ website/src/controllers/api-v3/groups.js | 97 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js diff --git a/common/locales/en/quests.json b/common/locales/en/quests.json index 7b3fe7af43..5edae258e5 100644 --- a/common/locales/en/quests.json +++ b/common/locales/en/quests.json @@ -78,5 +78,6 @@ "whichQuestStart": "Which quest do you want to start?", "getMoreQuests": "Get more quests", "unlockedAQuest": "You unlocked a quest!", - "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!" + "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!", + "questInvitationDoesNotExist": "No quest invitation has been sent out yet." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js new file mode 100644 index 0000000000..e5f7517eff --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -0,0 +1,98 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/invite/:questKey', () => { + let questingGroup; + let member; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(member.post(`/groups/${generateUUID()}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when group is not a party', async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(groupLeader.post(`/groups/${group._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('returns an error when quest is not found', async () => { + let questKey = 'fakeQuestName'; + + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${questKey}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotFound', { key: questKey }), + }); + }); + + it('returns an error when user is not on the quest', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questNotOwned'), + }); + }); + + it('returns an error when group is not on a quest', async () => { + await member.update(userQuestUpdate); + + await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInvitationDoesNotExist'), + }); + }); + }); + + context('successfully quest rejection', () => { + it('rejects a quest invitation', async () => { + await member.update(userQuestUpdate); + await questingGroup.update({'quest.key': PET_QUEST}); + + await member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`); + + let userWithRejectInvitation = await member.get('/user'); + expect(userWithRejectInvitation.party.quest.key).to.be.null; + expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index b9b633c111..785ea564ba 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,6 +17,8 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; +import { quests as questScrolls } from '../../../../common/script/content'; +import { mockAnalyticsService } from '../../libs/api-v3/analyticsService'; let api = {}; @@ -616,4 +618,99 @@ api.inviteToGroup = { }, }; +<<<<<<< e3c7d2834e6e5fa024afb71032c21177ca4124a7 +======= +/** + * @api {post} /groups/:groupId/quests/invite Invite users to a quest + * @apiVersion 3.0.0 + * @apiName InviteToQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.inviteToQuest = { + method: 'POST', + url: '/groups/:groupId/quests/invite/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); + if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + + // TODO Logic for quest invite and send back quest object + res.respond(200, {}); + }, +}; + +/** + * @api {post} /groups/:groupId/quests/reject Reject a quest + * @apiVersion 3.0.0 + * @apiName RejectQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} questKey The quest _id + * + * @apiSuccess {Object} Quest Object + */ +api.rejectQuest = { + method: 'POST', + url: '/groups/:groupId/quests/reject/:questKey', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let questKey = req.params.questKey; + let quest = questScrolls[questKey]; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); + if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + + let analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }; + mockAnalyticsService.track('quest', analyticsData); + + // @TODO: Are we tracking members this way? + // group.quest.members[user._id] = false; + + user.party.quest.RSVPNeeded = false; + user.party.quest.key = null; + await user.save(); + + // questStart(req,res,next); + + res.respond(200, {}); + }, +}; +>>>>>>> Added quest reject route and intial tests export default api; From 1daf87531aefe9a8a21bd000c68924d7c14314e6 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 3 Feb 2016 11:29:19 +0100 Subject: [PATCH 186/213] add population to join and update routes of challenges and groups, remove chat flags info everywhere --- .../POST-challenges_challengeId_join.test.js | 16 +++++++ .../PUT-challenges_challengeId.test.js | 12 +++++- test/api/v3/integration/chat/GET-chat.test.js | 4 +- .../groups/POST-groups_groupId_join.test.js | 4 +- .../v3/integration/groups/PUT-groups.test.js | 2 + website/src/controllers/api-v3/challenges.js | 43 +++++++++++++------ website/src/controllers/api-v3/chat.js | 12 +++--- website/src/controllers/api-v3/groups.js | 43 ++++++++++++------- website/src/models/group.js | 17 +++++++- 9 files changed, 114 insertions(+), 39 deletions(-) diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js index 3eda5716cd..34e018d69d 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -55,6 +55,22 @@ describe('POST /challenges/:challengeId/join', () => { }); }); + it('returns challenge data', async () => { + let res = await authorizedUser.post(`/challenges/${challenge._id}/join`); + + expect(res.group).to.eql({ + _id: group._id, + privacy: group.privacy, + name: group.name, + type: group.type, + }); + expect(res.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(res.name).to.equal(challenge.name); + }); + it('adds challenge to user challenges', async () => { await authorizedUser.post(`/challenges/${challenge._id}/join`); diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js index 33cc2e5166..63d2102968 100644 --- a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -63,13 +63,21 @@ describe('PUT /challenges/:challengeId', () => { }); expect(res.prize).to.equal(0); - expect(res.group).to.equal(privateGuild._id); + expect(res.group).to.eql({ + _id: privateGuild._id, + privacy: privateGuild.privacy, + name: privateGuild.name, + type: privateGuild.type, + }); 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.leader).to.eql({ + _id: member._id, + profile: {name: member.profile.name}, + }); expect(res.name).to.equal('New Challenge Name'); expect(res.description).to.equal('New challenge description.'); }); diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js index dbd631e362..91424d655e 100644 --- a/test/api/v3/integration/chat/GET-chat.test.js +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -23,8 +23,8 @@ describe('GET /groups/:groupId/chat', () => { privacy: 'public', }, { chat: [ - 'Hello', - 'Welcome to the Guild', + {text: 'Hello', flags: {}}, + {text: 'Welcome to the Guild', flags: {}}, ], }); }); diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js index d20c8b830d..b9b759bed9 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -35,9 +35,11 @@ describe('POST /group/:groupId/join', () => { }); it('allows non-invited users to join public guilds', async () => { - await joiningUser.post(`/groups/${publicGuild._id}/join`); + let res = await joiningUser.post(`/groups/${publicGuild._id}/join`); await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(publicGuild._id); + expect(res.leader._id).to.eql(user._id); + expect(res.leader.profile.name).to.eql(user.profile.name); }); it('promotes joining member in a public empty guild to leader', async () => { diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 414f495ff7..8d581d56ca 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -39,6 +39,8 @@ describe('PUT /group', () => { name: groupUpdatedName, }); + expect(updatedGroup.leader._id).to.eql(leader._id); + expect(updatedGroup.leader.profile.name).to.eql(leader.profile.name); expect(updatedGroup.name).to.equal(groupUpdatedName); }); }); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index c14a12d643..bc9e6f03b4 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -93,20 +93,21 @@ api.createChallenge = { let results = await Q.all([challenge.save({ validateBeforeSave: false, // already validate }), group.save()]); - let savedChal = results[0]; - // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - // await Q.ninvoke(savedChal, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise + + await savedChal.syncToUser(user); // (it also saves the user) + let response = savedChal.toJSON(); - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); - response.group = { + response.leader = { // the leader is the authenticated user + _id: user._id, + profile: {name: user.profile.name}, + }; + response.group = { // we already have the group data _id: group._id, name: group.name, type: group.type, privacy: group.privacy, }; - - await savedChal.syncToUser(user); // (it also saves the user) res.respond(201, response); }, }; @@ -136,14 +137,24 @@ api.joinChallenge = { if (!challenge) throw new NotFound(res.t('challengeNotFound')); if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound')); challenge.memberCount += 1; // Add all challenge's tasks to user's tasks and save the challenge - await Q.all([challenge.syncToUser(user), challenge.save()]); - res.respond(200, challenge); + let results = await Q.all([challenge.syncToUser(user), challenge.save()]); + + let response = results[1].toJSON(); + response.group = { // we already have the group data + _id: group._id, + name: group.name, + type: group.type, + privacy: group.privacy, + }; + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + + res.respond(200, response); }, }; @@ -412,14 +423,22 @@ api.updateChallenge = { let challenge = await Challenge.findById(challengeId).exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id name type privacy', optionalMembership: true}); + let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, 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); + let response = savedChal.toJSON(); + response.group = { // we already have the group data + _id: group._id, + name: group.name, + type: group.type, + privacy: group.privacy, + }; + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(200, response); }, }; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js index 5c8d4fd3ed..079fbb66cd 100644 --- a/website/src/controllers/api-v3/chat.js +++ b/website/src/controllers/api-v3/chat.js @@ -38,7 +38,7 @@ api.getChat = { let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); - res.respond(200, group.chat); + res.respond(200, Group.toJSONCleanChat(group, user).chat); }, }; @@ -88,7 +88,7 @@ api.postChat = { let savedGroup = await group.save(); if (chatUpdated) { - res.respond(200, {chat: savedGroup.chat}); + res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); } else { res.respond(200, {message: savedGroup.chat[0]}); } @@ -138,7 +138,7 @@ api.likeChat = { {_id: group._id, 'chat.id': message.id}, update ); - res.respond(200, message); + res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned? }, }; @@ -385,9 +385,9 @@ api.deleteChat = { ); if (chatUpdated) { - group = group.toJSON(); - removeFromArray(group.chat, {id: chatId}); - res.respond(200, group.chat); + let chatRes = Group.toJSONCleanChat(group, user).chat; + removeFromArray(chatRes, {id: chatId}); + res.respond(200, chatRes); } else { res.respond(200, {}); } diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index ff223bcef8..d4f19ea2ec 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -59,12 +59,17 @@ api.createGroup = { let results = await Q.all([user.save(), group.save()]); let savedGroup = results[1]; + // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 // await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise let response = savedGroup.toJSON(); - response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + // the leader is the authenticated user + response.leader = { + _id: user._id, + profile: {name: user.profile.name}, + }; + res.respond(201, response); // do not remove chat flags data as we've just created the group - res.respond(201, response); firebase.updateGroupData(savedGroup); firebase.addUserToGroup(savedGroup._id, user._id); }, @@ -162,16 +167,10 @@ api.getGroup = { let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false}); 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; - }); - } - + group = Group.toJSONCleanChat(group, user); // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 group.leader = (await User.findById(group.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(200, group); }, }; @@ -206,7 +205,18 @@ api.updateGroup = { _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body))); let savedGroup = await group.save(); - res.respond(200, savedGroup); + let response = Group.toJSONCleanChat(savedGroup, user); + // If the leader changed fetch new data, otherwise use authenticated user + if (response.leader !== user._id) { + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + } else { + response.leader = { + _id: user._id, + profile: {name: user.profile.name}, + }; + } + res.respond(200, response); + firebase.updateGroupData(savedGroup); }, }; @@ -219,7 +229,7 @@ api.updateGroup = { * * @apiParam {UUID} groupId The group _id * - * @apiSuccess {Object} empty An empty object + * @apiSuccess {Object} group The group */ api.joinGroup = { method: 'POST', @@ -234,8 +244,8 @@ api.joinGroup = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - // Do not fetch chat and work even if the user is not yet a member of the group - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group + // Works even if the user is not yet a member of the group + let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group if (!group) throw new NotFound(res.t('groupNotFound')); let isUserInvited = false; @@ -288,8 +298,11 @@ api.joinGroup = { await Q.all(promises); + let response = Group.toJSONCleanChat(promises[0], user); + response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true}); + res.respond(200, response); + firebase.addUserToGroup(group._id, user._id); - res.respond(200, {}); // TODO what to return? }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index 890662de4c..9e0a1b7a52 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -137,7 +137,22 @@ schema.statics.getGroup = function getGroup (options = {}) { if (fields) mQuery.select(fields); if (populateLeader === true) mQuery.populate('leader', nameFields); return mQuery.exec(); - // TODO purge chat flags info? in tojson? +}; + +// When converting to json remove chat messages with more than 1 flag and remove all flags info +// unless the user is an admin +// Not putting into toJSON because there we can't access user +schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) { + let toJSON = group.toJSON(); + console.log(group.chat, toJSON.chat) + if (!user.contributor.admin) { + _.remove(toJSON.chat, chatMsg => { + console.log(chatMsg) + chatMsg.flags = {}; + return chatMsg.flagCount >= 2; + }); + } + return toJSON; }; schema.methods.removeGroupInvitations = async function removeGroupInvitations () { From 19a709c36084291c6f264e264a68c3db5e589f0b Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 3 Feb 2016 11:30:09 +0100 Subject: [PATCH 187/213] remove console.log statements --- website/src/models/group.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index 9e0a1b7a52..cab711d4a6 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -144,10 +144,8 @@ schema.statics.getGroup = function getGroup (options = {}) { // Not putting into toJSON because there we can't access user schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) { let toJSON = group.toJSON(); - console.log(group.chat, toJSON.chat) if (!user.contributor.admin) { _.remove(toJSON.chat, chatMsg => { - console.log(chatMsg) chatMsg.flags = {}; return chatMsg.flagCount >= 2; }); From 0684e307909406b41bf11737dc9a0a4471a5b902 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Wed, 3 Feb 2016 17:04:57 -0600 Subject: [PATCH 188/213] Added quest cancel route and initial tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_cancel.test.js | 66 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 49 ++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..5179067850 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "cantCancelActiveQuest": "You can not cancel an active quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js new file mode 100644 index 0000000000..84111ab5ea --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -0,0 +1,66 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('cancels a quest', async () => { + await member.update(userQuestUpdate); + await questingGroup.update({'quest.key': PET_QUEST}); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + + await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + let userThatCanceled = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(userThatCanceled.party.quest.key).to.be.null; + expect(userThatCanceled.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members).to.be.empty; + }); + + it('returns an error when quest is active', async () => { + await questingGroup.update({'quest.active': true}); + await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cantCancelActiveQuest'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..8ebe969071 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -3,6 +3,9 @@ import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { + model as User, +} from '../../models/user'; import { NotFound, NotAuthorized, @@ -65,4 +68,50 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/cancel Cancels a quest + * @apiVersion 3.0.0 + * @apiName CancelQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Group Object + */ +api.cancelQuest = { + method: 'POST', + url: '/groups/:groupId/quests/cancel', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) + // Quest scroll has not yet left quest owner's inventory so no need to return it. + // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); + + group.quest = {key: null, progress: {}, leader: null, members: {}}; + group.markModified('quest'); + await group.save(); + + await User.update( + {'party._id': groupId}, + {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}, + {multi: true} + ); + + res.respond(200, group); + }, +}; + + export default api; From 442a654e94874586d5a56decef683190b71d9fc9 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 12:27:51 -0600 Subject: [PATCH 189/213] Removed questKey from url, added quest member support, removed extra tests, and fixed analytics import --- .../POST-groups_groupid_quests_reject.test.js | 46 ++------- website/src/controllers/api-v3/groups.js | 97 ------------------- website/src/controllers/api-v3/quests.js | 56 +++++++++++ 3 files changed, 66 insertions(+), 133 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index e5f7517eff..a222778948 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -30,7 +30,7 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { context('failure conditions', () => { it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/reject/${PET_QUEST}`)) + await expect(member.post(`/groups/${generateUUID()}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,43 +38,10 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - it('returns an error when group is not a party', async () => { - let { group, groupLeader } = await createAndPopulateGroup({ - groupDetails: { type: 'guild', privacy: 'private' }, - }); - - await expect(groupLeader.post(`/groups/${group._id}/quests/reject/${PET_QUEST}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('guildQuestsNotSupported'), - }); - }); - - it('returns an error when quest is not found', async () => { - let questKey = 'fakeQuestName'; - - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${questKey}`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('questNotFound', { key: questKey }), - }); - }); - - it('returns an error when user is not on the quest', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('questNotOwned'), - }); - }); - it('returns an error when group is not on a quest', async () => { await member.update(userQuestUpdate); - await expect(member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`)) + await expect(member.post(`/groups/${questingGroup._id}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -88,11 +55,18 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { await member.update(userQuestUpdate); await questingGroup.update({'quest.key': PET_QUEST}); - await member.post(`/groups/${questingGroup._id}/quests/reject/${PET_QUEST}`); + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + let rejectResult = await member.post(`/groups/${questingGroup._id}/quests/reject`); let userWithRejectInvitation = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + expect(userWithRejectInvitation.party.quest.key).to.be.null; expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members[member._id]).to.be.false; + expect(updatedGroup.quest).to.deep.equal(rejectResult); }); }); }); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 785ea564ba..b9b633c111 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -17,8 +17,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import * as firebase from '../../libs/api-v3/firebase'; import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email'; import { encrypt } from '../../libs/api-v3/encryption'; -import { quests as questScrolls } from '../../../../common/script/content'; -import { mockAnalyticsService } from '../../libs/api-v3/analyticsService'; let api = {}; @@ -618,99 +616,4 @@ api.inviteToGroup = { }, }; -<<<<<<< e3c7d2834e6e5fa024afb71032c21177ca4124a7 -======= -/** - * @api {post} /groups/:groupId/quests/invite Invite users to a quest - * @apiVersion 3.0.0 - * @apiName InviteToQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * - * @apiSuccess {Object} Quest Object - */ -api.inviteToQuest = { - method: 'POST', - url: '/groups/:groupId/quests/invite/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); - if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - - // TODO Logic for quest invite and send back quest object - res.respond(200, {}); - }, -}; - -/** - * @api {post} /groups/:groupId/quests/reject Reject a quest - * @apiVersion 3.0.0 - * @apiName RejectQuest - * @apiGroup Group - * - * @apiParam {string} groupId The group _id (or 'party') - * @apiParam {string} questKey The quest _id - * - * @apiSuccess {Object} Quest Object - */ -api.rejectQuest = { - method: 'POST', - url: '/groups/:groupId/quests/reject/:questKey', - middlewares: [authWithHeaders(), cron], - async handler (req, res) { - let user = res.locals.user; - let questKey = req.params.questKey; - let quest = questScrolls[questKey]; - - req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); - - let validationErrors = req.validationErrors(); - if (validationErrors) throw validationErrors; - - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); - if (!group) throw new NotFound(res.t('groupNotFound')); - if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); - if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey })); - if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned')); - if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - - let analyticsData = { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }; - mockAnalyticsService.track('quest', analyticsData); - - // @TODO: Are we tracking members this way? - // group.quest.members[user._id] = false; - - user.party.quest.RSVPNeeded = false; - user.party.quest.key = null; - await user.save(); - - // questStart(req,res,next); - - res.respond(200, {}); - }, -}; ->>>>>>> Added quest reject route and intial tests export default api; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..6a7423bc0a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -8,6 +8,8 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import { track } from '../../libs/api-v3/analyticsService'; +import Q from 'q'; let api = {}; @@ -65,4 +67,58 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/reject Reject a quest + * @apiVersion 3.0.0 + * @apiName RejectQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} questKey The quest _id + * + * @apiSuccess {Object} Quest Object + */ +api.rejectQuest = { + method: 'POST', + url: '/groups/:groupId/quests/reject', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + + let analyticsData = { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }; + track('quest', analyticsData); + + group.quest.members[user._id] = false; + group.markModified('quest.members'); + + user.party.quest.RSVPNeeded = false; + user.party.quest.key = null; + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + // questStart(req,res,next); + + res.respond(200, savedGroup.quest); + }, +}; + export default api; From 5ca663db57ec22ce98a44d2471664ec5de00eb24 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 12:31:38 -0600 Subject: [PATCH 190/213] Added quest leave route and initial tests --- common/locales/en/api-v3.json | 5 +- .../POST-groups_groupid_quests_leave.test.js | 85 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 54 ++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..c98411ed22 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,8 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "noActiveQuestToLeave": "No active quest to leave", + "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest", + "notPartOfQuest": "You are not part of the quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js new file mode 100644 index 0000000000..eec1e33842 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -0,0 +1,85 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(member.post(`/groups/${generateUUID()}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when quest is not active', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToLeave'), + }); + }); + + it('returns an error when quest leader attempts to leave', async () => { + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotLeaveQuest'), + }); + }); + + it('returns an error when non quest member attempts to leave', async () => { + await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notPartOfQuest'), + }); + }); + + it('leaves a quest', async () => { + await member.update(userQuestUpdate); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + + let leaveResult = await member.post(`/groups/${questingGroup._id}/quests/leave`); + let userThatLeft = await member.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(userThatLeft.party.quest.key).to.be.null; + expect(userThatLeft.party.quest.RSVPNeeded).to.be.false; + expect(updatedGroup.quest.members[member._id]).to.be.false; + expect(updatedGroup.quest).to.deep.equal(leaveResult); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..8944f06081 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -8,6 +8,7 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import Q from 'q'; let api = {}; @@ -65,4 +66,57 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/leave Leaves a quest + * @apiVersion 3.0.0 + * @apiName LeaveQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Empty Object + */ +api.leaveQuest = { + method: 'POST', + url: '/groups/:groupId/quests/leave', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + if (!(group.quest && group.quest.active)) { + throw new NotFound(res.t('noActiveQuestToLeave')); + } + + if (group.quest.leader === user._id) { + throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); + } + + if (!(group.quest.members && group.quest.members[user._id])) { + throw new NotAuthorized(res.t('notPartOfQuest')); + } + + group.quest.members[user._id] = false; + group.markModified('quest.members'); + + user.party.quest = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + }, +}; + export default api; From 6f606df211ff79c5c3a33dfc7465bef17631091a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Thu, 4 Feb 2016 15:22:37 -0600 Subject: [PATCH 191/213] Added quest abort route and initial tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_abort.test.js | 78 +++++++++++++++++++ website/src/controllers/api-v3/quests.js | 52 +++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..2818fe6307 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "noActiveQuestToAbort": "There is no active quest to abort" } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js new file mode 100644 index 0000000000..1ed6871307 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -0,0 +1,78 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/abort', () => { + let questingGroup, member, leader; + const PET_QUEST = 'whale'; + let userQuestUpdate = { + items: { + quests: {}, + }, + 'party.quest.RSVPNeeded': true, + 'party.quest.key': PET_QUEST, + }; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 1, + }); + + leader = groupLeader; + questingGroup = group; + member = members[0]; + + userQuestUpdate.items.quests[PET_QUEST] = 1; + }); + + it('returns an error when group is not found', async () => { + await expect(leader.post(`/groups/${generateUUID()}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error when quest is not active', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToAbort'), + }); + }); + + xit('returns an error when non quest leader attempts to abort', async () => { + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + await expect(member.post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotAbortQuest'), + }); + }); + + it('aborts a quest', async () => { + await member.update(userQuestUpdate); + + let questMembers = {}; + questMembers[member._id] = true; + await questingGroup.update({'quest.members': questMembers}); + await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + + let abortResult = await leader.post(`/groups/${questingGroup._id}/quests/abort`); + let updatedMember = await member.get('/user'); + let updatedLeader = await leader.get('/user'); + let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + + expect(updatedMember.party.quest.key).to.be.null; + expect(updatedMember.party.quest.RSVPNeeded).to.be.false; + expect(updatedLeader.items.quests[PET_QUEST]).to.equal(1); + expect(updatedGroup.quest).to.deep.equal(abortResult); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..53e2274959 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -3,11 +3,13 @@ import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +import Q from 'q'; let api = {}; @@ -65,4 +67,54 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/abort Abort a quest + * @apiVersion 3.0.0 + * @apiName AbortQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} Quest Object + */ +api.abortQuest = { + method: 'POST', + url: '/groups/:groupId/quests/abort', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + // Abort a quest AFTER it has begun (see questCancel for BEFORE) + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort')); + + let memberUpdates = User.update( + {'party._id': groupId}, + { + $set: {'party.quest': Group.cleanQuestProgress()}, + $inc: {_v: 1}, + }, + {multi: true}, + ); + + let update = {$inc: {}}; + update.$inc[`items.quests.${group.quest.key}`] = 1; + let questLeaderUpdate = User.update({_id: group.quest.leader}, update).exec(); + + group.quest = {key: null, progress: {collect: {}}, leader: null, members: {}, extra: {}, active: false}; + group.markModified('quest'); + + let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); + + res.respond(200, groupSaved.quest); + }, +}; + export default api; From 18958bde5a5f76f4b6ae8969f0dce91046563f71 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 5 Feb 2016 11:06:40 +0100 Subject: [PATCH 192/213] review and fix group model methods --- website/src/models/group.js | 256 +++++++++++++++++------------------- 1 file changed, 119 insertions(+), 137 deletions(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index e416317a47..b9cf7acd98 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -104,9 +104,8 @@ schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { // TODO test schema.pre('remove', true, async function preRemoveGroup (next, done) { next(); - let group = this; try { - await group.removeGroupInvitations(); + await this.removeGroupInvitations(); done(); } catch (err) { done(err); @@ -257,7 +256,7 @@ schema.methods.sendChat = function sendChat (message, user) { this.chat.splice(200); // Kick off chat notifications in the background. // TODO refactor - let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; // TODO standardize this _v inc at the user level + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; if (this._id === 'habitrpg') { @@ -289,12 +288,12 @@ function _cleanQuestProgress (merge) { collect: {}, }, completed: null, - RSVPNeeded: false, // TODO absolutely change this cryptic name + RSVPNeeded: false, }; - if (merge) { // TODO why does it do 2 merges? + if (merge) { _.merge(clean, _.omit(merge, 'progress')); - _.merge(clean.progress, merge.progress); + if (merge.progress) _.merge(clean.progress, merge.progress); } return clean; @@ -303,6 +302,7 @@ function _cleanQuestProgress (merge) { schema.statics.cleanQuestProgress = _cleanQuestProgress; // Participants: Grant rewards & achievements, finish quest +// Returns the promise from update().exec() schema.methods.finishQuest = function finishQuest (quest) { let questK = quest.key; let updates = {$inc: {}, $set: {}}; @@ -344,48 +344,88 @@ schema.methods.finishQuest = function finishQuest (quest) { let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}}; this.quest = {}; this.markModified('quest'); - return User.update(q, updates, {multi: true}); + return User.update(q, updates, {multi: true}).exec(); }; function _isOnQuest (user, progress, group) { return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true; } -schema.statics.collectQuest = function collectQuest (user, progress) { - return this.findOne({ - type: 'party', - members: {$in: [user._id]}, - }).then(group => { - if (!_isOnQuest(user, progress, group)) return; - let quest = shared.content.quests[group.quest.key]; +// Returns a promise +schema.statics.collectQuest = async function collectQuest (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); - _.each(progress.collect, (v, k) => { - group.quest.progress.collect[k] += v; - }); + if (!_isOnQuest(user, progress, group)) return; + let quest = shared.content.quests[group.quest.key]; - let foundText = _.reduce(progress.collect, (m, v, k) => { - m.push(`${v} ${quest.collect[k].text('en')}`); - return m; - }, []); + _.each(progress.collect, (v, k) => { + group.quest.progress.collect[k] += v; + }); - foundText = foundText ? foundText.join(', ') : 'nothing'; - group.sendChat(`\`${user.profile.name} found ${foundText}.\``); - group.markModified('quest.progress.collect'); + let foundText = _.reduce(progress.collect, (m, v, k) => { + m.push(`${v} ${quest.collect[k].text('en')}`); + return m; + }, []); - // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { - return group.quest.progress.collect[k] < v.count; - })) return group.save(); + foundText = foundText ? foundText.join(', ') : 'nothing'; + group.sendChat(`\`${user.profile.name} found ${foundText}.\``); + group.markModified('quest.progress.collect'); - // TODO use promise - return group.finishQuest(quest) - .then(() => { - group.sendChat('`All items found! Party has received their rewards.`'); - return group.save(); - }); - }) - // TODO ok to catch even if we're returning a promise? - .catch(); + // Still needs completing + if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => { + return group.quest.progress.collect[k] < v.count; + })) return group.save(); + + await group.finishQuest(quest); + group.sendChat('`All items found! Party has received their rewards.`'); + return group.save(); + // TODO cath? +}; + +schema.statics.bossQuest = async function bossQuest (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); + if (!_isOnQuest(user, progress, group)) return; + + let quest = shared.content.quests[group.quest.key]; + if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point, log? + + let down = progress.down * quest.boss.str; // multiply by boss strength + + group.quest.progress.hp -= progress.up; + // TODO Create a party preferred language option so emits like this can be localized + group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); + + // If boss has Rage, increment Rage as well + if (quest.boss.rage) { + group.quest.progress.rage += Math.abs(down); + if (group.quest.progress.rage >= quest.boss.rage.value) { + group.sendChat(quest.boss.rage.effect('en')); + group.quest.progress.rage = 0; + + // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage + if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; + if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; + } + } + + // Everyone takes damage + await User.update({ + _id: {$in: _.keys(group.quest.members)}, + }, { + $inc: {'stats.hp': down, _v: 1}, + }, {multi: true}).exec(); + + // Boss slain, finish quest + if (group.quest.progress.hp <= 0) { + group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); + + // Participants: Grant rewards & achievements, finish quest + await group.finishQuest(shared.content.quests[group.quest.key]); + return group.save(); + } + + return group.save(); + // TODO catch? }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` @@ -408,123 +448,65 @@ process.nextTick(() => { }); }); -// TODO promise? -schema.statics.tavernBoss = function tavernBoss (user, progress) { +// returns a promise +schema.statics.tavernBoss = async function tavernBoss (user, progress) { if (!progress) return; // hack: prevent crazy damage to world boss let dmg = Math.min(900, Math.abs(progress.up || 0)); let rage = -Math.min(900, Math.abs(progress.down || 0)); - this.findOne(tavernQ).exec() - .then(tavern => { - if (!(tavern && tavern.quest && tavern.quest.key)) return; + let tavern = await this.findOne(tavernQ).exec(); + if (!(tavern && tavern.quest && tavern.quest.key)) return; - let quest = shared.content.quests[tavern.quest.key]; + let quest = shared.content.quests[tavern.quest.key]; - if (tavern.quest.progress.hp <= 0) { - tavern.sendChat(quest.completionChat('en')); - tavern.finishQuest(quest, () => {}); - _.assign(tavernQuest, {extra: null}); - return tavern.save(); - } else { - // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, - // use those first - which allows us to update the boss on the go if things are too easy/hard. - if (!tavern.quest.extra) tavern.quest.extra = {}; - tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); - tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); + if (tavern.quest.progress.hp <= 0) { + tavern.sendChat(quest.completionChat('en')); + await tavern.finishQuest(quest); + _.assign(tavernQuest, {extra: null}); + return tavern.save(); + } else { + // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, + // use those first - which allows us to update the boss on the go if things are too easy/hard. + if (!tavern.quest.extra) tavern.quest.extra = {}; + tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); + tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); - if (tavern.quest.progress.rage >= quest.boss.rage.value) { - if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; + if (tavern.quest.progress.rage >= quest.boss.rage.value) { + if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; - let wd = tavern.quest.extra.worldDmg; - // Burnout attacks Ian, Seasonal Sorceress, tavern - let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary + let wd = tavern.quest.extra.worldDmg; + // Burnout attacks Ian, Seasonal Sorceress, tavern + let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary - if (!scene) { - tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); - tavern.quest.progress.rage = 0; // quest.boss.rage.value; - } else { - tavern.sendChat(quest.boss.rage[scene]('en')); - tavern.quest.extra.worldDmg[scene] = true; - tavern.quest.extra.worldDmg.recent = scene; - tavern.markModified('quest.extra.worldDmg'); - tavern.quest.progress.rage = 0; - if (quest.boss.rage.healing) { - tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; - } + if (!scene) { + tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``); + tavern.quest.progress.rage = 0; // quest.boss.rage.value; + } else { + tavern.sendChat(quest.boss.rage[scene]('en')); + tavern.quest.extra.worldDmg[scene] = true; + tavern.quest.extra.worldDmg.recent = scene; + tavern.markModified('quest.extra.worldDmg'); + tavern.quest.progress.rage = 0; + if (quest.boss.rage.healing) { + tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp; } } - - if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { - tavern.sendChat(quest.boss.desperation.text('en')); - tavern.quest.extra.desperate = true; - tavern.quest.extra.def = quest.boss.desperation.def; - tavern.quest.extra.str = quest.boss.desperation.str; - tavern.markModified('quest.extra'); - } - - _.assign(module.exports.tavernQuest, tavern.quest.toObject()); - return tavern.save(); - } - }) - .catch(err => { - throw err; - }); -}; - -schema.statics.bossQuest = function bossQuest (user, progress) { - return this.findOne({ - type: 'party', - members: {$in: [user._id]}, - }).exec() - .then(group => { - if (!_isOnQuest(user, progress, group)) return; - - let quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point - - let down = progress.down * quest.boss.str; // multiply by boss strength - - group.quest.progress.hp -= progress.up; - group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``); // TODO Create a party preferred language option so emits like this can be localized - - // If boss has Rage, increment Rage as well - if (quest.boss.rage) { - group.quest.progress.rage += Math.abs(down); - if (group.quest.progress.rage >= quest.boss.rage.value) { - group.sendChat(quest.boss.rage.effect('en')); - group.quest.progress.rage = 0; - - // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage - if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing; - if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; - } } - // Everyone takes damage - let promise = User.update({ - _id: {$in: _.keys(group.quest.members)}, - }, { - $inc: {'stats.hp': down, _v: 1}, - }, {multi: true}); - - // Boss slain, finish quest - if (group.quest.progress.hp <= 0) { - group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``); - // Participants: Grant rewards & achievements, finish quest - - return promise - .then(() => group.finishQuest()) - .then(() => group.save()); + if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) { + tavern.sendChat(quest.boss.desperation.text('en')); + tavern.quest.extra.desperate = true; + tavern.quest.extra.def = quest.boss.desperation.def; + tavern.quest.extra.str = quest.boss.desperation.str; + tavern.markModified('quest.extra'); } - return promise.then(() => group.save()); - }) - // TODO necessary to catch if we're returning a promise? - .catch(err => { - throw err; - }); + _.assign(tavernQuest, tavern.quest.toObject()); + return tavern.save(); + } + // TODO catch }; schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { From 03c9c2933f27f33e40616a07aa4eac73da0ee713 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 5 Feb 2016 09:28:43 -0600 Subject: [PATCH 193/213] feat(api-v3): finish first iteration of group.startQuest method --- test/api/v3/unit/models/group.test.js | 238 ++++++++++++++++++++------ website/src/models/group.js | 66 ++++--- 2 files changed, 225 insertions(+), 79 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index bdc49355f7..c5939b1424 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,42 +1,72 @@ import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; import { quests as questScrolls } from '../../../../../common/script/content'; +import * as email from '../../../../../website/src/libs/api-v3/email'; +import Q from 'q'; describe('Group Model', () => { context('Instance Methods', () => { - let party; - - beforeEach(() => { - party = new Group({ - type: 'party', - }); - }); - describe('#startQuest', () => { + let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; + + beforeEach(async () => { + sandbox.stub(email, 'sendTxn'); + sandbox.spy(Q, 'allSettled'); + + party = new Group({ + name: 'test party', + type: 'party', + privacy: 'private', + }); + + questLeader = new User({ + party: { _id: party._id }, + items: { + quests: { + whale: 1, + }, + }, + }); + + party.leader = questLeader._id; + + participatingMember = new User({ + party: { _id: party._id }, + }); + nonParticipatingMember = new User({ + party: { _id: party._id }, + }); + undecidedMember = new User({ + party: { _id: party._id }, + }); + + await Promise.all([ + party.save(), + questLeader.save(), + participatingMember.save(), + nonParticipatingMember.save(), + undecidedMember.save(), + ]); + }); + context('Failure Conditions', () => { - it('throws an error if group is not a party', () => { + it('throws an error if group is not a party', async () => { let guild = new Group({ type: 'guild', }); - expect(() => { - guild.startQuest(); - }).to.throw('Must be a party to use this method'); + await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected; }); - it('throws an error if party is not on a quest', () => { - expect(() => { - party.startQuest(); - }).to.throw('Party does not have a pending quest'); + it('throws an error if party is not on a quest', async () => { + await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; }); - it('throws an error if quest is already active', () => { + it('throws an error if quest is already active', async () => { party.quest.key = 'whale'; party.quest.active = true; - expect(() => { - party.startQuest(); - }).to.throw('Quest is already active'); + await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; }); }); @@ -44,19 +74,16 @@ describe('Group Model', () => { beforeEach(() => { party.quest.key = 'whale'; party.quest.active = false; - party.quest.leader = 'quest-leader'; - party.quest.members = { - 'quest-leader': true, - 'participating-member': true, - 'non-participating-member': false, - 'undecided-member': null, - }; - - sandbox.stub(User, 'update').returns({ exec: sandbox.spy() }); + party.quest.leader = questLeader._id; + party.quest.members = { }; + party.quest.members[questLeader._id] = true; + party.quest.members[participatingMember._id] = true; + party.quest.members[nonParticipatingMember._id] = false; + party.quest.members[undecidedMember._id] = null; }); it('activates quest', () => { - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.active).to.eql(true); }); @@ -65,7 +92,7 @@ describe('Group Model', () => { let bossQuest = questScrolls.whale; party.quest.key = bossQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp); }); @@ -74,7 +101,7 @@ describe('Group Model', () => { let rageBossQuest = questScrolls.trex_undead; party.quest.key = rageBossQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.rage).to.eql(0); }); @@ -82,7 +109,7 @@ describe('Group Model', () => { it('sets up collection quest', () => { let collectionQuest = questScrolls.vice2; party.quest.key = collectionQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ lightCrystal: 0, @@ -92,7 +119,7 @@ describe('Group Model', () => { it('sets up collection quest with multiple items', () => { let collectionQuest = questScrolls.evilsanta2; party.quest.key = collectionQuest.key; - party.startQuest(); + party.startQuest(participatingMember); expect(party.quest.progress.collect).to.eql({ tracks: 0, @@ -100,34 +127,135 @@ describe('Group Model', () => { }); }); - it('updates quest object for participating members', () => { - party.startQuest(); + it('prunes non-participating members from quest members object', () => { + party.startQuest(participatingMember); - expect(User.update).to.be.calledTwice; - expect(User.update).to.not.be.calledWith({ _id: 'non-participating-member' }); - expect(User.update).to.not.be.calledWith({ _id: 'undecided-member' }); - expect(User.update).to.be.calledWith( - { _id: 'participating-member' }, - sinon.match({ $set: { 'party.quest.key': 'whale' }}), - ); - expect(User.update).to.be.calledWith( - { _id: 'quest-leader' }, - sinon.match({ $set: { 'party.quest.key': 'whale' }}), - ); + let expectedQuestMembers = {}; + expectedQuestMembers[questLeader._id] = true; + expectedQuestMembers[participatingMember._id] = true; + + expect(party.quest.members).to.eql(expectedQuestMembers); }); - it('removes quest scroll from quest leader', () => { - party.startQuest(); + it('applies updates to user object directly if user is participating', async () => { + await party.startQuest(participatingMember); - expect(User.update).to.be.calledWith( - { _id: 'quest-leader' }, - sinon.match({ $inc: { 'items.quests.whale': -1 }}), - ); + expect(participatingMember.party.quest.key).to.eql('whale'); + expect(participatingMember.party.quest.progress.down).to.eql(0); + expect(participatingMember.party.quest.collect).to.eql({}); + expect(participatingMember.party.quest.completed).to.eql(null); }); - it('sends email to participating members that quest has started'); + it('applies updates to other participating members', async () => { + await party.startQuest(nonParticipatingMember); - it('sends email only to members who have not opted out'); + questLeader = await User.findById(questLeader._id); + participatingMember = await User.findById(participatingMember._id); + + expect(participatingMember.party.quest.key).to.eql('whale'); + expect(participatingMember.party.quest.progress.down).to.eql(0); + expect(participatingMember.party.quest.progress.collect).to.eql({}); + expect(participatingMember.party.quest.completed).to.eql(null); + + expect(questLeader.party.quest.key).to.eql('whale'); + expect(questLeader.party.quest.progress.down).to.eql(0); + expect(questLeader.party.quest.progress.collect).to.eql({}); + expect(questLeader.party.quest.completed).to.eql(null); + }); + + it('does not apply updates to nonparticipating members', async () => { + await party.startQuest(participatingMember); + + nonParticipatingMember = await User.findById(nonParticipatingMember ._id); + undecidedMember = await User.findById(undecidedMember._id); + + expect(nonParticipatingMember.party.quest.key).to.not.eql('whale'); + expect(undecidedMember.party.quest.key).to.not.eql('whale'); + }); + + it('removes quest scroll from quest leader', async () => { + await party.startQuest(participatingMember); + + questLeader = await User.findById(questLeader._id); + + expect(questLeader.items.quests.whale).to.eql(0); + }); + + it('sends email to participating members that quest has started', async () => { + participatingMember.preferences.emailNotifications.questStarted = true; + questLeader.preferences.emailNotifications.questStarted = true; + await Promise.all([ + participatingMember.save(), + questLeader.save(), + ]); + + await party.startQuest(nonParticipatingMember); + + expect(email.sendTxn).to.be.calledOnce; + + let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); + let typeOfEmail = email.sendTxn.args[0][1]; + + expect(memberIds).to.have.a.lengthOf(2); + expect(memberIds).to.include(participatingMember._id); + expect(memberIds).to.include(questLeader._id); + expect(typeOfEmail).to.eql('quest-started'); + }); + + it('sends email only to members who have not opted out', async () => { + participatingMember.preferences.emailNotifications.questStarted = false; + questLeader.preferences.emailNotifications.questStarted = true; + await Promise.all([ + participatingMember.save(), + questLeader.save(), + ]); + + await party.startQuest(nonParticipatingMember); + + expect(email.sendTxn).to.be.calledOnce; + + let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); + + expect(memberIds).to.have.a.lengthOf(1); + expect(memberIds).to.not.include(participatingMember._id); + expect(memberIds).to.include(questLeader._id); + }); + + it('does not send email to initiating member', async () => { + participatingMember.preferences.emailNotifications.questStarted = true; + questLeader.preferences.emailNotifications.questStarted = true; + await Promise.all([ + participatingMember.save(), + questLeader.save(), + ]); + + await party.startQuest(participatingMember); + + expect(email.sendTxn).to.be.calledOnce; + + let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); + + expect(memberIds).to.have.a.lengthOf(1); + expect(memberIds).to.not.include(participatingMember._id); + expect(memberIds).to.include(questLeader._id); + }); + + it('adds participating members to background save operations', async () => { + await party.startQuest(nonParticipatingMember); + + expect(Q.allSettled).to.be.calledOnce; + + let savePromises = Q.allSettled.args[0][0]; + expect(savePromises).to.have.a.lengthOf(2); + }); + + it('does not include initiating user in background save operations', async () => { + await party.startQuest(participatingMember); + + expect(Q.allSettled).to.be.calledOnce; + let savePromises = Q.allSettled.args[0][0]; + expect(savePromises).to.have.a.lengthOf(1); + }); }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index b9cf7acd98..1f18a311a7 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -11,6 +11,7 @@ import { removeFromArray } from '../libs/api-v3/collectionManipulators'; import { BadRequest } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; +import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; import { quests as questScrolls } from '../../../common/script/content'; import Q from 'q'; import nconf from 'nconf'; @@ -169,11 +170,12 @@ schema.methods.isMember = function isGroupMember (user) { } }; -schema.methods.startQuest = function startQuest () { +schema.methods.startQuest = async function startQuest (user) { if (this.type !== 'party') throw new BadRequest('Must be a party to use this method'); if (!this.quest.key) throw new BadRequest('Party does not have a pending quest'); if (this.quest.active) throw new BadRequest('Quest is already active'); + let userIsParticipating = this.quest.members[user._id]; let quest = questScrolls[this.quest.key]; let collected = {}; if (quest.collect) { @@ -193,38 +195,54 @@ schema.methods.startQuest = function startQuest () { this.quest.progress.collect = collected; } - _.each(this.quest.members, (participating, memberId) => { - if (!participating) return; + // Changes quest.members to only include participating members + // TODO: is that important? What does it matter if the non-participating members + // are still on the object? + // TODO: is it important to run clean quest progress on non-members like we did in v2? + this.quest.members = _.pick(this.quest.members, _.identity); + let nonUserQuestMembers = _.without(_.keys(this.quest.members), user._id); - let update = { - $set: { - // Do *not* reset party.quest.progress.up - // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 - 'party.quest.key': this.quest.key, - 'party.quest.progress.down': 0, - 'party.quest.collect': collected, - 'party.quest.completed': null, - }, - $inc: { _v: 1 }, - }; + let members = await User.find( + { _id: { $in: nonUserQuestMembers } }, + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications', + ).exec(); - if (this.quest.leader === memberId) { - update.$inc[`items.quests.${this.quest.key}`] = -1; + if (userIsParticipating) { + members.unshift(user); // put participating user at the beginning of the array + } + + _.each(members, (member) => { + member.party.quest.key = this.quest.key; + member.party.quest.progress.down = 0; + member.party.quest.collect = collected; + member.party.quest.completed = null; + member.markModified('party.quest'); + + if (this.quest.leader === member._id) { + member.items.quests[this.quest.key] -= 1; + member.markModified('items.quests'); } - backgroundOperations.push(User.update({ _id: memberId }, update).exec()); + if (member._id !== user._id) { + backgroundOperations.push(member.save()); + } }); - // TODO Add emails to users that quest has started to background ops + let usersToEmail = _.filter(members, (member) => { + return member.preferences.emailNotifications.questStarted !== false && + member._id !== user._id; + }); + + sendTxnEmail(usersToEmail, 'quest-started', [ + { name: 'PARTY_URL', content: '/#/options/groups/party' }, + ]); // These operations should run in the background // and not hold up the quest routes from resolving - // TODO: What here? - // Q.all(backgroundOperations).then(() => { - // }).catch(err => { - // TODO: How to handle errors? - // IE, user deleted their account? - // }); + Q.allSettled(backgroundOperations).catch(err => { + // TODO: what to do with err? + throw err; + }); }; export function chatDefaults (msg, user) { From 3b9c921c2f531a502e3334fcde4b58480dd6554e Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 5 Feb 2016 10:25:49 -0600 Subject: [PATCH 194/213] feat(api-v3): First iteration of quest invite --- .../POST-groups_groupId_quests_invite.test.js | 59 ++++++++++++------- website/src/controllers/api-v3/quests.js | 52 ++++++++++++---- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js index 4311cf7911..973a51a1a5 100644 --- a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js @@ -1,6 +1,7 @@ import { createAndPopulateGroup, translate as t, + sleep, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; import { quests as questScrolls } from '../../../../../common/script/content'; @@ -122,8 +123,8 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { ]); }); - xit('adds quest details to group object', async () => { - await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('adds quest details to group object', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await questingGroup.sync(); @@ -131,47 +132,61 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { expect(quest.key).to.eql(PET_QUEST); expect(quest.active).to.eql(false); - expect(quest.leader).to.eql(false); - expect(quest.members).to.have.property(leader._id, null); + expect(quest.leader).to.eql(leader._id); + expect(quest.members).to.have.property(leader._id, true); expect(quest.members).to.have.property(member._id, null); expect(quest).to.have.property('progress'); }); - xit('adds quest details to user objects', async () => { - await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('adds quest details to user objects', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await sleep(0.1); // member updates happen in the background await Promise.all([ leader.sync(), member.sync(), ]); - [leader, member].forEach((user) => { - let quest = user.party.quest; - - expect(quest.key).to.eql(PET_QUEST); - expect(quest.active).to.eql(false); - expect(quest.leader).to.eql(false); - expect(quest.members).to.have.property(leader._id, null); - expect(quest.members).to.have.property(member._id, null); - expect(quest).to.have.property('progress'); - }); + expect(leader.party.quest.key).to.eql(PET_QUEST); + expect(member.party.quest.key).to.eql(PET_QUEST); + expect(leader.party.quest.RSVPNeeded).to.eql(false); + expect(member.party.quest.RSVPNeeded).to.eql(true); }); - xit('sends back the quest object', async () => { - let inviteResponse = await leader.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('sends back the quest object', async () => { + let inviteResponse = await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); expect(inviteResponse.key).to.eql(PET_QUEST); expect(inviteResponse.active).to.eql(false); - expect(inviteResponse.leader).to.eql(false); - expect(inviteResponse.members).to.have.property(leader._id, null); + expect(inviteResponse.leader).to.eql(leader._id); + expect(inviteResponse.members).to.have.property(leader._id, true); expect(inviteResponse.members).to.have.property(member._id, null); expect(inviteResponse).to.have.property('progress'); }); - xit('allows non-leader party members to send invites', async () => { - let inviteResponse = await member.post(`groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + it('allows non-party-leader party members to send invites', async () => { + let inviteResponse = await member.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await questingGroup.sync(); expect(inviteResponse.key).to.eql(PET_QUEST); + expect(questingGroup.quest.key).to.eql(PET_QUEST); + }); + + it('starts quest automatically if user is in a solo party', async () => { + let leaderDetails = { balance: 10 }; + leaderDetails[`items.quests.${PET_QUEST}`] = 1; + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + leaderDetails, + }); + + await groupLeader.post(`/groups/${group._id}/quests/invite/${PET_QUEST}`); + + await group.sync(); + + expect(group.quest.active).to.eql(true); }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 38020fcae5..77cec58d6a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -1,14 +1,25 @@ +import _ from 'lodash'; +import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Group, } from '../../models/group'; +import { + model as User, +} from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; import { quests as questScrolls } from '../../../../common/script/content'; +function canStartQuestAutomatically (group) { + // If all members are either true (accepted) or false (rejected) return true + // If any member is null/undefined (undecided) return false + return _.every(group.quest.members, Boolean); +} + let api = {}; /** @@ -44,24 +55,43 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); + let members = await User.find({ 'party._id': group._id }, 'auth.facebook auth.local preferences.emailNotifications').exec(); + let backgroundOperations = []; + group.markModified('quest'); group.quest.key = questKey; group.quest.leader = user._id; group.quest.members = {}; + group.quest.members[user._id] = true; - // let memberUpdate = { - // '$set': { - // 'party.quest.key': questKey, - // 'party.quest.progress.down': 0, - // 'party.quest.completed': null, - // }, - // }; + user.party.quest.RSVPNeeded = false; + user.party.quest.key = questKey; - // TODO collect members of party - // TODO Logic for quest invite and send back quest object + _.each(members, (member) => { + if (member._id !== user._id) { + group.quest.members[member._id] = null; + member.party.quest.RSVPNeeded = true; + member.party.quest.key = questKey; + // TODO: Send Quest invite email + backgroundOperations.push(member.save()); + } + }); - await group.save(); - res.respond(200, {}); + if (canStartQuestAutomatically(group)) { + group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + Q.allSettled(backgroundOperations).catch(err => { + // TODO what to do about errors in background ops + throw err; + }); }, }; From 35e6274cd62a3e2ef04d0d514726699b86324869 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 22:18:57 +0100 Subject: [PATCH 195/213] Move cron and preening code to server, add support for quests in cron --- common/script/api-v3/cron.js | 264 -------------- common/script/cron.js | 1 + website/src/controllers/api-v3/tasks.js | 2 +- .../src/libs}/api-v3/preening.js | 0 website/src/middlewares/api-v3/cron.js | 323 ++++++++++++++++-- website/src/models/group.js | 14 +- 6 files changed, 297 insertions(+), 307 deletions(-) delete mode 100644 common/script/api-v3/cron.js rename {common/script => website/src/libs}/api-v3/preening.js (100%) diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js deleted file mode 100644 index 6f8626c7d8..0000000000 --- a/common/script/api-v3/cron.js +++ /dev/null @@ -1,264 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; -import scoreTask from './scoreTask'; -import { preenUserHistory } from './preening'; -import common from '../../'; -import { - shouldDo, -} from '../cron'; - -let clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, -}; - -// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -// For incomplete Dailys, deduct experience -// Make sure to run this function once in a while as server will not take care of overnight calculations. -// And you have to run it every time client connects. -export default function cron (options = {}) { - let {user, tasksByType, analytics, now, daysMissed} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - // Reset the lastDrop count to zero - if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; - - // "Perfect Day" achievement for perfect-days - let perfect = true; - - // end-of-month perks for subscribers - let plan = user.purchased.plan; - if (user.isSubscribed()) { - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; // reset gem-cap - plan.dateUpdated = now; - // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks - // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 - plan.consecutive.count++; - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { // every 3 months - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) - } - } - - // If user cancelled subscription, we give them until 30day's end until it terminates - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); - - user.markModified('purchased.plan'); - } - } - - // User is resting at the inn. - // On cron, buffs are cleared and all dailies are reset without performing damage - if (user.preferences.sleep === true) { - user.stats.buffs = _.cloneDeep(clearBuffs); - - tasksByType.dailys.forEach((daily) => { - let completed = daily.completed; - let thatDay = moment(now).subtract({days: 1}); - - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - daily.checklist.forEach(box => box.completed = false); - } - daily.completed = false; - }); - - return; - } - - let multiDaysCountAsOneDay = true; - // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. - // When site-wide difficulty settings are introduced, this can be a user preference option. - - // Tally each task - let todoTally = 0; - - tasksByType.todos.forEach(task => { // make uncompleted todos redder - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - // TODO pass req for analytics? - }); - - todoTally += task.value; - }); - - let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were cun-hecked? - if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; - - tasksByType.dailys.forEach((task) => { - let completed = task.completed; - // Deduct points for missed Daily tasks - let EvadeTask = 0; - let scheduleMisses = daysMissed; - - if (completed) { - dailyChecked += 1; - } else { - // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - - for (let i = 0; i < daysMissed; i++) { - let thatDay = moment(now).subtract({days: i + 1}); - - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) break; - } - } - - if (scheduleMisses > EvadeTask) { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. - } - } - - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - task.completed = false; - - if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed - } - }); - - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; - } - }); - - // Finished tallying - user.history.todos.push({date: now, value: todoTally}); - - // tally experience - let expTally = user.stats.exp; - let lvl = 0; // iterator - while (lvl < user.stats.lvl - 1) { - lvl++; - expTally += common.tnl(lvl); - } - user.history.exp.push({date: now, value: expTally}); - - // preen user history so that it doesn't become a performance problem - // also for subscribed users but differentyly - // premium subscribers can keep their full history. - preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); - - if (perfect) { - user.achievements.perfect++; - let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); - user.stats.buffs = { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false, - }; - } else { - user.stats.buffs = _.cloneDeep(clearBuffs); - } - - // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit - // Adjust for fraction of dailies completed - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - - // After all is said and done, progress up user's effect on quest, return those values & reset the user's - let progress = user.party.quest.progress; - let _progress = _.cloneDeep(progress); - _.merge(progress, {down: 0, up: 0}); - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - // Clean PMs - keep 200 for subscribers and 50 for free users - // TODO tests - let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too - let numberOfPMs = Object.keys(user.inbox.messages).length; - if (Object.keys(user.inbox.messages).length > maxPMs) { - _(user.inbox.messages) - .sortBy('timestamp') - .takeRight(numberOfPMs - maxPMs) - .each(pm => { - user.inbox.messages[pm.id] = undefined; - }).value(); - - user.markModified('inbox.messages'); - } - - // Analytics - user.flags.cronCount++; - analytics.track('Cron', { - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user, // TODO is it really necessary passing the whole user object? - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down, - }); - - return _progress; -} diff --git a/common/script/cron.js b/common/script/cron.js index 639bbf6b65..7eca56cb54 100644 --- a/common/script/cron.js +++ b/common/script/cron.js @@ -1,3 +1,4 @@ +// TODO what can be moved to /website/src? /* ------------------------------------------------------ Cron and time / day functions diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 0197d38dbb..e0c89b66ad 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -14,7 +14,7 @@ import Q from 'q'; import _ from 'lodash'; import moment from 'moment'; import scoreTask from '../../../../common/script/api-v3/scoreTask'; -import { preenHistory } from '../../../../common/script/api-v3/preening'; +import { preenHistory } from '../../libs/api-v3/preening'; let api = {}; diff --git a/common/script/api-v3/preening.js b/website/src/libs/api-v3/preening.js similarity index 100% rename from common/script/api-v3/preening.js rename to website/src/libs/api-v3/preening.js diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 288452e670..d57bfb28f8 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -2,15 +2,274 @@ import _ from 'lodash'; import moment from 'moment'; import { daysSince, + shouldDo, } from '../../../../common/script/cron'; -import cron from '../../../../common/script/api-v3/cron'; import common from '../../../../common'; import Task from '../../models/task'; import Q from 'q'; -// import Group from '../../models/group'; +import Group from '../../models/group'; +import User from '../../models/user'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import { preenUserHistory } from '../../libs/api-v3/preening'; + +let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, +}; + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export function cron (options = {}) { + let {user, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach(task => { // make uncompleted todos redder + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + todoTally += task.value; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos.push({date: now, value: todoTally}); + + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(clearBuffs); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + // Clean PMs - keep 200 for subscribers and 50 for free users + // TODO tests + let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too + let numberOfPMs = Object.keys(user.inbox.messages).length; + if (Object.keys(user.inbox.messages).length > maxPMs) { + _(user.inbox.messages) + .sortBy('timestamp') + .takeRight(numberOfPMs - maxPMs) + .each(pm => { + user.inbox.messages[pm.id] = undefined; + }).value(); + + user.markModified('inbox.messages'); + } + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} // TODO check that it's used everywhere -export default function cronMiddleware (req, res, next) { +export default async function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -32,7 +291,7 @@ export default function cronMiddleware (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - cron({user, tasksByType, now, daysMissed, analytics}); + let progress = cron({user, tasksByType, now, daysMissed, analytics}); // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? @@ -44,44 +303,36 @@ export default function cronMiddleware (req, res, next) { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), }, 'challenge.id': {$exists: false}, - }).exec(); // TODO catch error or at least log it + }).exec(); // TODO catch error or at least log it, wait before returning? let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; // if (ranCron) res.locals.wasModified = true; // TODO remove? if (!ranCron) return next(); - // TODO Group.tavernBoss(user, progress); - if (!quest || true /* TODO remove */) { - // Save user and tasks - let toSave = [user.save()]; - tasks.forEach(task => { - if (task.isModified) toSave.push(task.save()); + + // Group.tavernBoss(user, progress); + + // Save user and tasks + let toSave = [user.save()]; + tasks.forEach(task => { + if (task.isModified) toSave.push(task.save()); + }); + Q.all(toSave) + .then(saved => { + user = res.locals.user = saved[0]; + if (!quest) return; + + // If user is on a quest, roll for boss & player, or handle collections + let questType = quest.boss ? 'boss' : 'collect'; + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + return Group[`${questType}Quest`](user, progress) + .then(() => User.findById(user._id).exec()) // fetch the updated user... + .then(updatedUser => { + res.locals.user = updatedUser; }); - - return Q.all(toSave).then(() => next()).catch(next); - } - - // If user is on a quest, roll for boss & player, or handle collections - // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? - // TODO do - /* async.waterfall([ - function(cb){ - user.save(cb); // make sure to save the cron effects - }, - function(saved, count, cb){ - var type = quest.boss ? 'boss' : 'collect'; - Group[type+'Quest'](user,progress,cb); - }, - function(){ - var cb = arguments[arguments.length-1]; - // User has been updated in boss-grapple, reload - User.findById(user._id, cb); - } - ], function(err, saved) { - res.locals.user = saved; - next(err,saved); - user = progress = quest = null; - });*/ + }) + .then(() => next()) + .catch(next); }); } diff --git a/website/src/models/group.js b/website/src/models/group.js index 1f18a311a7..4d991f2e6a 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { model as Challenge} from './challenge'; import validator from 'validator'; import { removeFromArray } from '../libs/api-v3/collectionManipulators'; -import { BadRequest } from '../libs/api-v3/errors'; +import { InternalServerError } from '../libs/api-v3/errors'; import * as firebase from '../libs/api-v2/firebase'; import baseModel from '../libs/api-v3/baseModel'; import { sendTxn as sendTxnEmail } from '../libs/api-v3/email'; @@ -171,9 +171,9 @@ schema.methods.isMember = function isGroupMember (user) { }; schema.methods.startQuest = async function startQuest (user) { - if (this.type !== 'party') throw new BadRequest('Must be a party to use this method'); - if (!this.quest.key) throw new BadRequest('Party does not have a pending quest'); - if (this.quest.active) throw new BadRequest('Quest is already active'); + if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); + if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); + if (this.quest.active) throw new InternalServerError('Quest is already active'); let userIsParticipating = this.quest.members[user._id]; let quest = questScrolls[this.quest.key]; @@ -397,7 +397,6 @@ schema.statics.collectQuest = async function collectQuest (user, progress) { await group.finishQuest(quest); group.sendChat('`All items found! Party has received their rewards.`'); return group.save(); - // TODO cath? }; schema.statics.bossQuest = async function bossQuest (user, progress) { @@ -432,6 +431,10 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { }, { $inc: {'stats.hp': down, _v: 1}, }, {multi: true}).exec(); + // Apply changes the currently cronning user locally so we don't have to reload it to get the updated state + // TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167 + // must be notModified or otherwise could overwrite future changes + // if (down) user.stats.hp += down; // Boss slain, finish quest if (group.quest.progress.hp <= 0) { @@ -443,7 +446,6 @@ schema.statics.bossQuest = async function bossQuest (user, progress) { } return group.save(); - // TODO catch? }; // to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` From 2f3ae32a0e0e9f2fb12d31d17eb91eaea1437d80 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 23:30:49 +0100 Subject: [PATCH 196/213] finalize invite to quest route and startQuest method --- .../POST-groups_groupId_quests_accept.test.js | 0 .../POST-groups_groupId_quests_invite.test.js | 0 test/api/v3/unit/models/group.test.js | 2 +- website/src/controllers/api-v3/quests.js | 55 +++++++++--- website/src/libs/api-v3/analyticsService.js | 1 + .../src/libs/api-v3/collectionManipulators.js | 7 +- website/src/models/group.js | 87 +++++++++++-------- 7 files changed, 98 insertions(+), 54 deletions(-) rename test/api/v3/integration/{groups => quests}/POST-groups_groupId_quests_accept.test.js (100%) rename test/api/v3/integration/{groups => quests}/POST-groups_groupId_quests_invite.test.js (100%) diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_accept.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js diff --git a/test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js similarity index 100% rename from test/api/v3/integration/groups/POST-groups_groupId_quests_invite.test.js rename to test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index c5939b1424..b86cc7a1d9 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -4,7 +4,7 @@ import { quests as questScrolls } from '../../../../../common/script/content'; import * as email from '../../../../../website/src/libs/api-v3/email'; import Q from 'q'; -describe('Group Model', () => { +describe.skip('Group Model', () => { context('Instance Methods', () => { describe('#startQuest', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 77cec58d6a..05f4a69223 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import Q from 'q'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; +import analytics from '../../libs/api-v3/analyticsService'; import { model as Group, } from '../../models/group'; @@ -12,6 +13,10 @@ import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; +import { + getUserInfo, + sendTxn as sendTxnEmail, +} from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; function canStartQuestAutomatically (group) { @@ -55,8 +60,11 @@ api.inviteToQuest = { if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl })); if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway')); - let members = await User.find({ 'party._id': group._id }, 'auth.facebook auth.local preferences.emailNotifications').exec(); - let backgroundOperations = []; + let members = await User.find({ + 'party._id': group._id, + _id: {$ne: user._id}, + }).select('auth.facebook auth.local preferences.emailNotifications profile.name') + .exec(); group.markModified('quest'); group.quest.key = questKey; @@ -67,18 +75,22 @@ api.inviteToQuest = { user.party.quest.RSVPNeeded = false; user.party.quest.key = questKey; + await User.update({ + 'party._id': group._id, + _id: {$ne: user._id}, + }, { + $set: { + 'party.quest.RSVPNeeded': true, + 'party.quest.key': questKey, + }, + }, {multi: true}).exec(); + _.each(members, (member) => { - if (member._id !== user._id) { - group.quest.members[member._id] = null; - member.party.quest.RSVPNeeded = true; - member.party.quest.key = questKey; - // TODO: Send Quest invite email - backgroundOperations.push(member.save()); - } + group.quest.members[member._id] = null; }); if (canStartQuestAutomatically(group)) { - group.startQuest(user); + await group.startQuest(user); } let [savedGroup] = await Q.all([ @@ -88,9 +100,26 @@ api.inviteToQuest = { res.respond(200, savedGroup.quest); - Q.allSettled(backgroundOperations).catch(err => { - // TODO what to do about errors in background ops - throw err; + // send out invites + let inviterVars = getUserInfo(user, ['name', 'email']); + let membersToEmail = members.filter(member => { + return member.preferences.emailNotifications.invitedQuest !== false; + }); + sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [ + {name: 'QUEST_NAME', content: quest.text()}, + {name: 'INVITER', content: inviterVars.name}, + {name: 'REPLY_TO_ADDRESS', content: inviterVars.email}, + {name: 'PARTY_URL', content: '/#/options/groups/party'}, + ]); + + // track that the inviting user has accepted the quest + analytics.track('quest', { + category: 'behavior', + owner: true, + response: 'accept', + gaLabel: 'accept', + questName: questKey, + uuid: user._id, }); }, }; diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js index ae9ad6f60f..2c435da9dd 100644 --- a/website/src/libs/api-v3/analyticsService.js +++ b/website/src/libs/api-v3/analyticsService.js @@ -210,6 +210,7 @@ let _sendPurchaseDataToGoogle = (data) => { }); }; +// TODO log errors... function track (eventType, data) { return Q.all([ _sendDataToAmplitude(eventType, data), diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js index ce40085552..95d3981601 100644 --- a/website/src/libs/api-v3/collectionManipulators.js +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -1,9 +1,12 @@ -import { findIndex } from 'lodash'; +import { + findIndex, + isPlainObject, +} from 'lodash'; export function removeFromArray (array, element) { let elementIndex; - if (typeof element === 'object') { + if (isPlainObject(element)) { elementIndex = findIndex(array, element); } else { elementIndex = array.indexOf(element); diff --git a/website/src/models/group.js b/website/src/models/group.js index 4d991f2e6a..f8764d546e 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -171,6 +171,7 @@ schema.methods.isMember = function isGroupMember (user) { }; schema.methods.startQuest = async function startQuest (user) { + // not using i18n strings because these errors are meant for devs who forgot to pass some parameters if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest'); if (this.quest.active) throw new InternalServerError('Quest is already active'); @@ -184,8 +185,6 @@ schema.methods.startQuest = async function startQuest (user) { }); } - let backgroundOperations = []; - this.markModified('quest'); this.quest.active = true; if (quest.boss) { @@ -200,48 +199,60 @@ schema.methods.startQuest = async function startQuest (user) { // are still on the object? // TODO: is it important to run clean quest progress on non-members like we did in v2? this.quest.members = _.pick(this.quest.members, _.identity); - let nonUserQuestMembers = _.without(_.keys(this.quest.members), user._id); - - let members = await User.find( - { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications', - ).exec(); + let nonUserQuestMembers = _.keys(this.quest.members); + removeFromArray(nonUserQuestMembers, user._id); if (userIsParticipating) { - members.unshift(user); // put participating user at the beginning of the array + user.party.quest.key = this.quest.key; + user.party.quest.progress.down = 0; + user.party.quest.collect = collected; + user.party.quest.completed = null; + user.markModified('party.quest'); } - _.each(members, (member) => { - member.party.quest.key = this.quest.key; - member.party.quest.progress.down = 0; - member.party.quest.collect = collected; - member.party.quest.completed = null; - member.markModified('party.quest'); + // Remove the quest from the quest leader items (if he's the current user) + if (this.quest.leader === user._id) { + user.items.quests[this.quest.key] -= 1; + user.markModified('items.quests'); + } else { // another user is starting the quest, update the leader separately + await User.update({_id: this.quest.leader}, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + $inc: { + [`items.quests${this.quest.key}`]: -1, + }, + }).exec(); + removeFromArray(nonUserQuestMembers, this.quest.leader); + } - if (this.quest.leader === member._id) { - member.items.quests[this.quest.key] -= 1; - member.markModified('items.quests'); - } + // update the remaining users + await User.update({ + _id: { $in: nonUserQuestMembers }, + }, { + $set: { + 'party.quest.key': this.quest.key, + 'party.quest.progress.down': 0, + 'party.quest.collect': collected, + 'party.quest.completed': null, + }, + }, { multi: true }).exec(); - if (member._id !== user._id) { - backgroundOperations.push(member.save()); - } - }); - - let usersToEmail = _.filter(members, (member) => { - return member.preferences.emailNotifications.questStarted !== false && - member._id !== user._id; - }); - - sendTxnEmail(usersToEmail, 'quest-started', [ - { name: 'PARTY_URL', content: '/#/options/groups/party' }, - ]); - - // These operations should run in the background - // and not hold up the quest routes from resolving - Q.allSettled(backgroundOperations).catch(err => { - // TODO: what to do with err? - throw err; + // send notifications in the background without blocking + User.find( + { _id: { $in: nonUserQuestMembers } }, + 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications profile.name', + ).exec().then(membersToEmail => { + membersToEmail = _.filter(membersToEmail, (member) => { + return member.preferences.emailNotifications.questStarted !== false && + member._id !== user._id; + }); + sendTxnEmail(membersToEmail, 'quest-started', [ + { name: 'PARTY_URL', content: '/#/options/groups/party' }, + ]); }); }; From 0b336d8012c04bd268c209487613608c7e972f80 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 8 Feb 2016 23:40:03 +0100 Subject: [PATCH 197/213] add missing dot in field path --- website/src/models/group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/models/group.js b/website/src/models/group.js index f8764d546e..23524646d1 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -223,7 +223,7 @@ schema.methods.startQuest = async function startQuest (user) { 'party.quest.completed': null, }, $inc: { - [`items.quests${this.quest.key}`]: -1, + [`items.quests.${this.quest.key}`]: -1, }, }).exec(); removeFromArray(nonUserQuestMembers, this.quest.leader); From b6ed2f8c443c22f4ef74971f428c850a0694d05c Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 10 Feb 2016 08:21:36 -0600 Subject: [PATCH 198/213] refactor: Move sleep function to seprate file --- test/helpers/api-integration/v3/index.js | 9 +-------- test/helpers/api-unit.helper.js | 2 ++ test/helpers/sleep.js | 7 +++++++ 3 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 test/helpers/sleep.js diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 4ee2e6cfbe..6ae15d7ca6 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -8,11 +8,4 @@ export { requester }; export { translate } from '../translate'; export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; - -export async function sleep (seconds) { - let milliseconds = seconds * 1000; - - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} +export { sleep } from '../../sleep'; diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 3370bd0887..1a590f8a3a 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -10,6 +10,8 @@ afterEach((done) => { mongoose.connection.db.dropDatabase(done); }); +export { sleep } from './sleep'; + export function generateUser (options = {}) { return new User(options).toObject(); } diff --git a/test/helpers/sleep.js b/test/helpers/sleep.js new file mode 100644 index 0000000000..f8dd9ab165 --- /dev/null +++ b/test/helpers/sleep.js @@ -0,0 +1,7 @@ +export async function sleep (seconds) { + let milliseconds = seconds * 1000; + + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} From 8ff4cfe709668aeed1a3b0846b84b5b28fc3e5ef Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Wed, 10 Feb 2016 08:22:08 -0600 Subject: [PATCH 199/213] tests: Finish start quest --- test/api/v3/unit/models/group.test.js | 76 ++++++++++++++++++++------- website/src/models/group.js | 9 +--- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index b86cc7a1d9..ef76e7b984 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -1,17 +1,16 @@ +import { sleep } from '../../../../helpers/api-unit.helper'; import { model as Group } from '../../../../../website/src/models/group'; import { model as User } from '../../../../../website/src/models/user'; import { quests as questScrolls } from '../../../../../common/script/content'; import * as email from '../../../../../website/src/libs/api-v3/email'; -import Q from 'q'; -describe.skip('Group Model', () => { +describe('Group Model', () => { context('Instance Methods', () => { describe('#startQuest', () => { let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; beforeEach(async () => { sandbox.stub(email, 'sendTxn'); - sandbox.spy(Q, 'allSettled'); party = new Group({ name: 'test party', @@ -173,14 +172,6 @@ describe.skip('Group Model', () => { expect(undecidedMember.party.quest.key).to.not.eql('whale'); }); - it('removes quest scroll from quest leader', async () => { - await party.startQuest(participatingMember); - - questLeader = await User.findById(questLeader._id); - - expect(questLeader.items.quests.whale).to.eql(0); - }); - it('sends email to participating members that quest has started', async () => { participatingMember.preferences.emailNotifications.questStarted = true; questLeader.preferences.emailNotifications.questStarted = true; @@ -191,6 +182,8 @@ describe.skip('Group Model', () => { await party.startQuest(nonParticipatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -212,6 +205,8 @@ describe.skip('Group Model', () => { await party.startQuest(nonParticipatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -231,6 +226,8 @@ describe.skip('Group Model', () => { await party.startQuest(participatingMember); + await sleep(0.5); + expect(email.sendTxn).to.be.calledOnce; let memberIds = _.pluck(email.sendTxn.args[0][0], '_id'); @@ -240,21 +237,62 @@ describe.skip('Group Model', () => { expect(memberIds).to.include(questLeader._id); }); - it('adds participating members to background save operations', async () => { + it('updates participting members (not including user)', async () => { + sandbox.spy(User, 'update'); + await party.startQuest(nonParticipatingMember); - expect(Q.allSettled).to.be.calledOnce; + let members = [questLeader._id, participatingMember._id]; - let savePromises = Q.allSettled.args[0][0]; - expect(savePromises).to.have.a.lengthOf(2); + expect(User.update).to.be.calledWith( + { _id: { $in: members } }, + { + $set: { + 'party.quest.key': 'whale', + 'party.quest.progress.down': 0, + 'party.quest.collect': {}, + 'party.quest.completed': null, + }, + } + ); }); - it('does not include initiating user in background save operations', async () => { + it('updates non-user quest leader and decrements quest scroll', async () => { + sandbox.spy(User, 'update'); + await party.startQuest(participatingMember); - expect(Q.allSettled).to.be.calledOnce; - let savePromises = Q.allSettled.args[0][0]; - expect(savePromises).to.have.a.lengthOf(1); + expect(User.update).to.be.calledWith( + { _id: questLeader._id }, + { + $inc: { + 'items.quests.whale': -1, + }, + } + ); + }); + + it('modifies the participating initiating user directly', async () => { + await party.startQuest(participatingMember); + + let userQuest = participatingMember.party.quest; + + expect(userQuest.key).to.eql('whale'); + expect(userQuest.progress.down).to.eql(0); + expect(userQuest.collect).to.eql({}); + expect(userQuest.completed).to.eql(null); + }); + + it('does not modify user if not participating', async () => { + await party.startQuest(nonParticipatingMember); + + expect(nonParticipatingMember.party.quest.key).to.not.eql('whale'); + }); + + it('removes the quest directly if initiating user is the quest leader', async () => { + await party.startQuest(questLeader); + + expect(questLeader.items.quests.whale).to.eql(0); }); }); }); diff --git a/website/src/models/group.js b/website/src/models/group.js index 23524646d1..4046b7d5ee 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -210,23 +210,16 @@ schema.methods.startQuest = async function startQuest (user) { user.markModified('party.quest'); } - // Remove the quest from the quest leader items (if he's the current user) + // Remove the quest from the quest leader items (if they are the current user) if (this.quest.leader === user._id) { user.items.quests[this.quest.key] -= 1; user.markModified('items.quests'); } else { // another user is starting the quest, update the leader separately await User.update({_id: this.quest.leader}, { - $set: { - 'party.quest.key': this.quest.key, - 'party.quest.progress.down': 0, - 'party.quest.collect': collected, - 'party.quest.completed': null, - }, $inc: { [`items.quests.${this.quest.key}`]: -1, }, }).exec(); - removeFromArray(nonUserQuestMembers, this.quest.leader); } // update the remaining users From 02a61e260b1854324036f5306c8989949cbf30b3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 16:10:00 +0100 Subject: [PATCH 200/213] add tests for GET challenges/:challengeId --- .../GET-challenges_challengeId.test.js | 139 ++++++++++++++++++ website/src/controllers/api-v3/challenges.js | 5 +- 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 test/api/v3/integration/challenges/GET-challenges_challengeId.test.js diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js new file mode 100644 index 0000000000..8528fee892 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js @@ -0,0 +1,139 @@ +import { + generateUser, + createAndPopulateGroup, + generateChallenge, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId', () => { + it('fails if challenge doesn\'t exists', async () => { + let user = await generateUser(); + await expect(user.get(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('public guild', () => { + let groupLeader; + let group; + let challenge; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + groupDetails: {type: 'guild', privacy: 'public'}, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + + challenge = await generateChallenge(groupLeader, group); + }); + + it('should return challenge data', async () => { + let chal = await user.get(`/challenges/${challenge._id}`); + expect(chal.memberCount).to.equal(challenge.memberCount); + expect(chal.name).to.equal(challenge.name); + expect(chal._id).to.equal(challenge._id); + + expect(chal.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + }); + }); + + context('private guild', () => { + let groupLeader; + let group; + let challenge; + let members; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + groupDetails: {type: 'guild', privacy: 'private'}, + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + members = populatedGroup.members; + + challenge = await generateChallenge(groupLeader, group); + await members[0].post(`/challenges/${challenge._id}/join`); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should return challenge data', async () => { + let chal = await members[0].get(`/challenges/${challenge._id}`); + expect(chal.name).to.equal(challenge.name); + expect(chal._id).to.equal(challenge._id); + + expect(chal.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + }); + }); + + context('party', () => { + let groupLeader; + let group; + let challenge; + let members; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + groupDetails: {type: 'party'}, + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + members = populatedGroup.members; + + challenge = await generateChallenge(groupLeader, group); + await members[0].post(`/challenges/${challenge._id}/join`); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should return challenge data', async () => { + let chal = await members[0].get(`/challenges/${challenge._id}`); + expect(chal.name).to.equal(challenge.name); + expect(chal._id).to.equal(challenge._id); + + expect(chal.leader).to.eql({ + _id: groupLeader._id, + profile: {name: groupLeader.profile.name}, + }); + expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy'])); + }); + }); +}); diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index bc9e6f03b4..c239a63dd3 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -307,11 +307,12 @@ api.getChallenge = { let challengeId = req.params.challengeId; let challenge = await Challenge.findById(challengeId) - // .populate('leader', nameFields) // don't populate the group as we'll fetch it manually later + // Don't populate the group as we'll fetch it manually later + // .populate('leader', nameFields) .exec(); if (!challenge) throw new NotFound(res.t('challengeNotFound')); - // Fetching basicGroupFields + // Fetching basic group data let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true}); if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound')); From fa14464a0c1e229eda8b4a364e8000da557b06ec Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 17:07:34 +0100 Subject: [PATCH 201/213] add accept quest route --- website/src/controllers/api-v3/quests.js | 56 ++++++++++++- website/src/models/group.js | 102 +++++++++++------------ 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 05f4a69223..13584b139c 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -35,7 +35,7 @@ let api = {}; * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.inviteToQuest = { method: 'POST', @@ -124,4 +124,58 @@ api.inviteToQuest = { }, }; +/** + * @api {post} /groups/:groupId/quests/accept Accept a pending quest + * @apiVersion 3.0.0 + * @apiName AcceptQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} quest Quest Object + */ +api.acceptQuest = { + method: 'POST', + url: '/groups/:groupId/quests/accept', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); + + group.quest.members[user._id] = true; + user.party.quest.RSVPNeeded = false; + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + // track that an user has accepted the quest + analytics.track('quest', { + category: 'behavior', + owner: false, + response: 'accept', + gaLabel: 'accept', + questName: group.quest.key, + uuid: user._id, + }); + }, +}; + export default api; diff --git a/website/src/models/group.js b/website/src/models/group.js index 4046b7d5ee..ff7a22b63c 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -170,6 +170,57 @@ schema.methods.isMember = function isGroupMember (user) { } }; +export function chatDefaults (msg, user) { + let message = { + id: shared.uuid(), + text: msg, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + + if (user) { + _.defaults(message, { + uuid: user._id, + contributor: user.contributor && user.contributor.toObject(), + backer: user.backer && user.backer.toObject(), + user: user.profile.name, + }); + } else { + message.uuid = 'system'; + } + + return message; +} + +schema.methods.sendChat = function sendChat (message, user) { + this.chat.unshift(chatDefaults(message, user)); + this.chat.splice(200); + + // Kick off chat notifications in the background. // TODO refactor + let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; + lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; + + if (this._id === 'habitrpg') { + // TODO For Tavern, only notify them if their name was mentioned + // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? + // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); + } else { + let query = {}; + + if (this.type === 'party') { + query['party._id'] = this._id; + } else { + query.guilds = this._id; + } + + query._id = { $ne: user ? user._id : ''}; + + User.update(query, lastSeenUpdate, {multi: true}).exec(); + } +}; + schema.methods.startQuest = async function startQuest (user) { // not using i18n strings because these errors are meant for devs who forgot to pass some parameters if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method'); @@ -249,57 +300,6 @@ schema.methods.startQuest = async function startQuest (user) { }); }; -export function chatDefaults (msg, user) { - let message = { - id: shared.uuid(), - text: msg, - timestamp: Number(new Date()), - likes: {}, - flags: {}, - flagCount: 0, - }; - - if (user) { - _.defaults(message, { - uuid: user._id, - contributor: user.contributor && user.contributor.toObject(), - backer: user.backer && user.backer.toObject(), - user: user.profile.name, - }); - } else { - message.uuid = 'system'; - } - - return message; -} - -schema.methods.sendChat = function sendChat (message, user) { - this.chat.unshift(chatDefaults(message, user)); - this.chat.splice(200); - - // Kick off chat notifications in the background. // TODO refactor - let lastSeenUpdate = {$set: {}, $inc: {_v: 1}}; - lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true}; - - if (this._id === 'habitrpg') { - // TODO For Tavern, only notify them if their name was mentioned - // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? - // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); - } else { - let query = {}; - - if (this.type === 'party') { - query['party._id'] = this._id; - } else { - query.guilds = this._id; - } - - query._id = { $ne: user ? user._id : ''}; - - User.update(query, lastSeenUpdate, {multi: true}).exec(); - } -}; - function _cleanQuestProgress (merge) { // TODO clone? (also in sendChat message) let clean = { From a2af6c390bea1e701002f7f3a12c992e132e36a3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 10 Feb 2016 22:27:59 +0100 Subject: [PATCH 202/213] fix quest accept route and add tests --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupId_quests_accept.test.js | 85 ++++++++++++++++--- website/src/controllers/api-v3/quests.js | 4 + 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index cdb6718327..a2495529e7 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -73,5 +73,6 @@ "questNotFound": "Quest \"<%= key %>\" not found.", "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", - "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended." + "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", + "questAlreadyAccepted": "You already accepted the quest invitation." } diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js index e218d7e3d9..665a185279 100644 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js @@ -1,27 +1,37 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; -describe.skip('POST /groups/:groupId/quests/accept', () => { +describe('POST /groups/:groupId/quests/accept', () => { + const PET_QUEST = 'whale'; + let questingGroup; let leader; - let member; + let partyMembers; + let user; beforeEach(async () => { + user = await generateUser(); + let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); questingGroup = group; leader = groupLeader; - member = members[0]; + partyMembers = members; + + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); }); context('failure conditions', () => { it('does not accept quest without an invite', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`, {})) + await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -30,25 +40,80 @@ describe.skip('POST /groups/:groupId/quests/accept', () => { }); it('does not accept quest for a group in which user is not a member', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/accept`, {})) + await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', - message: t('questInviteNotFound'), + message: t('groupNotFound'), + }); + }); + + it('does not accept quest for a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('does not accept invite twice', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyAccepted'), + }); + }); + + it('does not accept invite for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), }); }); }); + context('successfully accepting a quest invitation', () => { - it('joins a quest from an invitation', () => { + it('joins a quest from an invitation', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await Promise.all([partyMembers[0].sync(), questingGroup.sync()]); + expect(leader.party.quest.RSVPNeeded).to.equal(false); + expect(questingGroup.quest.members[partyMembers[0]._id]); }); - it('does not begin the quest if pending invitations remain', () => { + it('does not begin the quest if pending invitations remain', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await questingGroup.sync(); + expect(questingGroup.quest.active).to.equal(false); }); - it('begins the quest if accepting the last pending invite', () => { + it('begins the quest if accepting the last pending invite', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + await questingGroup.sync(); + expect(questingGroup.quest.active).to.equal(true); }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 13584b139c..162954b7dd 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -12,6 +12,7 @@ import { import { NotFound, NotAuthorized, + BadRequest, } from '../../libs/api-v3/errors'; import { getUserInfo, @@ -151,7 +152,10 @@ api.acceptQuest = { if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); + group.markModified('quest'); group.quest.members[user._id] = true; user.party.quest.RSVPNeeded = false; From 879506b38a0662b29e4ccc60251758b0d73f886c Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 12:50:28 +0100 Subject: [PATCH 203/213] update reject quest route --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_reject.test.js | 125 +++++++++++++----- website/src/controllers/api-v3/quests.js | 40 +++--- website/src/models/group.js | 1 - 4 files changed, 117 insertions(+), 52 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a2495529e7..62c4df4d8b 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -74,5 +74,6 @@ "questNotOwned": "You don't own that quest scroll.", "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", - "questAlreadyAccepted": "You already accepted the quest invitation." + "questAlreadyAccepted": "You already accepted the quest invitation.", + "questAlreadyRejected": "You already rejected the quest invitation." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index a222778948..84e364f770 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -1,36 +1,37 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/invite/:questKey', () => { +describe('POST /groups/:groupId/quests/reject', () => { let questingGroup; - let member; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; + let partyMembers; + let user; + let leader; - before(async () => { - let { group, members } = await createAndPopulateGroup({ + const PET_QUEST = 'whale'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); context('failure conditions', () => { it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/reject`)) + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', @@ -38,35 +39,97 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { }); }); - it('returns an error when group is not on a quest', async () => { - await member.update(userQuestUpdate); + it('does not accept quest for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); - await expect(member.post(`/groups/${questingGroup._id}/quests/reject`)) + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('returns an error when group is not on a quest', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('questInvitationDoesNotExist'), }); }); + + it('return an error when an user rejects an invite twice', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyRejected'), + }); + }); + + it('return an error when an user rejects an invite already accepted', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('questAlreadyAccepted'), + }); + }); + + it('does not reject invite for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + }); }); context('successfully quest rejection', () => { it('rejects a quest invitation', async () => { - await member.update(userQuestUpdate); - await questingGroup.update({'quest.key': PET_QUEST}); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + await partyMembers[0].sync(); + await questingGroup.sync(); - let rejectResult = await member.post(`/groups/${questingGroup._id}/quests/reject`); - let userWithRejectInvitation = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + expect(partyMembers[0].party.quest.key).to.be.null; + expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; + expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; + expect(questingGroup.quest.active).to.be.false; + }); - expect(userWithRejectInvitation.party.quest.key).to.be.null; - expect(userWithRejectInvitation.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members[member._id]).to.be.false; - expect(updatedGroup.quest).to.deep.equal(rejectResult); + it('starts the quest when the last user reject', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.be.true; }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 0f1f991b7b..b4a789b392 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -19,13 +19,11 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import { track } from '../../libs/api-v3/analyticsService'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true // If any member is null/undefined (undecided) return false - return _.every(group.quest.members, Boolean); + return _.every(group.quest.members, _.isBoolean); } let api = {}; @@ -191,9 +189,8 @@ api.acceptQuest = { * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') - * @apiParam {string} questKey The quest _id * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.rejectQuest = { method: 'POST', @@ -209,32 +206,37 @@ api.rejectQuest = { let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); - - let analyticsData = { - category: 'behavior', - owner: false, - response: 'reject', - gaLabel: 'reject', - questName: group.quest.key, - uuid: user._id, - }; - track('quest', analyticsData); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); + if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected')); group.quest.members[user._id] = false; group.markModified('quest.members'); - user.party.quest.RSVPNeeded = false; - user.party.quest.key = null; + user.party.quest = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } let [savedGroup] = await Q.all([ group.save(), user.save(), ]); - // questStart(req,res,next); - res.respond(200, savedGroup.quest); + + analytics.track('quest', { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }); }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index ff7a22b63c..bb2c08d436 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -301,7 +301,6 @@ schema.methods.startQuest = async function startQuest (user) { }; function _cleanQuestProgress (merge) { - // TODO clone? (also in sendChat message) let clean = { key: null, progress: { From 6c1950972b8b5a3b61784959ba6402449096ab8f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 13:19:46 +0100 Subject: [PATCH 204/213] update quest cancel routes --- common/locales/en/api-v3.json | 4 +- .../POST-groups_groupid_quests_cancel.test.js | 139 +++++++++++++----- website/src/controllers/api-v3/quests.js | 12 +- website/src/models/group.js | 15 ++ 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 7c5cc3b189..ec2be7a366 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -75,5 +75,7 @@ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", - "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality." + "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", + "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", + "questInvitationDoesNotExist": "No quest invitation has been sent out yet." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index 84111ab5ea..76f0102a5f 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -1,66 +1,137 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/leave', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; +describe('POST /groups/:groupId/quests/cancel', () => { + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(leader.post(`/groups/${generateUUID()}/quests/cancel`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not reject quest for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/cancel`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('cancels a quest', async () => { - await member.update(userQuestUpdate); - await questingGroup.update({'quest.key': PET_QUEST}); + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + await expect(guildLeader.post(`/groups/${guild._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); - await leader.post(`/groups/${questingGroup._id}/quests/cancel`); - let userThatCanceled = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + it('returns an error when group is not on a quest', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInvitationDoesNotExist'), + }); + }); - expect(userThatCanceled.party.quest.key).to.be.null; - expect(userThatCanceled.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members).to.be.empty; - }); + it('only the leader can cancel the quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - it('returns an error when quest is active', async () => { - await questingGroup.update({'quest.active': true}); - await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderCancelQuest'), + }); + }); + + it('does not cancel a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('cantCancelActiveQuest'), }); + }); + }); + + it('cancels a quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + + await Promise.all([ + leader.sync(), + partyMembers[0].sync(), + partyMembers[1].sync(), + questingGroup.sync(), + ]); + + let clean = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; + + expect(leader.party.quest).eql(clean); + expect(partyMembers[1].party.quest).eql(clean); + expect(partyMembers[0].party.quest).eql(clean); + + expect(questingGroup.quest).to.eql({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 3da381e26d..4cfad3155c 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -190,7 +190,7 @@ api.acceptQuest = { * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Group Object + * @apiSuccess {Object} quest Quest Object */ api.cancelQuest = { method: 'POST', @@ -210,20 +210,22 @@ api.cancelQuest = { let group = await Group.getGroup({user, groupId, fields: 'type quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); - + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest')); if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest')); - group.quest = {key: null, progress: {}, leader: null, members: {}}; + group.quest = Group.cleanGroupQuest(); group.markModified('quest'); await group.save(); await User.update( {'party._id': groupId}, - {$set: {'party.quest.RSVPNeeded': false, 'party.quest.key': null}}, + {$set: {'party.quest': Group.cleanQuestProgress()}}, {multi: true} ); - res.respond(200, group); + res.respond(200, group.quest); }, }; diff --git a/website/src/models/group.js b/website/src/models/group.js index ff7a22b63c..1af2c8f7b5 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -300,6 +300,7 @@ schema.methods.startQuest = async function startQuest (user) { }); }; +// return a clean object for user.quest function _cleanQuestProgress (merge) { // TODO clone? (also in sendChat message) let clean = { @@ -321,8 +322,22 @@ function _cleanQuestProgress (merge) { return clean; } +// TODO move to User.cleanQuestProgress? schema.statics.cleanQuestProgress = _cleanQuestProgress; +// returns a clean object for group.quest +schema.statics.cleanGroupQuest = function cleanGroupQuest () { + return { + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }; +}; + // Participants: Grant rewards & achievements, finish quest // Returns the promise from update().exec() schema.methods.finishQuest = function finishQuest (quest) { From 354b6bc39e2b028cfc4e75816a38f14ffa02b93f Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:07:45 +0100 Subject: [PATCH 205/213] update quest leave route --- .../POST-groups_groupid_quests_leave.test.js | 127 ++++++++++++------ website/src/controllers/api-v3/quests.js | 22 +-- 2 files changed, 90 insertions(+), 59 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js index eec1e33842..8b34278c08 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -1,85 +1,124 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; describe('POST /groups/:groupId/quests/leave', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(member.post(`/groups/${generateUUID()}/quests/leave`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('returns an error when quest is not active', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToLeave'), + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, }); - }); - it('returns an error when quest leader attempts to leave', async () => { - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); - - await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + await expect(guildLeader.post(`/groups/${guild._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('questLeaderCannotLeaveQuest'), + message: t('guildQuestsNotSupported'), }); - }); + }); - it('returns an error when non quest member attempts to leave', async () => { - await expect(member.post(`/groups/${questingGroup._id}/quests/leave`)) + it('returns an error when quest is not active', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToLeave'), + }); + }); + + it('returns an error when quest leader attempts to leave', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questLeaderCannotLeaveQuest'), + }); + }); + + it('returns an error when non quest member attempts to leave', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', message: t('notPartOfQuest'), }); + }); }); it('leaves a quest', async () => { - await member.update(userQuestUpdate); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); + let leaveResult = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`); + await Promise.all([ + partyMembers[0].sync(), + questingGroup.sync(), + ]); - let leaveResult = await member.post(`/groups/${questingGroup._id}/quests/leave`); - let userThatLeft = await member.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); - - expect(userThatLeft.party.quest.key).to.be.null; - expect(userThatLeft.party.quest.RSVPNeeded).to.be.false; - expect(updatedGroup.quest.members[member._id]).to.be.false; - expect(updatedGroup.quest).to.deep.equal(leaveResult); + expect(partyMembers[0].party.quest).to.eql({ + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }); + expect(questingGroup.quest).to.deep.equal(leaveResult); + expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 1e1b6f176d..39da2cec29 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -19,7 +19,6 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true @@ -184,14 +183,14 @@ api.acceptQuest = { }; /** - * @api {post} /groups/:groupId/quests/leave Leaves a quest + * @api {post} /groups/:groupId/quests/leave Leaves the active quest * @apiVersion 3.0.0 * @apiName LeaveQuest * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Empty Object + * @apiSuccess {Object} quest Quest Object */ api.leaveQuest = { method: 'POST', @@ -207,19 +206,12 @@ api.leaveQuest = { if (validationErrors) throw validationErrors; let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + if (!group) throw new NotFound(res.t('groupNotFound')); - - if (!(group.quest && group.quest.active)) { - throw new NotFound(res.t('noActiveQuestToLeave')); - } - - if (group.quest.leader === user._id) { - throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); - } - - if (!(group.quest.members && group.quest.members[user._id])) { - throw new NotAuthorized(res.t('notPartOfQuest')); - } + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave')); + if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest')); + if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest')); group.quest.members[user._id] = false; group.markModified('quest.members'); From dfb6ec2f48cea2e205913b74b16901d38285a9b9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:11:38 +0100 Subject: [PATCH 206/213] cancel quest: test response --- .../quests/POST-groups_groupid_quests_cancel.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index 76f0102a5f..daa995438a 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -100,7 +100,7 @@ describe('POST /groups/:groupId/quests/cancel', () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); - await leader.post(`/groups/${questingGroup._id}/quests/cancel`); + let res = await leader.post(`/groups/${questingGroup._id}/quests/cancel`); await Promise.all([ leader.sync(), @@ -124,6 +124,7 @@ describe('POST /groups/:groupId/quests/cancel', () => { expect(partyMembers[1].party.quest).eql(clean); expect(partyMembers[0].party.quest).eql(clean); + expect(res).to.eql(questingGroup.quest); expect(questingGroup.quest).to.eql({ key: null, active: false, From ab187720a5fcc11f064d8e1654c7d3e84e0a7a71 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:14:07 +0100 Subject: [PATCH 207/213] reject quest: test response --- .../POST-groups_groupid_quests_reject.test.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index 84e364f770..1eb62aa0c6 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -110,17 +110,28 @@ describe('POST /groups/:groupId/quests/reject', () => { }); context('successfully quest rejection', () => { + let cleanUserQuestObj = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; + it('rejects a quest invitation', async () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); - await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + let res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); await partyMembers[0].sync(); await questingGroup.sync(); - expect(partyMembers[0].party.quest.key).to.be.null; - expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; + expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false; expect(questingGroup.quest.active).to.be.false; + expect(res).to.eql(questingGroup.quest); }); it('starts the quest when the last user reject', async () => { From c79cb0efc6be139374dc4ad202e78cd382094035 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:32:25 +0100 Subject: [PATCH 208/213] update quest abort route --- common/locales/en/api-v3.json | 3 +- .../POST-groups_groupid_quests_abort.test.js | 134 ++++++++++++------ website/src/controllers/api-v3/quests.js | 36 ++--- 3 files changed, 111 insertions(+), 62 deletions(-) diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 72eb2f5c9e..671a046f8e 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -75,5 +75,6 @@ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", - "noActiveQuestToAbort": "There is no active quest to abort." + "noActiveQuestToAbort": "There is no active quest to abort.", + "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest." } diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 1ed6871307..e833dd16fe 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -1,78 +1,126 @@ import { createAndPopulateGroup, translate as t, + generateUser, } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/abort', () => { - let questingGroup, member, leader; - const PET_QUEST = 'whale'; - let userQuestUpdate = { - items: { - quests: {}, - }, - 'party.quest.RSVPNeeded': true, - 'party.quest.key': PET_QUEST, - }; +describe('POST /groups/:groupId/quests/leave', () => { + let questingGroup; + let partyMembers; + let user; + let leader; - before(async () => { + const PET_QUEST = 'whale'; + + beforeEach(async () => { let { group, groupLeader, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', privacy: 'private' }, - members: 1, + members: 2, }); - leader = groupLeader; questingGroup = group; - member = members[0]; + leader = groupLeader; + partyMembers = members; - userQuestUpdate.items.quests[PET_QUEST] = 1; + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + user = await generateUser(); }); - it('returns an error when group is not found', async () => { - await expect(leader.post(`/groups/${generateUUID()}/quests/abort`)) + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('returns an error for a group in which user is not a member', async () => { + await expect(user.post(`/groups/${questingGroup._id}/quests/abort`)) .to.eventually.be.rejected.and.eql({ code: 404, error: 'NotFound', message: t('groupNotFound'), }); - }); + }); - it('returns an error when quest is not active', async () => { - await expect(leader.post(`/groups/${questingGroup._id}/quests/abort`)) - .to.eventually.be.rejected.and.eql({ - code: 404, - error: 'NotFound', - message: t('noActiveQuestToAbort'), + it('returns an error when group is a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, }); - }); - xit('returns an error when non quest leader attempts to abort', async () => { - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); - - await expect(member.post(`/groups/${questingGroup._id}/quests/abort`)) + await expect(guildLeader.post(`/groups/${guild._id}/quests/abort`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('questLeaderCannotAbortQuest'), + message: t('guildQuestsNotSupported'), }); + }); + + it('returns an error when quest is not active', async () => { + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('noActiveQuestToAbort'), + }); + }); + + it('returns an error when non quest leader attempts to abort', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderAbortQuest'), + }); + }); }); it('aborts a quest', async () => { - await member.update(userQuestUpdate); + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); - let questMembers = {}; - questMembers[member._id] = true; - await questingGroup.update({'quest.members': questMembers}); - await questingGroup.update({quest: {key: PET_QUEST, active: true, leader: leader._id}}); + let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); + Promise.all([ + leader.sync(), + questingGroup.sync(), + partyMembers[0].sync(), + partyMembers[1].sync(), + ]); - let abortResult = await leader.post(`/groups/${questingGroup._id}/quests/abort`); - let updatedMember = await member.get('/user'); - let updatedLeader = await leader.get('/user'); - let updatedGroup = await member.get(`/groups/${questingGroup._id}`); + let cleanUserQuestObj = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; - expect(updatedMember.party.quest.key).to.be.null; - expect(updatedMember.party.quest.RSVPNeeded).to.be.false; - expect(updatedLeader.items.quests[PET_QUEST]).to.equal(1); - expect(updatedGroup.quest).to.deep.equal(abortResult); + expect(leader.party.quest).to.eql(cleanUserQuestObj); + expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj); + expect(partyMembers[1].party.quest).to.eql(cleanUserQuestObj); + expect(leader.items.quests[PET_QUEST]).to.equal(1); + expect(questingGroup.quest).to.deep.equal(abortResult); + expect(questingGroup.quest).to.eql({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); }); }); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index b3fb2e0e8e..ea49204f9a 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -7,9 +7,6 @@ import { model as Group, } from '../../models/group'; import { model as User } from '../../models/user'; -import { - model as User, -} from '../../models/user'; import { NotFound, NotAuthorized, @@ -20,7 +17,6 @@ import { sendTxn as sendTxnEmail, } from '../../libs/api-v3/email'; import { quests as questScrolls } from '../../../../common/script/content'; -import Q from 'q'; function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true @@ -185,14 +181,14 @@ api.acceptQuest = { }; /** - * @api {post} /groups/:groupId/quests/abort Abort a quest + * @api {post} /groups/:groupId/quests/abort Abort the current quest * @apiVersion 3.0.0 * @apiName AbortQuest * @apiGroup Group * * @apiParam {string} groupId The group _id (or 'party') * - * @apiSuccess {Object} Quest Object + * @apiSuccess {Object} quest Quest Object */ api.abortQuest = { method: 'POST', @@ -208,24 +204,28 @@ api.abortQuest = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + let group = await Group.getGroup({user, groupId, fields: 'type quest leader'}); if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort')); + if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest')); - let memberUpdates = User.update( - {'party._id': groupId}, - { + let memberUpdates = User.update({ + 'party._id': groupId + }, { $set: {'party.quest': Group.cleanQuestProgress()}, - $inc: {_v: 1}, + $inc: {_v: 1}, // TODO update middleware + }, {multi: true}).exec(); + + let questLeaderUpdate = User.update({ + _id: group.quest.leader + }, { + $inc: { + [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader }, - {multi: true}, - ); + }).exec(); - let update = {$inc: {}}; - update.$inc[`items.quests.${group.quest.key}`] = 1; - let questLeaderUpdate = User.update({_id: group.quest.leader}, update).exec(); - - group.quest = {key: null, progress: {collect: {}}, leader: null, members: {}, extra: {}, active: false}; + group.quest = Group.cleanGroupQuest(); group.markModified('quest'); let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); From 41c3276e66beed584ff5aabc3928ca8172cad46d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 15:36:21 +0100 Subject: [PATCH 209/213] fetch leader id when cancelling quest --- website/src/controllers/api-v3/quests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index 4cfad3155c..fc1555f54d 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -208,7 +208,7 @@ api.cancelQuest = { let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId, fields: 'type quest'}); + let group = await Group.getGroup({user, groupId, fields: 'type leader quest'}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); From befdb189fc02f9458a3d67f3036f621bc353c677 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 11 Feb 2016 21:09:14 +0100 Subject: [PATCH 210/213] fix tests for quests routes --- .../quests/POST-groups_groupid_quests_abort.test.js | 4 ++-- .../quests/POST-groups_groupid_quests_cancel.test.js | 6 +++--- .../quests/POST-groups_groupid_quests_leave.test.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js index 955d31bc92..850cf53646 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -5,7 +5,7 @@ import { } from '../../../../helpers/api-v3-integration.helper'; import { v4 as generateUUID } from 'uuid'; -describe('POST /groups/:groupId/quests/leave', () => { +describe('POST /groups/:groupId/quests/abort', () => { let questingGroup; let partyMembers; let user; @@ -90,7 +90,7 @@ describe('POST /groups/:groupId/quests/leave', () => { await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); - Promise.all([ + await Promise.all([ leader.sync(), questingGroup.sync(), partyMembers[0].sync(), diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js index daa995438a..f3bd03a180 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -120,9 +120,9 @@ describe('POST /groups/:groupId/quests/cancel', () => { RSVPNeeded: false, }; - expect(leader.party.quest).eql(clean); - expect(partyMembers[1].party.quest).eql(clean); - expect(partyMembers[0].party.quest).eql(clean); + expect(leader.party.quest).to.eql(clean); + expect(partyMembers[1].party.quest).to.eql(clean); + expect(partyMembers[0].party.quest).to.eql(clean); expect(res).to.eql(questingGroup.quest); expect(questingGroup.quest).to.eql({ diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js index 8b34278c08..65d781c163 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -88,7 +88,7 @@ describe('POST /groups/:groupId/quests/leave', () => { await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`); - await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`)) + await expect(partyMembers[1].post(`/groups/${questingGroup._id}/quests/leave`)) .to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', From 4360f01936d787991a3d43aba4484bc80607952b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Fri, 12 Feb 2016 08:04:14 -0600 Subject: [PATCH 211/213] feat(api-v3): Add force-start quest route --- common/locales/en/api-v3.json | 4 +- ...-groups_groupId_quests_force-start.test.js | 126 ++++++++++++++++++ website/src/controllers/api-v3/quests.js | 53 ++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 87c63de581..649adf8c94 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -83,5 +83,7 @@ "questAlreadyRejected": "You already rejected the quest invitation.", "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.", "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", - "questInvitationDoesNotExist": "No quest invitation has been sent out yet." + "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", + "questNotPending": "There is no quest to start.", + "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest" } diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js new file mode 100644 index 0000000000..b6d43f826b --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js @@ -0,0 +1,126 @@ +import { + createAndPopulateGroup, + translate as t, + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:groupId/quests/force-start', () => { + const PET_QUEST = 'whale'; + + let questingGroup; + let leader; + let partyMembers; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { type: 'party', privacy: 'private' }, + members: 2, + }); + + questingGroup = group; + leader = groupLeader; + partyMembers = members; + + await leader.update({ + [`items.quests.${PET_QUEST}`]: 1, + }); + }); + + context('failure conditions', () => { + it('does not force start a quest for a group in which user is not a member', async () => { + let nonMember = await generateUser(); + + await expect(nonMember.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + it('does not force start quest for a guild', async () => { + let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({ + groupDetails: { type: 'guild', privacy: 'private' }, + }); + + await expect(guildLeader.post(`/groups/${guild._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + it('does not force start for a party without a pending quest', async () => { + await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questNotPending'), + }); + }); + + it('does not force start for a quest already underway', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + // quest will start after everyone has accepted + await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`); + + await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + }); + + it('does not allow non-quest leader or non-group leader to force start a quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/force-start`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('questOrGroupLeaderOnlyStartQuest'), + }); + }); + }); + + context('successfully force starting a quest', () => { + it('allows quest leader to force start quest', async () => { + let questLeader = partyMembers[0]; + await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); + await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await questLeader.post(`/groups/${questingGroup._id}/quests/force-start`); + + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.eql(true); + }); + + it('allows group leader to force start quest', async () => { + let questLeader = partyMembers[0]; + await questLeader.update({[`items.quests.${PET_QUEST}`]: 1}); + await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + await leader.post(`/groups/${questingGroup._id}/quests/force-start`); + + await questingGroup.sync(); + + expect(questingGroup.quest.active).to.eql(true); + }); + + it('sends back the quest object', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + let quest = await leader.post(`/groups/${questingGroup._id}/quests/force-start`); + + expect(quest.active).to.eql(true); + expect(quest.key).to.eql(PET_QUEST); + expect(quest.members).to.eql({ + [`${leader._id}`]: true, + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js index bf25b81396..e0ad3b86d4 100644 --- a/website/src/controllers/api-v3/quests.js +++ b/website/src/controllers/api-v3/quests.js @@ -238,6 +238,59 @@ api.rejectQuest = { }, }; + +/** + * @api {post} /groups/:groupId/quests/force-start Accept a pending quest + * @apiVersion 3.0.0 + * @apiName forceStart + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} quest Quest Object + */ +api.forceStart = { + method: 'POST', + url: '/groups/:groupId/quests/force-start', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'}); + + if (!group) throw new NotFound(res.t('groupNotFound')); + if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questNotPending')); + if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway')); + if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest')); + + group.markModified('quest'); + + await group.startQuest(user); + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + analytics.track('quest', { + category: 'behavior', + owner: user._id === group.quest.leader, + response: 'force-start', + gaLabel: 'force-start', + questName: group.quest.key, + uuid: user._id, + }); + }, +}; + /** * @api {post} /groups/:groupId/quests/cancel Cancels a quest * @apiVersion 3.0.0 From 599510aa78b48cdbc8517eeddbcce159d53016a1 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 12 Feb 2016 19:49:53 +0100 Subject: [PATCH 212/213] higher maxBuffer for integration tests --- tasks/gulp-tests.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index b8ccf23f68..c8af5e8b18 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -356,6 +356,7 @@ gulp.task('test:api-v3:unit', (done) => { gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), + {maxBuffer: 500*1028}, (err, stdout, stderr) => done(err) ) @@ -365,6 +366,7 @@ gulp.task('test:api-v3:integration', (done) => { gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), + {maxBuffer: 500*1028}, (err, stdout, stderr) => done(err) ) From edaf3ef4db7f29884fb73f4c8a6366bae3075e23 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 12 Feb 2016 19:50:50 +0100 Subject: [PATCH 213/213] fix typo: 1028->1024 bytes --- tasks/gulp-tests.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index c8af5e8b18..6256440998 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -356,7 +356,7 @@ gulp.task('test:api-v3:unit', (done) => { gulp.task('test:api-v3:integration', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive'), - {maxBuffer: 500*1028}, + {maxBuffer: 500*1024}, (err, stdout, stderr) => done(err) ) @@ -366,7 +366,7 @@ gulp.task('test:api-v3:integration', (done) => { gulp.task('test:api-v3:integration:separate-server', (done) => { let runner = exec( testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'), - {maxBuffer: 500*1028}, + {maxBuffer: 500*1024}, (err, stdout, stderr) => done(err) )