diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0b810c76d1..935fdf2a1d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -27,5 +27,5 @@ "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 give tag." + "alreadyTagged": "The task is already tagged with given tag." } diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js new file mode 100644 index 0000000000..1bc8f29927 --- /dev/null +++ b/common/script/api-v3/cron.js @@ -0,0 +1,250 @@ +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 new file mode 100644 index 0000000000..50ffb1518f --- /dev/null +++ b/common/script/api-v3/preenHistory.js @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000000..25c59393b6 --- /dev/null +++ b/common/script/api-v3/scoreTask.js @@ -0,0 +1,251 @@ +import _ from 'lodash'; +import { + NotAuthorized, +} from '../../../website/src/libs/api-v3/errors'; +import i18n from '../i18n'; + +const MAX_TASK_VALUE = 21.27; +const MIN_TASK_VALUE = -47.27; +const CLOSE_ENOUGH = 0.00001; + +function _getTaskValue (taskValue) { + if (taskValue < MIN_TASK_VALUE) { + return MIN_TASK_VALUE; + } else if (taskValue > MAX_TASK_VALUE) { + return MAX_TASK_VALUE; + } else { + return taskValue; + } +} + +// Calculates the next task.value based on direction +// Uses a capped inverse log y=.95^x, y>= -5 +function _calculateDelta (task, direction, cron) { + // Min/max on task redness + let currVal = _getTaskValue(task.value); + let nextDelta = Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Checklists + if (task.checklist && task.checklist.length > 0) { + // If the Daily, only dock them them a portion based on their checklist completion + if (direction === 'down' && task.type === 'daily' && cron) { + nextDelta *= 1 - _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + } + + // If To-Do, point-match the TD per checklist item completed + if (task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + } + + return nextDelta; +} + +// Approximates the reverse delta for the task value +// This is meant to return the task value to its original value when unchecking a task. +// First, calculate the the value using the normal way for our first guess although +// it will be a bit off +function _calculateReverseDelta (task, direction) { + let currVal = _getTaskValue(task.value); + let testVal = currVal + Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Now keep moving closer to the original value until we get "close enough" + // Check how close we are to the original value by computing the delta off our guess + // and looking at the difference between that and our current value. + while (true) { // eslint-disable-line no-constant-condition + let calc = testVal + Math.pow(0.9747, testVal); + let diff = currVal - calc; + + if (Math.abs(diff) < CLOSE_ENOUGH) break; + + if (diff > 0) { + testVal -= diff; + } else { + testVal += diff; + } + } + + // When we get close enough, return the difference between our approximated value + // and the current value. This will be the delta calculated from the original value + // before the task was checked. + let nextDelta = testVal - currVal; + + // Checklists - If To-Do, point-match the TD per checklist item completed + if (task.checklist && task.checklist.length > 0 && task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + + return nextDelta; +} + +function _gainMP (user, val) { + val *= user._tmp.crit || 1; + user.stats.mp += val; + + if (user.stats.mp >= user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + if (user.stats.mp < 0) { + user.stats.mp = 0; + return user.stats.mp; + } +} + +// HP modifier +// ===== CONSTITUTION ===== +// TODO Decreases HP loss from bad habits / missed dailies by 0.5% per point. +function _subtractPoints (user, task, stats, delta) { + let conBonus = 1 - user._statsComputed.con / 250; + if (conBonus < 0.1) conBonus = 0.1; + + let hpMod = delta * conBonus * task.priority * 2; // constant 2 multiplier for better results + stats.hp += Math.round(hpMod * 10) / 10; // round to 1dp + return stats.hp; +} + +function _addPoints (user, task, stats, direction, delta) { + // ===== CRITICAL HITS ===== + // allow critical hit only when checking off a task, not when unchecking it: + let _crit = delta > 0 ? user.fns.crit() : 1; + // if there was a crit, alert the user via notification + if (_crit > 1) user._tmp.crit = _crit; + + // Exp Modifier + // ===== Intelligence ===== + // TODO Increases Experience gain by .2% per point. + let intBonus = 1 + user._statsComputed.int * 0.025; + stats.exp += Math.round(delta * intBonus * task.priority * _crit * 6); + + // GP modifier + // ===== PERCEPTION ===== + // TODO Increases Gold gained from tasks by .3% per point. + let perBonus = 1 + user._statsComputed.per * 0.02; + let gpMod = delta * task.priority * _crit * perBonus; + + if (task.streak) { + let currStreak = direction === 'down' ? task.streak - 1 : task.streak; + let streakBonus = currStreak / 100 + 1; // eg, 1-day streak is 1.01, 2-day is 1.02, etc + let afterStreak = gpMod * streakBonus; + if (currStreak > 0 && gpMod > 0) { + user._tmp.streakBonus = afterStreak - gpMod; // keep this on-hand for later, so we can notify streak-bonus + } + + stats.gp += afterStreak; + } else { + stats.gp += gpMod; + } +} + +function _changeTaskValue (user, task, direction, times, cron) { + let addToDelta = 0; + + // If multiple days have passed, multiply times days missed + _.times(times, () => { + // Each iteration calculate the nextDelta, which is then accumulated in the total delta. + let nextDelta = !cron && direction === 'down' ? _calculateReverseDelta(task, direction) : _calculateDelta(task, direction, cron); + + if (task.type !== 'reward') { + if (user.preferences.automaticAllocation === true && user.preferences.allocationMode === 'taskbased' && !(task.type === 'todo' && direction === 'down')) { + user.stats.training[task.attribute] += nextDelta; + } + + if (direction === 'up') { // Make progress on quest based on STR + user.party.quest.progress.up = user.party.quest.progress.up || 0; + + if (task.type === 'todo' || task.type === 'daily') { + user.party.quest.progress.up += nextDelta * (1 + user._statsComputed.str / 200); + } else if (task.type === 'habit') { + user.party.quest.progress.up += nextDelta * (0.5 + user._statsComputed.str / 400); + } + } + + task.value += nextDelta; + } + + addToDelta += nextDelta; + }); + + return addToDelta; +} + +export default function scoreTask (options = {}, req = {}) { + let {user, task, direction, times = 1, cron = false} = options; + let delta = 0; + let stats = { + gp: user.stats.gp, + hp: user.stats.hp, + exp: user.stats.exp, + }; + + // TODO return or pass to cb, don't add to user object + // This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying + // the API consumer, then cleared afterwards + user._tmp = {}; + + // If they're trying to purhcase a too-expensive reward, don't allow them to do that. + if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + + // ===== starting to actually do stuff, most of above was definitions ===== + if (task.type === 'habit') { + delta += _changeTaskValue(user, task, direction, times, cron); + // Add habit value to habit-history (if different) + if (delta > 0) { + _addPoints(user, task, stats, direction, delta); + } else { + _subtractPoints(user, task, stats, delta); + } + _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + // Add history entry, even more than 1 per day + task.history.push({ + date: Number(new Date()), // TODO are we going to cast history entries? + value: task.value, + }); + } else if (task.type === 'daily') { + if (cron) { + delta += _changeTaskValue(user, task, direction, times, cron); + _subtractPoints(user, task, stats, delta); + if (!user.stats.buffs.streaks) task.streak = 0; + } else { + 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 + _addPoints(user, task, stats, direction, delta); // obviously for delta>0, but also a trick to undo accidental checkboxes + _gainMP(user, _.max([1, 0.01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + if (direction === 'up') { + task.streak += 1; + // Give a streak achievement when the streak is a multiple of 21 + if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; + } else { + // Remove a streak achievement if streak was a multiple of 21 and the daily was undone + if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; + task.streak -= 1; + } + } + } else if (task.type === 'todo') { + if (cron) { // don't touch stats on cron + delta += _changeTaskValue(user, task, direction, times, cron); + } else { + if (direction === 'up') task.dateCompleted = new Date(); + + 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 + _addPoints(user, task, stats, direction, delta); + + // MP++ per checklist item in ToDo, bonus per CLI + let multiplier = _.max([_.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 1), 1]); + _gainMP(user, _.max([multiplier, 0.01 * user._statsComputed.maxMP * multiplier]) * (direction === 'down' ? -1 : 1)); + } + } else if (task.type === 'reward') { + // Don't adjust values for rewards + delta += _changeTaskValue(user, task, direction, times, cron); + // purchase item + stats.gp -= Math.abs(task.value); + // hp - gp difference + if (stats.gp < 0) { + stats.hp += stats.gp; + stats.gp = 0; + } + } + + user.fns.updateStats(stats, req); + return delta; +} diff --git a/common/script/index.js b/common/script/index.js index 292f579de2..b81007d42d 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -2063,7 +2063,7 @@ api.wrap = function(user, main) { return content.gear.flat[type + "_base_0"]; } return item; - }, + }, handleTwoHanded: function(item, type, req) { var message, currentWeapon, currentShield; if (type == null) { @@ -2071,17 +2071,17 @@ api.wrap = function(user, main) { } currentShield = content.gear.flat[user.items.gear[type].shield]; currentWeapon = content.gear.flat[user.items.gear[type].weapon]; - + if (item.type === "shield" && (currentWeapon ? currentWeapon.twoHanded : false)) { user.items.gear[type].weapon = 'weapon_base_0'; message = i18n.t('messageTwoHandedUnequip', { twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language), }, req.language); - } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { - user.items.gear[type].shield = "shield_base_0"; + } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { + user.items.gear[type].shield = "shield_base_0"; message = i18n.t('messageTwoHandedEquip', { twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language), - }, req.language); + }, req.language); } return message; }, @@ -2692,11 +2692,14 @@ api.wrap = function(user, main) { return computed; } }); - return Object.defineProperty(user, 'tasks', { - get: function() { - var tasks; - tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); - return _.object(_.pluck(tasks, "id"), tasks); - } - }); + + if (typeof window !== 'undefined') { + Object.defineProperty(user, 'tasks', { + get: function() { + var tasks; + tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); + return _.object(_.pluck(tasks, "id"), tasks); + } + }); + } }; diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 3698fd1f64..0c3cf30eb4 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -380,9 +380,9 @@ gulp.task('test:all', (done) => { runSequence( 'lint', // 'test:e2e:safe', - 'test:common:safe', + //'test:common:safe', // 'test:content:safe', - 'test:server_side:safe', + //'test:server_side:safe', // 'test:karma:safe', // 'test:api-legacy:safe', // 'test:api-v2:safe', diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js new file mode 100644 index 0000000000..0cf4764679 --- /dev/null +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('DELETE /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('deletes a tag given it\'s id', () => { + let length; + let tag; + + return api.post('/tags', {name: 'Tag 1'}) + .then((createdTag) => { + tag = createdTag; + return api.get(`/tags`); + }) + .then((tags) => { + length = tags.length; + return api.del(`/tags/${tag._id}`); + }) + .then(() => api.get(`/tags`)) + .then((tags) => { + expect(tags.length).to.equal(length - 1); + expect(tags[tags.length - 1].name).to.not.equal('Tag 1'); + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js new file mode 100644 index 0000000000..bc0439ffb6 --- /dev/null +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -0,0 +1,26 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tags', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns all user\'s tags', () => { + return api.post('/tags', {name: 'Tag 1'}) + .then(() => api.post('/tags', {name: 'Tag 2'})) + .then(() => api.get('/tags')) + .then((tags) => { + expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task + expect(tags[tags.length - 2].name).to.equal('Tag 1'); + expect(tags[tags.length - 1].name).to.equal('Tag 2'); + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js new file mode 100644 index 0000000000..86c63094f3 --- /dev/null +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -0,0 +1,28 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns a tag given it\'s id', () => { + let createdTag; + + return api.post('/tags', {name: 'Tag 1'}) + .then((tag) => { + createdTag = tag; + return api.get(`/tags/${createdTag._id}`) + }) + .then((tag) => { + expect(tag).to.deep.equal(createdTag); + }); + }); +}); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js new file mode 100644 index 0000000000..0bed7e71f5 --- /dev/null +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -0,0 +1,32 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /tags', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('creates a tag correctly', () => { + let createdTag; + + return api.post('/tags', { + name: 'Tag 1', + ignored: false, + }).then((tag) => { + createdTag = tag; + expect(tag.name).to.equal('Tag 1'); + expect(tag.ignored).to.be.a('undefined'); + return api.get(`/tags/${createdTag._id}`) + }) + .then((tag) => { + expect(tag).to.deep.equal(createdTag); + }); + }); +}); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js new file mode 100644 index 0000000000..ace042afa8 --- /dev/null +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -0,0 +1,36 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('PUT /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('updates a tag given it\'s id', () => { + let length; + + return api.post('/tags', {name: 'Tag 1'}) + .then((createdTag) => { + return api.put(`/tags/${createdTag._id}`, { + name: 'Tag updated', + ignored: true + }); + }) + .then((updatedTag) => { + expect(updatedTag.name).to.equal('Tag updated'); + expect(updatedTag.ignored).to.be.a('undefined'); + return api.get(`/tags/${updatedTag._id}`); + }) + .then((tag) => { + expect(tag.name).to.equal('Tag updated'); + expect(tag.ignored).to.be.a('undefined'); + }); + }); +}); 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 6c6c722703..4e69788c6c 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { let user, api; @@ -18,18 +19,51 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - // generate task - // task = generatedTask; + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((createdTask) => { + task = createdTask; + }); }); - it('gets specified task'); + it('gets specified task', () => { + return api.get('/tasks/' + task._id) + .then((getTask) => { + expect(getTask).to.eql(task); + }); + }); + // TODO after challenges are implemented it('can get active challenge task that user does not own'); // Yes? }); context('task cannot accessed', () => { - it('cannot get a non-existant task'); + it('cannot get a non-existant task', () => { + return expect(api.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); - it('cannot get a task owned by someone else'); + it('cannot get a task owned by someone else', () => { + let api2; + + return generateUser() + .then((user2) => { + api2 = requester(user2); + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }) + }).then((task) => { + return expect(api2.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + }); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index e7e72e1389..09ca58be01 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -37,9 +37,15 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.text is absent'); - - it('ignores setting userId field'); + it('returns an error if req.body.text is absent', () => { + return expect(api.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', () => { return api.post('/tasks', { @@ -50,21 +56,41 @@ describe('POST /tasks', () => { }); }); - it('ignores setting history field'); + it(`ignores setting userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`, () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: 'never', + }).then((task) => { + expect(task.userId).to.equal(user._id); + expect(task.history).to.eql([]); + expect(task.createdAt).not.to.equal('yesterday'); + expect(task.updatedAt).not.to.equal('tomorrow'); + expect(task.challenge).not.to.equal('no'); + expect(task.completed).to.equal(false); + expect(task.streak).to.equal(0); + expect(task.streak).not.to.equal('never'); + }); + }); - it('ignores setting createdAt field'); - - it('ignores setting updatedAt field'); - - it('ignores setting challenge field'); - - it('ignores setting completed field'); - - it('ignores setting streak field'); - - it('ignores setting dateCompleted field'); - - it('ignores invalid fields'); + it('ignores invalid fields', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + notValid: true, + }).then((task) => { + expect(task).not.to.have.property('notValid'); + }); + }); }); context('habits', () => { @@ -85,9 +111,28 @@ describe('POST /tasks', () => { }); }); - it('defaults to setting up and down to true'); + it('defaults to setting up and down to true', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + notes: 1976, + }).then((task) => { + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); + }); + }); - it('cannot create checklists'); + it('cannot create checklists', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task).not.to.have.property('checklist'); + }); + }); }); context('todos', () => { @@ -104,7 +149,22 @@ describe('POST /tasks', () => { }); }); - it('can create checklists'); + it('can create checklists', () => { + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); + }); + }); }); context('dailys', () => { @@ -129,13 +189,74 @@ describe('POST /tasks', () => { }); }); - it('defaults to a weekly frequency, with every day set'); + it('defaults to a weekly frequency, with every day set', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + expect(task.frequency).to.eql('weekly'); + expect(task.everyX).to.eql(1); + expect(task.repeat).to.eql({ + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + su: true, + }); + }); + }); - it('allows repeat field to be configured'); + it('allows repeat field to be configured', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + repeat: { + m: false, + w: false, + su: false, + }, + }).then((task) => { + expect(task.repeat).to.eql({ + m: false, + t: true, + w: false, + th: true, + f: true, + s: true, + su: false, + }); + }); + }); - it('defaults startDate to today'); + it('defaults startDate to today', () => { + let today = (new Date()).getDay(); - it('can create checklists'); + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + expect((new Date(task.startDate)).getDay()).to.eql(today); + }); + }); + + it('can create checklists', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); + }); + }); }); context('rewards', () => { @@ -154,10 +275,35 @@ describe('POST /tasks', () => { }); }); - it('defaults to a 0 value'); + it('defaults to a 0 value', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + }).then((task) => { + expect(task.value).to.eql(0); + }); + }); - it('requires value to be coerced into a number'); + it('requires value to be coerced into a number', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + value: "10", + }).then((task) => { + expect(task.value).to.eql(10); + }); + }); - it('cannot create checklists'); + it('cannot create checklists', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task).not.to.have.property('checklist'); + }); + }); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js new file mode 100644 index 0000000000..622d63dc4f --- /dev/null +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -0,0 +1,343 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:id/score/:direction', () => { + let user, api; + + beforeEach(() => { + return generateUser({ + 'stats.gp': 100, + }).then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + context('all', () => { + it('requires a task id', () => { + return expect(api.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('requires a task direction', () => { + return expect(api.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + }); + + context('todos', () => { + let todo; + + beforeEach(() => { + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + }).then((task) => { + todo = task; + }); + }); + + it('completes todo when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/tasks/${todo._id}`)) + .then((task) => expect(task.completed).to.equal(true)); + }); + + it('moves completed todos out of user.tasksOrder.todos', () => { + return api.get('/user') + .then(user => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + }).then(() => api.post(`/tasks/${todo._id}/score/up`)) + .then(() => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(true); + return api.get('/user'); + }) + .then((user) => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1) + }); + }); + + it('moves un-completed todos back into user.tasksOrder.todos', () => { + return api.get('/user') + .then(user => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + }).then(() => api.post(`/tasks/${todo._id}/score/up`)) + .then(() => api.post(`/tasks/${todo._id}/score/down`)) + .then(() => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(false); + return api.get('/user'); + }) + .then((user) => { + let l = user.tasksOrder.todos.length; + expect(user.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom + }); + }); + + it('uncompletes todo when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(false); + }); + }); + + it('scores up todo even if it is already completed'); // Yes? + + it('scores down todo even if it is already uncompleted'); // Yes? + + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); + }); + }); + + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + }); + + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); + }); + }); + + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + }); + + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); + }); + }); + + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); + }); + }); + }); + + context('dailys', () => { + let daily; + + beforeEach(() => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + daily = task; + }); + }); + + it('completes daily when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/tasks/${daily._id}`)) + .then((task) => expect(task.completed).to.equal(true)); + }); + + it('uncompletes daily when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/tasks/${daily._id}`)) + .then((task) => expect(task.completed).to.equal(false)); + }); + + it('scores up daily even if it is already completed'); // Yes? + + it('scores down daily even if it is already uncompleted'); // Yes? + + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); + }); + }); + + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + }); + + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); + }); + }); + + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + }); + }); + + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); + }); + }); + + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); + }); + }); + }); + + context('habits', () => { + let habit, minusHabit, plusHabit, neitherHabit; + + beforeEach(() => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((task) => { + habit = task; + return api.post('/tasks', { + text: 'test min habit', + type: 'habit', + up: false, + }); + }).then((task) => { + minusHabit = task; + return api.post('/tasks', { + text: 'test plus habit', + type: 'habit', + down: false, + }) + }).then((task) => { + plusHabit = task; + api.post('/tasks', { + text: 'test neither habit', + type: 'habit', + up: false, + down: false, + }) + }).then((task) => { + neitherHabit = task; + }); + }); + + it('prevents plus only habit from scoring down'); // Yes? + + it('prevents minus only habit from scoring up'); // Yes? + + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); + }); + }); + + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${habit._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + }); + }); + + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); + }); + }); + + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); + }); + }); + }); + + context('reward', () => { + let reward; + + beforeEach(() => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + value: 5, + }).then((task) => { + reward = task; + }); + }); + + it('purchases reward', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); + }); + }); + + it('does not change user\'s mp', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp).to.equal(updatedUser.stats.mp); + }); + }); + + it('does not change user\'s exp', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp).to.equal(updatedUser.stats.exp); + }); + }); + + it('does not allow a down direction', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp).to.equal(updatedUser.stats.mp); + }); + }); + }); +}); diff --git a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js deleted file mode 100644 index 46264aee7c..0000000000 --- a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js +++ /dev/null @@ -1,121 +0,0 @@ -import { - generateUser, - requester, - translate as t, -} from '../../../../helpers/api-integration.helper'; - -describe('POST /tasks/score/:id/:direction', () => { - let user, api; - - before(() => { - return generateUser().then((generatedUser) => { - user = generatedUser; - api = requester(user); - }); - }); - - context('all', () => { - it('requires a task id'); - - it('requires a task direction'); - }); - - context('todos', () => { - let todo; - - beforeEach(() => { - // todo = createdTodo - }); - - it('completes todo when direction is up'); - - it('uncompletes todo when direction is down'); - - it('scores up todo even if it is already completed'); // Yes? - - it('scores down todo even if it is already uncompleted'); // Yes? - - it('increases user\'s mp when direction is up'); - - it('decreases user\'s mp when direction is down'); - - it('increases user\'s exp when direction is up'); - - it('decreases user\'s exp when direction is down'); - - it('increases user\'s gold when direction is up'); - - it('decreases user\'s gold when direction is down'); - }); - - context('dailys', () => { - let daily; - - beforeEach(() => { - // daily = createdDaily - }); - - it('completes daily when direction is up'); - - it('uncompletes daily when direction is down'); - - it('scores up daily even if it is already completed'); // Yes? - - it('scores down daily even if it is already uncompleted'); // Yes? - - it('increases user\'s mp when direction is up'); - - it('decreases user\'s mp when direction is down'); - - it('increases user\'s exp when direction is up'); - - it('decreases user\'s exp when direction is down'); - - it('increases user\'s gold when direction is up'); - - it('decreases user\'s gold when direction is down'); - }); - - context('habits', () => { - let habit, minusHabit, plusHabit, neitherHabit; - - beforeEach(() => { - // habit = createdHabit - // plusHabit = createdPlusHabit - // minusHabit = createdMinusHabit - // neitherHabit = createdNeitherHabit - }); - - it('prevents plus only habit from scoring down'); // Yes? - - it('prevents minus only habit from scoring up'); // Yes? - - it('increases user\'s mp when direction is up'); - - it('decreases user\'s mp when direction is down'); - - it('increases user\'s exp when direction is up'); - - it('decreases user\'s exp when direction is down'); - - it('increases user\'s gold when direction is up'); - - it('decreases user\'s gold when direction is down'); - }); - - context('reward', () => { - let reward; - - beforeEach(() => { - // reward = createdReward - }); - - it('purchases reward'); - - it('does not change user\'s mp'); - - it('does not change user\'s exp'); - - it('does not allow a down direction'); - }); -}); 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 dc7143decf..f78f7ca35e 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { let user, api; @@ -18,31 +19,49 @@ describe('PUT /tasks/:id', () => { let task; beforeEach(() => { - // create sample task - // task = createdTask + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((createdTask) => { + task = createdTask; + }); }); - it('ignores setting type field'); + it(`ignores setting _id, type, userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`, () => { + api.put('/tasks/' + task._id, { + _id: 123, + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: 'never', + }).then((savedTask) => { + expect(savedTask._id).to.equal(task._id); + expect(savedTask.type).to.equal(task.type); + expect(savedTask.userId).to.equal(user._id); + expect(savedTask.history).to.eql([]); + expect(savedTask.createdAt).not.to.equal('yesterday'); + expect(savedTask.updatedAt).not.to.equal('tomorrow'); + expect(savedTask.challenge).not.to.equal('no'); + expect(savedTask.completed).to.equal(false); + expect(savedTask.streak).to.equal(0); + expect(savedTask.streak).not.to.equal('never'); + }); + }); - it('ignores setting userId field'); - - it('ignores setting history field'); - - it('ignores setting createdAt field'); - - it('ignores setting updatedAt field'); - - it('ignores setting challenge field'); - - it('ignores setting value field'); - - it('ignores setting completed field'); - - it('ignores setting streak field'); - - it('ignores setting dateCompleted field'); - - it('ignores invalid fields'); + it('ignores invalid fields', () => { + api.put('/tasks/' + task._id, { + notValid: true, + }).then((savedTask) => { + expect(savedTask.notValid).to.be.a('undefined'); + }); + }); }); context('habits', () => { @@ -96,7 +115,38 @@ describe('PUT /tasks/:id', () => { }); }); - it('can update checklists'); // Can it? + it('can update checklists (replace it)', () => { + return api.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ] + }).then((savedTodo) => { + return api.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 789, completed: false}, + ] + }); + }).then((savedTodo2) => { + expect(savedTodo2.checklist.length).to.equal(1); + expect(savedTodo2.checklist[0].text).to.equal("789"); + expect(savedTodo2.checklist[0].completed).to.equal(false); + }); + }); + + it('can update tags (replace them)', () => { + let finalUUID = generateUUID(); + return api.put(`/tasks/${todo._id}`, { + tags: [generateUUID(), generateUUID()], + }).then((savedTodo) => { + return api.put(`/tasks/${todo._id}`, { + tags: [finalUUID] + }); + }).then((savedTodo2) => { + expect(savedTodo2.tags.length).to.equal(1); + expect(savedTodo2.tags[0]).to.equal(finalUUID); + }); + }); }); context('dailys', () => { @@ -128,13 +178,81 @@ describe('PUT /tasks/:id', () => { }); }); - it('can update checklists'); // Can it? + it('can update checklists (replace it)', () => { + return api.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ] + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 789, completed: false}, + ] + }); + }).then((savedDaily2) => { + expect(savedDaily2.checklist.length).to.equal(1); + expect(savedDaily2.checklist[0].text).to.equal("789"); + expect(savedDaily2.checklist[0].completed).to.equal(false); + }); + }); - it('updates repeat, even if frequency is set to daily'); + it('can update tags (replace them)', () => { + let finalUUID = generateUUID(); + return api.put(`/tasks/${daily._id}`, { + tags: [generateUUID(), generateUUID()], + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + tags: [finalUUID] + }); + }).then((savedDaily2) => { + expect(savedDaily2.tags.length).to.equal(1); + expect(savedDaily2.tags[0]).to.equal(finalUUID); + }); + }); - it('updates everyX, even if frequency is set to weekly'); + it('updates repeat, even if frequency is set to daily', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'daily', + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + repeat: { + m: false, + su: false + } + }); + }).then((savedDaily2) => { + expect(savedDaily2.repeat).to.eql({ + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + su: false, + }); + }); + }); - it('defaults startDate to today if none date object is passed in'); + it('updates everyX, even if frequency is set to weekly', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + everyX: 5, + }); + }).then((savedDaily2) => { + expect(savedDaily2.everyX).to.eql(5); + }); + }); + + it('defaults startDate to today if none date object is passed in', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }).then((savedDaily2) => { + expect((new Date(savedDaily2.startDate)).getDay()).to.eql((new Date()).getDay()); + }); + }); }); context('rewards', () => { @@ -163,6 +281,12 @@ describe('PUT /tasks/:id', () => { }); }); - it('requires value to be coerced into a number'); + it('requires value to be coerced into a number', () => { + return api.put(`/tasks/${reward._id}`, { + value: "100", + }).then((task) => { + expect(task.value).to.eql(100); + }); + }); }); }); diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js new file mode 100644 index 0000000000..013ba69725 --- /dev/null +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -0,0 +1,86 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:taskId/checklist/:itemId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('deletes a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + }).then(() => { + return api.get(`/tasks/${task._id}`); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(0); + }); + }); + + it('does not work with habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not work with rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js new file mode 100644 index 0000000000..e3ec919308 --- /dev/null +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -0,0 +1,76 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/checklist/', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('adds a checklist item to a task', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); + }); + }); + + it('does not add a checklist to habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not add a checklist to rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.post(`/tasks/${generateUUID()}/checklist`, { + text: 'Checklist Item 1' + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); +}); 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 new file mode 100644 index 0000000000..32c09ba1c8 --- /dev/null +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -0,0 +1,85 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/checklist/:itemId/score', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('scores a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].completed).to.equal(true); + }); + }); + + it('fails on habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js new file mode 100644 index 0000000000..e272d05da8 --- /dev/null +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -0,0 +1,87 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('PUT /tasks/:taskId/checklist/:itemId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('updates a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + }); + }); + + it('fails on habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js new file mode 100644 index 0000000000..6fa2c94d68 --- /dev/null +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -0,0 +1,53 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:taskId/tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('removes a tag from a task', () => { + let tag; + let task; + + return api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(savedTask => { + return api.del(`/tasks/${task._id}/tags/${tag._id}`); + }).then(() => api.get(`/tasks/${task._id}`)) + .then(updatedTask => { + expect(updatedTask.tags.length).to.equal(0); + }); + }); + + it('only deletes existing tags', () => { + let task; + + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + return api.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('tagNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js new file mode 100644 index 0000000000..b5e8f4e485 --- /dev/null +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -0,0 +1,70 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('adds a tag to a task', () => { + let tag; + let task; + + return api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(savedTask => { + expect(savedTask.tags[0]).to.equal(tag._id); + }); + }); + + it('does not add a tag to a task twice', () => { + let tag; + let task; + + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(() => { + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('alreadyTagged'), + }); + }); + + it('does not add a non existing tag to a task', () => { + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then((task) => { + return api.post(`/tasks/${task._id}/tags/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); +}); diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js new file mode 100644 index 0000000000..47c8eb4cc9 --- /dev/null +++ b/test/api/v3/integration/user/GET-user.test.js @@ -0,0 +1,31 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /user', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns the authenticated user', () => { + return api.get('/user') + .then(returnedUser => { + expect(returnedUser._id).to.equal(user._id); + }); + }); + + it('does not return private paths (and apiToken)', () => { + return api.get('/user') + .then(returnedUser => { + expect(returnedUser.auth.local.hashed_password).to.be.a('undefined'); + expect(returnedUser.auth.local.salt).to.be.a('undefined'); + expect(returnedUser.apiToken).to.be.a('undefined'); + }); + }); +}); diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js index 750272cc73..8c18e3c92c 100644 --- a/test/common/algos.mocha.js +++ b/test/common/algos.mocha.js @@ -13,6 +13,7 @@ import { startOfDay, daysSince, } from '../../common/script/cron'; +import scoreTask from '../../common/script/api-v3/scoreTask'; let expect = require('expect.js'); let sinon = require('sinon'); @@ -650,12 +651,9 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_POTION; random <= MAX_RANGE_FOR_POTION; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.be.empty; expect(user.items.hatchingPotions).to.not.be.empty; expect(user.items.food).to.be.empty; @@ -669,12 +667,8 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_EGG; random <= MAX_RANGE_FOR_EGG; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.not.be.empty; expect(user.items.hatchingPotions).to.be.empty; expect(user.items.food).to.be.empty; @@ -688,12 +682,8 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_FOOD; random <= MAX_RANGE_FOR_FOOD; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.be.empty; expect(user.items.hatchingPotions).to.be.empty; expect(user.items.food).to.not.be.empty; @@ -704,12 +694,8 @@ describe('User', () => { it('does not get a drop', function () { sinon.stub(user.fns, 'predictableRandom').returns(0.5); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.eql({}); expect(user.items.hatchingPotions).to.eql({}); expect(user.items.food).to.eql({}); @@ -869,80 +855,42 @@ describe('Simple Scoring', () => { }); it('Habits : Up', function () { - this.after.ops.score({ - params: { - id: this.after.habits[0].id, - direction: 'down', - }, - query: { - times: 5, - }, - }); + let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'down', times: 5}); + this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); expectLostPoints(this.before, this.after, 'habit'); }); it('Habits : Down', function () { - this.after.ops.score({ - params: { - id: this.after.habits[0].id, - direction: 'up', - }, - query: { - times: 5, - }, - }); + let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'up', times: 5}); + this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); expectGainedPoints(this.before, this.after, 'habit'); }); it('Dailys : Up', function () { - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'up', - }, - }); + let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); expectGainedPoints(this.before, this.after, 'daily'); }); it('Dailys : Up, Down', function () { - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'up', - }, - }); - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'down', - }, - }); + let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); + let delta2 = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'down'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta2}, {}); expectClosePoints(this.before, this.after, 'daily'); }); it('Todos : Up', function () { - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'up', - }, - }); + let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); expectGainedPoints(this.before, this.after, 'todo'); }); it('Todos : Up, Down', function () { - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'up', - }, - }); - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'down', - }, - }); + let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); + let delta2 = scoreTask({task: this.after.todos[0], user: this.after, direction: 'down'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta2}, {}); expectClosePoints(this.before, this.after, 'todo'); }); }); diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 48ee56739d..973414a3a8 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -1,7 +1,7 @@ /* eslint-disable no-use-before-define */ import { - assign, + set, each, isEmpty, times, @@ -277,16 +277,23 @@ function _updateDocument (collectionName, doc, update, cb) { return cb(); } + // TODO use config for db url? mongo.connect('mongodb://localhost/habitrpg_test', (connectErr, db) => { if (connectErr) throw new Error(`Error connecting to database when updating ${collectionName} collection: ${connectErr}`); let collection = db.collection(collectionName); - collection.update({ _id: doc._id }, { $set: update }, (updateErr) => { + collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); - assign(doc, update); + _updateLocalDocument(doc, update); db.close(); cb(); }); }); } + +function _updateLocalDocument (doc, update) { + each(update, (value, param) => { + set(doc, param, value); + }); +} diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 7a818d9877..2c7e00cadc 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -1,6 +1,7 @@ import validator from 'validator'; import passport from 'passport'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, } from '../../libs/api-v3/errors'; @@ -30,7 +31,7 @@ api.registerLocal = { url: '/user/auth/local/register', handler (req, res, next) { let fbUser = res.locals.user; // If adding local auth to social user - + // TODO check user doesn't have local auth req.checkBody({ email: { notEmpty: {errorMessage: res.t('missingEmail')}, @@ -138,6 +139,7 @@ function _loginRes (user, req, res, next) { api.loginLocal = { method: 'POST', url: '/user/auth/local/login', + middlewares: [cron], handler (req, res, next) { req.checkBody({ username: { @@ -182,6 +184,7 @@ api.loginLocal = { 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) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; @@ -247,7 +250,7 @@ api.loginSocial = { api.deleteSocial = { method: 'DELETE', url: '/user/auth/social/:network', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; let network = req.params.network; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 4917858449..20d3f2f5ed 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { model as Tag } from '../../models/tag'; import { NotFound, @@ -18,7 +19,7 @@ let api = {}; api.createTag = { method: 'POST', url: '/tags', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -45,7 +46,7 @@ api.createTag = { api.getTags = { method: 'GET', url: '/tags', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res) { let user = res.locals.user; res.respond(200, user.tags); @@ -65,11 +66,11 @@ api.getTags = { api.getTag = { method: 'GET', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; - req.checkParams('taskId', res.t('tagIdRequired')).notEmpty().isUUID(); + req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -93,14 +94,14 @@ api.getTag = { api.updateTag = { method: 'PUT', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty - let tagId = req.params.id; + let tagId = req.params.tagId; let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -127,9 +128,9 @@ api.updateTag = { * @apiSuccess {object} empty An empty object */ api.deleteTag = { - method: 'GET', + method: 'DELETE', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 509aafbe02..ef7180088e 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,12 +1,16 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; import { NotFound, NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; +import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; let api = {}; @@ -23,7 +27,7 @@ let api = {}; api.createTask = { method: 'POST', url: '/tasks', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); @@ -61,7 +65,7 @@ api.createTask = { api.getTasks = { method: 'GET', url: '/tasks', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); @@ -117,7 +121,7 @@ api.getTasks = { api.getTask = { method: 'GET', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -151,7 +155,7 @@ api.getTask = { api.updateTask = { method: 'PUT', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -171,12 +175,22 @@ api.updateTask = { // If checklist is updated -> replace the original one if (req.body.checklist) { - delete req.body.checklist; task.checklist = req.body.checklist; + delete req.body.checklist; } - // TODO merge goes deep into objects, it's ok? - // TODO also check that array and mixed fields are updated correctly without marking modified - _.merge(task, Tasks.Task.sanitizeUpdate(req.body)); + + // If tags are updated -> replace the original ones + if (req.body.tags) { + task.tags = req.body.tags; + delete req.body.tags; + } + + // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances + // TODO regarding comment above make sure other models with nested fields are using this trick too + _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); + // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) + // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() + // see https://github.com/Automattic/mongoose/issues/2749 return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) @@ -184,8 +198,33 @@ api.updateTask = { }, }; +function _generateWebhookTaskData (task, direction, delta, stats, user) { + let extendedStats = _.extend(stats, { + toNextLevel: shared.tnl(user.stats.lvl), + maxHealth: shared.maxHealth, + maxMP: user._statsComputed.maxMP, // TODO refactor as method not getter + }); + + let userData = { + _id: user._id, + _tmp: user._tmp, + stats: extendedStats, + }; + + let taskData = { + details: task, + direction, + delta, + }; + + return { + task: taskData, + user: userData, + }; +} + /** - * @api {put} /tasks/score/:taskId/:direction Score a task + * @api {put} /tasks/:taskId/score/:direction Score a task * @apiVersion 3.0.0 * @apiName ScoreTask * @apiGroup Task @@ -197,16 +236,17 @@ api.updateTask = { */ api.scoreTask = { method: 'POST', - url: 'tasks/score/:taskId/:direction', - middlewares: [authWithHeaders()], + url: '/tasks/:taskId/score/:direction', + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); + 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); let user = res.locals.user; + let direction = req.params.direction; Tasks.Task.findOne({ _id: req.params.taskId, @@ -214,8 +254,47 @@ api.scoreTask = { }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); + + let wasCompleted = task.completed; + if (task.type === 'daily' || task.type === 'todo') { + task.completed = direction === 'up'; // TODO move into scoreTask + } + + let delta = scoreTask({task, user, direction}, req); + // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) + if (direction === 'up') user.fns.randomDrop({task, delta}, req); + + // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list + if (task.type === 'todo') { + if (!wasCompleted && task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i !== -1) user.tasksOrder.todos.splice(i, 1); + } else if (wasCompleted && !task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i === -1) { + user.tasksOrder.todos.push(task._id); // TODO push at the top? + } else { // If for some reason it hadn't been removed TODO ok? + user.tasksOrder.todos.splice(i, 1); + user.tasksOrder.push(task._id); + } + } + } + + return Q.all([ + user.save(), + task.save(), + ]).then((results) => { + let savedUser = results[0]; + + let userStats = savedUser.stats.toJSON(); + let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); + res.respond(200, resJsonData); + + sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + + // TODO sync challenge + }); }) - .then(() => res.respond(200, {})) // TODO what to return .catch(next); }, }; @@ -236,7 +315,7 @@ api.scoreTask = { api.moveTask = { method: 'POST', url: '/tasks/move/:taskId/to/:position', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); @@ -288,7 +367,7 @@ api.moveTask = { api.addChecklistItem = { method: 'POST', url: '/tasks/:taskId/checklist', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -306,7 +385,7 @@ api.addChecklistItem = { if (!task) throw new NotFound(res.t('taskNotFound')); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - task.checklist.push(req.body); + task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) // TODO what to return @@ -328,7 +407,7 @@ api.addChecklistItem = { api.scoreCheckListItem = { method: 'POST', url: '/tasks/:taskId/checklist/:itemId/score', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -371,7 +450,7 @@ api.scoreCheckListItem = { api.updateChecklistItem = { method: 'PUT', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -392,8 +471,7 @@ api.updateChecklistItem = { let item = _.find(task.checklist, {_id: req.params.itemId}); if (!item) throw new NotFound(res.t('checklistItemNotFound')); - delete req.body.id; // Simple sanitization to prevent the ID to be changed - _.merge(item, req.body); + _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) // TODO what to return @@ -415,7 +493,7 @@ api.updateChecklistItem = { api.removeChecklistItem = { method: 'DELETE', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -457,8 +535,8 @@ api.removeChecklistItem = { */ api.addTagToTask = { method: 'POST', - url: '/tasks/:taskId/tags', - middlewares: [authWithHeaders()], + url: '/tasks/:taskId/tags/:tagId', + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -477,7 +555,7 @@ api.addTagToTask = { if (!task) throw new NotFound(res.t('taskNotFound')); let tagId = req.params.tagId; - let alreadyTagged = task.tags.indexOf(tagId) === -1; + let alreadyTagged = task.tags.indexOf(tagId) !== -1; if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); task.tags.push(tagId); @@ -502,7 +580,7 @@ api.addTagToTask = { api.removeTagFromTask = { method: 'DELETE', url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -519,7 +597,7 @@ api.removeTagFromTask = { .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - let tagI = _.findIndex(task.tags, {_id: req.params.tagId}); + let tagI = task.tags.indexOf(req.params.tagId); if (tagI === -1) throw new NotFound(res.t('tagNotFound')); task.tags.splice(tagI, 1); @@ -559,7 +637,7 @@ function _removeTaskTasksOrder (user, taskId) { api.deleteTask = { method: 'DELETE', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js new file mode 100644 index 0000000000..ef6749209c --- /dev/null +++ b/website/src/controllers/api-v3/user.js @@ -0,0 +1,34 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import common from '../../../../common'; + +let api = {}; + +/** + * @api {get} /user Get the authenticated user's profile + * @apiVersion 3.0.0 + * @apiName UserGet + * @apiGroup User + * + * @apiSuccess {Object} user The user object + */ +api.getUser = { + method: 'GET', + middlewares: [authWithHeaders(), cron], + url: '/user', + 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 + delete user.apiToken; + + // TODO move to model (maybe virtuals, maybe in toJSON) + user.stats.toNextLevel = common.tnl(user.stats.lvl); + user.stats.maxHealth = common.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + + return res.respond(200, user); + }, +}; + +export default api; diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js new file mode 100644 index 0000000000..d0c24d700d --- /dev/null +++ b/website/src/middlewares/api-v3/cron.js @@ -0,0 +1,65 @@ +import _ from 'lodash'; +import { + daysSince, +} 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'; + +// TODO check that it's usef everywhere +export default 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? + + // Fetch active tasks (no completed todos) + Task.find({ + userId: user._id, + $or: [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ], + }).exec() + .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 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 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; + });*/ + }); +} diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index e0ad096d1c..f3b4f65cdb 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -48,7 +48,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l // Handle mongoose validation errors if (err.name === 'ValidationError') { - responseErr = new BadRequest(err.message); + responseErr = new BadRequest(err.message); // TODO standard message? translate? responseErr.errors = map(err.errors, (mongooseErr) => { return { message: mongooseErr.message, diff --git a/website/src/models/task.js b/website/src/models/task.js index 77cd0cfa2f..a4e376446d 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -46,17 +46,23 @@ TaskSchema.plugin(baseModel, { }); // A list of additional fields that cannot be set on creation (but can be set on updare) -let noCreate = ['completed']; +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 }; // A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['_id', 'type']; // TODO should prevent changes to checlist.*.id +let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define }; +// Sanitize checklist objects (disallowing _id) +TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { + delete checklistObj._id; + return checklistObj; +}; + export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields diff --git a/website/src/models/user.js b/website/src/models/user.js index 6fef3af2ce..379763aeed 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -45,6 +45,7 @@ export let schema = new Schema({ // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update _v: { type: Number, default: 0 }, + // TODO give all this a default of 0? achievements: { originalUser: Boolean, habitSurveys: Number, @@ -65,7 +66,7 @@ export let schema = new Schema({ quests: Schema.Types.Mixed, // TODO remove, use dictionary? rebirths: Number, rebirthLevel: Number, - perfect: Number, + perfect: {type: Number, default: 0}, habitBirthdays: Number, valentine: Number, costumeContest: Boolean, // Superseded by costumeContests @@ -373,7 +374,7 @@ export let schema = new Schema({ toolbarCollapsed: {type: Boolean, default: false}, background: String, displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true}, - webhooks: {type: Schema.Types.Mixed, default: {}}, + webhooks: {type: Schema.Types.Mixed, default: {}}, // TODO array? and proper controller... unless VersionError becomes problematic // For the following fields make sure to use strict comparison when searching for falsey values (=== false) // As users who didn't login after these were introduced may have them undefined/null emailNotifications: { @@ -468,7 +469,8 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], + // 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'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? @@ -627,6 +629,11 @@ schema.pre('save', true, function preSaveUser (next, done) { } }); +// TODO unit test this? +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;