diff --git a/.eslintrc b/.eslintrc index 7613632120..85937b0e6e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -23,7 +23,6 @@ "no-lone-blocks": 2, "no-loop-func": 2, "no-implicit-coercion": 2, - "no-implied-eval": 2, "no-native-reassign": 2, "no-new-func": 2, "no-new-wrappers": 2, @@ -59,7 +58,6 @@ "no-path-concat": 2, "arrow-spacing": 2, "constructor-super": 2, - "generator-star-spacing": 2, "no-arrow-condition": 2, "no-class-assign": 2, "no-const-assign": 2, diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 935fdf2a1d..649adf8c94 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -15,7 +15,8 @@ "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", + "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\".", "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", @@ -27,5 +28,62 @@ "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.", + "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", + "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\"", + "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.", + "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.", + "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.", + "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.", + "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.", + "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", + "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.", + "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", + "questInviteNotFound": "No quest invitation found.", + "guildQuestsNotSupported": "Guilds cannot be invited on quests.", + "questNotFound": "Quest \"<%= key %>\" not found.", + "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.", + "noActiveQuestToLeave": "No active quest to leave", + "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest", + "notPartOfQuest": "You are not part of the quest", + "noActiveQuestToAbort": "There is no active quest to abort.", + "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest.", + "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.", + "questNotPending": "There is no quest to start.", + "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest" } 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/common/script/api-v3/cron.js b/common/script/api-v3/cron.js deleted file mode 100644 index 1bc8f29927..0000000000 --- a/common/script/api-v3/cron.js +++ /dev/null @@ -1,250 +0,0 @@ -import moment from 'moment'; -import _ from 'lodash'; -import scoreTask from './scoreTask'; -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, tasks, 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'); // TODO necessary? - } - } - - // 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 - let completed = task.completed; - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - // TODO pass req for analytics? - }); - - let absVal = completed ? Math.abs(task.value) : task.value; - todoTally += absVal; - }); - - 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({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. - user.fns.preenUserHistory(tasks); - - 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); - - - // 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/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js deleted file mode 100644 index 50ffb1518f..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(user.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/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/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/package.json b/package.json index fd5659a079..a068f199e1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cookie-parser": "^1.4.0", "cookie-session": "^1.2.0", "coupon-code": "~0.3.0", + "csv-stringify": "^1.0.1", "domain-middleware": "~0.1.0", "estraverse": "^4.1.1", "express": "~4.13.3", @@ -93,12 +94,13 @@ "npm": "^3.3.10" }, "scripts": { - "test": "gulp test", + "test": "gulp test:api-v3", "test:api-v2:unit": "mocha test/server_side", "test:api-v2:integration": "mocha test/api/v2 --recursive", - "test:api-v3": "mocha test/api/v3 --recursive", - "test:api-v3:unit": "mocha test/api/v3/unit --recursive", - "test:api-v3:integration": "mocha test/api/v3/integration --recursive", + "test:api-v3": "gulp test:api-v3", + "test:api-v3:unit": "gulp test:api-v3:unit", + "test:api-v3:integration": "gulp test:api-v3:integration", + "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server", "test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy", "test:common": "mocha test/common", "test:content": "mocha test/content", diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index ad14f4a54f..41d28f7a69 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -5,6 +5,9 @@ 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/challenge.js', + './website/src/models/tag.js', './website/src/models/emailUnsubscription.js', './website/src/server.js', ]; diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 5fbd32cac8..6256440998 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -13,6 +13,8 @@ import Q from 'q'; import runSequence from 'run-sequence'; import os from 'os'; +// TODO rewrite + const TEST_SERVER_PORT = 3003 const TEST_DB = 'habitrpg_test' let server; @@ -42,9 +44,9 @@ let testBin = (string, additionalEnvVariables = '') => { additionalEnvVariables = additionalEnvVariables.split(' ').join('&&set '); additionalEnvVariables = 'set ' + additionalEnvVariables + '&&'; } - return `set NODE_ENV=testing&&${additionalEnvVariables}${string}`; + return `set NODE_ENV=test&&${additionalEnvVariables}${string}`; } else { - return `NODE_ENV=testing ${additionalEnvVariables} ${string}`; + return `NODE_ENV=test ${additionalEnvVariables} ${string}`; } }; @@ -342,13 +344,53 @@ 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'), + {maxBuffer: 500*1024}, + (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'), + {maxBuffer: 500*1024}, + (err, stdout, stderr) => done(err) + ) + + pipe(runner); +}); + +gulp.task('test:api-v3', (done) => { + runSequence( + 'lint', + 'test:api-v3:unit', + 'test:api-v3:integration', + done + ); +}); + +// Old tests tasks +/* gulp.task('test:api-v3', ['test:api-v3:unit', 'test:api-v3:integration']); gulp.task('test:api-v3:watch', ['test:api-v3:unit:watch', 'test:api-v3:integration:watch']); -gulp.task('test:api-v3:unit', (done) => { - runMochaTests('./test/api/v3/unit/**/*.js', null, done) -}); +gulp.task('test:api-v3:unit', (done) => {*/ +// runMochaTests('./test/api/v3/unit/**/*.js', null, done) +/*}); gulp.task('test:api-v3:unit:watch', () => { gulp.watch(['website/src/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']); @@ -356,9 +398,9 @@ gulp.task('test:api-v3:unit:watch', () => { gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => { process.env.API_VERSION = 'v3'; - awaitPort(TEST_SERVER_PORT).then(() => { - runMochaTests('./test/api/v3/integration/**/*.js', server, done) - }); + awaitPort(TEST_SERVER_PORT).then(() => {*/ +// runMochaTests('./test/api/v3/integration/**/*.js', server, done) +/* }); }); gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => { @@ -430,4 +472,4 @@ gulp.task('test', ['test:all'], () => { console.log('\n\x1b[36mThanks for helping keep Habitica clean!\x1b[0m'); process.exit(); } -}); +});*/ diff --git a/test/README.md b/test/README.md deleted file mode 100644 index 8115753727..0000000000 --- a/test/README.md +++ /dev/null @@ -1,5 +0,0 @@ -We need to clean up this directory. The *real* tests are in spec/ mock/ e2e/ and api.mocha.coffee. We want to: - -1. Move all old / deprecated tests from casper, test2, etc into spec, mock, e2e -1. Remove dependency of api.mocha.coffee on Derby, port it to Mongoose -1. Add better test-coverage diff --git a/test/api-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/api/README.md b/test/api/README.md index b5223cc0f4..ff3296721f 100644 --- a/test/api/README.md +++ b/test/api/README.md @@ -1,5 +1,7 @@ # So you want to write API integration tests? +@TODO rewrite + That's great! This README will serve as a quick primer for style conventions and practices for these tests. ## What is this? @@ -73,7 +75,7 @@ POST-groups_id_leave.test.js To mitigate [callback hell](http://callbackhell.com/) :imp:, we've written a helper method to generate a user object that can make http requests that [return promises](https://babeljs.io/docs/learn-es2015/#promises). This makes it very easy to chain together commands. All you need to do to make a subsequent request is return another promise and then call `.then((result) => {})` on the surrounding block, like so: ```js -it('does something', () => { +it('does something', () => { let user; return generateUser().then((_user) => { // We return the initial promise so this test can be run asyncronously @@ -97,7 +99,7 @@ it('does something', () => { If the test is simple, you can use the [chai-as-promised](http://chaijs.com/plugins/chai-as-promised) `return expect(somePromise).to.eventually` syntax to make your assertion. ```js -it('makes the party creator the leader automatically', () => { +it('makes the party creator the leader automatically', () => { return expect(user.post('/groups', { type: 'party', })).to.eventually.have.deep.property('leader._id', user._id); @@ -107,7 +109,7 @@ it('makes the party creator the leader automatically', () => { If the test is checking that the request returns an error, use the `.eventually.be.rejected.and.eql` syntax. ```js -it('returns an error', () => { +it('returns an error', () => { return expect(user.get('/groups/id-of-a-party-that-user-does-not-belong-to')) .to.eventually.be.rejected.and.eql({ code: 404, diff --git a/test/api/v3/README.md b/test/api/v3/README.md new file mode 100644 index 0000000000..ad9b55a32c --- /dev/null +++ b/test/api/v3/README.md @@ -0,0 +1,4 @@ +# How to run tests: + +1. `npm test` is equivalent to `gulp test:api-v3` which will run, in order, `gulp lint`, `gulp test:api-v3:unit` and `gulp test:api-v3:integration`. If one of these fails, the whole `npm test` command blocks and fails. Each of these commands can also be run as a standalone command. +2. To run the server and the integrations tests in two different terminals (to better inspect the output in the server) run `npm start` in one and `npm test:api-v3:integration:separate-server` in the other diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js new file mode 100644 index 0000000000..e8bd7a66d9 --- /dev/null +++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js @@ -0,0 +1,95 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + sleep, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /challenges/:challengeId', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/test`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Deleting a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let taskText = 'A challenge task text'; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup(); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + + challenge = await generateChallenge(groupLeader, group); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to delete the challenge', async () => { + let user = await generateUser(); + + await expect(user.del(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderDeleteChal'), + }); + }); + + it('deletes challenge', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); + }); + + it('refunds gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); + }); + + it('sets broken and doesn\'t set winner flags for user\'s challenge tasks', async () => { + await groupLeader.del(`/challenges/${challenge._id}`); + + await sleep(0.5); + + let tasks = await groupLeader.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_DELETED'); + expect(testTask.challenge.winner).to.be.null; + }); + }); +}); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId.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/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js new file mode 100644 index 0000000000..e4c1ceee34 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -0,0 +1,71 @@ +import { + generateUser, + createAndPopulateGroup, + generateChallenge, + translate as t, + sleep, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /challenges/:challengeId/export/csv', () => { + let groupLeader; + let group; + let challenge; + let members; + let user; + + beforeEach(async () => { + user = await generateUser(); + + let populatedGroup = await createAndPopulateGroup({ + members: 3, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + members = populatedGroup.members; + + challenge = await generateChallenge(groupLeader, group); + await members[0].post(`/challenges/${challenge._id}/join`); + await members[1].post(`/challenges/${challenge._id}/join`); + await members[2].post(`/challenges/${challenge._id}/join`); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: 'Task 1'}, + {type: 'todo', text: 'Task 2'}, + ]); + await sleep(0.5); // Make sure tasks are synced to the users + await members[0].sync(); + await members[1].sync(); + await members[2].sync(); + }); + + it('fails if challenge doesn\'t exists', async () => { + await expect(user.get(`/challenges/${generateUUID()}/export/csv`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('fails if user doesn\'t have access to the challenge', async () => { + await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('should return a valid CSV file with export data', async () => { + let res = await members[0].get(`/challenges/${challenge._id}/export/csv`); + let sortedMembers = _.sortBy([members[0], members[1], members[2], groupLeader], '_id'); + let splitRes = res.split('\n'); + + expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Task,Value,Notes'); + expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[5]).to.equal(''); + }); +}); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js new file mode 100644 index 0000000000..ae037b53e4 --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js @@ -0,0 +1,107 @@ +import { + generateUser, + generateGroup, + generateChallenge, + 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); + 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', + 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 generateChallenge(leader, group); + 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 generateChallenge(anotherUser, group); + 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 generateChallenge(user, group); + + 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 generateChallenge(user, group); + + 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()); + }); +}); 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..315d7bef2c --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js @@ -0,0 +1,107 @@ +import { + generateUser, + generateChallenge, + 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 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({ + 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 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, + 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 generateChallenge(groupLeader, group); + 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 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}`); + 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 generateChallenge(user, group); + 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/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..3f116d833d --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -0,0 +1,112 @@ +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 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 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}, + }); + }); + }); + + 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 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 new file mode 100644 index 0000000000..21ae0aa96b --- /dev/null +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -0,0 +1,122 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET challenges/user', () => { + let user, member, nonMember, challenge, challenge2, publicGuild; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'TestGuild', + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + user = groupLeader; + publicGuild = group; + 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; + 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 () => { + let challenges = await user.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 () => { + let challenges = await member.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 () => { + 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/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..2f4878d91a --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -0,0 +1,308 @@ +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 group 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`, { + group: 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`, { + group: '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`, { + group: 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`, { + group: 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`, { + group: 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]; + + let chal = await groupMember.post(`/challenges`, { + group: group._id, + name: 'Test Challenge', + shortName: 'TC', + }); + + 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 () => { + let oldUserBalance = groupLeader.balance; + let oldGroupBalance = group.balance; + + await groupLeader.post(`/challenges`, { + group: 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`, { + group: 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`, { + group: 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`, { + group: 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`, { + group: 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`, { + group: 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`, { + group: 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`, { + group: 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`, { + group: 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`, { + group: group._id, + name, + shortName, + description, + prize, + }); + + 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.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`, { + group: group._id, + name: 'Test Challenge', + shortName: 'TC', + }); + + await expect(groupLeader.sync()).to.eventually.have.property('challenges').to.include(challenge._id); + }); + }); +}); 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..34e018d69d --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -0,0 +1,126 @@ +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('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`); + + 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/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js new file mode 100644 index 0000000000..9694b263a9 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js @@ -0,0 +1,123 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/leave', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post('/challenges/test/leave')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Leaving a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let notInChallengeUser; + let leavingUser; + let taskText; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 2, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + leavingUser = populatedGroup.members[0]; + notInChallengeUser = populatedGroup.members[1]; + + challenge = await generateChallenge(groupLeader, group); + + taskText = 'A challenge task text'; + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await leavingUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to view the challenge', async () => { + let unauthorizedUser = await generateUser(); + + await expect(unauthorizedUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + it('returns an error when user isn\'t a member of the challenge', async () => { + await expect(notInChallengeUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('challengeMemberNotFound'), + }); + }); + + it('removes challenge from user challenges', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await leavingUser.sync(); + + expect(leavingUser).to.have.property('challenges').to.not.include(challenge._id); + }); + + it('decreases memberCount of challenge', async () => { + let oldMemberCount = challenge.memberCount; + + await leavingUser.post(`/challenges/${challenge._id}/leave`); + + await challenge.sync(); + + expect(challenge).to.have.property('memberCount', oldMemberCount - 1); + }); + + it('unlinks challenge tasks from leaving user when remove-all is passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'remove-all', + }); + let tasks = await leavingUser.get('/tasks/user'); + let tasksTexts = tasks.map((task) => { + return task.text; + }); + + expect(tasksTexts).to.not.include(taskText); + }); + + it('doesn\'t unlink challenge tasks from leaving user when remove-all isn\'t passed', async () => { + await leavingUser.post(`/challenges/${challenge._id}/leave`, { + keep: 'test', + }); + + let tasks = await leavingUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask).to.not.be.undefined; + expect(testTask.challenge).to.be.undefined; + }); + }); +}); diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js new file mode 100644 index 0000000000..98dfc20926 --- /dev/null +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -0,0 +1,139 @@ +import { + generateUser, + generateChallenge, + createAndPopulateGroup, + sleep, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /challenges/:challengeId/winner/:winnerId', () => { + it('returns error when challengeId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/test/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when winnerId is not a valid UUID', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/selectWinner/test`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when challengeId is not for a valid challenge', async () => { + let user = await generateUser(); + + await expect(user.post(`/challenges/${generateUUID()}/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('challengeNotFound'), + }); + }); + + context('Selecting winner for a valid challenge', () => { + let groupLeader; + let group; + let challenge; + let winningUser; + let taskText = 'A challenge task text'; + + beforeEach(async () => { + let populatedGroup = await createAndPopulateGroup({ + members: 1, + }); + + groupLeader = populatedGroup.groupLeader; + group = populatedGroup.group; + winningUser = populatedGroup.members[0]; + + challenge = await generateChallenge(groupLeader, group, { + prize: 1, + }); + + await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ + {type: 'habit', text: taskText}, + ]); + + await winningUser.post(`/challenges/${challenge._id}/join`); + + await challenge.sync(); + }); + + it('returns an error when user doesn\'t have permissions to select winner', async () => { + await expect(winningUser.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyLeaderDeleteChal'), + }); + }); + + it('returns an error when winning user isn\'t part of the challenge', async () => { + let notInChallengeUser = await generateUser(); + + await expect(groupLeader.post(`/challenges/${challenge._id}/selectWinner/${notInChallengeUser._id}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('winnerNotFound', {userId: notInChallengeUser._id}), + }); + }); + + it('deletes challenge after winner is selected', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false); + }); + + it('adds challenge to winner\'s achievements', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(winningUser.sync()).to.eventually.have.deep.property('achievements.challenges').to.include(challenge.name); + }); + + it('gives winner gems as reward', async () => { + let oldBalance = winningUser.balance; + + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(winningUser.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4); + }); + + it('doesn\'t refund gems to group leader', async () => { + let oldBalance = (await groupLeader.sync()).balance; + + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance); + }); + + it('sets broken and winner flags for user\'s challenge tasks', async () => { + await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`); + + await sleep(0.5); + + let tasks = await winningUser.get('/tasks/user'); + let testTask = _.find(tasks, (task) => { + return task.text === taskText; + }); + + expect(testTask.challenge.broken).to.eql('CHALLENGE_CLOSED'); + expect(testTask.challenge.winner).to.eql(winningUser.profile.name); + }); + }); +}); diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js new file mode 100644 index 0000000000..63d2102968 --- /dev/null +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -0,0 +1,84 @@ +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, + group: '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.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.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/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js new file mode 100644 index 0000000000..5d1f73f867 --- /dev/null +++ b/test/api/v3/integration/chat/DELETE-chat_id.test.js @@ -0,0 +1,81 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /groups/:groupId/chat/:chatId', () => { + let groupWithChat, message, user, userThatDidNotCreateChat, admin; + + before(async () => { + let { group, groupLeader } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + }); + + groupWithChat = group; + user = groupLeader; + message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + userThatDidNotCreateChat = await generateUser(); + admin = await generateUser({'contributor.admin': true}); + }); + + context('Chat errors', () => { + it('returns an error is message does not exist', async () => { + let fakeChatId = generateUUID(); + await expect(user.del(`/groups/${groupWithChat._id}/chat/${fakeChatId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('returns an error when user does not have permission to delete', async () => { + await expect(userThatDidNotCreateChat.del(`/groups/${groupWithChat._id}/chat/${message.id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyCreatorOrAdminCanDeleteChat'), + }); + }); + }); + + context('Chat success', () => { + let nextMessage; + + beforeEach(async () => { + nextMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some new message' }); + nextMessage = nextMessage.message; + }); + + it('allows creator to delete a their message', async () => { + await user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('allows admin to delete another user\'s message', async () => { + await admin.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`); + let messages = await user.get(`/groups/${groupWithChat._id}/chat/`); + expect(messages).is.an('array'); + expect(messages).to.not.include(nextMessage); + }); + + it('returns empty when previous message parameter is passed and the last message was deleted', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${nextMessage.id}`)) + .to.eventually.be.empty; + }); + + it('returns the update chat when previous message parameter is passed and the chat is updated', async () => { + await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${message.id}`)) + .eventually + .is.an('array') + .to.include(message) + .to.be.lengthOf(1); + }); + }); +}); diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js new file mode 100644 index 0000000000..91424d655e --- /dev/null +++ b/test/api/v3/integration/chat/GET-chat.test.js @@ -0,0 +1,65 @@ +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /groups/:groupId/chat', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + context('public Guild', () => { + let group; + + before(async () => { + let leader = await generateUser({balance: 2}); + + group = await generateGroup(leader, { + name: 'test group', + type: 'guild', + privacy: 'public', + }, { + chat: [ + {text: 'Hello', flags: {}}, + {text: 'Welcome to the Guild', flags: {}}, + ], + }); + }); + + 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(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', 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 new file mode 100644 index 0000000000..51a3abb164 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -0,0 +1,84 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { find } from 'lodash'; + +describe('POST /chat/:chatId/flag', () => { + let user, admin, anotherUser, group; + const TEST_MESSAGE = 'Test Message'; + + before(async () => { + user = await generateUser({balance: 1}); + admin = await generateUser({balance: 1, 'contributor.admin': true}); + anotherUser = await generateUser(); + + group = await user.post('/groups', { + name: 'Test Guild', + type: 'guild', + privacy: 'public', + }); + }); + + it('Returns an error when chat message is not found', async () => { + await expect(user.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', async () => { + 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 = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; + + 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 message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; + + 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 = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE}); + message = message.message; + + 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 new file mode 100644 index 0000000000..d7ae6047df --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.like.test.js @@ -0,0 +1,75 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { find } from 'lodash'; + +describe('POST /chat/:chatId/like', () => { + let user; + let groupWithChat; + let testMessage = 'Test Message'; + let anotherUser; + + 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', async () => { + await expect(user.post(`/groups/${groupWithChat._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', 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', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + + 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', async () => { + let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + + 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 new file mode 100644 index 0000000000..99d1469af8 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -0,0 +1,81 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /chat', () => { + let user, groupWithChat, userWithChatRevoked, member; + let testMessage = 'Test Message'; + + 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', async () => { + await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: ''})) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + 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', 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', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + + expect(message.message.id).to.exist; + }); + + it('notifies other users of new messages for a guild', async () => { + let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + let memberWithNotification = await member.get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist; + }); + + it('notifies other users of new messages for a party', async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage}); + let memberWithNotification = await members[0].get('/user'); + + expect(message.message.id).to.exist; + expect(memberWithNotification.newMessages[`${group._id}`]).to.exist; + }); +}); diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js new file mode 100644 index 0000000000..8b22461a04 --- /dev/null +++ b/test/api/v3/integration/chat/POST-chat_seen.test.js @@ -0,0 +1,63 @@ +import { + createAndPopulateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:id/chat/seen', () => { + context('Guild', () => { + let guild, guildLeader, guildMember, guildMessage; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + guild = group; + guildLeader = groupLeader; + guildMember = members[0]; + + guildMessage = await guildLeader.post(`/groups/${guild._id}/chat`, { message: 'Some guild message' }); + guildMessage = guildMessage.message; + }); + + it('clears new messages for a guild', async () => { + await guildMember.post(`/groups/${guild._id}/chat/seen`); + + let guildThatHasSeenChat = await guildMember.get('/user'); + + expect(guildThatHasSeenChat.newMessages).to.be.empty; + }); + }); + + context('Party', () => { + let party, partyLeader, partyMember, partyMessage; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'party', + privacy: 'private', + }, + members: 1, + }); + + party = group; + partyLeader = groupLeader; + partyMember = members[0]; + + partyMessage = await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some party message' }); + partyMessage = partyMessage.message; + }); + + it('clears new messages for a party', async () => { + await partyMember.post(`/groups/${party._id}/chat/seen`); + + let partyMemberThatHasSeenChat = await partyMember.get('/user'); + + expect(partyMemberThatHasSeenChat.newMessages).to.be.empty; + }); + }); +}); diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js new file mode 100644 index 0000000000..87cad5d6f0 --- /dev/null +++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js @@ -0,0 +1,101 @@ +import { + createAndPopulateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:id/chat/:id/clearflags', () => { + let groupWithChat, message, author, nonAdmin, admin; + + before(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + groupWithChat = group; + author = groupLeader; + nonAdmin = members[0]; + admin = await generateUser({'contributor.admin': true}); + + message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); + message = message.message; + admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/flag`); + }); + + context('Single Message', () => { + it('returns error when non-admin attempts to clear flags', async () => { + return expect(nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupChatAdminClearFlagCount'), + }); + }); + + it('returns error if message does not exist', async () => { + let fakeMessageID = generateUUID(); + + await expect(admin.post(`/groups/${groupWithChat._id}/chat/${fakeMessageID}/clearflags`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('messageGroupChatNotFound'), + }); + }); + + it('clears flags and leaves old flags on the flag object', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + expect(messages[0].flagCount).to.eql(0); + expect(messages[0].flags).to.have.property(admin._id, true); + }); + }); + + context('admin user, group with multiple messages', () => { + let message2, message3, message4; + + before(async () => { + message2 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 2' }); + message2 = message2.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message2.id}/flag`); + + message3 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 3' }); + message3 = message3.message; + await admin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + await nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`); + + message4 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 4' }); + message4 = message4.message; + }); + + it('changes only the message that is flagged', async () => { + await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`); + let messages = await admin.get(`/groups/${groupWithChat._id}/chat`); + + expect(messages).to.have.lengthOf(4); + + let messageThatWasUnflagged = messages[3]; + let messageWith1Flag = messages[2]; + let messageWith2Flag = messages[1]; + let messageWithoutFlags = messages[0]; + + expect(messageThatWasUnflagged.flagCount).to.eql(0); + expect(messageThatWasUnflagged.flags).to.have.property(admin._id, true); + + expect(messageWith1Flag.flagCount).to.eql(5); + expect(messageWith1Flag.flags).to.have.property(admin._id, true); + + expect(messageWith2Flag.flagCount).to.eql(6); + expect(messageWith2Flag.flags).to.have.property(admin._id, true); + expect(messageWith2Flag.flags).to.have.property(nonAdmin._id, true); + + expect(messageWithoutFlags.flagCount).to.eql(0); + expect(messageWithoutFlags.flags).to.eql({}); + }); + }); +}); diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js new file mode 100644 index 0000000000..b37b87f890 --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups.test.js @@ -0,0 +1,96 @@ +import { + generateUser, + resetHabiticaDB, + generateGroup, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /groups', () => { + let user; + const NUMBER_OF_PUBLIC_GUILDS = 3; + const NUMBER_OF_USERS_PRIVATE_GUILDS = 1; + const NUMBER_OF_GROUPS_USER_CAN_VIEW = 5; + + before(async () => { + await resetHabiticaDB(); + + let leader = await generateUser({ balance: 10 }); + user = await generateUser({balance: 4}); + + let publicGuildUserIsMemberOf = await generateGroup(leader, { + name: 'public guild - is member', + type: 'guild', + privacy: 'public', + }); + await leader.post(`/groups/${publicGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); + await user.post(`/groups/${publicGuildUserIsMemberOf._id}/join`); + + await generateGroup(leader, { + name: 'public guild - is not member', + type: 'guild', + privacy: 'public', + }); + + let privateGuildUserIsMemberOf = await generateGroup(leader, { + name: 'private guild - is member', + type: 'guild', + privacy: 'private', + }); + await leader.post(`/groups/${privateGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]}); + await user.post(`/groups/${privateGuildUserIsMemberOf._id}/join`); + + await generateGroup(leader, { + name: 'private guild - is not member', + type: 'guild', + privacy: 'private', + }); + + await generateGroup(leader, { + name: 'party - is not member', + type: 'party', + privacy: 'private', + }); + + await user.post('/groups', { + name: 'party - is member', + type: 'party', + privacy: 'private', + }); + }); + + it('returns error when no query passed in', async () => { + await expect(user.get('/groups')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Invalid request parameters.', + }); + }); + + it('returns only the tavern when tavern passed in as query', async () => { + await expect(user.get('/groups?type=tavern')) + .to.eventually.have.a.lengthOf(1) + .and.to.have.deep.property('[0]') + .and.to.have.property('_id', 'habitrpg'); + }); + + it('returns only the user\'s party when party passed in as query', async () => { + await expect(user.get('/groups?type=party')) + .to.eventually.have.a.lengthOf(1) + .and.to.have.deep.property('[0]'); + }); + + it('returns all public guilds when publicGuilds passed in as query', async () => { + await expect(user.get('/groups?type=publicGuilds')) + .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS); + }); + + it('returns all private guilds user is a part of when privateGuilds passed in as query', async () => { + await expect(user.get('/groups?type=privateGuilds')) + .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_PRIVATE_GUILDS); + }); + + it('returns a list of groups user has access to', async () => { + await expect(user.get('/groups?type=privateGuilds,publicGuilds,party,tavern')) + .to.eventually.have.lengthOf(NUMBER_OF_GROUPS_USER_CAN_VIEW); + }); +}); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js new file mode 100644 index 0000000000..d1604a4d84 --- /dev/null +++ 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++) { + invitesToGenerate.push(generateUser()); + } + 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 => { + 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(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()); + }); +}); 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..15193db012 --- /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/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js new file mode 100644 index 0000000000..2ecb53a33f --- /dev/null +++ b/test/api/v3/integration/groups/GET-groups_id.test.js @@ -0,0 +1,298 @@ +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); + }); + }); + }); + + context('Non-member of a public guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('returns the group object for a non-member', async () => { + let group = await nonMember.get(`/groups/${createdGroup._id}`); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-member of a private guild', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Non-member of a party', () => { + let nonMember, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + nonMember = await generateUser(); + }); + + it('does not return the group object for a non-member', async () => { + await expect(nonMember.get(`/groups/${createdGroup._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Member of a party', () => { + let member, createdGroup; + + before(async () => { + let groupData = await createAndPopulateGroup({ + members: 1, + groupDetails: { + name: 'test party', + type: 'party', + privacy: 'private', + }, + }); + + createdGroup = groupData.group; + member = groupData.members[0]; + }); + + it('returns the user\'s party if an id of "party" is passed in', async () => { + let group = await member.get('/groups/party'); + + expect(group._id).to.eql(createdGroup._id); + expect(group.name).to.eql(createdGroup.name); + expect(group.type).to.eql(createdGroup.type); + expect(group.privacy).to.eql(createdGroup.privacy); + }); + }); + + context('Non-existent group', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns error if group does not exist', async () => { + await expect(user.get('/groups/group-that-does-not-exist')) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + }); + + context('Flagged messages', () => { + let group; + + let chat1 = { + id: 'chat1', + text: 'chat 1', + flags: {}, + }; + + let chat2 = { + id: 'chat2', + text: 'chat 2', + flags: {}, + flagCount: 0, + }; + + let chat3 = { + id: 'chat3', + text: 'chat 3', + flags: { + 'user-id': true, + }, + flagCount: 1, + }; + + let chat4 = { + id: 'chat4', + text: 'chat 4', + flags: { + 'user-id': true, + 'other-user-id': true, + }, + flagCount: 2, + }; + + let chat5 = { + id: 'chat5', + text: 'chat 5', + flags: { + 'user-id': true, + 'other-user-id': true, + 'yet-another-user-id': true, + }, + flagCount: 3, + }; + + beforeEach(async () => { + let groupData = await createAndPopulateGroup({ + groupDetails: { + name: 'test guild', + type: 'guild', + privacy: 'public', + chat: [ + chat1, + chat2, + chat3, + chat4, + chat5, + ], + }, + }); + + group = groupData.group; + + await group.addChat([chat1, chat2, chat3, chat4, chat5]); + }); + + context('non-admin', () => { + let nonAdmin; + + beforeEach(async () => { + nonAdmin = await generateUser(); + }); + + it('does not include messages with a flag count of 2 or greater', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(3); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + }); + + it('does not include user ids in flags object', async () => { + let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql({}); + }); + }); + + context('admin', () => { + let admin; + + beforeEach(async () => { + admin = await generateUser({ + 'contributor.admin': true, + }); + }); + + it('includes all messages', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + + expect(fetchedGroup.chat).to.have.lengthOf(5); + expect(fetchedGroup.chat[0].id).to.eql(chat1.id); + expect(fetchedGroup.chat[1].id).to.eql(chat2.id); + expect(fetchedGroup.chat[2].id).to.eql(chat3.id); + expect(fetchedGroup.chat[3].id).to.eql(chat4.id); + expect(fetchedGroup.chat[4].id).to.eql(chat5.id); + }); + + it('includes user ids in flags object', async () => { + let fetchedGroup = await admin.get(`/groups/${group._id}`); + let chatWithOneFlag = fetchedGroup.chat[2]; + + expect(chatWithOneFlag.id).to.eql(chat3.id); + expect(chat3.flags).to.eql({ 'user-id': true }); + expect(chatWithOneFlag.flags).to.eql(chat3.flags); + }); + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js new file mode 100644 index 0000000000..8036d6b0d8 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups.test.js @@ -0,0 +1,217 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /group', () => { + let user; + + beforeEach(async () => { + user = await generateUser({ balance: 10 }); + }); + + context('All Groups', () => { + it('it returns validation error when type is not provided', async () => { + await expect( + user.post('/groups', { name: 'Test Group Without Type' }) + ).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Group validation failed', + }); + }); + + 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 () => { + 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, + }, + }); + }); + }); + + context('Guilds', () => { + 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({ + code: 401, + error: 'NotAuthorized', + message: t('messageInsufficientGems'), + }); + }); + + 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'; + let groupType = 'guild'; + let groupPrivacy = 'public'; + + let publicGuild = await user.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); + expect(publicGuild.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); + }); + }); + + context('private guild', () => { + let groupName = 'Test Private Guild'; + let groupType = 'guild'; + let groupPrivacy = 'private'; + + it('creates a group', async () => { + let privateGuild = await user.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); + 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 () => { + let privateGuild = await user.post('/groups', { + name: groupName, + type: groupType, + privacy: groupPrivacy, + }); + + expect(privateGuild.balance).to.eql(1); + + let updatedUser = await user.get('/user'); + + expect(updatedUser.balance).to.eql(user.balance - 1); + }); + }); + }); + + context('Parties', () => { + let partyName = 'Test Party'; + let partyType = 'party'; + + 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); + expect(party.leader).to.eql({ + _id: user._id, + profile: { + name: user.profile.name, + }, + }); + }); + + 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, + type: partyType, + }); + + await expect(user.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/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js new file mode 100644 index 0000000000..b9b759bed9 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js @@ -0,0 +1,223 @@ +import { + generateUser, + createAndPopulateGroup, + checkExistence, + translate as t, +} from '../../../../helpers/api-v3-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('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 () => { + 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 () => { + await user.post(`/groups/${publicGuild._id}/leave`); + + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.deep.property('leader._id', joiningUser._id); + }); + + it('increments memberCount when joining guilds', async () => { + let oldMemberCount = publicGuild.memberCount; + + await joiningUser.post(`/groups/${publicGuild._id}/join`); + + await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1); + }); + }); + + context('Joining a private guild', () => { + let user, invitedUser, guild; + + beforeEach(async () => { + 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 userWithoutInvite = await generateUser(); + + await expect(userWithoutInvite.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), + }); + }); + + context('User is invited', () => { + 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('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`); + + 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); + }); + }); + }); + + context('Joining a party', () => { + let user, invitedUser, party; + + beforeEach(async () => { + 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 userWithoutInvite = await generateUser(); + + await expect(userWithoutInvite.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('messageGroupRequiresInvite'), + }); + }); + + context('User is invited', () => { + it('allows invited user to join party', async () => { + await invitedUser.post(`/groups/${party._id}/join`); + + await expect(invitedUser.get('/user')).to.eventually.have.deep.property('party._id', party._id); + }); + + it('clears invitation from user when joining party', async () => { + await invitedUser.post(`/groups/${party._id}/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`); + + 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); + }); + + it('deletes previous party where the user was the only member', async () => { + let userToInvite = await generateUser(); + let oldParty = await userToInvite.post('/groups', { // add user to a party + name: 'Another Test Party', + type: 'party', + }); + + await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(true); + await user.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + await userToInvite.post(`/groups/${party._id}/join`); + + await expect(user.get('/user')).to.eventually.have.deep.property('party._id', party._id); + await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(false); + }); + + xit('invites joining member to active quest', async () => { + // TODO start quest + + await invitedUser.post(`/groups/${party._id}/join`); + + invitedUser = await user.get('/user'); + party = await user.get(`/groups/${party._id}`); + + expect(user).to.have.deep.property('party.quest.RSVPNeeded', true); + expect(user).to.have.deep.property('party.quest.key', party.quest.key); + + expect(party.quest.members[invitedUser._id]).to.be.undefined; + }); + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_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/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js new file mode 100644 index 0000000000..0ebde39361 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -0,0 +1,130 @@ +import { + generateUser, + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:groupId/removeMember/:memberId', () => { + let leader; + let invitedUser; + let guild; + let member; + let member2; + + beforeEach(async () => { + let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Guild', + type: 'guild', + privacy: 'private', + }, + invites: 1, + members: 2, + }); + + guild = group; + leader = groupLeader; + invitedUser = invitees[0]; + member = members[0]; + member2 = members[1]; + }); + + context('All Groups', () => { + it('returns an error when user is not member of the group', async () => { + let nonMember = await generateUser(); + + expect(nonMember.post(`/groups/${guild._id}/removeMember/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + type: 'NotAuthorized', + message: t('onlyLeaderCanRemoveMember'), + }); + }); + + it('returns an error when user is a non-leader member of a group', async () => { + expect(member2.post(`/groups/${guild._id}/removeMember/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + type: 'NotAuthorized', + message: t('onlyLeaderCanRemoveMember'), + }); + }); + + it('does not allow leader to remove themselves', async () => { + expect(leader.post(`/groups/${guild._id}/removeMember/${leader._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + text: t('messageGroupCannotRemoveSelf'), + }); + }); + }); + + context('Guilds', () => { + it('can remove other members', async () => { + await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); + let memberRemoved = await member.get('/user'); + + expect(memberRemoved.guilds.indexOf(guild._id)).eql(-1); + }); + + it('updates memberCount', async () => { + let oldMemberCount = guild.memberCount; + await leader.post(`/groups/${guild._id}/removeMember/${member._id}`); + await expect(leader.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); + }); + + it('can remove other invites', async () => { + await leader.post(`/groups/${guild._id}/removeMember/${invitedUser._id}`); + + let invitedUserWithoutInvite = await invitedUser.get('/user'); + + expect(_.findIndex(invitedUserWithoutInvite.invitations.guilds, {id: guild._id})).eql(-1); + }); + }); + + context('Party', () => { + let party; + let partyleader; + let partyInvitedUser; + let partyMember; + + beforeEach(async () => { + let { group, groupLeader, invitees, members } = await createAndPopulateGroup({ + groupDetails: { + name: 'Test Party', + type: 'party', + privacy: 'private', + }, + invites: 1, + members: 1, + }); + + party = group; + partyleader = groupLeader; + partyInvitedUser = invitees[0]; + partyMember = members[0]; + }); + + it('can remove other members', async () => { + await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); + + let memberRemoved = await partyMember.get('/user'); + + expect(memberRemoved.party._id).eql(undefined); + }); + + it('updates memberCount', async () => { + let oldMemberCount = party.memberCount; + await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`); + await expect(partyleader.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1); + }); + + it('can remove other invites', async () => { + await partyleader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`); + + let invitedUserWithoutInvite = await partyInvitedUser.get('/user'); + + expect(_.findIndex(invitedUserWithoutInvite.invitations.party, {id: party._id})).eql(-1); + }); + }); +}); diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js new file mode 100644 index 0000000000..55c25d9153 --- /dev/null +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -0,0 +1,325 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +const INVITES_LIMIT = 100; + +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 = generateUUID(); + + 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 = generateUUID(); + + 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('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(); + + 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); + }); + + 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@habitica.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('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], + })).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@habitica.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('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@habitica.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 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, 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], + })) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('userAlreadyInAParty'), + }); + }); + + it('allow inviting an user to a party if he\'s partying solo', async () => { + let userToInvite = await generateUser(); + await userToInvite.post('/groups', { // add user to a party + name: 'Another Test Party', + type: 'party', + }); + + await inviter.post(`/groups/${party._id}/invite`, { + uuids: [userToInvite._id], + }); + expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id); + }); + }); +}); diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js new file mode 100644 index 0000000000..8d581d56ca --- /dev/null +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -0,0 +1,46 @@ +import { + createAndPopulateGroup, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('PUT /group', () => { + let leader, nonLeader, groupToUpdate; + let groupName = 'Test Public Guild'; + let groupType = 'guild'; + let groupUpdatedName = 'Test Public Guild Updated'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + name: groupName, + type: groupType, + privacy: 'public', + }, + members: 1, + }); + + groupToUpdate = group; + leader = groupLeader; + nonLeader = members[0]; + }); + + it('returns an error when a non group leader tries to update', async () => { + await expect(nonLeader.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 updatedGroup = await leader.put(`/groups/${groupToUpdate._id}`, { + 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/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..9b4c734ea9 --- /dev/null +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -0,0 +1,49 @@ +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('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}, + 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/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js new file mode 100644 index 0000000000..665a185279 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js @@ -0,0 +1,119 @@ +import { + createAndPopulateGroup, + translate as t, + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /groups/:groupId/quests/accept', () => { + const PET_QUEST = 'whale'; + + let questingGroup; + let leader; + let partyMembers; + let user; + + beforeEach(async () => { + user = await generateUser(); + + 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 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(user.post(`/groups/${questingGroup._id}/quests/accept`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + 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', 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', 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', 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/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/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js new file mode 100644 index 0000000000..973a51a1a5 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js @@ -0,0 +1,192 @@ +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'; + +describe('POST /groups/:groupId/quests/invite/:questKey', () => { + let questingGroup; + let leader; + let member; + const PET_QUEST = 'whale'; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { 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 { 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', + message: t('groupNotFound'), + }); + }); + + it('does not issue invites for Guilds', async () => { + 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', + 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 = 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, + error: 'NotAuthorized', + message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}), + }); + }); + + it('does not issue invites if a quest is already underway', async () => { + const QUEST_IN_PROGRESS = 'atom1'; + const leaderUpdate = {}; + leaderUpdate[`items.quests.${PET_QUEST}`] = 1; + + 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, + error: 'NotAuthorized', + message: t('questAlreadyUnderway'), + }); + }); + }); + + context('successfully issuing a quest invitation', () => { + beforeEach(async () => { + const memberUpdate = {}; + memberUpdate[`items.quests.${PET_QUEST}`] = 1; + + await Promise.all([ + leader.update(memberUpdate), + member.update(memberUpdate), + ]); + }); + + it('adds quest details to group object', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + 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(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'); + }); + + 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(), + ]); + + 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); + }); + + 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(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'); + }); + + 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/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..850cf53646 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js @@ -0,0 +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; + let partyMembers; + let user; + let leader; + + const PET_QUEST = 'whale'; + + 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, + }); + user = await generateUser(); + }); + + 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 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/abort`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + 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 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 res = await leader.post(`/groups/${questingGroup._id}/quests/abort`); + await Promise.all([ + leader.sync(), + questingGroup.sync(), + partyMembers[0].sync(), + partyMembers[1].sync(), + ]); + + let cleanUserQuestObj = { + key: null, + progress: { + up: 0, + down: 0, + collect: {}, + }, + completed: null, + RSVPNeeded: false, + }; + + 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(res); + expect(questingGroup.quest).to.eql({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); + }); +}); 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..f3bd03a180 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js @@ -0,0 +1,138 @@ +import { + createAndPopulateGroup, + translate as t, + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/cancel', () => { + let questingGroup; + let partyMembers; + let user; + let leader; + + const PET_QUEST = 'whale'; + + 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, + }); + user = await generateUser(); + }); + + 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('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/cancel`)) + .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/cancel`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('questInvitationDoesNotExist'), + }); + }); + + it('only the leader can cancel the quest', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + + 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`); + + let res = 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).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({ + key: null, + active: false, + leader: null, + progress: { + collect: {}, + }, + members: {}, + }); + }); +}); 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..65d781c163 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js @@ -0,0 +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; + let partyMembers; + let user; + let leader; + + const PET_QUEST = 'whale'; + + 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, + }); + user = await generateUser(); + }); + + 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 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/leave`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('guildQuestsNotSupported'), + }); + }); + + 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[1].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 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 leaveResult = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`); + await Promise.all([ + partyMembers[0].sync(), + questingGroup.sync(), + ]); + + 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/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..1eb62aa0c6 --- /dev/null +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -0,0 +1,146 @@ +import { + createAndPopulateGroup, + translate as t, + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /groups/:groupId/quests/reject', () => { + let questingGroup; + let partyMembers; + let user; + let leader; + + const PET_QUEST = 'whale'; + + 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, + }); + user = await generateUser(); + }); + + context('failure conditions', () => { + it('returns an error when group is not found', async () => { + await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/reject`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('groupNotFound'), + }); + }); + + 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'), + }); + }); + + 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', () => { + 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}`); + + let res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + await partyMembers[0].sync(); + await questingGroup.sync(); + + 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 () => { + 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/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js index 9393c1db12..9dec366104 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/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js index 8be31c7a51..f3dca7c02a 100644 --- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js +++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js @@ -14,7 +14,7 @@ describe('DELETE /tasks/:id', () => { let task; beforeEach(async () => { - task = await user.post('/tasks', { + task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -42,7 +42,7 @@ describe('DELETE /tasks/:id', () => { it('cannot delete a task owned by someone else', async () => { let anotherUser = await generateUser(); - let anotherUsersTask = await anotherUser.post('/tasks', { + let anotherUsersTask = await 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 deleted file mode 100644 index 9053fa7226..0000000000 --- a/test/api/v3/integration/tasks/GET-tasks.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - generateUser, -} from '../../../../helpers/api-integration/v3'; -import Q from 'q'; - -describe('GET /tasks', () => { - let user; - - beforeEach(async () => { - user = await generateUser(); - }); - - it('returns all user\'s tasks', async () => { - let createdTasks = await Q.all([ - user.post('/tasks', {text: 'test habit', type: 'habit'}), - ]); - - 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', 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 - it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified'); -}); 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 d05473b843..99f2d769f4 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -15,7 +15,7 @@ describe('GET /tasks/:id', () => { let task; beforeEach(async () => { - task = await user.post('/tasks', { + task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -43,7 +43,7 @@ describe('GET /tasks/:id', () => { it('cannot get a task owned by someone else', async () => { let anotherUser = await generateUser(); - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js new file mode 100644 index 0000000000..cd11100599 --- /dev/null +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -0,0 +1,42 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v3'; + +describe('GET /tasks/user', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns all user\'s tasks', 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'); + 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/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]); + 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.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'}); + + 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 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.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js deleted file mode 100644 index 0f467a9ef2..0000000000 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ /dev/null @@ -1,339 +0,0 @@ -import { - generateUser, - translate as t, -} from '../../../../helpers/api-integration/v3'; - -describe('POST /tasks', () => { - let user; - - before(async () => { - user = await generateUser(); - }); - - context('validates params', async () => { - it('returns an error if req.body.type is absent', async () => { - await expect(user.post('/tasks', { - notType: 'habit', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns an error if req.body.type is not valid', async () => { - await expect(user.post('/tasks', { - type: 'habitF', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: t('invalidReqParams'), - }); - }); - - it('returns an error if req.body.text is absent', async () => { - await expect(user.post('/tasks', { - type: 'habit', - })).to.eventually.be.rejected.and.eql({ - code: 400, - error: 'BadRequest', - message: 'habit validation failed', - }); - }); - - it('automatically sets "task.userId" to user\'s uuid', async () => { - let task = await user.post('/tasks', { - text: 'test habit', - type: 'habit', - }); - - expect(task.userId).to.equal(user._id); - }); - - it(`ignores setting userId, history, createdAt, - updatedAt, challenge, completed, streak, - dateCompleted fields`, async () => { - let task = await user.post('/tasks', { - text: 'test daily', - type: 'daily', - userId: 123, - history: [123], - createdAt: 'yesterday', - updatedAt: 'tomorrow', - challenge: 'no', - completed: true, - streak: 25, - dateCompleted: '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', async () => { - let task = await user.post('/tasks', { - text: 'test daily', - type: 'daily', - notValid: true, - }); - - expect(task).not.to.have.property('notValid'); - }); - }); - - context('habits', () => { - it('creates a habit', async () => { - let task = await user.post('/tasks', { - text: 'test habit', - type: 'habit', - up: false, - down: true, - notes: 1976, - }); - - 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('defaults to setting up and down to true', async () => { - let task = await user.post('/tasks', { - text: 'test habit', - type: 'habit', - notes: 1976, - }); - - expect(task.up).to.eql(true); - expect(task.down).to.eql(true); - }); - - it('cannot create checklists', async () => { - let task = await user.post('/tasks', { - text: 'test habit', - type: 'habit', - checklist: [ - {_id: 123, completed: false, text: 'checklist'}, - ], - }); - - expect(task).not.to.have.property('checklist'); - }); - }); - - context('todos', () => { - it('creates a todo', async () => { - let task = await user.post('/tasks', { - text: 'test todo', - type: 'todo', - notes: 1976, - }); - - 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('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('can create checklists', async () => { - let task = await user.post('/tasks', { - text: 'test todo', - type: 'todo', - checklist: [ - {completed: false, text: 'checklist'}, - ], - }); - - 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', async () => { - let now = new Date(); - - let task = await user.post('/tasks', { - text: 'test daily', - type: 'daily', - notes: 1976, - frequency: 'daily', - everyX: 5, - startDate: 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('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('defaults to a weekly frequency, with every day set', async () => { - let task = await user.post('/tasks', { - text: 'test daily', - type: 'daily', - }); - - 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', async () => { - let task = await user.post('/tasks', { - text: 'test daily', - type: 'daily', - repeat: { - m: false, - w: false, - 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', async () => { - let today = (new Date()).getDay(); - - let task = await user.post('/tasks', { - text: 'test daily', - type: 'daily', - }); - - expect((new Date(task.startDate)).getDay()).to.eql(today); - }); - - it('can create checklists', async () => { - let task = await user.post('/tasks', { - text: 'test daily', - type: 'daily', - checklist: [ - {completed: false, text: 'checklist'}, - ], - }); - - 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', async () => { - let task = await user.post('/tasks', { - text: 'test reward', - type: 'reward', - notes: 1976, - value: 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('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('defaults to a 0 value', async () => { - let task = await user.post('/tasks', { - text: 'test reward', - type: 'reward', - }); - - expect(task.value).to.eql(0); - }); - - it('requires value to be coerced into a number', async () => { - let task = await user.post('/tasks', { - text: 'test reward', - type: 'reward', - value: '10', - }); - - expect(task.value).to.eql(10); - }); - - it('cannot create checklists', async () => { - let task = await user.post('/tasks', { - text: 'test reward', - type: 'reward', - checklist: [ - {_id: 123, completed: false, text: 'checklist'}, - ], - }); - - expect(task).not.to.have.property('checklist'); - }); - }); -}); 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..d6cdf21749 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js @@ -0,0 +1,43 @@ +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=todos'); + 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 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_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index d3897df87d..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 @@ -35,7 +35,7 @@ describe('POST /tasks/:id/score/:direction', () => { let todo; beforeEach(async () => { - todo = await user.post('/tasks', { + todo = await user.post('/tasks/user', { text: 'test todo', type: 'todo', }); @@ -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? @@ -134,7 +136,7 @@ describe('POST /tasks/:id/score/:direction', () => { let daily; beforeEach(async () => { - daily = await user.post('/tasks', { + daily = await user.post('/tasks/user', { text: 'test daily', type: 'daily', }); @@ -205,24 +207,24 @@ describe('POST /tasks/:id/score/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars beforeEach(async () => { - habit = await user.post('/tasks', { + habit = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); - minusHabit = await user.post('/tasks', { + minusHabit = await user.post('/tasks/user', { text: 'test min habit', type: 'habit', up: false, }); - plusHabit = await user.post('/tasks', { + plusHabit = await user.post('/tasks/user', { text: 'test plus habit', type: 'habit', down: false, }); - neitherHabit = await user.post('/tasks', { + neitherHabit = await user.post('/tasks/user', { text: 'test neither habit', type: 'habit', up: false, @@ -267,7 +269,7 @@ describe('POST /tasks/:id/score/:direction', () => { let reward, updatedUser; beforeEach(async () => { - reward = await user.post('/tasks', { + reward = await user.post('/tasks/user', { text: 'test reward', type: 'reward', value: 5, 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/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js new file mode 100644 index 0000000000..afae951fb1 --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -0,0 +1,571 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('POST /tasks/user', () => { + let user; + + before(async () => { + user = await generateUser(); + }); + + context('validates params', async () => { + it('returns an error if req.body.type is absent', async () => { + await expect(user.post('/tasks/user', { + notType: 'habit', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidTaskType'), + }); + }); + + it('returns an error if req.body.type is not valid', async () => { + await expect(user.post('/tasks/user', { + type: 'habitF', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidTaskType'), + }); + }); + + it('returns an error if one object inside an array is invalid', async () => { + await expect(user.post('/tasks/user', [ + {type: 'habitF'}, + {type: 'habit'}, + ])).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidTaskType'), + }); + }); + + it('returns an error if req.body.text is absent', async () => { + await expect(user.post('/tasks/user', { + type: 'habit', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }); + }); + + it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => { + let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; + await expect(user.post('/tasks/user', { + type: 'habit', + })).to.eventually.be.rejected.and.eql({ // this block is necessary + code: 400, + error: 'BadRequest', + message: 'habit validation failed', + }); + + 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; + await expect(user.post('/tasks/user', [ + {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', + }); + + 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/user'); + await expect(user.post('/tasks/user', [ + {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/user'); + + expect(updatedTasks).to.eql(originalTasks); + }); + }); + + it('automatically sets "task.userId" to user\'s uuid', async () => { + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + }); + + expect(task.userId).to.equal(user._id); + }); + + it(`ignores setting userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`, async () => { + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: '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', async () => { + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + notValid: true, + }); + + expect(task).not.to.have.property('notValid'); + }); + }); + + context('habits', () => { + it('creates a habit', async () => { + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }); + + 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('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/user', { + 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/user', [{ + 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/user', [{ + text: 'test habit', + type: 'habit', + up: false, + down: true, + notes: 1976, + }, { + text: 'test habit 2', + type: 'habit', + up: true, + down: false, + notes: 1977, + }]); + + 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', async () => { + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + notes: 1976, + }); + + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); + }); + + it('cannot create checklists', async () => { + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }); + + expect(task).not.to.have.property('checklist'); + }); + }); + + context('todos', () => { + it('creates a todo', async () => { + let task = await user.post('/tasks/user', { + text: 'test todo', + type: 'todo', + notes: 1976, + }); + + 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', async () => { + let [task, task2] = await user.post('/tasks/user', [{ + text: 'test todo', + type: 'todo', + notes: 1976, + }, { + text: 'test todo 2', + type: 'todo', + notes: 1977, + }]); + + 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('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/user', { + 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/user', [{ + 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/user', { + text: 'test todo', + type: 'todo', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }); + + 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', async () => { + let now = new Date(); + + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + notes: 1976, + frequency: 'daily', + everyX: 5, + startDate: 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', async () => { + let [task, task2] = await user.post('/tasks/user', [{ + text: 'test daily', + type: 'daily', + notes: 1976, + }, { + text: 'test daily 2', + type: 'daily', + notes: 1977, + }]); + + 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('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/user', { + 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/user', [{ + 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/user', { + text: 'test daily', + type: 'daily', + }); + + 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', async () => { + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + repeat: { + m: false, + w: false, + 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', async () => { + let today = (new Date()).getDay(); + + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + }); + + expect((new Date(task.startDate)).getDay()).to.eql(today); + }); + + it('can create checklists', async () => { + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }); + + 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', async () => { + let task = await user.post('/tasks/user', { + text: 'test reward', + type: 'reward', + notes: 1976, + value: 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', async () => { + let [task, task2] = await user.post('/tasks/user', [{ + text: 'test reward', + type: 'reward', + notes: 1976, + value: 11, + }, { + text: 'test reward 2', + type: 'reward', + notes: 1977, + value: 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('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/user', { + 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/user', [{ + 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/user', { + text: 'test reward', + type: 'reward', + }); + + expect(task.value).to.eql(0); + }); + + it('requires value to be coerced into a number', async () => { + let task = await user.post('/tasks/user', { + text: 'test reward', + type: 'reward', + value: '10', + }); + + expect(task.value).to.eql(10); + }); + + it('cannot create checklists', async () => { + let task = await user.post('/tasks/user', { + text: 'test reward', + type: 'reward', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }); + + expect(task).not.to.have.property('checklist'); + }); + }); +}); 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 dc171d0400..c1cf952197 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -14,7 +14,7 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(async () => { - task = await user.post('/tasks', { + task = await user.post('/tasks/user', { text: 'test habit', type: 'habit', }); @@ -61,7 +61,7 @@ describe('PUT /tasks/:id', () => { let habit; beforeEach(async () => { - habit = await user.post('/tasks', { + habit = await user.post('/tasks/user', { text: 'test habit', type: 'habit', notes: 1976, @@ -87,7 +87,7 @@ describe('PUT /tasks/:id', () => { let todo; beforeEach(async () => { - todo = await user.post('/tasks', { + todo = await user.post('/tasks/user', { text: 'test todo', type: 'todo', notes: 1976, @@ -142,7 +142,7 @@ describe('PUT /tasks/:id', () => { let daily; beforeEach(async () => { - daily = await user.post('/tasks', { + daily = await user.post('/tasks/user', { text: 'test daily', type: 'daily', notes: 1976, @@ -244,7 +244,7 @@ describe('PUT /tasks/:id', () => { let reward; beforeEach(async () => { - reward = await user.post('/tasks', { + reward = await user.post('/tasks/user', { text: 'test reward', type: 'reward', notes: 1976, 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); + }); +}); 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 000bc83d60..7b00896738 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 @@ -12,7 +12,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('deletes a checklist item', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -26,7 +26,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('does not work with habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -39,7 +39,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/user', { type: 'reward', text: 'reward with checklist', }); @@ -60,7 +60,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }); 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 b685dc5457..ce9e2dab7c 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 @@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/checklist/', () => { }); it('adds a checklist item to a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -32,7 +32,7 @@ describe('POST /tasks/:taskId/checklist/', () => { }); it('does not add a checklist to habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -47,7 +47,7 @@ describe('POST /tasks/:taskId/checklist/', () => { }); it('does not add a checklist to rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); 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 6090b89125..3efdd9642f 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 @@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('scores a checklist item', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -29,7 +29,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -44,7 +44,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/user', { type: 'reward', text: 'reward with checklist', }); @@ -65,7 +65,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { }); it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }); 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 8332497565..3f375c3c77 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 @@ -12,7 +12,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('updates a checklist item', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'daily', text: 'Daily with checklist', }); @@ -35,7 +35,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on habits', async () => { - let habit = await user.post('/tasks', { + let habit = await user.post('/tasks/user', { type: 'habit', text: 'habit with checklist', }); @@ -48,7 +48,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on rewards', async () => { - let reward = await user.post('/tasks', { + let reward = await user.post('/tasks/user', { type: 'reward', text: 'reward with checklist', }); @@ -69,7 +69,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => { }); it('fails on checklist item not found', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'daily', text: 'daily with checklist', }); 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 71348fb680..fecb64b405 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 @@ -12,7 +12,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('removes a tag from a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); @@ -28,7 +28,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => { }); it('only deletes existing tags', async () => { - let createdTask = await user.post('/tasks', { + let createdTask = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); 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 2030bc9981..b6e7174022 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 @@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('adds a tag to a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); @@ -24,7 +24,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a tag to a task twice', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); @@ -41,7 +41,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => { }); it('does not add a non existing tag to a task', async () => { - let task = await user.post('/tasks', { + let task = await user.post('/tasks/user', { type: 'habit', text: 'Task with tag', }); diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js index 95960c704d..ebde4ee3ea 100644 --- a/test/api/v3/unit/libs/baseModel.test.js +++ b/test/api/v3/unit/libs/baseModel.test.js @@ -1,23 +1,16 @@ import baseModel from '../../../../../website/src/libs/api-v3/baseModel'; +import mongoose from 'mongoose'; describe('Base model plugin', () => { - let schema = { - add () { - return true; - }, - statics: {}, - options: {}, - pre () { - return true; - }, - }; + let schema; beforeEach(() => { + schema = new mongoose.Schema(); sandbox.stub(schema, 'add'); }); it('adds a _id field to the schema', () => { - baseModel(schema); + schema.plugin(baseModel); expect(schema.add).to.be.calledWith(sinon.match({ _id: sinon.match.object, @@ -25,13 +18,13 @@ describe('Base model plugin', () => { }); it('can add timestamps fields', () => { - baseModel(schema, {timestamps: true}); + schema.plugin(baseModel, {timestamps: true}); expect(schema.add).to.be.calledTwice; }); it('can sanitize input objects', () => { - baseModel(schema, { + schema.plugin(baseModel, { noSet: ['noUpdateForMe'], }); @@ -44,7 +37,7 @@ describe('Base model plugin', () => { }); it('accepts an array of additional fields to sanitize at runtime', () => { - baseModel(schema, { + schema.plugin(baseModel, { noSet: ['noUpdateForMe'], }); @@ -58,7 +51,7 @@ describe('Base model plugin', () => { it('can make fields private', () => { - baseModel(schema, { + schema.plugin(baseModel, { private: ['amPrivate'], }); @@ -76,7 +69,7 @@ describe('Base model plugin', () => { toJSONTransform: sandbox.stub().returns(true), }; - baseModel(schema, options); + schema.plugin(baseModel, options); let objToTransform = {ok: true, amPrivate: true}; let privatized = schema.options.toJSON.transform({}, objToTransform); @@ -91,7 +84,7 @@ describe('Base model plugin', () => { sanitizeTransform: sandbox.stub().returns(true), }; - baseModel(schema, options); + schema.plugin(baseModel, options); expect(schema.options.toJSON.transform).to.exist; let objToSanitize = {ok: true, noUpdateForMe: true}; diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js new file mode 100644 index 0000000000..e32e953d51 --- /dev/null +++ b/test/api/v3/unit/libs/collectionManipulators.test.js @@ -0,0 +1,88 @@ +import mongoose from 'mongoose'; +import { + removeFromArray, +} from '../../../../../website/src/libs/api-v3/collectionManipulators'; + +describe('Collection Manipulators', () => { + describe('removeFromArray', () => { + it('removes element from array', () => { + let array = ['a', 'b', 'c', 'd']; + + removeFromArray(array, 'c'); + + expect(array).to.not.include('c'); + }); + + it('removes object from array', () => { + let array = [ + { id: 'a', foo: 'bar' }, + { id: 'b', foo: 'bar' }, + { id: 'c', foo: 'bar' }, + { id: 'd', foo: 'bar' }, + { id: 'e', foo: 'bar' }, + ]; + + removeFromArray(array, { id: 'c' }); + + expect(array).to.not.include({ id: 'c', foo: 'bar' }); + }); + + it('does not change array if value is not found', () => { + let array = ['a', 'b', 'c', 'd']; + + removeFromArray(array, 'z'); + + expect(array).to.have.a.lengthOf(4); + expect(array[0]).to.eql('a'); + expect(array[1]).to.eql('b'); + expect(array[2]).to.eql('c'); + expect(array[3]).to.eql('d'); + }); + + it('returns the removed element', () => { + let array = ['a', 'b', 'c']; + + let result = removeFromArray(array, 'b'); + + expect(result).to.eql('b'); + }); + + it('returns the removed object element', () => { + let array = [ + { id: 'a', foo: 'bar' }, + { id: 'b', foo: 'bar' }, + { id: 'c', foo: 'bar' }, + { id: 'd', foo: 'bar' }, + { id: 'e', foo: 'bar' }, + ]; + + let result = removeFromArray(array, { id: 'c' }); + + expect(result).to.eql({ id: 'c', foo: 'bar' }); + }); + + it('returns false if item is not found', () => { + let array = ['a', 'b', 'c']; + + let result = removeFromArray(array, 'z'); + + expect(result).to.eql(false); + }); + + it('persists removal of element when mongoose document is saved', async () => { + let schema = new mongoose.Schema({ + array: Array, + }); + let Model = mongoose.model('ModelToTestRemoveFromArray', schema); + let model = await new Model({ + array: ['a', 'b', 'c'], + }).save(); // Initial creation + + removeFromArray(model.array, 'b'); + + let savedModel = await model.save(); + + expect(savedModel.array).to.not.include('b'); + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js index bcaa7898e2..eb238c6fa9 100644 --- a/test/api/v3/unit/middlewares/analytics.test.js +++ b/test/api/v3/unit/middlewares/analytics.test.js @@ -53,4 +53,3 @@ describe('analytics middleware', () => { expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase); }); }); - diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js new file mode 100644 index 0000000000..ef76e7b984 --- /dev/null +++ b/test/api/v3/unit/models/group.test.js @@ -0,0 +1,300 @@ +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'; + +describe('Group Model', () => { + context('Instance Methods', () => { + describe('#startQuest', () => { + let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; + + beforeEach(async () => { + sandbox.stub(email, 'sendTxn'); + + 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', async () => { + let guild = new Group({ + type: 'guild', + }); + + await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected; + }); + + 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', async () => { + party.quest.key = 'whale'; + party.quest.active = true; + + await expect(party.startQuest(participatingMember)).to.eventually.be.rejected; + }); + }); + + context('Successes', () => { + beforeEach(() => { + party.quest.key = 'whale'; + party.quest.active = false; + 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(participatingMember); + + expect(party.quest.active).to.eql(true); + }); + + it('sets up boss quest', () => { + let bossQuest = questScrolls.whale; + party.quest.key = bossQuest.key; + + party.startQuest(participatingMember); + + 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(participatingMember); + + expect(party.quest.progress.rage).to.eql(0); + }); + + it('sets up collection quest', () => { + let collectionQuest = questScrolls.vice2; + party.quest.key = collectionQuest.key; + party.startQuest(participatingMember); + + 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(participatingMember); + + expect(party.quest.progress.collect).to.eql({ + tracks: 0, + branches: 0, + }); + }); + + it('prunes non-participating members from quest members object', () => { + party.startQuest(participatingMember); + + let expectedQuestMembers = {}; + expectedQuestMembers[questLeader._id] = true; + expectedQuestMembers[participatingMember._id] = true; + + expect(party.quest.members).to.eql(expectedQuestMembers); + }); + + it('applies updates to user object directly if user is participating', async () => { + await party.startQuest(participatingMember); + + 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('applies updates to other participating members', async () => { + await party.startQuest(nonParticipatingMember); + + 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('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); + + await sleep(0.5); + + 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); + + await sleep(0.5); + + 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); + + await sleep(0.5); + + 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('updates participting members (not including user)', async () => { + sandbox.spy(User, 'update'); + + await party.startQuest(nonParticipatingMember); + + let members = [questLeader._id, participatingMember._id]; + + 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('updates non-user quest leader and decrements quest scroll', async () => { + sandbox.spy(User, 'update'); + + await party.startQuest(participatingMember); + + 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/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/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js index 3fe7adbca7..f584826520 100644 --- a/test/helpers/api-integration/api-classes.js +++ b/test/helpers/api-integration/api-classes.js @@ -4,7 +4,7 @@ import { requester } from './requester'; import { getDocument as getDocumentFromMongo, updateDocument as updateDocumentInMongo, -} from './mongo'; +} from '../mongo'; import { assign, each, @@ -59,6 +59,29 @@ export class ApiGroup extends ApiObject { this._docType = 'groups'; } + + async addChat (chat) { + let group = this; + + if (!chat) { + chat = { + id: 'Test_ID', + text: 'Test message', + flagCount: 0, + timestamp: Date(), + likes: {}, + flags: {}, + uuid: group.leader, + contributor: {}, + backer: {}, + user: group.leader, + }; + } + + let update = { chat }; + + return await this.update(update); + } } export class ApiChallenge extends ApiObject { diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js index a55da4d3d8..bcf580e653 100644 --- a/test/helpers/api-integration/requester.js +++ b/test/helpers/api-integration/requester.js @@ -1,8 +1,9 @@ /* eslint-disable no-use-before-define */ import superagent from 'superagent'; +import nconf from 'nconf'; -const API_TEST_SERVER_PORT = 3003; +const API_TEST_SERVER_PORT = nconf.get('PORT'); let apiVersion; // Sets up an abject that can make all REST requests @@ -51,7 +52,8 @@ function _requestMaker (user, method, additionalSets) { reject(parsedError); } - resolve(response.body); + let contentType = response.headers['content-type'] || ''; + resolve(contentType.indexOf('json') !== -1 ? response.body : response.text); }); }); }; diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js index 0c440ade26..6ae15d7ca6 100644 --- a/test/helpers/api-integration/v3/index.js +++ b/test/helpers/api-integration/v3/index.js @@ -6,5 +6,6 @@ requester.setApiVersion('v3'); export { requester }; export { translate } from '../translate'; -export { checkExistence, resetHabiticaDB } from '../mongo'; +export { checkExistence, resetHabiticaDB } from '../../mongo'; export * from './object-generators'; +export { sleep } from '../../sleep'; diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index f14ecdad67..57f6e2163b 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 @@ -72,18 +72,18 @@ 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, () => { - return generateUser(); + return generateUser(groupMembershipTypes[group.type]); }) ); - 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, () => { @@ -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.group = 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; +} diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index cefd1eeeeb..1a590f8a3a 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -1,12 +1,17 @@ import '../../website/src/libs/api-v3/i18n'; +import mongoose from 'mongoose'; import { defaultsDeep as defaults } from 'lodash'; import { model as User } from '../../website/src/models/user'; import { model as Group } from '../../website/src/models/group'; +import mongo from './mongo'; // eslint-disable-line -afterEach(() => { +afterEach((done) => { sandbox.restore(); + mongoose.connection.db.dropDatabase(done); }); +export { sleep } from './sleep'; + export function generateUser (options = {}) { return new User(options).toObject(); } diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js index a90aba70c0..4f703efdd6 100644 --- a/test/helpers/api-v3-integration.helper.js +++ b/test/helpers/api-v3-integration.helper.js @@ -1 +1 @@ -export * from './api-integration/v3'; +module.exports = require('./api-integration/v3'); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index 19f3ebfc71..9e92a85de9 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -1,5 +1,6 @@ /* eslint-disable no-undef */ -require('babel-core/register'); +/* eslint-disable global-require */ +/* eslint-disable no-process-env */ //------------------------------ // Global modules //------------------------------ @@ -12,7 +13,25 @@ global.expect = chai.expect; global.sinon = require('sinon'); global.sandbox = sinon.sandbox.create(); +import nconf from 'nconf'; +import mongoose from 'mongoose'; +import Q from 'q'; + //------------------------------ // Load nconf for unit tests //------------------------------ -require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); +if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose + require('../../website/src/libs/api-v3/setupNconf')('./config.json'); + // Use Q promises instead of mpromise in mongoose + mongoose.Promise = Q.Promise; + mongoose.connect(nconf.get('NODE_DB_URI')); +} else { // When running tests and the server in the same process + require('../../website/src/libs/api-v3/setupNconf')('./config.json.example'); + nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test'); + nconf.set('NODE_ENV', 'test'); + nconf.set('IS_TEST', true); + // We require src/server and npt src/index because + // 1. nconf is already setup + // 2. we don't need clustering + require('../../website/src/server'); +} diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/mongo.js similarity index 52% rename from test/helpers/api-integration/mongo.js rename to test/helpers/mongo.js index 52be5263b4..d99c195c3c 100644 --- a/test/helpers/api-integration/mongo.js +++ b/test/helpers/mongo.js @@ -1,24 +1,17 @@ -/* eslint-disable no-use-before-define */ - -import { MongoClient as mongo } from 'mongodb'; - -const DB_URI = 'mongodb://localhost/habitrpg_test'; +import mongoose from 'mongoose'; // Useful for checking things that have been deleted, // but you no longer have access to, // like private parties or users export async function checkExistence (collectionName, id) { - let db = await connectToMongo(); - return new Promise((resolve, reject) => { - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); collection.find({_id: id}, {_id: 1}).limit(1).toArray((findError, docs) => { if (findError) return reject(findError); let exists = docs.length > 0; - db.close(); resolve(exists); }); }); @@ -27,66 +20,67 @@ export async function checkExistence (collectionName, id) { // Specifically helpful for the GET /groups tests, // resets the db to an empty state and creates a tavern document export async function resetHabiticaDB () { - let db = await connectToMongo(); - return new Promise((resolve, reject) => { - db.dropDatabase((dbErr) => { + mongoose.connection.db.dropDatabase((dbErr) => { if (dbErr) return reject(dbErr); - let groups = db.collection('groups'); + let groups = mongoose.connection.db.collection('groups'); - groups.insertOne({ - _id: 'habitrpg', - chat: [], - leader: '9', - name: 'HabitRPG', - type: 'guild', - privacy: 'public', - members: [], - }, (insertErr) => { - if (insertErr) return reject(insertErr); + // For some mysterious reason after a dropDatabase there can still be a group... + groups.count({_id: 'habitrpg'}, (err, count) => { + if (err) return reject(err); + if (count > 0) return resolve(); - db.close(); - resolve(); + groups.insertOne({ + _id: 'habitrpg', + chat: [], + leader: '9', + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }, (insertErr) => { + if (insertErr) return reject(insertErr); + + resolve(); + }); }); }); }); } export async function updateDocument (collectionName, doc, update) { - let db = await connectToMongo(); - - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); return new Promise((resolve) => { collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); - db.close(); resolve(); }); }); } export async function getDocument (collectionName, doc) { - let db = await connectToMongo(); - - let collection = db.collection(collectionName); + let collection = mongoose.connection.db.collection(collectionName); return new Promise((resolve) => { collection.findOne({ _id: doc._id }, (lookupErr, found) => { if (lookupErr) throw new Error(`Error looking up ${collectionName}: ${lookupErr}`); - db.close(); resolve(found); }); }); } -export function connectToMongo () { - return new Promise((resolve, reject) => { - mongo.connect(DB_URI, (err, db) => { - if (err) return reject(err); - - resolve(db); - }); +before((done) => { + mongoose.connection.on('open', (err) => { + if (err) return done(err); + resetHabiticaDB() + .then(() => done()) + .catch(done); }); -} +}); +after((done) => { + mongoose.connection.db.dropDatabase((err) => { + if (err) return done(err); + mongoose.connection.close(done); + }); +}); diff --git a/test/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); + }); +} diff --git a/test/mocha.opts b/test/mocha.opts index bcdefd3e55..c55b351ceb 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -5,5 +5,4 @@ --growl --globals io --compilers js:babel-core/register ---require test/api-legacy/api-helper --require ./test/helpers/globals.helper diff --git a/website/src/controllers/api-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/auth.js b/website/src/controllers/api-v3/auth.js index 2c7e00cadc..c66b254e9b 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 = await 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,80 @@ 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); + 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,16 +255,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}}) - .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/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js new file mode 100644 index 0000000000..c239a63dd3 --- /dev/null +++ b/website/src/controllers/api-v3/challenges.js @@ -0,0 +1,568 @@ +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, + basicFields as basicGroupFields, +} from '../../models/group'; +import { + model as User, + nameFields, +} from '../../models/user'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import shared from '../../../../common'; +import * as Tasks from '../../models/task'; +import { sendTxn as txnEmail } from '../../libs/api-v3/email'; +import pushNotify from '../../libs/api-v3/pushNotifications'; +import Q from 'q'; +import csvStringify from '../../libs/api-v3/csvStringify'; + +let api = {}; + +/** + * @api {post} /challenges Create a new challenge + * @apiVersion 3.0.0 + * @apiName CreateChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} challenge The newly created challenge + */ +api.createChallenge = { + method: 'POST', + url: '/challenges', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkBody('group', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let groupId = req.body.group; + let prize = req.body.prize; + + 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')); + } + + 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; + } + } + + group.challengeCount += 1; + + req.body.leader = user._id; + req.body.official = user.contributor.admin && req.body.official; + let challenge = new Challenge(Challenge.sanitize(req.body)); + + // 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) + + let response = savedChal.toJSON(); + 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, + }; + res.respond(201, response); + }, +}; + +/** + * @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.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge')); + + 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 + 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); + }, +}; + +/** + * @api {post} /challenges/:challengeId/leave Leaves a challenge + * @apiVersion 3.0.0 + * @apiName LeaveChallenge + * @apiGroup Challenge + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} empty An empty object + */ +api.leaveChallenge = { + method: 'POST', + url: '/challenges/:challengeId/leave', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all'; + + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challenge = await Challenge.findOne({ _id: req.params.challengeId }); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + let group = await Group.getGroup({user, groupId: challenge.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')); + + challenge.memberCount -= 1; + + // Unlink challenge's tasks from user's tasks and save the challenge + await Q.all([user.unlinkChallengeTasks(challenge._id, keep), challenge.save()]); + res.respond(200, {}); + }, +}; + +/** + * @api {get} /challenges/user Get challenges for a user + * @apiVersion 3.0.0 + * @apiName GetUserChallenges + * @apiGroup Challenge + * + * @apiSuccess {Array} challenges An array of challenges + */ +api.getUserChallenges = { + method: 'GET', + url: '/challenges/user', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + let challenges = await Challenge.find({ + $or: [ + {_id: {$in: user.challenges}}, // Challenges where the user is participating + {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') + // .populate('group', basicGroupFields) + // .populate('leader', nameFields) + .exec(); + + 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); + }, +}; + +/** + * @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({group: groupId}) + .sort('-official -timestamp') + // .populate('leader', nameFields) // Only populate the leader as the group is implicit + .exec(); + + 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); + }, +}; + +/** + * @api {get} /challenges/:challengeId Get a challenge given its id + * @apiVersion 3.0.0 + * @apiName GetChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The challenge object + */ +api.getChallenge = { + method: 'GET', + 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) + // 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 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')); + + 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); + }, +}; + +/** + * @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 group leader tasksOrder').exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + 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 + // results on the server so the perf difference isn't that big (hopefully) + + let [members, tasks] = await Q.all([ + User.find({challenges: challengeId}) + .select(nameFields) + .sort({_id: 1}) + .lean() // so we don't involve mongoose + .exec(), + + Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}}) + .sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(), + ]); + + let resArray = members.map(member => [member._id, member.profile.name]); + + // We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0] + let lastUserId; + let index = -1; + tasks.forEach(task => { + if (task.userId !== lastUserId) { + lastUserId = task.userId; + index++; + } + + resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes); + }); + + // The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks + let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => { + return result.concat(array); + }, []).sort(); + resArray.unshift(['UUID', 'name']); + _.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes')); + + res.set({ + 'Content-Type': 'text/csv', + 'Content-disposition': `attachment; filename=${challengeId}.csv`, + }); + + let csvRes = await csvStringify(resArray); + res.status(200).send(csvRes); + }, +}; + +/** + * @api {put} /challenges/:challengeId Update a challenge + * @apiVersion 3.0.0 + * @apiName UpdateChallenge + * @apiGroup Challenge + * + * @apiParam {UUID} challengeId The challenge _id + * + * @apiSuccess {object} challenge The updated challenge object + */ +api.updateChallenge = { + method: 'PUT', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + + let challenge = await Challenge.findById(challengeId).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + + let group = await Group.getGroup({user, groupId: challenge.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(); + 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); + }, +}; + +// TODO everything here should be moved to a worker +// 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; + + // 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.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.group}, {$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 + User.update({ + challenges: 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(), + ]; + + Q.allSettled(backgroundTasks); // TODO look if allSettled 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], + 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}).exec(); + 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, {}); + }, +}; + +/** + * @api {post} /challenges/:challengeId/selectWinner/:winnerId Select winner for challenge + * @apiVersion 3.0.0 + * @apiName SelectChallengeWinner + * @apiGroup Challenge + * + * @apiSuccess {object} empty An empty object + */ +api.selectChallengeWinner = { + method: 'POST', + url: '/challenges/:challengeId/selectWinner/:winnerId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec(); + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + 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.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, {}); + }, +}; + +export default api; diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js new file mode 100644 index 0000000000..079fbb66cd --- /dev/null +++ b/website/src/controllers/api-v3/chat.js @@ -0,0 +1,397 @@ +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 _ from 'lodash'; +import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; +import { sendTxn } from '../../libs/api-v3/email'; +import nconf from 'nconf'; + +let api = {}; + +/** + * @api {get} /groups/:groupId/chat Get chat messages from a group + * @apiVersion 3.0.0 + * @apiName GetChat + * @apiGroup Chat + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Array} chat An array of chat messages + */ +api.getChat = { + method: 'GET', + url: '/groups/:groupId/chat', + 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: 'chat'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + res.respond(200, Group.toJSONCleanChat(group, user).chat); + }, +}; + +/** + * @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], + async handler (req, res) { + 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) throw validationErrors; + + let group = await Group.getGroup({user, groupId}); + + 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(); // TODO why this is non-blocking? must catch? + } + + let savedGroup = await group.save(); + if (chatUpdated) { + res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat}); + } else { + res.respond(200, {message: savedGroup.chat[0]}); + } + }, +}; + +/** + * @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], + async handler (req, res) { + 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) throw validationErrors; + + 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.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage')); + + let update = {$set: {}}; + + if (!message.likes) message.likes = {}; + + message.likes[user._id] = !message.likes[user._id]; + update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id]; + + await Group.update( + {_id: group._id, 'chat.id': message.id}, + update + ); + res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned? + }, +}; + +/** + * @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], + async handler (req, res) { + 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) throw validationErrors; + + 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.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage')); + + let author = await User.findOne({_id: message.uuid}, {auth: 1}); + + 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; + if (user.contributor.admin) { + // Arbitraty amount, higher than 2 + message.flagCount = 5; + } else { + message.flagCount++; + } + update.$set['chat.$.flagCount'] = message.flagCount; + + 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; + + 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); + }, +}; + +/** + * @api {post} /groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags + * @apiVersion 3.0.0 + * @apiName ClearFlags + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * @apiParam {chatId} chatId The chat message _id + * + * @apiSuccess {Object} An empty object + */ +api.clearChatFlags = { + method: 'Post', + url: '/groups/:groupId/chat/:chatId/clearflags', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatId = req.params.chatId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.contributor.admin) { + throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount')); + } + + let group = await Group.getGroup({user, groupId}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let message = _.find(group.chat, {id: chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + message.flagCount = 0; + + await Group.update( + {_id: group._id, 'chat.id': message.id}, + {$set: {'chat.$.flagCount': message.flagCount}} + ); + + res.respond(200, {}); + }, +}; + +/** + * @api {post} /groups/:groupId/chat/:chatId/seen Seen a group chat message + * @apiVersion 3.0.0 + * @apiName SeenChat + * @apiGroup Chat + * + * @apiParam {groupId} groupId The group _id + * + * @apiSuccess {None} + */ +api.seenChat = { + method: 'POST', + url: '/groups/:groupId/chat/seen', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let update = {$unset: {}}; + update.$unset[`newMessages.${groupId}`] = true; + + await User.update({_id: user._id}, update).exec(); + res.respond(200); + }, +}; + +/** + * @api {delete} /groups/:groupId/chat/:chatId Delete chat message from a group + * @apiVersion 3.0.0 + * @apiName DeleteChat + * @apiGroup Chat + * + * @apiParam {string} groupId The group _id (or 'party') + * @apiParam {string} chatId The chat _id + * + * @apiSuccess {Array} The update chat array + * @apiSuccess {Object} An empty object when the previous message was deleted + */ +api.deleteChat = { + method: 'DELETE', + url: '/groups/:groupId/chat/:chatId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let groupId = req.params.groupId; + let chatId = req.params.chatId; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('chatId', res.t('chatIdRequired')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let group = await Group.getGroup({user, groupId, fields: 'chat'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + let message = _.find(group.chat, {id: chatId}); + if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); + + if (user._id !== message.uuid && !user.contributor.admin) { + throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat')); + } + + let lastClientMsg = req.query.previousMsg; + let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false; + + await Group.update( + {_id: group._id}, + {$pull: {chat: {id: chatId}}} + ); + + if (chatUpdated) { + let chatRes = Group.toJSONCleanChat(group, user).chat; + removeFromArray(chatRes, {id: chatId}); + res.respond(200, chatRes); + } else { + res.respond(200, {}); + } + }, +}; + +export default api; diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js new file mode 100644 index 0000000000..d4f19ea2ec --- /dev/null +++ b/website/src/controllers/api-v3/groups.js @@ -0,0 +1,641 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import Q from 'q'; +import _ from 'lodash'; +import cron from '../../middlewares/api-v3/cron'; +import { + INVITES_LIMIT, + model as Group, + basicFields as basicGroupFields, +} from '../../models/group'; +import { + model as User, + nameFields, +} from '../../models/user'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; +import { + NotFound, + 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'; + +let api = {}; + +// TODO shall we accept party as groupId in all routes? + +/** + * @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], + 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) throw new NotAuthorized(res.t('messageInsufficientGems')); + + group.balance = 1; + + user.balance--; + user.guilds.push(group._id); + } else { + 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; + } + + 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(); + // 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 + + firebase.updateGroupData(savedGroup); + firebase.addUserToGroup(savedGroup._id, user._id); + }, +}; + +/** + * @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], + 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) throw validationErrors; + + // TODO validate types are acceptable? probably not necessary + let types = req.query.type.split(','); + let groupFields = basicGroupFields.concat('description memberCount balance'); + let sort = '-memberCount'; + let queries = []; + + types.forEach(type => { + switch (type) { + case 'party': + queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields})); + break; + case 'privateGuilds': + queries.push(Group.find({ + type: 'guild', + privacy: 'private', + _id: {$in: user.guilds}, + }).select(groupFields).sort(sort).exec()); + break; + case 'publicGuilds': + queries.push(Group.find({ + type: 'guild', + privacy: 'public', + }).select(groupFields).sort(sort).exec()); // TODO use lean? + break; + case 'tavern': + if (types.indexOf('publicGuilds') === -1) { + queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields})); + } + break; + } + }); + + // If no valid value for type was supplied, return an error + if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); + + // 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, results); + }, +}; + +/** + * @api {get} /groups/:groupId Get group + * @apiVersion 3.0.0 + * @apiName GetGroup + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} group The group object + */ +api.getGroup = { + method: 'GET', + url: '/groups/:groupId', + 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, populateLeader: false}); + if (!group) throw new NotFound(res.t('groupNotFound')); + + 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); + }, +}; + +/** + * @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], + 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}); + 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))); + + let savedGroup = await group.save(); + 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); + }, +}; + +/** + * @api {post} /groups/:groupId/join Join a group + * @apiVersion 3.0.0 + * @apiName JoinGroup + * @apiGroup Group + * + * @apiParam {UUID} groupId The group _id + * + * @apiSuccess {Object} group The group + */ +api.joinGroup = { + method: 'POST', + url: '/groups/:groupId/join', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let inviter; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + // 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; + + 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 + if (group.quest.key && !group.quest.active) { + user.party.quest.RSVPNeeded = true; + user.party.quest.key = group.quest.key; + user.party.quest.progress = undefined; // Make sure to reset progress from ay previous quest + group.quest.members[user._id] = undefined; + group.markModified('quest.members'); + } + + // If user was in a different party (when partying solo you can be invited to a new party) + // make him leave that party before doing anything + if (user.party._id) { + let userPreviousParty = await Group.getGroup({user, groupId: user.party._id}); + if (userPreviousParty) await userPreviousParty.leave(user); + } + + user.party._id = group._id; // Set group as user's party + + isUserInvited = true; + } else if (group.type === 'guild') { + let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id }); + + if (hasInvitation) { + isUserInvited = true; + } 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 + + group.memberCount += 1; + + 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); + + 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); + }, +}; + +/** + * @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], + async handler (req, res) { + 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']); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + 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 + 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')); + } + } + + await group.leave(user, req.query.keep); + res.respond(200, {}); + }, +}; + +// 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) { + sendTxnEmail(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], + async handler (req, res) { + let user = res.locals.user; + + req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); + req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + 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.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; + if (member.party._id === group._id) { + isInGroup = 'party'; + } else if (member.guilds.indexOf(group._id) !== -1) { + isInGroup = 'guild'; + } + + let isInvited; + if (member.invitations.party && member.invitations.party.id === group._id) { + isInvited = 'party'; + } else if (_.findIndex(member.invitations.guilds, {id: group._id}) !== -1) { + isInvited = 'guild'; + } + + if (isInGroup) { + group.memberCount -= 1; + + if (group.quest && group.quest.leader === member._id) { + group.quest.key = undefined; + group.quest.leader = undefined; + } else if (group.quest && group.quest.members) { + // remove member from quest + group.quest.members[member._id] = undefined; // TODO remmeber to check these are mark modified everywhere + group.markModified('quest.members'); + } + + if (isInGroup === 'guild') { + removeFromArray(member.guilds, group._id); + } + if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? + + if (member.newMessages[group._id]) { + member.newMessages[group._id] = undefined; + member.markModified('newMessages'); + } + + if (group.quest && group.quest.active && group.quest.leader === member._id) { + member.items.quests[group.quest.key] += 1; // TODO why this? + } + } else if (isInvited) { + if (isInvited === 'guild') { + removeFromArray(member.invitations.guilds, { id: group._id }); + } + 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, {}); + }, +}; + +async function _inviteByUUID (uuid, group, inviter, req, res) { + // TODO: Add Push Notifications + let userToInvite = await User.findById(uuid).exec(); + + if (!userToInvite) { + throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); + } + + 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: inviter._id}); + } else if (group.type === 'party') { + if (!_.isEmpty(userToInvite.invitations.party)) { + throw new NotAuthorized(res.t('userAlreadyPendingInvitation')); + } + + if (userToInvite.party._id) { + 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')); + } + + userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id}; + } + + 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}, + ]; + + 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'}, + ); + } + + 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 + * @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], + 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: '-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 uuidsIsArray = Array.isArray(uuids); + let emailsIsArray = Array.isArray(emails); + + if (!uuids && !emails) { + throw new BadRequest(res.t('canOnlyInviteEmailUuid')); + } + + let results = []; + let totalInvites = 0; + + if (uuids) { + if (!uuidsIsArray) { + throw new BadRequest(res.t('uuidsMustBeAnArray')); + } else { + totalInvites += uuids.length; + } + } + + 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) { + 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); + }, +}; + +export default 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..de2213d3b6 --- /dev/null +++ b/website/src/controllers/api-v3/members.js @@ -0,0 +1,234 @@ +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 { model as Challenge } from '../../models/challenge'; +import { + NotFound, +} from '../../libs/api-v3/errors'; +import * as Tasks from '../../models/task'; + +let api = {}; + +/** + * @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})); + + // manually call toJSON with minimize: true so empty paths aren't returned + res.respond(200, member.toJSON({minimize: true})); + }, +}; + +// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge +// type is `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 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; + + if (type === 'challenge-members') { + 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.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'}); + if (!group) throw new NotFound(res.t('groupNotFound')); + } + + let query = {}; + let fields = nameFields; + + if (type === 'challenge-members') { + query.challenges = challenge._id; + } else if (type === 'group-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 (type === 'group-invites') { + 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 + .find(query) + .sort({_id: 1}) + .limit(30) + .select(fields) + .exec(); + + // manually call toJSON with minimize: true so empty paths aren't returned + res.respond(200, members.map(member => member.toJSON({minimize: true}))); + }; +} + +/** + * @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 + * + * @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: _getMembersForItem('group-members'), +}; + +/** + * @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 + * + * @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], + handler: _getMembersForItem('group-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], + 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')); + + // 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.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')); + + 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/quests.js b/website/src/controllers/api-v3/quests.js new file mode 100644 index 0000000000..e0ad3b86d4 --- /dev/null +++ b/website/src/controllers/api-v3/quests.js @@ -0,0 +1,443 @@ +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'; +import { model as User } from '../../models/user'; +import { + NotFound, + NotAuthorized, + BadRequest, +} 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) { + // 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, _.isBoolean); +} + +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 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')); + + 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; + group.quest.leader = user._id; + group.quest.members = {}; + group.quest.members[user._id] = true; + + 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) => { + group.quest.members[member._id] = null; + }); + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + // 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, + }); + }, +}; + +/** + * @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')); + 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; + + 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, + }); + }, +}; + +/** + * @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') + * + * @apiSuccess {Object} quest 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.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); + if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); + 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 = Group.cleanQuestProgress(); + user.markModified('party.quest'); + + if (canStartQuestAutomatically(group)) { + await group.startQuest(user); + } + + let [savedGroup] = await Q.all([ + group.save(), + user.save(), + ]); + + res.respond(200, savedGroup.quest); + + analytics.track('quest', { + category: 'behavior', + owner: false, + response: 'reject', + gaLabel: 'reject', + questName: group.quest.key, + uuid: user._id, + }); + }, +}; + + +/** + * @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 + * @apiName CancelQuest + * @apiGroup Group + * + * @apiParam {string} groupId The group _id (or 'party') + * + * @apiSuccess {Object} quest Quest 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 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')); + 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 = Group.cleanGroupQuest(); + group.markModified('quest'); + + let [savedGroup] = await Promise.all([ + group.save(), + User.update( + {'party._id': groupId}, + {$set: {'party.quest': Group.cleanQuestProgress()}}, + {multi: true} + ), + ]); + + res.respond(200, savedGroup.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 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 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, + }, { + $set: {'party.quest': Group.cleanQuestProgress()}, + $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 + }, + }).exec(); + + group.quest = Group.cleanGroupQuest(); + group.markModified('quest'); + + let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]); + + res.respond(200, groupSaved.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} quest Quest 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.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'); + + 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; 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 ef7180088e..9ec66296a1 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,7 +1,10 @@ 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 { model as Group } from '../../models/group'; import { NotFound, NotAuthorized, @@ -10,101 +13,215 @@ 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 '../../libs/api-v3/preening'; 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 + * @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 * - * @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 = { +api.createUserTasks = { method: 'POST', - url: '/tasks', + url: '/tasks/user', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { - req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); - - let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); - - let user = res.locals.user; - let taskType = req.body.type; - - let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(req.body)); - newTask.userId = user._id; - - user.tasksOrder[`${taskType}s`].unshift(newTask._id); - - Q.all([ - newTask.save(), - user.save(), - ]) - .then((results) => res.respond(201, results[0])) - .catch(next); + async handler (req, res) { + let tasks = await _createTasks(req, res, res.locals.user); + res.respond(201, tasks.length === 1 ? tasks[0] : tasks); }, }; /** - * @api {get} /tasks Get an user's 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 GetTasks + * @apiName CreateChallengeTasks * @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 {UUID} challengeId The id of the challenge the new task(s) will belong to. * - * @apiSuccess {Array} tasks An array of task objects + * @apiSuccess {Object|Array} task The newly created task(s) */ -api.getTasks = { - method: 'GET', - url: '/tasks', +api.createChallengeTasks = { + method: 'POST', + url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural? middlewares: [authWithHeaders(), cron], - handler (req, res, next) { - req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); + async handler (req, res) { + req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); - let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + let reqValidationErrors = req.validationErrors(); + if (reqValidationErrors) throw reqValidationErrors; let user = res.locals.user; - let query = {userId: user._id}; - let type = req.query.type; + let challengeId = req.params.challengeId; - 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']}}, - ]; - } + let challenge = await Challenge.findOne({_id: challengeId}).exec(); - if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) { - let queryCompleted = Tasks.Task.find({ + // 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 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) { + 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, }); - - Q.all([ - queryCompleted.exec(), - Tasks.Task.find(query).exec(), - ]) - .then((results) => res.respond(200, results[1].concat(results[0]))) - .catch(next); } else { - Tasks.Task.find(query).exec() - .then((tasks) => res.respond(200, tasks)) - .catch(next); + query.type = type.slice(0, -1); // removing the final "s" } + } else { + query.$or = [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ]; + } + + let tasks = await Tasks.Task.find(query).exec(); + + // 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); + } +} + +/** + * @api {get} /tasks/user Get an user's tasks + * @apiVersion 3.0.0 + * @apiName GetUserTasks + * @apiGroup Task + * + * @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 + */ +api.getUserTasks = { + method: 'GET', + url: '/tasks/user', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + 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; + + 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="habits","dailys","todos","rewards"} 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.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID(); + 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; + + let user = res.locals.user; + let challengeId = req.params.challengeId; + + 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.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); }, }; @@ -122,23 +239,30 @@ 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')); + } 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 (!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 + throw new NotFound(res.t('taskNotFound')); + } + + res.respond(200, task); }, }; @@ -156,45 +280,53 @@ api.updateTask = { method: 'PUT', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + 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 // 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')); + } 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 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, 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); + if (challenge) challenge.updateTask(savedTask); // TODO catch/log }, }; @@ -238,119 +370,154 @@ 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) { + removeFromArray(user.tasksOrder.todos, task._id); + } else if (wasCompleted && !task.completed) { + 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.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 sync challenge - }); - }) - .catch(next); + sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + + // 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') { + // 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(); + } catch (e) { + // TODO handle + } + } }, }; // 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 + * @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], - 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) { + if (!task) throw new NotFound(res.t('taskNotFound')); + 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); + + // 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 (!order[to] && to !== -1) { + order.push(task._id); + } else { + if (currentIndex !== -1) order.splice(currentIndex, 1); + if (to === -1) { order.push(task._id); } else { - let taskToMove = order.splice(currentIndex, 1)[0]; - order.splice(to, 0, taskToMove); + order.splice(to, 0, task._id); } + } - return user.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + await user.save(); + res.respond(200, order); }, }; @@ -368,28 +535,37 @@ api.addChecklistItem = { method: 'POST', url: '/tasks/:taskId/checklist', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + 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 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')); + } 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); }, }; @@ -408,31 +584,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 }, }; @@ -451,31 +626,39 @@ 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; + let challenge; 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')); + } 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')); - _.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 + if (challenge) challenge.updateTask(savedTask); }, }; @@ -494,31 +677,37 @@ 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; + let challenge; 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')); + } 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.splice(itemI, 1); - return task.save(); - }) - .then(() => res.respond(200, {})) // TODO what to return - .catch(next); + 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 + if (challenge) challenge.updateTask(savedTask); }, }; @@ -537,7 +726,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(); @@ -545,24 +734,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 }, }; @@ -581,51 +769,110 @@ 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 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.tasksOrder -function _removeTaskTasksOrder (user, 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 index = list.indexOf(taskId); +// 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']); - if (index !== -1) { - list.splice(index, 1); - break; + 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 + removeFromArray(user.tasksOrder[`${task.type}s`], taskId); + await Q.all([user.save(), task.remove()]); + } else { + await task.remove(); + } } - } - return; -} + res.respond(200, {}); // TODO what to return + }, +}; /** - * @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 @@ -638,30 +885,39 @@ api.deleteTask = { method: 'DELETE', url: '/tasks/:taskId', middlewares: [authWithHeaders(), cron], - handler (req, res, next) { + async handler (req, res) { let user = res.locals.user; + let challenge; req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); - if (validationErrors) return next(validationErrors); + if (validationErrors) throw validationErrors; - 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')); + let taskId = req.params.taskId; + let task = await Tasks.Task.findById(taskId).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')); + } 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')); + } + + if (task.type !== 'todo' || !task.completed) { + removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId); + 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/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 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'); 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 new file mode 100644 index 0000000000..95d3981601 --- /dev/null +++ b/website/src/libs/api-v3/collectionManipulators.js @@ -0,0 +1,22 @@ +import { + findIndex, + isPlainObject, +} from 'lodash'; + +export function removeFromArray (array, element) { + let elementIndex; + + if (isPlainObject(element)) { + elementIndex = findIndex(array, element); + } else { + elementIndex = array.indexOf(element); + } + + if (elementIndex !== -1) { + let removedElement = array[elementIndex]; + array.splice(elementIndex, 1); + return removedElement; + } + + return false; +} diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js new file mode 100644 index 0000000000..3a597ff55c --- /dev/null +++ b/website/src/libs/api-v3/csvStringify.js @@ -0,0 +1,11 @@ +import csvStringify from 'csv-stringify'; +import Q from 'q'; + +export default function (input) { + return Q.promise((resolve, reject) => { + csvStringify(input, (err, output) => { + if (err) return reject(err); + return resolve(output); + }); + }); +} diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js index 0d00ca7f4b..3b259eda6b 100644 --- a/website/src/libs/api-v3/logger.js +++ b/website/src/libs/api-v3/logger.js @@ -4,12 +4,15 @@ import winston from 'winston'; import nconf from 'nconf'; const IS_PROD = nconf.get('IS_PROD'); +const IS_TEST = nconf.get('IS_TEST'); let logger = new winston.Logger(); if (IS_PROD) { // TODO production logging, use loggly // log errors to console too +} else if (IS_TEST) { + // Do not log anything when testing } else { logger .add(winston.transports.Console, { diff --git a/website/src/libs/api-v3/preening.js b/website/src/libs/api-v3/preening.js new file mode 100644 index 0000000000..ee6a201b3a --- /dev/null +++ b/website/src/libs/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/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js new file mode 100644 index 0000000000..fbf5e2666a --- /dev/null +++ b/website/src/libs/api-v3/pushNotifications.js @@ -0,0 +1,56 @@ +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); + }); +} + +// TODO test +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; + } + }); +} 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/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js index 1228786011..bfe2ab93e7 100644 --- a/website/src/libs/api-v3/setupRoutes.js +++ b/website/src/libs/api-v3/setupRoutes.js @@ -6,6 +6,10 @@ import _ from 'lodash'; const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/'); 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) .filter(fileName => fileName.match(/\.js$/)) @@ -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/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index d0c24d700d..d57bfb28f8 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -1,21 +1,282 @@ 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 Group from '../../models/group'; +import Q from 'q'; +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'; -// TODO check that it's usef everywhere -export default function cronMiddleware (req, res, next) { +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 async function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; 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({ @@ -25,41 +286,53 @@ 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)); // Run cron - cron({user, tasks, 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? + Task.remove({ + userId: user._id, + type: 'todo', + completed: true, + dateCompleted: { + $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), + }, + 'challenge.id': {$exists: false}, + }).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(null, user); // TODO why are we passing user to next? - // TODO Group.tavernBoss(user, progress); - if (!quest || true /* TODO remove */) return user.save(next); + if (!ranCron) return 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; - });*/ + // 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; + }); + }) + .then(() => next()) + .catch(next); }); } 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/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/challenge.js b/website/src/models/challenge.js index a59f0d0939..b67e8b7832 100644 --- a/website/src/models/challenge.js +++ b/website/src/models/challenge.js @@ -1,120 +1,216 @@ -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'; +import { model as User } from './user'; -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}, 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}, + memberCount: {type: Number, default: 1}, + prize: {type: Number, default: 0, min: 0}, // TODO no update? }); -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'], + timestamps: true, }); -ChallengeSchema.methods.toJSON = function(){ - var doc = this.toObject(); - doc._isMember = this._isMember; - return doc; -} +// A list of additional fields that cannot be updated (but can be set on creation) +let noUpdate = ['group', 'official', 'shortName', 'prize']; +schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { + return this.sanitize(updateObj, noUpdate); +}; -// -------------- -// Syncing logic -// -------------- +// Returns true if user is a member of the challenge +schema.methods.isMember = function isChallengeMember (user) { + return user.challenges.indexOf(this._id) !== -1; +}; -function syncableAttrs(task) { - var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument +// 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; +}; + +// 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; + return user.getGroups().indexOf(this.group) !== -1; +}; + +// Returns true if user can view the challenge +// 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 (this.isMember(user)) return true; + return this.hasAccess(user, group); +}; + +// 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 - var omitAttrs = 'challenge history tags completed streak notes'.split(' '); - if (t.type != 'reward') omitAttrs.push('value'); + 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); } -/** - * 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(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; +// 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; // 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)); - }) + let [challengeTasks, userTasks] = await 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(), + ]); + + let toSave = []; // An array of things to save + + challengeTasks.forEach(chalTask => { + let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id); + + 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" - _.each(user.tasks, function(task){ - if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { - task.challenge.broken = 'TASK_DELETED'; + userTasks.forEach(userTask => { + if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) { + userTask.challenge.broken = 'TASK_DELETED'; + toSave.push(userTask.save()); } - }) + }); - user.save(cb); + toSave.push(user.save()); + return Q.all(toSave); }; +async function _fetchMembersIds (challengeId) { + return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id); +} -module.exports.schema = ChallengeSchema; -module.exports.model = mongoose.model("Challenge", ChallengeSchema); +// 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.save()); + }); + + // 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; + + // Set the task as broken + await Tasks.Task.update({ + userId: {$exists: true}, + 'challenge.id': challenge.id, + 'challenge.taskId': task._id, + }, { + $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt? + }, {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 0d3011cc38..f89147f246 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -1,26 +1,34 @@ -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, + nameFields, +} from './user'; +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 { 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'; +import { quests as questScrolls } from '../../../common/script/content'; +import Q from 'q'; +import nconf from 'nconf'; -// 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({ + // TODO don't break validation on _id === 'habitrpg' + 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, + 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 ? + chat: Array, // TODO ? /* # [{ # timestamp: Date @@ -32,41 +40,62 @@ 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: 1}, + 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})) 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 + // 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 {}; + }}, + 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', '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 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 * 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,425 +103,526 @@ 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); - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); +// TODO test +schema.pre('remove', true, async function preRemoveGroup (next, done) { 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); + try { + await this.removeGroupInvitations(); + done(); + } catch (err) { + done(err); + } }); -GroupSchema.post('remove', function(group) { +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.statics.getGroup = function getGroup (options = {}) { + let {user, groupId, fields, optionalMembership = false, populateLeader = false} = options; + let query; - //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}); + // When optionalMembership is true it's not required for the user to be a member of the group + 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 { + query = {type: 'guild', privacy: 'public', _id: groupId}; + } - // @see pre('save') comment above - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); + let mQuery = this.findOne(query); + if (fields) mQuery.select(fields); + if (populateLeader === true) mQuery.populate('leader', nameFields); + return mQuery.exec(); +}; - return doc; -} +// 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(); + if (!user.contributor.admin) { + _.remove(toJSON.chat, chatMsg => { + chatMsg.flags = {}; + return chatMsg.flagCount >= 2; + }); + } + return toJSON; +}; -var chatDefaults = module.exports.chatDefaults = function(msg,user){ - var message = { +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 { + removeFromArray(user.invitations.guilds, { id: group._id }); + } + 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') { + 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(), 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}}; + 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(); - } -} + let query = {}; -var cleanQuestProgress = function(merge){ - var clean = { + 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'); + 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]; + let collected = {}; + if (quest.collect) { + collected = _.transform(quest.collect, (result, n, itemToCollect) => { + result[itemToCollect] = 0; + }); + } + + 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; + } + + // 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 = _.keys(this.quest.members); + removeFromArray(nonUserQuestMembers, user._id); + + if (userIsParticipating) { + 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'); + } + + // 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}, { + $inc: { + [`items.quests.${this.quest.key}`]: -1, + }, + }).exec(); + } + + // 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(); + + // 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' }, + ]); + }); +}; + +// return a clean object for user.quest +function _cleanQuestProgress (merge) { + let clean = { key: null, progress: { up: 0, down: 0, - collect: {} + collect: {}, }, completed: null, - RSVPNeeded: false + RSVPNeeded: false, }; - 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) { + _.merge(clean, _.omit(merge, 'progress')); + if (merge.progress) _.merge(clean.progress, merge.progress); } - _.each(quest.drop.items, function(item){ - var dropK = item.key; + 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) { + 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'); + 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; } -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]; +// Returns a promise +schema.statics.collectQuest = async function collectQuest (user, progress) { + let group = await this.getGroup({user, groupId: 'party'}); - _.each(progress.collect,function(v,k){ - group.quest.progress.collect[k] += v; - }); + if (!_isOnQuest(user, progress, group)) return; + let quest = shared.content.quests[group.quest.key]; - var foundText = _.reduce(progress.collect, function(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.markModified('quest.progress.collect'); + _.each(progress.collect, (v, k) => { + group.quest.progress.collect[k] += v; + }); - // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, function(v,k){ - return group.quest.progress.collect[k] < v.count; - })) return group.save(cb); + let foundText = _.reduce(progress.collect, (m, v, k) => { + m.push(`${v} ${quest.collect[k].text('en')}`); + return m; + }, []); - async.series([ - function(cb2){ - group.finishQuest(quest,cb2); - }, - function(cb2){ - group.sendChat('`All items found! Party has received their rewards.`'); - group.save(cb2); - } - ],cb); - }) -} + foundText = foundText ? foundText.join(', ') : 'nothing'; + group.sendChat(`\`${user.profile.name} found ${foundText}.\``); + group.markModified('quest.progress.collect'); + + // 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(); +}; + +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(); + // 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) { + 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(); +}; // 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}}; -process.nextTick(function(){ - mongoose.model('Group').findOne(tavernQ, function(err,tavern){ +// 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}}; + +// we use process.nextTick because at this point the model is not yet avalaible +process.nextTick(() => { + model // eslint-disable-line no-use-before-define + .findOne(tavernQ).exec() + .then(tavern => { 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()); + }) + .catch(err => { + throw err; }); }); -GroupSchema.statics.tavernBoss = function(user,progress) { +// returns a promise +schema.statics.tavernBoss = async 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){ - mongoose.model('Group').findOne(tavernQ,cb); - }, - function(tavern,cb){ - if (!(tavern && tavern.quest && tavern.quest.key)) return cb(true); + let tavern = await this.findOne(tavernQ).exec(); + 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}); + let quest = shared.content.quests[tavern.quest.key]; + + 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 = {}; + + 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 { - // 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); - } - } + 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 logging.error(err); - dmg = rage = null; - }) -} -GroupSchema.statics.bossQuest = 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]; - 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 - - 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; - 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 - 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); - } - ] - - // 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 - series.push(function(cb2){ - group.finishQuest(quest,cb2); - }); + 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'); } - series.push(function(cb2){group.save(cb2)}); - async.series(series,cb); - }) -} - -// Remove user from this group -GroupSchema.methods.leave = function(user, keep, mainCb){ - if(!user) return mainCb(new Error('Missing user.')); - - if(keep && typeof keep === 'function'){ - mainCb = keep; - keep = null; + _.assign(tavernQuest, tavern.quest.toObject()); + return tavern.save(); } - if(typeof keep !== 'string') keep = 'keep-all'; // can be also 'remove-all' + // TODO catch +}; - var group = this; +schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') { + let group = this; - async.parallel([ - // 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); - }, - - // Update the group - function(cb){ - // 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') - )){ - group.remove(cb) - }else{ // otherwise just remove a member - var update = {$pull: {members: user._id}}; - - // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - var leader = group.leader; - - if(leader == user._id || !~group.members.indexOf(leader)){ - var seniorMember = _.find(group.members, function (m) {return 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); - } - } - ], function(err){ - if(err) return mainCb(err); - - firebase.removeUserFromGroup(group._id, user._id); - return mainCb(); + let challenges = await Challenge.find({ + _id: {$in: user.challenges}, + group: 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}; + 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}; + } + promises.push(group.update(update).exec()); + firebase.removeUserFromGroup(group._id, user._id); + + return Q.all(promises); }; - -GroupSchema.methods.toJSON = function() { - var doc = this.toObject(); - - return doc; -}; - - -module.exports.schema = GroupSchema; -var Group = module.exports.model = mongoose.model("Group", GroupSchema); +export const INVITES_LIMIT = 100; +export let model = mongoose.model('Group', schema); // initialize tavern if !exists (fresh installs) -Group.count({_id: 'habitrpg'}, function(err, ct){ - if (ct > 0) return; - - new Group({ - _id: 'habitrpg', - chat: [], - leader: '9', - name: 'HabitRPG', - type: 'guild', - privacy: 'public' - }).save(); -}); +// do not run when testing as it's handled by the tests and can easily cause a race condition +if (!nconf.get('IS_TEST')) { + model.count({_id: 'habitrpg'}, (err, ct) => { + if (err) throw err; + if (ct > 0) return; + new model({ // eslint-disable-line babel/new-cap + _id: 'habitrpg', + leader: '9', // TODO change this user id + name: 'HabitRPG', + type: 'guild', + privacy: 'public', + }).save({ + validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise + }); // TODO catch/log? + }); +} diff --git a/website/src/models/task.js b/website/src/models/task.js index a4e376446d..c6a326999a 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -21,15 +21,15 @@ 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'}, // 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.']}, // 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 TODO unique index? + broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']}, winner: String, // user.profile.name TODO necessary? }, }, _.defaults({ @@ -48,13 +48,13 @@ TaskSchema.plugin(baseModel, { // A list of additional fields that cannot be set on creation (but can be set on updare) let noCreate = ['completed']; // TODO completed should be removed for updates too? TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { - return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define + return this.sanitize(createObj, noCreate); }; // A list of additional fields that cannot be updated (but can be set on creation) let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { - return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define + return this.sanitize(updateObj, noUpdate); }; // Sanitize checklist objects (disallowing _id) diff --git a/website/src/models/user.js b/website/src/models/user.js index e517fb498f..31272dc874 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'; @@ -206,11 +207,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,15 +320,25 @@ 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: { 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}, @@ -441,12 +447,13 @@ export let schema = new Schema({ }, tags: [TagSchema], - challenges: [{type: String, ref: 'Challenge'}], 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: { @@ -466,22 +473,34 @@ 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'], + // 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 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 + // TODO this works? + // doc.filters = {}; + // doc._tmp = this._tmp; // be sure to send down drop notifs return doc; }, }); +// 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); }); @@ -635,40 +654,44 @@ 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; +// 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; +}; - if (!cid) { - return cb('Could not remove challenge tasks. Please delete them manually.'); - } +// Unlink challenges tasks (and the challenge itself) from user +schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) { + let user = this; + let findQuery = { + userId: user._id, + 'challenge.id': challengeId, + }; - let self = this; + removeFromArray(user.challenges, challengeId); - 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(); + + await user.save(); + } else { // keep = 'remove-all' + 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) { + removeFromArray(user.tasksOrder[`${task.type}s`], task._id); } - }); - } 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(); + }); + + taskPromises.push(user.save()); + return Q.all(taskPromises); + } }; export let model = mongoose.model('User', schema);