diff --git a/Dockerfile-Production b/Dockerfile-Production index ca04b42d368..b1984019e3f 100644 --- a/Dockerfile-Production +++ b/Dockerfile-Production @@ -20,7 +20,7 @@ RUN npm install -g gulp mocha # Clone Habitica repo and install dependencies RUN mkdir -p /usr/src/habitrpg WORKDIR /usr/src/habitrpg -RUN git clone --branch v4.11.0 https://github.com/HabitRPG/habitica.git /usr/src/habitrpg +RUN git clone --branch v4.20.1 https://github.com/HabitRPG/habitica.git /usr/src/habitrpg RUN npm install RUN gulp build:prod --force diff --git a/Vagrantfile.example b/Vagrantfile.example index 061c20f2778..5507219c7fd 100644 --- a/Vagrantfile.example +++ b/Vagrantfile.example @@ -16,5 +16,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.hostname = "habitrpg" config.vm.network "forwarded_port", guest: 3000, host: 3000, auto_correct: true config.vm.usable_port_range = (3000..3050) + config.vm.network "forwarded_port", guest: 8080, host: 8080, auto_correct: true + config.vm.usable_port_range = (8080..8130) config.vm.provision :shell, :path => "vagrant_scripts/vagrant.sh" end diff --git a/config.json.example b/config.json.example index c217657c436..f2274ce9401 100644 --- a/config.json.example +++ b/config.json.example @@ -71,6 +71,7 @@ }, "IAP_GOOGLE_KEYDIR": "/path/to/google/public/key/dir/", "LOGGLY_TOKEN": "token", + "LOGGLY_CLIENT_TOKEN": "token", "LOGGLY_ACCOUNT": "account", "PUSH_CONFIGS": { "GCM_SERVER_API_KEY": "", diff --git a/migrations/20140831_increase_gems_for_previous_contributions.js b/migrations/20140831_increase_gems_for_previous_contributions.js index 2bdc5dd5ebd..164dda444b0 100644 --- a/migrations/20140831_increase_gems_for_previous_contributions.js +++ b/migrations/20140831_increase_gems_for_previous_contributions.js @@ -16,7 +16,7 @@ var migrationName = '20140831_increase_gems_for_previous_contributions'; * https://github.com/HabitRPG/habitrpg/issues/3933 * Increase Number of Gems for Contributors * author: Alys (d904bd62-da08-416b-a816-ba797c9ee265) - * + * * Increase everyone's gems per their contribution level. * Originally they were given 2 gems per tier. * Now they are given 3 gems per tier for tiers 1,2,3 @@ -70,7 +70,7 @@ dbUsers.findEach(query, fields, function(err, user) { var extraGems = tier; // tiers 1,2,3 if (tier > 3) { extraGems = 3 + (tier - 3) * 2; } if (tier == 8) { extraGems = 11; } - extraBalance = extraGems / 4; + var extraBalance = extraGems / 4; set['balance'] = user.balance + extraBalance; // Capture current state of user: diff --git a/migrations/20151021_usernames_emails_lowercase.js b/migrations/20151021_usernames_emails_lowercase.js index 432d7eb650b..b40aaad9d7c 100644 --- a/migrations/20151021_usernames_emails_lowercase.js +++ b/migrations/20151021_usernames_emails_lowercase.js @@ -39,7 +39,7 @@ function findUsers(gt){ console.log('User: ', countUsers, user._id); var update = { - $set: {}; + $set: {} }; if(user.auth && user.auth.local) { @@ -60,4 +60,4 @@ function findUsers(gt){ }); }; -findUsers(); \ No newline at end of file +findUsers(); diff --git a/migrations/20171230_nye_hats.js b/migrations/20171230_nye_hats.js new file mode 100644 index 00000000000..08db9a4158d --- /dev/null +++ b/migrations/20171230_nye_hats.js @@ -0,0 +1,103 @@ +var migrationName = '20171230_nye_hats.js'; +var authorName = 'Sabe'; // in case script author needs to know when their ... +var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done + +/* + * Award New Year's Eve party hats to users in sequence + */ + +var monk = require('monk'); +var connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE +var dbUsers = monk(connectionString).get('users', { castIds: false }); + +function processUsers(lastId) { + // specify a query to limit the affected users (empty for all users): + var query = { + 'migration': {$ne:migrationName}, + 'auth.timestamps.loggedin': {$gt:new Date('2017-11-30')}, + }; + + if (lastId) { + query._id = { + $gt: lastId + } + } + + dbUsers.find(query, { + sort: {_id: 1}, + limit: 250, + fields: [ + 'items.gear.owned', + ] // specify fields we are interested in to limit retrieved data (empty if we're not reading data): + }) + .then(updateUsers) + .catch(function (err) { + console.log(err); + return exiting(1, 'ERROR! ' + err); + }); +} + +var progressCount = 1000; +var count = 0; + +function updateUsers (users) { + if (!users || users.length === 0) { + console.warn('All appropriate users found and modified.'); + displayData(); + return; + } + + var userPromises = users.map(updateUser); + var lastUser = users[users.length - 1]; + + return Promise.all(userPromises) + .then(function () { + processUsers(lastUser._id); + }); +} + +function updateUser (user) { + count++; + + var set = {}; + var push = {}; + + if (typeof user.items.gear.owned.head_special_nye2016 !== 'undefined') { + set = {'migration':migrationName, 'items.gear.owned.head_special_nye2017':false}; + push = {pinnedItems: {type: 'marketGear', path: 'gear.flat.head_special_nye2017', '_id': monk.id()}}; + } else if (typeof user.items.gear.owned.head_special_nye2015 !== 'undefined') { + set = {'migration':migrationName, 'items.gear.owned.head_special_nye2016':false}; + push = {pinnedItems: {type: 'marketGear', path: 'gear.flat.head_special_nye2016', '_id': monk.id()}}; + } else if (typeof user.items.gear.owned.head_special_nye2014 !== 'undefined') { + set = {'migration':migrationName, 'items.gear.owned.head_special_nye2015':false}; + push = {pinnedItems: {type: 'marketGear', path: 'gear.flat.head_special_nye2015', '_id': monk.id()}}; + } else if (typeof user.items.gear.owned.head_special_nye !== 'undefined') { + set = {'migration':migrationName, 'items.gear.owned.head_special_nye2014':false}; + push = {pinnedItems: {type: 'marketGear', path: 'gear.flat.head_special_nye2014', '_id': monk.id()}}; + } else { + set = {'migration':migrationName, 'items.gear.owned.head_special_nye':false}; + push = {pinnedItems: {type: 'marketGear', path: 'gear.flat.head_special_nye', '_id': monk.id()}}; + } + + dbUsers.update({_id: user._id}, {$set: set, $push: push}); + + if (count % progressCount == 0) console.warn(count + ' ' + user._id); + if (user._id == authorUuid) console.warn(authorName + ' processed'); +} + +function displayData() { + console.warn('\n' + count + ' users processed\n'); + return exiting(0); +} + +function exiting(code, msg) { + code = code || 0; // 0 = success + if (code && !msg) { msg = 'ERROR!'; } + if (msg) { + if (code) { console.error(msg); } + else { console.log( msg); } + } + process.exit(code); +} + +module.exports = processUsers; diff --git a/migrations/20180110_nextPaymentProcessing.js b/migrations/20180110_nextPaymentProcessing.js new file mode 100644 index 00000000000..293dcb354c2 --- /dev/null +++ b/migrations/20180110_nextPaymentProcessing.js @@ -0,0 +1,79 @@ +/* + * Convert purchased.plan.nextPaymentProcessing from a double to a date field for Apple subscribers + */ + +var monk = require('monk'); +var connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE +var dbUsers = monk(connectionString).get('users', { castIds: false }); + +function processUsers(lastId) { + // specify a query to limit the affected users (empty for all users): + var query = { + 'purchased.plan.paymentMethod': "Apple", + 'purchased.plan.nextPaymentProcessing': {$type: 'double'}, + }; + + if (lastId) { + query._id = { + $gt: lastId + } + } + + dbUsers.find(query, { + sort: {_id: 1}, + limit: 250, + }) + .then(updateUsers) + .catch(function (err) { + console.log(err); + return exiting(1, 'ERROR! ' + err); + }); +} + +var progressCount = 100; +var count = 0; + +function updateUsers (users) { + if (!users || users.length === 0) { + console.warn('All appropriate users found and modified.'); + displayData(); + return; + } + + var userPromises = users.map(updateUser); + var lastUser = users[users.length - 1]; + + return Promise.all(userPromises) + .then(function () { + processUsers(lastUser._id); + }); +} + +function updateUser (user) { + count++; + + var set = { + 'purchased.plan.nextPaymentProcessing': new Date(user.purchased.plan.nextPaymentProcessing), + }; + + dbUsers.update({_id: user._id}, {$set: set}); + + if (count % progressCount == 0) console.warn(count + ' ' + user._id); +} + +function displayData() { + console.warn('\n' + count + ' users processed\n'); + return exiting(0); +} + +function exiting(code, msg) { + code = code || 0; // 0 = success + if (code && !msg) { msg = 'ERROR!'; } + if (msg) { + if (code) { console.error(msg); } + else { console.log( msg); } + } + process.exit(code); +} + +module.exports = processUsers; diff --git a/migrations/docs/mongo-indexes.md b/migrations/docs/mongo-indexes.md new file mode 100644 index 00000000000..9a2ab2fe4b5 --- /dev/null +++ b/migrations/docs/mongo-indexes.md @@ -0,0 +1,58 @@ +# Indexes + +This file contains a list of indexes that are on Habitica's production Mongo server. +If we ever have an issue, use this list to reindex. + +## Challenges + - `{ "group": 1, "official": -1, "timestamp": -1 }` + - `{ "leader": 1, "official": -1, "timestamp": -1 }` + - `{ "official": -1, "timestamp": -1 }` + +## Groups + - `{ "privacy": 1, "type": 1, "memberCount": -1 }` + - `{ "privacy": 1 }` + - `{ "purchased.plan.customerId": 1 }` + - `{ "purchased.plan.paymentMethod": 1 }` + - `{ "purchased.plan.planId": 1, "purchased.plan.dateTerminated": 1 }` + - `{ "type": 1, "memberCount": -1, "_id": 1 }` + - `{ "type": 1 }` + +## Tasks + - `{ "challenge.id": 1 }` + - `{ "challenge.taskId": 1 }` + - `{ "group.id": 1 }` + - `{ "group.taskId": 1 }` + - `{ "type": 1, "everyX": 1, "frequency": 1 }` + - `{ "userId": 1 }` + - `{ "yesterDaily": 1, "type": 1 }` + +## Users + - `{ "_id": 1, "apiToken": 1 }` + - `{ "auth.facebook.emails.value": 1 }` + - `{ "auth.facebook.id": 1 }` + - `{ "auth.google.emails.value": 1 }` + - `{ "auth.google.id": 1 }` + - `{ "auth.local.email": 1 }` + - `{ "auth.local.lowerCaseUsername": 1 }` + - `{ "auth.local.username": 1 }` + - `{ "auth.timestamps.created": 1 }` + - `{ "auth.timestamps.loggedin": 1, "_lastPushNotification": 1, "preferences.timezoneOffset": 1 }` + - `{ "auth.timestamps.loggedin": 1 }` + - `{ "backer.tier": -1 }` + - `{ "challenges": 1, "_id": 1 }` + - `{ "contributor.admin": 1, "contributor.level": -1, "backer.npc": -1, "profile.name": 1 }` + - `{ "contributor.level": 1 }` + - `{ "flags.newStuff": 1 }` + - `{ "guilds": 1, "_id": 1 }` + - `{ "invitations.guilds.id": 1, "_id": 1 }` + - `{ "invitations.party.id": 1 }` + - `{ "loginIncentives": 1 }` + - `{ "migration": 1 }` + - {` "party._id": 1, "_id": 1 }` + - `{ "preferences.sleep": 1, "_id": 1, "flags.lastWeeklyRecap": 1, "preferences.emailNotifications.unsubscribeFromAll": 1, "preferences.emailNotifications.weeklyRecaps": 1 }` + - `{ "preferences.sleep": 1, "_id": 1, "lastCron": 1, "preferences.emailNotifications.importantAnnouncements": 1, "preferences.emailNotifications.unsubscribeFromAll": 1, "flags.recaptureEmailsPhase": 1 }` + - `{ "profile.name": 1 }` + - `{ "purchased.plan.customerId": 1 }` + - `{ "purchased.plan.paymentMethod": 1 }` + - `{ "stats.score.overall": 1 }` + - `{ "webhooks.type": 1 }` diff --git a/migrations/migration-runner.js b/migrations/migration-runner.js index a29f068bd58..b594e403d24 100644 --- a/migrations/migration-runner.js +++ b/migrations/migration-runner.js @@ -17,12 +17,5 @@ function setUpServer () { setUpServer(); // Replace this with your migration -const processUsers = require('./users/account-transfer'); -processUsers() - .then(() => { - process.exit(); - }) - .catch(function (err) { - console.log(err); - process.exit(); - }); +const processUsers = require('./tasks/tasks-set-everyX'); +processUsers(); diff --git a/migrations/mystery_items.js b/migrations/mystery_items.js index aca1034c2d4..886ca4edfb2 100644 --- a/migrations/mystery_items.js +++ b/migrations/mystery_items.js @@ -2,7 +2,7 @@ var _id = ''; var update = { $addToSet: { 'purchased.plan.mysteryItems':{ - $each:['armor_mystery_201711','body_mystery_201711'] + $each:['armor_mystery_201712','head_mystery_201712'] } } }; diff --git a/migrations/takeThis.js b/migrations/takeThis.js index 32d4c00ef67..17336e394d1 100644 --- a/migrations/takeThis.js +++ b/migrations/takeThis.js @@ -1,4 +1,4 @@ -var migrationName = '20170502_takeThis.js'; // Update per month +var migrationName = '20180102_takeThis.js'; // Update per month var authorName = 'Sabe'; // in case script author needs to know when their ... var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done @@ -7,14 +7,14 @@ var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done */ var monk = require('monk'); -var connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE +var connectionString = 'mongodb://sabrecat:z8e8jyRA8CTofMQ@ds013393-a0.mlab.com:13393/habitica?auto_reconnect=true'; var dbUsers = monk(connectionString).get('users', { castIds: false }); function processUsers(lastId) { // specify a query to limit the affected users (empty for all users): var query = { 'migration':{$ne:migrationName}, - 'challenges':{$in:['69999331-d4ea-45a0-8c3f-f725d22b56c8']} // Update per month + 'challenges':{$in:['5f70ce5b-2d82-4114-8e44-ca65615aae62']} // Update per month }; if (lastId) { diff --git a/migrations/tasks/tasks-set-everyX.js b/migrations/tasks/tasks-set-everyX.js new file mode 100644 index 00000000000..c0a4acc7fb9 --- /dev/null +++ b/migrations/tasks/tasks-set-everyX.js @@ -0,0 +1,88 @@ +var migrationName = 'tasks-set-everyX'; +var authorName = 'Sabe'; // in case script author needs to know when their ... +var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done + +/* + * Iterates over all tasks and sets invalid everyX values (less than 0 or more than 9999 or not an int) field to 0 + */ + +var monk = require('monk'); +var connectionString = 'mongodb://sabrecat:z8e8jyRA8CTofMQ@ds013393-a0.mlab.com:13393/habitica?auto_reconnect=true'; +var dbTasks = monk(connectionString).get('tasks', { castIds: false }); + +function processTasks(lastId) { + // specify a query to limit the affected tasks (empty for all tasks): + var query = { + type: "daily", + everyX: { + $not: { + $gte: 0, + $lte: 9999, + $type: "int", + } + }, + }; + + if (lastId) { + query._id = { + $gt: lastId + } + } + + dbTasks.find(query, { + sort: {_id: 1}, + limit: 250, + fields: [], + }) + .then(updateTasks) + .catch(function (err) { + console.log(err); + return exiting(1, 'ERROR! ' + err); + }); +} + +var progressCount = 1000; +var count = 0; + +function updateTasks (tasks) { + if (!tasks || tasks.length === 0) { + console.warn('All appropriate tasks found and modified.'); + displayData(); + return; + } + + var taskPromises = tasks.map(updatetask); + var lasttask = tasks[tasks.length - 1]; + + return Promise.all(taskPromises) + .then(function () { + processTasks(lasttask._id); + }); +} + +function updatetask (task) { + count++; + var set = {'everyX': 0}; + + dbTasks.update({_id: task._id}, {$set:set}); + + if (count % progressCount == 0) console.warn(count + ' ' + task._id); + if (task._id == authorUuid) console.warn(authorName + ' processed'); +} + +function displayData() { + console.warn('\n' + count + ' tasks processed\n'); + return exiting(0); +} + +function exiting(code, msg) { + code = code || 0; // 0 = success + if (code && !msg) { msg = 'ERROR!'; } + if (msg) { + if (code) { console.error(msg); } + else { console.log( msg); } + } + process.exit(code); +} + +module.exports = processTasks; diff --git a/migrations/users/achievement-restore.js b/migrations/users/achievement-restore.js new file mode 100644 index 00000000000..bd7e91a3272 --- /dev/null +++ b/migrations/users/achievement-restore.js @@ -0,0 +1,93 @@ +const migrationName = 'AchievementRestore'; +const authorName = 'TheHollidayInn'; // in case script author needs to know when their ... +const authorUuid = ''; //... own data is done + +/* + * This migraition will copy user data from prod to test + */ +import Bluebird from 'bluebird'; + +const monk = require('monk'); +const connectionString = 'mongodb://localhost/new-habit'; +const Users = monk(connectionString).get('users', { castIds: false }); + +const monkOld = require('monk'); +const oldConnectionSting = 'mongodb://localhost/old-habit'; +const UsersOld = monk(oldConnectionSting).get('users', { castIds: false }); + +function getAchievementUpdate (newUser, oldUser) { + const oldAchievements = oldUser.achievements; + const newAchievements = newUser.achievements; + + let achievementsUpdate = Object.assign({}, newAchievements); + + // ultimateGearSets + if (!achievementsUpdate.ultimateGearSets && oldAchievements.ultimateGearSets) { + achievementsUpdate.ultimateGearSets = oldAchievements.ultimateGearSets; + } else if (oldAchievements.ultimateGearSets) { + for (let index in oldAchievements.ultimateGearSets) { + if (oldAchievements.ultimateGearSets[index]) achievementsUpdate.ultimateGearSets[index] = true; + } + } + + // challenges + if (!newAchievements.challenges) newAchievements.challenges = []; + if (!oldAchievements.challenges) oldAchievements.challenges = []; + achievementsUpdate.challenges = newAchievements.challenges.concat(oldAchievements.challenges); + + // Quests + if (!achievementsUpdate.quests) achievementsUpdate.quests = {}; + for (let index in oldAchievements.quests) { + if (!achievementsUpdate.quests[index]) { + achievementsUpdate.quests[index] = oldAchievements.quests[index]; + } else { + achievementsUpdate.quests[index] += oldAchievements.quests[index]; + } + } + + // Rebirth level + if (achievementsUpdate.rebirthLevel) { + achievementsUpdate.rebirthLevel = Math.max(achievementsUpdate.rebirthLevel, oldAchievements.rebirthLevel); + } else if (oldAchievements.rebirthLevel) { + achievementsUpdate.rebirthLevel = oldAchievements.rebirthLevel; + } + + //All others + const indexsToIgnore = ['ultimateGearSets', 'challenges', 'quests', 'rebirthLevel']; + for (let index in oldAchievements) { + if (indexsToIgnore.indexOf(index) !== -1) continue; + + if (!achievementsUpdate[index]) { + achievementsUpdate[index] = oldAchievements[index]; + continue; + } + + if (Number.isInteger(oldAchievements[index])) { + achievementsUpdate[index] += oldAchievements[index]; + } else { + if (oldAchievements[index] === true) achievementsUpdate[index] = true; + } + } + + return achievementsUpdate; +} + +module.exports = async function achievementRestore () { + const userIds = [ + ]; + + for (let index in userIds) { + const userId = userIds[index]; + const oldUser = await UsersOld.findOne({_id: userId}, 'achievements'); + const newUser = await Users.findOne({_id: userId}, 'achievements'); + const achievementUpdate = getAchievementUpdate(newUser, oldUser); + await Users.update( + {_id: userId}, + { + $set: { + 'achievements': achievementUpdate, + }, + }); + console.log(`Updated ${userId}`); + } +}; diff --git a/package-lock.json b/package-lock.json index 1c01a87b8bb..47f0ac8eb78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "habitica", - "version": "4.12.1", + "version": "4.20.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -32,6 +32,15 @@ "ws": "1.1.5" } }, + "JSONStream": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.1.tgz", + "integrity": "sha1-cH92HgHa6eFvG8+TcDt4xwlmV5o=", + "requires": { + "jsonparse": "1.3.1", + "through": "2.3.8" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1648,9 +1657,9 @@ } }, "bootstrap": { - "version": "4.0.0-beta.2", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.0.0-beta.2.tgz", - "integrity": "sha512-DzGtdTlKbrMoGMpz0LigKSqJ+MgtFKxA791PU/q062OlRG0HybNZcTLH7rpDAmLS66Y3esN9yzKHLLbqa5UR3w==" + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.0.0-beta.3.tgz", + "integrity": "sha512-/Qe1Q2d1muLEZRX2iCteMQHZBBAm6ZIjJ9FcBYK/xLr05+HvDtBOVBN+Cz7mCNZuy0zr+y5artZHM05W7mIz6g==" }, "bootstrap-vue": { "version": "1.2.0", @@ -1659,7 +1668,7 @@ "requires": { "lodash.startcase": "4.4.0", "opencollective": "1.0.3", - "popper.js": "1.12.9", + "popper.js": "1.13.0", "vue-functional-data-merge": "1.0.7" } }, @@ -1771,9 +1780,9 @@ "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.0.2.tgz", "integrity": "sha1-+GzWzvT1MAyOY+B6TVEvZfv/RTE=", "requires": { + "JSONStream": "1.3.1", "combine-source-map": "0.7.2", "defined": "1.0.0", - "JSONStream": "1.3.1", "through2": "2.0.3", "umd": "3.0.1" } @@ -1803,6 +1812,7 @@ "resolved": "https://registry.npmjs.org/browserify/-/browserify-12.0.2.tgz", "integrity": "sha1-V/IeXm4wj/WYfE2v1EhAsrmPehk=", "requires": { + "JSONStream": "1.3.1", "assert": "1.3.0", "browser-pack": "6.0.2", "browser-resolve": "1.11.2", @@ -1824,7 +1834,6 @@ "inherits": "2.0.3", "insert-module-globals": "7.0.1", "isarray": "0.0.1", - "JSONStream": "1.3.1", "labeled-stream-splicer": "2.0.0", "module-deps": "4.1.1", "os-browserify": "0.1.2", @@ -6711,13 +6720,6 @@ } } }, - "string_decoder": { - "version": "1.0.1", - "bundled": true, - "requires": { - "safe-buffer": "5.0.1" - } - }, "string-width": { "version": "1.0.2", "bundled": true, @@ -6727,6 +6729,13 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "1.0.1", + "bundled": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, "stringstream": { "version": "0.0.5", "bundled": true, @@ -8899,10 +8908,10 @@ "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.0.1.tgz", "integrity": "sha1-wDv04BywhtW15azorQr+eInWOMM=", "requires": { + "JSONStream": "1.3.1", "combine-source-map": "0.7.2", "concat-stream": "1.5.2", "is-buffer": "1.1.6", - "JSONStream": "1.3.1", "lexical-scope": "1.2.0", "process": "0.11.10", "through2": "2.0.3", @@ -9738,15 +9747,6 @@ "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=" }, - "JSONStream": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.1.tgz", - "integrity": "sha1-cH92HgHa6eFvG8+TcDt4xwlmV5o=", - "requires": { - "jsonparse": "1.3.1", - "through": "2.3.8" - } - }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -11554,6 +11554,7 @@ "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-4.1.1.tgz", "integrity": "sha1-IyFYM/HaE/1gbMuAh7RIUty4If0=", "requires": { + "JSONStream": "1.3.1", "browser-resolve": "1.11.2", "cached-path-relative": "1.0.1", "concat-stream": "1.5.2", @@ -11561,7 +11562,6 @@ "detective": "4.5.0", "duplexer2": "0.1.4", "inherits": "2.0.3", - "JSONStream": "1.3.1", "parents": "1.0.1", "readable-stream": "2.0.6", "resolve": "1.5.0", @@ -11580,40 +11580,53 @@ "version": "git://github.com/habitrpg/moment-recur.git#f147ef27bbc26ca67638385f3db4a44084c76626" }, "mongodb": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.24.tgz", - "integrity": "sha1-gPQNbsW97A3ezw+c4BROeUxGRJo=", + "version": "2.2.33", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.33.tgz", + "integrity": "sha1-tTfEcdNKZlG0jzb9vyl1A0Dgi1A=", + "dev": true, "requires": { "es6-promise": "3.2.1", - "mongodb-core": "2.1.8", - "readable-stream": "2.1.5" + "mongodb-core": "2.1.17", + "readable-stream": "2.2.7" }, "dependencies": { "es6-promise": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz", - "integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q=" + "integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q=", + "dev": true }, "readable-stream": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", - "integrity": "sha1-ZvqLcg4UOLNkaB8q0aY8YYRIydA=", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz", + "integrity": "sha1-BwV6y+JGeyIELTb5jFrVBwVOlbE=", + "dev": true, "requires": { "buffer-shims": "1.0.0", "core-util-is": "1.0.2", "inherits": "2.0.3", "isarray": "1.0.0", "process-nextick-args": "1.0.7", - "string_decoder": "0.10.31", + "string_decoder": "1.0.3", "util-deprecate": "1.0.2" } + }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } } } }, "mongodb-core": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.8.tgz", - "integrity": "sha1-sz4DcNClnZe2yx7GEFJ76elcosA=", + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.17.tgz", + "integrity": "sha1-pBizN6FKFJkPtRC5I97mqBMXPfg=", + "dev": true, "requires": { "bson": "1.0.4", "require_optional": "1.0.1" @@ -11646,10 +11659,48 @@ "lodash": "4.17.4" } }, + "es6-promise": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz", + "integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q=" + }, + "mongodb": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.24.tgz", + "integrity": "sha1-gPQNbsW97A3ezw+c4BROeUxGRJo=", + "requires": { + "es6-promise": "3.2.1", + "mongodb-core": "2.1.8", + "readable-stream": "2.1.5" + } + }, + "mongodb-core": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.8.tgz", + "integrity": "sha1-sz4DcNClnZe2yx7GEFJ76elcosA=", + "requires": { + "bson": "1.0.4", + "require_optional": "1.0.1" + } + }, "ms": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz", "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U=" + }, + "readable-stream": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "integrity": "sha1-ZvqLcg4UOLNkaB8q0aY8YYRIydA=", + "requires": { + "buffer-shims": "1.0.0", + "core-util-is": "1.0.2", + "inherits": "2.0.3", + "isarray": "1.0.0", + "process-nextick-args": "1.0.7", + "string_decoder": "0.10.31", + "util-deprecate": "1.0.2" + } } } }, @@ -11675,7 +11726,7 @@ "requires": { "debug": "2.6.9", "gitbook-plugin-github": "2.0.0", - "mongodb": "2.2.24" + "mongodb": "2.2.33" } }, "morgan": { @@ -13881,9 +13932,9 @@ "integrity": "sha1-xi/0xMUdLJGUlLdhpvSZP01/5Wk=" }, "popper.js": { - "version": "1.12.9", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.12.9.tgz", - "integrity": "sha1-DfvC3/lsRRuzMu3Pz6r1ZtMx1bM=" + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.13.0.tgz", + "integrity": "sha1-4ef/ZcxD98+c8W8VEKdegfhPRWU=" }, "postcss": { "version": "5.2.18", @@ -15439,22 +15490,6 @@ "throttleit": "1.0.0" } }, - "require_optional": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", - "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", - "requires": { - "resolve-from": "2.0.0", - "semver": "5.4.1" - }, - "dependencies": { - "semver": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", - "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" - } - } - }, "require-again": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-again/-/require-again-2.0.0.tgz", @@ -15494,6 +15529,22 @@ } } }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "2.0.0", + "semver": "5.4.1" + }, + "dependencies": { + "semver": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" + } + } + }, "requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -16797,11 +16848,6 @@ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" - }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -16812,6 +16858,11 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, "stringstream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", @@ -18496,8 +18547,16 @@ "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.6.0.tgz", "integrity": "sha512-x3LV3wdmmERhVCYy3quqA57NJW7F3i6faas++pJQWtknWT+n7k30F4TVdHvCLn48peTJFRvCpxs3UuFPqgeELg==" }, + "vuedraggable": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.15.0.tgz", + "integrity": "sha1-NSat7pJL0itHigG3DAo038nLu08=", + "requires": { + "sortablejs": "1.7.0" + } + }, "vuejs-datepicker": { - "version": "git://github.com/habitrpg/vuejs-datepicker.git#825a866b6a9c52dd8c588a3e8b900880875ce914" + "version": "git://github.com/habitrpg/vuejs-datepicker.git#af96f357a746463dc0461ddf5c2c13b7a4e1f484" }, "w3counter": { "version": "3.0.1", diff --git a/package.json b/package.json index 427eabcd949..c88b29f142b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "habitica", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "4.12.1", + "version": "4.20.2", "main": "./website/server/index.js", "dependencies": { "@slack/client": "^3.8.1", @@ -31,7 +31,7 @@ "bcrypt": "^1.0.2", "bluebird": "^3.3.5", "body-parser": "^1.15.0", - "bootstrap": "4.0.0-beta.2", + "bootstrap": "^4.0.0-beta.3", "bootstrap-vue": "^1.0.2", "browserify": "~12.0.1", "compression": "^1.6.1", @@ -72,7 +72,7 @@ "merge-stream": "^1.0.0", "method-override": "^2.3.5", "moment": "^2.13.0", - "moment-recur": "git://github.com/habitrpg/moment-recur#f147ef27bbc26ca67638385f3db4a44084c76626", + "moment-recur": "git://github.com/habitrpg/moment-recur.git#f147ef27bbc26ca67638385f3db4a44084c76626", "mongoose": "~4.8.6", "mongoose-id-autoinc": "~2013.7.14-4", "morgan": "^1.7.0", @@ -89,12 +89,12 @@ "passport-google-oauth20": "1.0.0", "paypal-ipn": "3.0.0", "paypal-rest-sdk": "^1.2.1", - "popper.js": "^1.11.0", + "popper.js": "^1.13.0", "postcss-easy-import": "^2.0.0", "pretty-data": "^0.40.0", "ps-tree": "^1.0.0", "pug": "^2.0.0-beta.12", - "push-notify": "git://github.com/habitrpg/push-notify#6bc2b5fdb1bdc9649b9ec1964d79ca50187fc8a9", + "push-notify": "git://github.com/habitrpg/push-notify.git#6bc2b5fdb1bdc9649b9ec1964d79ca50187fc8a9", "pusher": "^1.3.0", "request": "~2.74.0", "rimraf": "^2.4.3", @@ -122,7 +122,8 @@ "vue-router": "^3.0.0", "vue-style-loader": "^3.0.0", "vue-template-compiler": "^2.5.2", - "vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker#825a866b6a9c52dd8c588a3e8b900880875ce914", + "vuedraggable": "^2.15.0", + "vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#af96f357a746463dc0461ddf5c2c13b7a4e1f484", "webpack": "^2.2.1", "webpack-merge": "^4.0.0", "winston": "^2.1.0", @@ -196,7 +197,7 @@ "lcov-result-merger": "^1.0.2", "lolex": "^1.4.0", "mocha": "^3.2.0", - "mongodb": "^2.0.46", + "mongodb": "^2.2.33", "mongoskin": "~2.1.0", "monk": "^4.0.0", "nightwatch": "^0.9.12", diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js index 2b98af9579f..d82bc498ad1 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js @@ -64,11 +64,11 @@ describe('GET /challenges/:challengeId/export/csv', () => { let sortedMembers = _.sortBy([members[0], members[1], members[2], groupLeader], '_id'); let splitRes = res.split('\n'); - expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Task,Value,Notes'); - expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,todo:Task 2,0,`); - expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,todo:Task 2,0,`); - expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,todo:Task 2,0,`); - expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,todo:Task 2,0,`); + expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Streak,Task,Value,Notes,Streak'); + expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,0,todo:Task 2,0,,0`); + expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,0,todo:Task 2,0,,0`); + expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,0,todo:Task 2,0,,0`); + expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,0,todo:Task 2,0,,0`); expect(splitRes[5]).to.equal(''); }); }); diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js index 29c312807d0..1f7ef43d6b9 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -101,19 +101,21 @@ describe('POST /challenges/:challengeId/join', () => { }); it('syncs challenge tasks to joining user', async () => { - let taskText = 'A challenge task text'; - + const taskText = 'A challenge task text'; await groupLeader.post(`/tasks/challenge/${challenge._id}`, [ - {type: 'habit', text: taskText}, + {type: 'daily', text: taskText}, ]); await authorizedUser.post(`/challenges/${challenge._id}/join`); - let tasks = await authorizedUser.get('/tasks/user'); - let tasksTexts = tasks.map((task) => { - return task.text; + + const tasks = await authorizedUser.get('/tasks/user'); + const syncedTask = tasks.find((task) => { + return task.text === taskText; }); - expect(tasksTexts).to.include(taskText); + expect(syncedTask.text).to.eql(taskText); + expect(syncedTask.isDue).to.exist; + expect(syncedTask.nextDue).to.exist; }); it('adds challenge tag to user tags', async () => { diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js index f25a6c669f4..e0dc561c668 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js @@ -149,13 +149,19 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { await sleep(0.5); - let tasks = await winningUser.get('/tasks/user'); - let testTask = _.find(tasks, (task) => { + const tasks = await winningUser.get('/tasks/user'); + const testTask = _.find(tasks, (task) => { return task.text === taskText; }); + const updatedUser = await winningUser.sync(); + const challengeTag = updatedUser.tags.find(tags => { + return tags.id === challenge._id; + }); + expect(testTask.challenge.broken).to.eql('CHALLENGE_CLOSED'); expect(testTask.challenge.winner).to.eql(winningUser.profile.name); + expect(challengeTag.challenge).to.eql('false'); }); }); }); diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js index 6f8355aa557..9255772ad22 100644 --- a/test/api/v3/integration/chat/POST-chat.test.js +++ b/test/api/v3/integration/chat/POST-chat.test.js @@ -1,5 +1,6 @@ import { createAndPopulateGroup, + generateUser, translate as t, sleep, server, @@ -363,6 +364,24 @@ describe('POST /chat', () => { expect(message.message.id).to.exist; }); + it('adds backer info to chat', async () => { + const backerInfo = { + npc: 'Town Crier', + tier: 800, + tokensApplied: true, + }; + const backer = await generateUser({ + backer: backerInfo, + }); + + const message = await backer.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage}); + const messageBackerInfo = message.message.backer; + + expect(messageBackerInfo.npc).to.equal(backerInfo.npc); + expect(messageBackerInfo.tier).to.equal(backerInfo.tier); + expect(messageBackerInfo.tokensApplied).to.equal(backerInfo.tokensApplied); + }); + it('sends group chat received webhooks', async () => { let userUuid = generateUUID(); let memberUuid = generateUUID(); diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js index b3d410675a8..35a2878847a 100644 --- a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js +++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js @@ -3,6 +3,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-v3-integration.helper'; +import config from '../../../../../config.json'; import { v4 as generateUUID } from 'uuid'; describe('POST /groups/:id/chat/:id/clearflags', () => { @@ -74,7 +75,7 @@ describe('POST /groups/:id/chat/:id/clearflags', () => { expect(messages[0].flagCount).to.eql(0); }); - it('can unflag a system message', async () => { + it('can\'t flag a system message', async () => { let { group, members } = await createAndPopulateGroup({ groupDetails: { type: 'party', @@ -95,13 +96,15 @@ describe('POST /groups/:id/chat/:id/clearflags', () => { await member.post('/user/class/cast/mpheal'); let [skillMsg] = await member.get(`/groups/${group.id}/chat`); - - await member.post(`/groups/${group._id}/chat/${skillMsg.id}/flag`); - await admin.post(`/groups/${group._id}/chat/${skillMsg.id}/clearflags`); - - let messages = await members[0].get(`/groups/${group._id}/chat`); - expect(messages[0].id).to.eql(skillMsg.id); - expect(messages[0].flagCount).to.eql(0); + await expect(member.post(`/groups/${group._id}/chat/${skillMsg.id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('messageCannotFlagSystemMessages', {communityManagerEmail: config.EMAILS.COMMUNITY_MANAGER_EMAIL}), + }); + // let messages = await members[0].get(`/groups/${group._id}/chat`); + // expect(messages[0].id).to.eql(skillMsg.id); + // expect(messages[0].flagCount).to.eql(0); }); }); diff --git a/test/api/v3/integration/coupons/GET-coupons.test.js b/test/api/v3/integration/coupons/GET-coupons.test.js index 6008d2b1df7..fd61ac1a983 100644 --- a/test/api/v3/integration/coupons/GET-coupons.test.js +++ b/test/api/v3/integration/coupons/GET-coupons.test.js @@ -1,8 +1,8 @@ import { generateUser, - translate as t, resetHabiticaDB, } from '../../../../helpers/api-v3-integration.helper'; +import apiMessages from '../../../../../website/server/libs/apiMessages'; describe('GET /coupons/', () => { let user; @@ -19,7 +19,7 @@ describe('GET /coupons/', () => { await expect(user.get('/coupons')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('noSudoAccess'), + message: apiMessages('noSudoAccess'), }); }); diff --git a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js index 09e4c763919..12730c8296f 100644 --- a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js +++ b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js @@ -4,6 +4,7 @@ import { resetHabiticaDB, } from '../../../../helpers/api-v3-integration.helper'; import couponCode from 'coupon-code'; +import apiMessages from '../../../../../website/server/libs/apiMessages'; describe('POST /coupons/generate/:event', () => { let user; @@ -25,7 +26,7 @@ describe('POST /coupons/generate/:event', () => { await expect(user.post('/coupons/generate/aaa')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('noSudoAccess'), + message: apiMessages('noSudoAccess'), }); }); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js index e74c6acfa6e..863a80c98db 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -72,7 +72,7 @@ describe('GET /groups/:groupId/members', () => { expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', - 'backer', 'contributor', 'auth', 'items', 'inbox', + 'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', ]); expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ @@ -93,7 +93,7 @@ describe('GET /groups/:groupId/members', () => { expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', - 'backer', 'contributor', 'auth', 'items', 'inbox', + 'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', ]); expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ @@ -161,4 +161,19 @@ describe('GET /groups/:groupId/members', () => { let resIds = res.concat(res2).map(member => member._id); expect(resIds).to.eql(expectedIds.sort()); }); + + it('searches members', async () => { + let group = await generateGroup(user, {type: 'party', name: generateUUID()}); + + let usersToGenerate = []; + for (let i = 0; i < 2; i++) { + usersToGenerate.push(generateUser({party: {_id: group._id}})); + } + const usersCreated = await Promise.all(usersToGenerate); + const userToSearch = usersCreated[0].profile.name; + + let res = await user.get(`/groups/party/members?search=${userToSearch}`); + expect(res.length).to.equal(1); + expect(res[0].profile.name).to.equal(userToSearch); + }); }); diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js index fa34fac096a..15e3fa9f56e 100644 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -32,7 +32,7 @@ describe('GET /members/:memberId', () => { let memberRes = await user.get(`/members/${member._id}`); expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', - 'backer', 'contributor', 'auth', 'items', 'inbox', + 'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', ]); expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ diff --git a/test/api/v3/integration/members/POST-send_private_message.test.js b/test/api/v3/integration/members/POST-send_private_message.test.js index 7498d2e560c..aaa3cb3a2c9 100644 --- a/test/api/v3/integration/members/POST-send_private_message.test.js +++ b/test/api/v3/integration/members/POST-send_private_message.test.js @@ -118,4 +118,56 @@ describe('POST /members/send-private-message', () => { expect(sendersMessageInReceiversInbox).to.exist; expect(sendersMessageInSendersInbox).to.exist; }); + + it('allows admin to send when sender has blocked the admin', async () => { + userToSendMessage = await generateUser({ + 'contributor.admin': 1, + }); + const receiver = await generateUser({'inbox.blocks': [userToSendMessage._id]}); + + await userToSendMessage.post('/members/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + }); + + const updatedReceiver = await receiver.get('/user'); + const updatedSender = await userToSendMessage.get('/user'); + + const sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { + return message.uuid === userToSendMessage._id && message.text === messageToSend; + }); + + const sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { + return message.uuid === receiver._id && message.text === messageToSend; + }); + + expect(sendersMessageInReceiversInbox).to.exist; + expect(sendersMessageInSendersInbox).to.exist; + }); + + it('allows admin to send when to user has opted out of messaging', async () => { + userToSendMessage = await generateUser({ + 'contributor.admin': 1, + }); + const receiver = await generateUser({'inbox.optOut': true}); + + await userToSendMessage.post('/members/send-private-message', { + message: messageToSend, + toUserId: receiver._id, + }); + + const updatedReceiver = await receiver.get('/user'); + const updatedSender = await userToSendMessage.get('/user'); + + const sendersMessageInReceiversInbox = _.find(updatedReceiver.inbox.messages, (message) => { + return message.uuid === userToSendMessage._id && message.text === messageToSend; + }); + + const sendersMessageInSendersInbox = _.find(updatedSender.inbox.messages, (message) => { + return message.uuid === receiver._id && message.text === messageToSend; + }); + + expect(sendersMessageInReceiversInbox).to.exist; + expect(sendersMessageInSendersInbox).to.exist; + }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 1baa24042e9..fa435c5e316 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -130,6 +130,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('uncompletes todo when direction is down', async () => { + await user.post(`/tasks/${todo._id}/score/up`); await user.post(`/tasks/${todo._id}/score/down`); let updatedTask = await user.get(`/tasks/${todo._id}`); @@ -137,9 +138,23 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedTask.dateCompleted).to.be.a('undefined'); }); - it('scores up todo even if it is already completed'); // Yes? + it('doesn\'t let a todo be completed twice', async () => { + await user.post(`/tasks/${todo._id}/score/up`); + await expect(user.post(`/tasks/${todo._id}/score/up`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('sessionOutdated'), + }); + }); - it('scores down todo even if it is already uncompleted'); // Yes? + it('doesn\'t let a todo be uncompleted twice', async () => { + await expect(user.post(`/tasks/${todo._id}/score/down`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('sessionOutdated'), + }); + }); context('user stats when direction is up', () => { let updatedUser; @@ -163,23 +178,25 @@ describe('POST /tasks/:id/score/:direction', () => { }); context('user stats when direction is down', () => { - let updatedUser; + let updatedUser, initialUser; beforeEach(async () => { + await user.post(`/tasks/${todo._id}/score/up`); + initialUser = await user.get('/user'); await user.post(`/tasks/${todo._id}/score/down`); updatedUser = await user.get('/user'); }); it('decreases user\'s mp', () => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + expect(updatedUser.stats.mp).to.be.lessThan(initialUser.stats.mp); }); it('decreases user\'s exp', () => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + expect(updatedUser.stats.exp).to.be.lessThan(initialUser.stats.exp); }); it('decreases user\'s gold', () => { - expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); + expect(updatedUser.stats.gp).to.be.lessThan(initialUser.stats.gp); }); }); }); @@ -202,6 +219,7 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('uncompletes daily when direction is down', async () => { + await user.post(`/tasks/${daily._id}/score/up`); await user.post(`/tasks/${daily._id}/score/down`); let task = await user.get(`/tasks/${daily._id}`); @@ -222,9 +240,22 @@ describe('POST /tasks/:id/score/:direction', () => { expect(task.nextDue.length).to.eql(6); }); - it('scores up daily even if it is already completed'); // Yes? + it('doesn\'t let a daily be completed twice', async () => { + await user.post(`/tasks/${daily._id}/score/up`); + await expect(user.post(`/tasks/${daily._id}/score/up`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('sessionOutdated'), + }); + }); - it('scores down daily even if it is already uncompleted'); // Yes? + it('doesn\'t let a daily be uncompleted twice', async () => { + await expect(user.post(`/tasks/${daily._id}/score/down`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('sessionOutdated'), + }); + }); context('user stats when direction is up', () => { let updatedUser; @@ -248,23 +279,25 @@ describe('POST /tasks/:id/score/:direction', () => { }); context('user stats when direction is down', () => { - let updatedUser; + let updatedUser, initialUser; beforeEach(async () => { + await user.post(`/tasks/${daily._id}/score/up`); + initialUser = await user.get('/user'); await user.post(`/tasks/${daily._id}/score/down`); updatedUser = await user.get('/user'); }); it('decreases user\'s mp', () => { - expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); + expect(updatedUser.stats.mp).to.be.lessThan(initialUser.stats.mp); }); it('decreases user\'s exp', () => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); + expect(updatedUser.stats.exp).to.be.lessThan(initialUser.stats.exp); }); it('decreases user\'s gold', () => { - expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); + expect(updatedUser.stats.gp).to.be.lessThan(initialUser.stats.gp); }); }); }); diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js index 729619037b7..29469f78dfc 100644 --- a/test/api/v3/integration/tasks/POST-tasks_user.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js @@ -302,6 +302,17 @@ describe('POST /tasks/user', () => { expect(task.alias).to.eql('a_alias012'); }); + + // This is a special case for iOS requests + it('will round a priority (difficulty)', async () => { + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + priority: 0.10000000000005, + }); + + expect(task.priority).to.eql(0.1); + }); }); context('habits', () => { @@ -628,6 +639,43 @@ describe('POST /tasks/user', () => { }); }); + it('returns an error if everyX is a non int', async () => { + await expect(user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + everyX: 2.5, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'daily validation failed', + }); + }); + + it('returns an error if everyX is negative', async () => { + await expect(user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + everyX: -1, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'daily validation failed', + }); + }); + + it('returns an error if everyX is above 9999', async () => { + await expect(user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + everyX: 10000, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'daily validation failed', + }); + }); + + it('can create checklists', async () => { let task = await user.post('/tasks/user', { text: 'test daily', diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js index 0fa8bafd30c..e02e6dd2fe6 100644 --- a/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenges_challengeId_tasks_id_score_direction.test.js @@ -82,6 +82,13 @@ describe('POST /tasks/:id/score/:direction', () => { }); it('should update the history', async () => { + let newCron = new Date(2015, 11, 20); + + await user.post('/debug/set-cron', { + lastCron: newCron, + }); + + await user.post('/cron'); await user.post(`/tasks/${usersChallengeTaskId}/score/up`); let tasks = await user.get(`/tasks/challenge/${challenge._id}`); diff --git a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js index a707e445a25..d50841d3bc2 100644 --- a/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/groups/POST-group_tasks_id_score_direction.test.js @@ -41,8 +41,9 @@ describe('POST /tasks/:id/score/:direction', () => { let memberTasks = await member.get('/tasks/user'); let syncedTask = find(memberTasks, findAssignedTask); + const direction = 'up'; - await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + await expect(member.post(`/tasks/${syncedTask._id}/score/${direction}`)) .to.eventually.be.rejected.and.to.eql({ code: 401, error: 'NotAuthorized', @@ -58,6 +59,7 @@ describe('POST /tasks/:id/score/:direction', () => { user: member.auth.local.username, taskName: updatedTask.text, taskId: updatedTask._id, + direction, }, 'cs')); // This test only works if we have the notification translated expect(user.notifications[1].data.groupId).to.equal(guild._id); @@ -71,8 +73,9 @@ describe('POST /tasks/:id/score/:direction', () => { }); let memberTasks = await member.get('/tasks/user'); let syncedTask = find(memberTasks, findAssignedTask); + const direction = 'up'; - await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) + await expect(member.post(`/tasks/${syncedTask._id}/score/${direction}`)) .to.eventually.be.rejected.and.to.eql({ code: 401, error: 'NotAuthorized', @@ -88,6 +91,7 @@ describe('POST /tasks/:id/score/:direction', () => { user: member.auth.local.username, taskName: updatedTask.text, taskId: updatedTask._id, + direction, })); expect(user.notifications[1].data.groupId).to.equal(guild._id); @@ -97,6 +101,7 @@ describe('POST /tasks/:id/score/:direction', () => { user: member.auth.local.username, taskName: updatedTask.text, taskId: updatedTask._id, + direction, })); expect(member2.notifications[0].data.groupId).to.equal(guild._id); }); diff --git a/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js b/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js index 50bcd46b1f9..1605819ec66 100644 --- a/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js +++ b/test/api/v3/integration/tasks/groups/POST-tasks_task_id_unassign.test.js @@ -75,15 +75,6 @@ describe('POST /tasks/:taskId/unassign/:memberId', () => { }); }); - it('returns error when non leader tries to create a task', async () => { - await expect(member.post(`/tasks/${task._id}/unassign/${member._id}`)) - .to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('onlyGroupLeaderCanEditTasks'), - }); - }); - it('unassigns a user from a task', async () => { await user.post(`/tasks/${task._id}/unassign/${member._id}`); @@ -129,4 +120,26 @@ describe('POST /tasks/:taskId/unassign/:memberId', () => { expect(groupTask[0].group.assignedUsers).to.not.contain(member._id); expect(syncedTask).to.not.exist; }); + + it('allows a user to unassign themselves', async () => { + await member.post(`/tasks/${task._id}/unassign/${member._id}`); + + let groupTask = await user.get(`/tasks/group/${guild._id}`); + let memberTasks = await member.get('/tasks/user'); + let syncedTask = find(memberTasks, findAssignedTask); + + expect(groupTask[0].group.assignedUsers).to.not.contain(member._id); + expect(syncedTask).to.not.exist; + }); + + // @TODO: Which do we want? The user to unassign themselves or not. This test was in + // here, but then we had a request to allow to unaissgn. + xit('returns error when non leader tries to unassign their a task', async () => { + await expect(member.post(`/tasks/${task._id}/unassign/${member._id}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('onlyGroupLeaderCanEditTasks'), + }); + }); }); diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js index a9def6c902d..d11fa679aec 100644 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -308,7 +308,7 @@ describe('DELETE /user', () => { })).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('incorrectDeletePhrase'), + message: t('incorrectDeletePhrase', {magicWord: 'DELETE'}), }); }); diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js index f828d4fbb2c..555672b079d 100644 --- a/test/api/v3/integration/user/GET-user.test.js +++ b/test/api/v3/integration/user/GET-user.test.js @@ -27,4 +27,13 @@ describe('GET /user', () => { expect(returnedUser.auth.local.salt).to.not.exist; expect(returnedUser.apiToken).to.not.exist; }); + + it('returns only user properties requested', async () => { + let returnedUser = await user.get('/user?userFields=achievements,items.mounts'); + + expect(returnedUser._id).to.equal(user._id); + expect(returnedUser.achievements).to.exist; + expect(returnedUser.items.mounts).to.exist; + expect(returnedUser.stats).to.not.exist; + }); }); diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 258cdc5de3e..046009d9972 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -37,6 +37,22 @@ describe('POST /user/auth/local/register', () => { expect(user.newUser).to.eql(true); }); + it('remove spaces from username', async () => { + let username = ' usernamewithspaces '; + let email = 'test@example.com'; + let password = 'password'; + + let user = await api.post('/user/auth/local/register', { + username, + email, + password, + confirmPassword: password, + }); + + expect(user.auth.local.username).to.eql(username.trim()); + expect(user.profile.name).to.eql(username.trim()); + }); + context('provides default tags and tasks', async () => { it('for a generic API consumer', async () => { let username = generateRandomUserName(); diff --git a/test/api/v3/integration/user/buy/POST-user_buy_gear.test.js b/test/api/v3/integration/user/buy/POST-user_buy_gear.test.js index 6056e2fbdfc..d3992ed1fef 100644 --- a/test/api/v3/integration/user/buy/POST-user_buy_gear.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy_gear.test.js @@ -25,12 +25,32 @@ describe('POST /user/buy-gear/:key', () => { }); }); - it('buys a piece of gear', async () => { + it('buys the first level weapon gear', async () => { + let key = 'weapon_warrior_0'; + + await user.post(`/user/buy-gear/${key}`); + await user.sync(); + + expect(user.items.gear.owned[key]).to.eql(true); + }); + + it('buys the first level armor gear', async () => { let key = 'armor_warrior_1'; await user.post(`/user/buy-gear/${key}`); await user.sync(); - expect(user.items.gear.owned.armor_warrior_1).to.eql(true); + expect(user.items.gear.owned[key]).to.eql(true); + }); + + it('tries to buy subsequent, level gear', async () => { + let key = 'armor_warrior_2'; + + return expect(user.post(`/user/buy-gear/${key}`)) + .to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: 'You need to purchase a lower level gear before this one.', + }); }); }); diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js deleted file mode 100644 index 757511121a7..00000000000 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ /dev/null @@ -1,739 +0,0 @@ -import moment from 'moment'; -import cc from 'coupon-code'; -import uuid from 'uuid'; - -import { - generateGroup, -} from '../../../../helpers/api-unit.helper.js'; -import { model as User } from '../../../../../website/server/models/user'; -import { model as Group } from '../../../../../website/server/models/group'; -import { model as Coupon } from '../../../../../website/server/models/coupon'; -import amzLib from '../../../../../website/server/libs/amazonPayments'; -import payments from '../../../../../website/server/libs/payments'; -import common from '../../../../../website/common'; - -const i18n = common.i18n; - -describe('Amazon Payments', () => { - let subKey = 'basic_3mo'; - - describe('checkout', () => { - let user, orderReferenceId, headers; - let setOrderReferenceDetailsSpy; - let confirmOrderReferenceSpy; - let authorizeSpy; - let closeOrderReferenceSpy; - - let paymentBuyGemsStub; - let paymentCreateSubscritionStub; - let amount = 5; - - function expectAmazonStubs () { - expect(setOrderReferenceDetailsSpy).to.be.calledOnce; - expect(setOrderReferenceDetailsSpy).to.be.calledWith({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerNote: amzLib.constants.SELLER_NOTE, - SellerOrderAttributes: { - SellerOrderId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - }, - }, - }); - - expect(confirmOrderReferenceSpy).to.be.calledOnce; - expect(confirmOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); - - expect(authorizeSpy).to.be.calledOnce; - expect(authorizeSpy).to.be.calledWith({ - AmazonOrderReferenceId: orderReferenceId, - AuthorizationReferenceId: common.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE, - TransactionTimeout: 0, - CaptureNow: true, - }); - - expect(closeOrderReferenceSpy).to.be.calledOnce; - expect(closeOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); - } - - beforeEach(function () { - user = new User(); - headers = {}; - orderReferenceId = 'orderReferenceId'; - - setOrderReferenceDetailsSpy = sinon.stub(amzLib, 'setOrderReferenceDetails'); - setOrderReferenceDetailsSpy.returnsPromise().resolves({}); - - confirmOrderReferenceSpy = sinon.stub(amzLib, 'confirmOrderReference'); - confirmOrderReferenceSpy.returnsPromise().resolves({}); - - authorizeSpy = sinon.stub(amzLib, 'authorize'); - authorizeSpy.returnsPromise().resolves({}); - - closeOrderReferenceSpy = sinon.stub(amzLib, 'closeOrderReference'); - closeOrderReferenceSpy.returnsPromise().resolves({}); - - paymentBuyGemsStub = sinon.stub(payments, 'buyGems'); - paymentBuyGemsStub.returnsPromise().resolves({}); - - paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription'); - paymentCreateSubscritionStub.returnsPromise().resolves({}); - - sinon.stub(common, 'uuid').returns('uuid-generated'); - }); - - afterEach(function () { - amzLib.setOrderReferenceDetails.restore(); - amzLib.confirmOrderReference.restore(); - amzLib.authorize.restore(); - amzLib.closeOrderReference.restore(); - payments.buyGems.restore(); - payments.createSubscription.restore(); - common.uuid.restore(); - }); - - it('should purchase gems', async () => { - sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); - await amzLib.checkout({user, orderReferenceId, headers}); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - }); - expectAmazonStubs(); - expect(user.canGetGems).to.be.calledOnce; - user.canGetGems.restore(); - }); - - it('should error if gem amount is too low', async () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'gems', - gems: { - amount: 0, - uuid: receivingUser._id, - }, - }; - - await expect(amzLib.checkout({gift, user, orderReferenceId, headers})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Amount must be at least 1.', - name: 'BadRequest', - }); - }); - - it('should error if user cannot get gems gems', async () => { - sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); - await expect(amzLib.checkout({user, orderReferenceId, headers})).to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('groupPolicyCannotGetGems'), - name: 'NotAuthorized', - }); - user.canGetGems.restore(); - }); - - it('should gift gems', async () => { - let receivingUser = new User(); - await receivingUser.save(); - let gift = { - type: 'gems', - uuid: receivingUser._id, - gems: { - amount: 16, - }, - }; - amount = 16 / 4; - await amzLib.checkout({gift, user, orderReferenceId, headers}); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, - headers, - gift, - }); - expectAmazonStubs(); - }); - - it('should gift a subscription', async () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - amount = common.content.subscriptionBlocks[subKey].price; - - await amzLib.checkout({user, orderReferenceId, headers, gift}); - - gift.member = receivingUser; - expect(paymentCreateSubscritionStub).to.be.calledOnce; - expect(paymentCreateSubscritionStub).to.be.calledWith({ - user, - paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, - headers, - gift, - }); - expectAmazonStubs(); - }); - }); - - describe('subscribe', () => { - let user, group, amount, billingAgreementId, sub, coupon, groupId, headers; - let amazonSetBillingAgreementDetailsSpy; - let amazonConfirmBillingAgreementSpy; - let amazongAuthorizeOnBillingAgreementSpy; - let createSubSpy; - - beforeEach(async () => { - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = 'customer-id'; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = 'customer-id'; - group.purchased.plan.planId = subKey; - await group.save(); - - amount = common.content.subscriptionBlocks[subKey].price; - billingAgreementId = 'billingAgreementId'; - sub = { - key: subKey, - price: amount, - }; - groupId = group._id; - headers = {}; - - amazonSetBillingAgreementDetailsSpy = sinon.stub(amzLib, 'setBillingAgreementDetails'); - amazonSetBillingAgreementDetailsSpy.returnsPromise().resolves({}); - - amazonConfirmBillingAgreementSpy = sinon.stub(amzLib, 'confirmBillingAgreement'); - amazonConfirmBillingAgreementSpy.returnsPromise().resolves({}); - - amazongAuthorizeOnBillingAgreementSpy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); - amazongAuthorizeOnBillingAgreementSpy.returnsPromise().resolves({}); - - createSubSpy = sinon.stub(payments, 'createSubscription'); - createSubSpy.returnsPromise().resolves({}); - - sinon.stub(common, 'uuid').returns('uuid-generated'); - }); - - afterEach(function () { - amzLib.setBillingAgreementDetails.restore(); - amzLib.confirmBillingAgreement.restore(); - amzLib.authorizeOnBillingAgreement.restore(); - payments.createSubscription.restore(); - common.uuid.restore(); - }); - - it('should throw an error if we are missing a subscription', async () => { - await expect(amzLib.subscribe({ - billingAgreementId, - coupon, - user, - groupId, - headers, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: i18n.t('missingSubscriptionCode'), - }); - }); - - it('should throw an error if we are missing a billingAgreementId', async () => { - await expect(amzLib.subscribe({ - sub, - coupon, - user, - groupId, - headers, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: 'Missing req.body.billingAgreementId', - }); - }); - - it('should throw an error when coupon code is missing', async () => { - sub.discount = 40; - - await expect(amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: i18n.t('couponCodeRequired'), - }); - }); - - it('should throw an error when coupon code is invalid', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - await couponModel.save(); - - sinon.stub(cc, 'validate').returns('invalid'); - - await expect(amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('invalidCoupon'), - }); - cc.validate.restore(); - }); - - it('subscribes with amazon with a coupon', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - let updatedCouponModel = await couponModel.save(); - - sinon.stub(cc, 'validate').returns(updatedCouponModel._id); - - await amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - }); - - expect(createSubSpy).to.be.calledOnce; - expect(createSubSpy).to.be.calledWith({ - user, - customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - sub, - headers, - groupId, - }); - - cc.validate.restore(); - }); - - it('subscribes with amazon', async () => { - await amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - }); - - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledOnce; - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - CustomInformation: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - }, - }, - }); - - expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; - expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledOnce; - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: common.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - SellerOrderAttributes: { - SellerOrderId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - }, - }); - - expect(createSubSpy).to.be.calledOnce; - expect(createSubSpy).to.be.calledWith({ - user, - customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - sub, - headers, - groupId, - }); - }); - - it('subscribes with amazon with price to existing users', async () => { - user = new User(); - user.guilds.push(groupId); - await user.save(); - group.memberCount = 2; - await group.save(); - sub.key = 'group_monthly'; - sub.price = 9; - amount = 12; - - await amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - }); - - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledOnce; - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - CustomInformation: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - }, - }, - }); - - expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; - expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledOnce; - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: common.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - SellerOrderAttributes: { - SellerOrderId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - }, - }); - - expect(createSubSpy).to.be.calledOnce; - expect(createSubSpy).to.be.calledWith({ - user, - customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - sub, - headers, - groupId, - }); - }); - }); - - describe('cancelSubscription', () => { - let user, group, headers, billingAgreementId, subscriptionBlock, subscriptionLength; - let getBillingAgreementDetailsSpy; - let paymentCancelSubscriptionSpy; - - function expectAmazonStubs () { - expect(getBillingAgreementDetailsSpy).to.be.calledOnce; - expect(getBillingAgreementDetailsSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - } - - beforeEach(async () => { - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = 'customer-id'; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = 'customer-id'; - group.purchased.plan.planId = subKey; - group.purchased.plan.lastBillingDate = new Date(); - await group.save(); - - subscriptionBlock = common.content.subscriptionBlocks[subKey]; - subscriptionLength = subscriptionBlock.months * 30; - - headers = {}; - - getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails'); - getBillingAgreementDetailsSpy.returnsPromise().resolves({ - BillingAgreementDetails: { - BillingAgreementStatus: {State: 'Closed'}, - }, - }); - - paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription'); - paymentCancelSubscriptionSpy.returnsPromise().resolves({}); - }); - - afterEach(function () { - amzLib.getBillingAgreementDetails.restore(); - payments.cancelSubscription.restore(); - }); - - it('should throw an error if we are missing a subscription', async () => { - user.purchased.plan.customerId = undefined; - - await expect(amzLib.cancelSubscription({user})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('missingSubscription'), - }); - }); - - it('should cancel a user subscription', async () => { - billingAgreementId = user.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, headers}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: undefined, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - expectAmazonStubs(); - }); - - it('should close a user subscription if amazon not closed', async () => { - amzLib.getBillingAgreementDetails.restore(); - getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails') - .returnsPromise() - .resolves({ - BillingAgreementDetails: { - BillingAgreementStatus: {State: 'Open'}, - }, - }); - let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); - billingAgreementId = user.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, headers}); - - expectAmazonStubs(); - expect(closeBillingAgreementSpy).to.be.calledOnce; - expect(closeBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: undefined, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - amzLib.closeBillingAgreement.restore(); - }); - - it('should throw an error if group is not found', async () => { - await expect(amzLib.cancelSubscription({user, groupId: 'fake-id'})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 404, - name: 'NotFound', - message: i18n.t('groupNotFound'), - }); - }); - - it('should throw an error if user is not group leader', async () => { - let nonLeader = new User(); - nonLeader.guilds.push(group._id); - await nonLeader.save(); - - await expect(amzLib.cancelSubscription({user: nonLeader, groupId: group._id})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('onlyGroupLeaderCanManageSubscription'), - }); - }); - - it('should cancel a group subscription', async () => { - billingAgreementId = group.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, groupId: group._id, headers}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: group._id, - nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - expectAmazonStubs(); - }); - - it('should close a group subscription if amazon not closed', async () => { - amzLib.getBillingAgreementDetails.restore(); - getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails') - .returnsPromise() - .resolves({ - BillingAgreementDetails: { - BillingAgreementStatus: {State: 'Open'}, - }, - }); - let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); - billingAgreementId = group.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, groupId: group._id, headers}); - - expectAmazonStubs(); - expect(closeBillingAgreementSpy).to.be.calledOnce; - expect(closeBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: group._id, - nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - amzLib.closeBillingAgreement.restore(); - }); - }); - - describe('#upgradeGroupPlan', () => { - let spy, data, user, group, uuidString; - - beforeEach(async function () { - user = new User(); - user.profile.name = 'sender'; - - data = { - user, - sub: { - key: 'basic_3mo', // @TODO: Validate that this is group - }, - customerId: 'customer-id', - paymentMethod: 'Payment Method', - headers: { - 'x-client': 'habitica-web', - 'user-agent': '', - }, - }; - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - await group.save(); - - spy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); - spy.returnsPromise().resolves([]); - - uuidString = 'uuid-v4'; - sinon.stub(uuid, 'v4').returns(uuidString); - - data.groupId = group._id; - data.sub.quantity = 3; - }); - - afterEach(function () { - sinon.restore(amzLib.authorizeOnBillingAgreement); - uuid.v4.restore(); - }); - - it('charges for a new member', async () => { - data.paymentMethod = amzLib.constants.PAYMENT_METHOD; - await payments.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - - updatedGroup.memberCount += 1; - await updatedGroup.save(); - - await amzLib.chargeForAdditionalGroupMember(updatedGroup); - - expect(spy.calledOnce).to.be.true; - expect(spy).to.be.calledWith({ - AmazonBillingAgreementId: updatedGroup.purchased.plan.customerId, - AuthorizationReferenceId: uuidString.substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: 3, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, - SellerOrderAttributes: { - SellerOrderId: uuidString, - StoreName: amzLib.constants.STORE_NAME, - }, - }); - }); - }); -}); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js index fbe82377ac4..7cb136698d2 100644 --- a/test/api/v3/unit/libs/payments.test.js +++ b/test/api/v3/unit/libs/payments.test.js @@ -153,6 +153,24 @@ describe('payments/index', () => { expect(recipient.purchased.plan.dateUpdated).to.exist; }); + it('sets plan.dateUpdated if it did exist but the user has cancelled', async () => { + recipient.purchased.plan.dateUpdated = moment().subtract(1, 'days').toDate(); + recipient.purchased.plan.dateTerminated = moment().subtract(1, 'days').toDate(); + recipient.purchased.plan.customerId = 'testing'; + + await api.createSubscription(data); + + expect(moment(recipient.purchased.plan.dateUpdated).date()).to.eql(moment().date()); + }); + + it('sets plan.dateUpdated if it did exist but the user has a corrupt plan', async () => { + recipient.purchased.plan.dateUpdated = moment().subtract(1, 'days').toDate(); + + await api.createSubscription(data); + + expect(moment(recipient.purchased.plan.dateUpdated).date()).to.eql(moment().date()); + }); + it('sets plan.dateCreated if it did not previously exist', async () => { expect(recipient.purchased.plan.dateCreated).to.not.exist; diff --git a/test/api/v3/unit/libs/payments/amazon/cancel.test.js b/test/api/v3/unit/libs/payments/amazon/cancel.test.js new file mode 100644 index 00000000000..13e524f58f1 --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/cancel.test.js @@ -0,0 +1,180 @@ +import moment from 'moment'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; +import { createNonLeaderGroupMember } from '../paymentHelpers'; + +const i18n = common.i18n; + +describe('Amazon Payments - Cancel Subscription', () => { + const subKey = 'basic_3mo'; + + let user, group, headers, billingAgreementId, subscriptionBlock, subscriptionLength; + let getBillingAgreementDetailsSpy; + let paymentCancelSubscriptionSpy; + + function expectAmazonStubs () { + expect(getBillingAgreementDetailsSpy).to.be.calledOnce; + expect(getBillingAgreementDetailsSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + } + + function expectAmazonCancelSubscriptionSpy (groupId, lastBillingDate) { + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + groupId, + nextBill: moment(lastBillingDate).add({ days: subscriptionLength }), + paymentMethod: amzLib.constants.PAYMENT_METHOD, + headers, + cancellationReason: undefined, + }); + } + + function expectAmazonCancelUserSubscriptionSpy () { + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expectAmazonCancelSubscriptionSpy(undefined, user.purchased.plan.lastBillingDate); + } + + function expectAmazonCancelGroupSubscriptionSpy (groupId) { + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expectAmazonCancelSubscriptionSpy(groupId, group.purchased.plan.lastBillingDate); + } + + function expectBillingAggreementDetailSpy () { + getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails') + .returnsPromise() + .resolves({ + BillingAgreementDetails: { + BillingAgreementStatus: {State: 'Open'}, + }, + }); + } + + beforeEach(async () => { + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = 'customer-id'; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = 'customer-id'; + group.purchased.plan.planId = subKey; + group.purchased.plan.lastBillingDate = new Date(); + await group.save(); + + subscriptionBlock = common.content.subscriptionBlocks[subKey]; + subscriptionLength = subscriptionBlock.months * 30; + + headers = {}; + + getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails'); + getBillingAgreementDetailsSpy.returnsPromise().resolves({ + BillingAgreementDetails: { + BillingAgreementStatus: {State: 'Closed'}, + }, + }); + + paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription'); + paymentCancelSubscriptionSpy.returnsPromise().resolves({}); + }); + + afterEach(function () { + amzLib.getBillingAgreementDetails.restore(); + payments.cancelSubscription.restore(); + }); + + it('should throw an error if we are missing a subscription', async () => { + user.purchased.plan.customerId = undefined; + + await expect(amzLib.cancelSubscription({user})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('should cancel a user subscription', async () => { + billingAgreementId = user.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, headers}); + + expectAmazonCancelUserSubscriptionSpy(); + expectAmazonStubs(); + }); + + it('should close a user subscription if amazon not closed', async () => { + amzLib.getBillingAgreementDetails.restore(); + expectBillingAggreementDetailSpy(); + let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); + billingAgreementId = user.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, headers}); + + expectAmazonStubs(); + expect(closeBillingAgreementSpy).to.be.calledOnce; + expect(closeBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + expectAmazonCancelUserSubscriptionSpy(); + amzLib.closeBillingAgreement.restore(); + }); + + it('should throw an error if group is not found', async () => { + await expect(amzLib.cancelSubscription({user, groupId: 'fake-id'})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + it('should throw an error if user is not group leader', async () => { + let nonLeader = await createNonLeaderGroupMember(group); + + await expect(amzLib.cancelSubscription({user: nonLeader, groupId: group._id})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + }); + }); + + it('should cancel a group subscription', async () => { + billingAgreementId = group.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, groupId: group._id, headers}); + + expectAmazonCancelGroupSubscriptionSpy(group._id); + expectAmazonStubs(); + }); + + it('should close a group subscription if amazon not closed', async () => { + amzLib.getBillingAgreementDetails.restore(); + expectBillingAggreementDetailSpy(); + let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); + billingAgreementId = group.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, groupId: group._id, headers}); + + expectAmazonStubs(); + expect(closeBillingAgreementSpy).to.be.calledOnce; + expect(closeBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + expectAmazonCancelGroupSubscriptionSpy(group._id); + amzLib.closeBillingAgreement.restore(); + }); +}); diff --git a/test/api/v3/unit/libs/payments/amazon/checkout.test.js b/test/api/v3/unit/libs/payments/amazon/checkout.test.js new file mode 100644 index 00000000000..9e74cdb145c --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/checkout.test.js @@ -0,0 +1,193 @@ +import { model as User } from '../../../../../../../website/server/models/user'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('Amazon Payments - Checkout', () => { + const subKey = 'basic_3mo'; + let user, orderReferenceId, headers; + let setOrderReferenceDetailsSpy; + let confirmOrderReferenceSpy; + let authorizeSpy; + let closeOrderReferenceSpy; + + let paymentBuyGemsStub; + let paymentCreateSubscritionStub; + let amount = 5; + + function expectOrderReferenceSpy () { + expect(setOrderReferenceDetailsSpy).to.be.calledOnce; + expect(setOrderReferenceDetailsSpy).to.be.calledWith({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: amount, + }, + SellerNote: amzLib.constants.SELLER_NOTE, + SellerOrderAttributes: { + SellerOrderId: common.uuid(), + StoreName: amzLib.constants.STORE_NAME, + }, + }, + }); + } + + function expectAuthorizeSpy () { + expect(authorizeSpy).to.be.calledOnce; + expect(authorizeSpy).to.be.calledWith({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: common.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: amount, + }, + SellerAuthorizationNote: amzLib.constants.SELLER_NOTE, + TransactionTimeout: 0, + CaptureNow: true, + }); + } + + function expectAmazonStubs () { + expectOrderReferenceSpy(); + + expect(confirmOrderReferenceSpy).to.be.calledOnce; + expect(confirmOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); + + expectAuthorizeSpy(); + + expect(closeOrderReferenceSpy).to.be.calledOnce; + expect(closeOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); + } + + beforeEach(function () { + user = new User(); + headers = {}; + orderReferenceId = 'orderReferenceId'; + + setOrderReferenceDetailsSpy = sinon.stub(amzLib, 'setOrderReferenceDetails'); + setOrderReferenceDetailsSpy.returnsPromise().resolves({}); + + confirmOrderReferenceSpy = sinon.stub(amzLib, 'confirmOrderReference'); + confirmOrderReferenceSpy.returnsPromise().resolves({}); + + authorizeSpy = sinon.stub(amzLib, 'authorize'); + authorizeSpy.returnsPromise().resolves({}); + + closeOrderReferenceSpy = sinon.stub(amzLib, 'closeOrderReference'); + closeOrderReferenceSpy.returnsPromise().resolves({}); + + paymentBuyGemsStub = sinon.stub(payments, 'buyGems'); + paymentBuyGemsStub.returnsPromise().resolves({}); + + paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription'); + paymentCreateSubscritionStub.returnsPromise().resolves({}); + + sinon.stub(common, 'uuid').returns('uuid-generated'); + }); + + afterEach(function () { + amzLib.setOrderReferenceDetails.restore(); + amzLib.confirmOrderReference.restore(); + amzLib.authorize.restore(); + amzLib.closeOrderReference.restore(); + payments.buyGems.restore(); + payments.createSubscription.restore(); + common.uuid.restore(); + }); + + function expectBuyGemsStub (paymentMethod, gift) { + expect(paymentBuyGemsStub).to.be.calledOnce; + + let expectedArgs = { + user, + paymentMethod, + headers, + }; + if (gift) expectedArgs.gift = gift; + expect(paymentBuyGemsStub).to.be.calledWith(expectedArgs); + } + + it('should purchase gems', async () => { + sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); + await amzLib.checkout({user, orderReferenceId, headers}); + + expectBuyGemsStub(amzLib.constants.PAYMENT_METHOD); + expectAmazonStubs(); + expect(user.canGetGems).to.be.calledOnce; + user.canGetGems.restore(); + }); + + it('should error if gem amount is too low', async () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'gems', + gems: { + amount: 0, + uuid: receivingUser._id, + }, + }; + + await expect(amzLib.checkout({gift, user, orderReferenceId, headers})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + message: 'Amount must be at least 1.', + name: 'BadRequest', + }); + }); + + it('should error if user cannot get gems gems', async () => { + sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); + await expect(amzLib.checkout({user, orderReferenceId, headers})).to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + message: i18n.t('groupPolicyCannotGetGems'), + name: 'NotAuthorized', + }); + user.canGetGems.restore(); + }); + + it('should gift gems', async () => { + let receivingUser = new User(); + await receivingUser.save(); + let gift = { + type: 'gems', + uuid: receivingUser._id, + gems: { + amount: 16, + }, + }; + amount = 16 / 4; + await amzLib.checkout({gift, user, orderReferenceId, headers}); + + expectBuyGemsStub(amzLib.constants.PAYMENT_METHOD_GIFT, gift); + expectAmazonStubs(); + }); + + it('should gift a subscription', async () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + amount = common.content.subscriptionBlocks[subKey].price; + + await amzLib.checkout({user, orderReferenceId, headers, gift}); + + gift.member = receivingUser; + expect(paymentCreateSubscritionStub).to.be.calledOnce; + expect(paymentCreateSubscritionStub).to.be.calledWith({ + user, + paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, + headers, + gift, + }); + expectAmazonStubs(); + }); +}); diff --git a/test/api/v3/unit/libs/payments/amazon/subscribe.test.js b/test/api/v3/unit/libs/payments/amazon/subscribe.test.js new file mode 100644 index 00000000000..8095f90c636 --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/subscribe.test.js @@ -0,0 +1,267 @@ +import cc from 'coupon-code'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Coupon } from '../../../../../../../website/server/models/coupon'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('Amazon Payments - Subscribe', () => { + const subKey = 'basic_3mo'; + let user, group, amount, billingAgreementId, sub, coupon, groupId, headers; + let amazonSetBillingAgreementDetailsSpy; + let amazonConfirmBillingAgreementSpy; + let amazonAuthorizeOnBillingAgreementSpy; + let createSubSpy; + + beforeEach(async () => { + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = 'customer-id'; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = 'customer-id'; + group.purchased.plan.planId = subKey; + await group.save(); + + amount = common.content.subscriptionBlocks[subKey].price; + billingAgreementId = 'billingAgreementId'; + sub = { + key: subKey, + price: amount, + }; + groupId = group._id; + headers = {}; + + amazonSetBillingAgreementDetailsSpy = sinon.stub(amzLib, 'setBillingAgreementDetails'); + amazonSetBillingAgreementDetailsSpy.returnsPromise().resolves({}); + + amazonConfirmBillingAgreementSpy = sinon.stub(amzLib, 'confirmBillingAgreement'); + amazonConfirmBillingAgreementSpy.returnsPromise().resolves({}); + + amazonAuthorizeOnBillingAgreementSpy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); + amazonAuthorizeOnBillingAgreementSpy.returnsPromise().resolves({}); + + createSubSpy = sinon.stub(payments, 'createSubscription'); + createSubSpy.returnsPromise().resolves({}); + + sinon.stub(common, 'uuid').returns('uuid-generated'); + }); + + afterEach(function () { + amzLib.setBillingAgreementDetails.restore(); + amzLib.confirmBillingAgreement.restore(); + amzLib.authorizeOnBillingAgreement.restore(); + payments.createSubscription.restore(); + common.uuid.restore(); + }); + + function expectAmazonAuthorizeBillingAgreementSpy () { + expect(amazonAuthorizeOnBillingAgreementSpy).to.be.calledOnce; + expect(amazonAuthorizeOnBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: common.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: amount, + }, + SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, + SellerOrderAttributes: { + SellerOrderId: common.uuid(), + StoreName: amzLib.constants.STORE_NAME, + }, + }); + } + + function expectAmazonSetBillingAgreementDetailsSpy () { + expect(amazonSetBillingAgreementDetailsSpy).to.be.calledOnce; + expect(amazonSetBillingAgreementDetailsSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: common.uuid(), + StoreName: amzLib.constants.STORE_NAME, + CustomInformation: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, + }, + }, + }); + } + + function expectCreateSpy () { + expect(createSubSpy).to.be.calledOnce; + expect(createSubSpy).to.be.calledWith({ + user, + customerId: billingAgreementId, + paymentMethod: amzLib.constants.PAYMENT_METHOD, + sub, + headers, + groupId, + }); + } + + it('should throw an error if we are missing a subscription', async () => { + await expect(amzLib.subscribe({ + billingAgreementId, + coupon, + user, + groupId, + headers, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: i18n.t('missingSubscriptionCode'), + }); + }); + + it('should throw an error if we are missing a billingAgreementId', async () => { + await expect(amzLib.subscribe({ + sub, + coupon, + user, + groupId, + headers, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: 'Missing req.body.billingAgreementId', + }); + }); + + it('should throw an error when coupon code is missing', async () => { + sub.discount = 40; + + await expect(amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: i18n.t('couponCodeRequired'), + }); + }); + + it('should throw an error when coupon code is invalid', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + await couponModel.save(); + + sinon.stub(cc, 'validate').returns('invalid'); + + await expect(amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('invalidCoupon'), + }); + cc.validate.restore(); + }); + + it('subscribes with amazon with a coupon', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + let updatedCouponModel = await couponModel.save(); + + sinon.stub(cc, 'validate').returns(updatedCouponModel._id); + + await amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + }); + + expectCreateSpy(); + + cc.validate.restore(); + }); + + it('subscribes with amazon', async () => { + await amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + }); + + expectAmazonSetBillingAgreementDetailsSpy(); + + expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; + expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + + expectAmazonAuthorizeBillingAgreementSpy(); + + expectCreateSpy(); + }); + + it('subscribes with amazon with price to existing users', async () => { + user = new User(); + user.guilds.push(groupId); + await user.save(); + group.memberCount = 2; + await group.save(); + sub.key = 'group_monthly'; + sub.price = 9; + amount = 12; + + await amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + }); + + expectAmazonSetBillingAgreementDetailsSpy(); + expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; + expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + expectAmazonAuthorizeBillingAgreementSpy(); + expectCreateSpy(); + }); +}); diff --git a/test/api/v3/unit/libs/payments/amazon/upgrade-groupplan.test.js b/test/api/v3/unit/libs/payments/amazon/upgrade-groupplan.test.js new file mode 100644 index 00000000000..a62e7f1b536 --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/upgrade-groupplan.test.js @@ -0,0 +1,83 @@ +import uuid from 'uuid'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Group } from '../../../../../../../website/server/models/group'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; + +describe('#upgradeGroupPlan', () => { + let spy, data, user, group, uuidString; + + beforeEach(async function () { + user = new User(); + user.profile.name = 'sender'; + + data = { + user, + sub: { + key: 'basic_3mo', // @TODO: Validate that this is group + }, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + headers: { + 'x-client': 'habitica-web', + 'user-agent': '', + }, + }; + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + await group.save(); + + spy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); + spy.returnsPromise().resolves([]); + + uuidString = 'uuid-v4'; + sinon.stub(uuid, 'v4').returns(uuidString); + + data.groupId = group._id; + data.sub.quantity = 3; + }); + + afterEach(function () { + sinon.restore(amzLib.authorizeOnBillingAgreement); + uuid.v4.restore(); + }); + + it('charges for a new member', async () => { + data.paymentMethod = amzLib.constants.PAYMENT_METHOD; + await payments.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + + updatedGroup.memberCount += 1; + await updatedGroup.save(); + + await amzLib.chargeForAdditionalGroupMember(updatedGroup); + + expect(spy.calledOnce).to.be.true; + expect(spy).to.be.calledWith({ + AmazonBillingAgreementId: updatedGroup.purchased.plan.customerId, + AuthorizationReferenceId: uuidString.substring(0, 32), + AuthorizationAmount: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: 3, + }, + SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + SellerOrderAttributes: { + SellerOrderId: uuidString, + StoreName: amzLib.constants.STORE_NAME, + }, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paymentHelpers.js b/test/api/v3/unit/libs/payments/paymentHelpers.js new file mode 100644 index 00000000000..99069a8f492 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paymentHelpers.js @@ -0,0 +1,7 @@ +import { model as User } from '../../../../../../website/server/models/user'; + +export async function createNonLeaderGroupMember (group) { + let nonLeader = new User(); + nonLeader.guilds.push(group._id); + return await nonLeader.save(); +} diff --git a/test/api/v3/unit/libs/payments/paypal/checkout-success.test.js b/test/api/v3/unit/libs/payments/paypal/checkout-success.test.js new file mode 100644 index 00000000000..5f63b99050a --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/checkout-success.test.js @@ -0,0 +1,87 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { model as User } from '../../../../../../../website/server/models/user'; + +describe('checkout success', () => { + const subKey = 'basic_3mo'; + let user, gift, customerId, paymentId; + let paypalPaymentExecuteStub, paymentBuyGemsStub, paymentsCreateSubscritionStub; + + beforeEach(() => { + user = new User(); + customerId = 'customerId-test'; + paymentId = 'paymentId-test'; + + paypalPaymentExecuteStub = sinon.stub(paypalPayments, 'paypalPaymentExecute').returnsPromise().resolves({}); + paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); + paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + paypalPayments.paypalPaymentExecute.restore(); + payments.buyGems.restore(); + payments.createSubscription.restore(); + }); + + it('purchases gems', async () => { + await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); + + expect(paypalPaymentExecuteStub).to.be.calledOnce; + expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + customerId, + paymentMethod: 'Paypal', + }); + }); + + it('gifts gems', async () => { + let receivingUser = new User(); + await receivingUser.save(); + gift = { + type: 'gems', + gems: { + amount: 16, + uuid: receivingUser._id, + }, + }; + + await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); + + expect(paypalPaymentExecuteStub).to.be.calledOnce; + expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + customerId, + paymentMethod: 'PayPal (Gift)', + gift, + }); + }); + + it('gifts subscription', async () => { + let receivingUser = new User(); + await receivingUser.save(); + gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + + await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); + + expect(paypalPaymentExecuteStub).to.be.calledOnce; + expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + customerId, + paymentMethod: 'PayPal (Gift)', + gift, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/checkout.test.js b/test/api/v3/unit/libs/payments/paypal/checkout.test.js new file mode 100644 index 00000000000..9ed40c1ad82 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/checkout.test.js @@ -0,0 +1,127 @@ +/* eslint-disable camelcase */ +import nconf from 'nconf'; + +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { model as User } from '../../../../../../../website/server/models/user'; +import common from '../../../../../../../website/common'; + +const BASE_URL = nconf.get('BASE_URL'); +const i18n = common.i18n; + +describe('checkout', () => { + const subKey = 'basic_3mo'; + let paypalPaymentCreateStub; + let approvalHerf; + + function getPaypalCreateOptions (description, amount) { + return { + intent: 'sale', + payer: { payment_method: 'Paypal' }, + redirect_urls: { + return_url: `${BASE_URL}/paypal/checkout/success`, + cancel_url: `${BASE_URL}`, + }, + transactions: [{ + item_list: { + items: [{ + name: description, + price: amount, + currency: 'USD', + quantity: 1, + }], + }, + amount: { + currency: 'USD', + total: amount, + }, + description, + }], + }; + } + + beforeEach(() => { + approvalHerf = 'approval_href'; + paypalPaymentCreateStub = sinon.stub(paypalPayments, 'paypalPaymentCreate') + .returnsPromise().resolves({ + links: [{ rel: 'approval_url', href: approvalHerf }], + }); + }); + + afterEach(() => { + paypalPayments.paypalPaymentCreate.restore(); + }); + + it('creates a link for gem purchases', async () => { + let link = await paypalPayments.checkout({user: new User()}); + + expect(paypalPaymentCreateStub).to.be.calledOnce; + expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 5.00)); + expect(link).to.eql(approvalHerf); + }); + + it('should error if gem amount is too low', async () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'gems', + gems: { + amount: 0, + uuid: receivingUser._id, + }, + }; + + await expect(paypalPayments.checkout({gift})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + message: 'Amount must be at least 1.', + name: 'BadRequest', + }); + }); + + it('should error if the user cannot get gems', async () => { + let user = new User(); + sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); + + await expect(paypalPayments.checkout({user})).to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + message: i18n.t('groupPolicyCannotGetGems'), + name: 'NotAuthorized', + }); + }); + + it('creates a link for gifting gems', async () => { + let receivingUser = new User(); + await receivingUser.save(); + let gift = { + type: 'gems', + uuid: receivingUser._id, + gems: { + amount: 16, + }, + }; + + let link = await paypalPayments.checkout({gift}); + + expect(paypalPaymentCreateStub).to.be.calledOnce; + expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems (Gift)', '4.00')); + expect(link).to.eql(approvalHerf); + }); + + it('creates a link for gifting a subscription', async () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + + let link = await paypalPayments.checkout({gift}); + + expect(paypalPaymentCreateStub).to.be.calledOnce; + expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00')); + expect(link).to.eql(approvalHerf); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/ipn.test.js b/test/api/v3/unit/libs/payments/paypal/ipn.test.js new file mode 100644 index 00000000000..7094b67cb91 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/ipn.test.js @@ -0,0 +1,66 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; + +describe('ipn', () => { + const subKey = 'basic_3mo'; + let user, group, txn_type, userPaymentId, groupPaymentId; + let ipnVerifyAsyncStub, paymentCancelSubscriptionSpy; + + beforeEach(async () => { + txn_type = 'recurring_payment_profile_cancel'; + userPaymentId = 'userPaymentId-test'; + groupPaymentId = 'groupPaymentId-test'; + + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = userPaymentId; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + await user.save(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = groupPaymentId; + group.purchased.plan.planId = subKey; + group.purchased.plan.lastBillingDate = new Date(); + await group.save(); + + ipnVerifyAsyncStub = sinon.stub(paypalPayments, 'ipnVerifyAsync').returnsPromise().resolves({}); + paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + }); + + afterEach(function () { + paypalPayments.ipnVerifyAsync.restore(); + payments.cancelSubscription.restore(); + }); + + it('should cancel a user subscription', async () => { + await paypalPayments.ipn({txn_type, recurring_payment_id: userPaymentId}); + + expect(ipnVerifyAsyncStub).to.be.calledOnce; + expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: userPaymentId}); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy.args[0][0].user._id).to.eql(user._id); + expect(paymentCancelSubscriptionSpy.args[0][0].paymentMethod).to.eql('Paypal'); + }); + + it('should cancel a group subscription', async () => { + await paypalPayments.ipn({txn_type, recurring_payment_id: groupPaymentId}); + + expect(ipnVerifyAsyncStub).to.be.calledOnce; + expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: groupPaymentId}); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ groupId: group._id, paymentMethod: 'Paypal' }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/subscribe-cancel.test.js b/test/api/v3/unit/libs/payments/paypal/subscribe-cancel.test.js new file mode 100644 index 00000000000..ef5b399fed8 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/subscribe-cancel.test.js @@ -0,0 +1,124 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import common from '../../../../../../../website/common'; +import { createNonLeaderGroupMember } from '../paymentHelpers'; + +const i18n = common.i18n; + +describe('subscribeCancel', () => { + const subKey = 'basic_3mo'; + let user, group, groupId, customerId, groupCustomerId, nextBillingDate; + let paymentCancelSubscriptionSpy, paypalBillingAgreementCancelStub, paypalBillingAgreementGetStub; + + beforeEach(async () => { + customerId = 'customer-id'; + groupCustomerId = 'groupCustomerId-test'; + + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = customerId; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = groupCustomerId; + group.purchased.plan.planId = subKey; + group.purchased.plan.lastBillingDate = new Date(); + await group.save(); + + nextBillingDate = new Date(); + + paypalBillingAgreementCancelStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCancel').returnsPromise().resolves({}); + paypalBillingAgreementGetStub = sinon.stub(paypalPayments, 'paypalBillingAgreementGet') + .returnsPromise().resolves({ + agreement_details: { + next_billing_date: nextBillingDate, + cycles_completed: 1, + }, + }); + paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + }); + + afterEach(function () { + paypalPayments.paypalBillingAgreementGet.restore(); + paypalPayments.paypalBillingAgreementCancel.restore(); + payments.cancelSubscription.restore(); + }); + + it('should throw an error if we are missing a subscription', async () => { + user.purchased.plan.customerId = undefined; + + await expect(paypalPayments.subscribeCancel({user})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('should throw an error if group is not found', async () => { + await expect(paypalPayments.subscribeCancel({user, groupId: 'fake-id'})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + it('should throw an error if user is not group leader', async () => { + let nonLeader = await createNonLeaderGroupMember(group); + + await expect(paypalPayments.subscribeCancel({user: nonLeader, groupId: group._id})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + }); + }); + + it('should cancel a user subscription', async () => { + await paypalPayments.subscribeCancel({user}); + + expect(paypalBillingAgreementGetStub).to.be.calledOnce; + expect(paypalBillingAgreementGetStub).to.be.calledWith(customerId); + expect(paypalBillingAgreementCancelStub).to.be.calledOnce; + expect(paypalBillingAgreementCancelStub).to.be.calledWith(customerId, { note: i18n.t('cancelingSubscription') }); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + groupId, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + cancellationReason: undefined, + }); + }); + + it('should cancel a group subscription', async () => { + await paypalPayments.subscribeCancel({user, groupId: group._id}); + + expect(paypalBillingAgreementGetStub).to.be.calledOnce; + expect(paypalBillingAgreementGetStub).to.be.calledWith(groupCustomerId); + expect(paypalBillingAgreementCancelStub).to.be.calledOnce; + expect(paypalBillingAgreementCancelStub).to.be.calledWith(groupCustomerId, { note: i18n.t('cancelingSubscription') }); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + groupId: group._id, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + cancellationReason: undefined, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/subscribe-success.test.js b/test/api/v3/unit/libs/payments/paypal/subscribe-success.test.js new file mode 100644 index 00000000000..5caaf34fc85 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/subscribe-success.test.js @@ -0,0 +1,77 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import common from '../../../../../../../website/common'; + +describe('subscribeSuccess', () => { + const subKey = 'basic_3mo'; + let user, group, block, groupId, token, headers, customerId; + let paypalBillingAgreementExecuteStub, paymentsCreateSubscritionStub; + + beforeEach(async () => { + user = new User(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + + token = 'test-token'; + headers = {}; + block = common.content.subscriptionBlocks[subKey]; + customerId = 'test-customerId'; + + paypalBillingAgreementExecuteStub = sinon.stub(paypalPayments, 'paypalBillingAgreementExecute') + .returnsPromise({}).resolves({ + id: customerId, + }); + paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + paypalPayments.paypalBillingAgreementExecute.restore(); + payments.createSubscription.restore(); + }); + + it('creates a user subscription', async () => { + await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); + + expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; + expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + groupId, + customerId, + paymentMethod: 'Paypal', + sub: block, + headers, + }); + }); + + it('create a group subscription', async () => { + groupId = group._id; + + await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); + + expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; + expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + groupId, + customerId, + paymentMethod: 'Paypal', + sub: block, + headers, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/subscribe.test.js b/test/api/v3/unit/libs/payments/paypal/subscribe.test.js new file mode 100644 index 00000000000..f0450ccfc1b --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/subscribe.test.js @@ -0,0 +1,112 @@ +/* eslint-disable camelcase */ +import moment from 'moment'; +import cc from 'coupon-code'; + +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { model as Coupon } from '../../../../../../../website/server/models/coupon'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('subscribe', () => { + const subKey = 'basic_3mo'; + let coupon, sub, approvalHerf; + let paypalBillingAgreementCreateStub; + + beforeEach(() => { + approvalHerf = 'approvalHerf-test'; + sub = Object.assign({}, common.content.subscriptionBlocks[subKey]); + + paypalBillingAgreementCreateStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCreate') + .returnsPromise().resolves({ + links: [{ rel: 'approval_url', href: approvalHerf }], + }); + }); + + afterEach(() => { + paypalPayments.paypalBillingAgreementCreate.restore(); + }); + + it('should throw an error when coupon code is missing', async () => { + sub.discount = 40; + + await expect(paypalPayments.subscribe({sub, coupon})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: i18n.t('couponCodeRequired'), + }); + }); + + it('should throw an error when coupon code is invalid', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + await couponModel.save(); + + sinon.stub(cc, 'validate').returns('invalid'); + + await expect(paypalPayments.subscribe({sub, coupon})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('invalidCoupon'), + }); + cc.validate.restore(); + }); + + it('subscribes with amazon with a coupon', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + let updatedCouponModel = await couponModel.save(); + + sinon.stub(cc, 'validate').returns(updatedCouponModel._id); + + let link = await paypalPayments.subscribe({sub, coupon}); + + expect(link).to.eql(approvalHerf); + expect(paypalBillingAgreementCreateStub).to.be.calledOnce; + let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; + expect(paypalBillingAgreementCreateStub).to.be.calledWith({ + name: billingPlanTitle, + description: billingPlanTitle, + start_date: moment().add({ minutes: 5 }).format(), + plan: { + id: sub.paypalKey, + }, + payer: { + payment_method: 'Paypal', + }, + }); + + cc.validate.restore(); + }); + + it('creates a link for a subscription', async () => { + delete sub.discount; + + let link = await paypalPayments.subscribe({sub, coupon}); + + expect(link).to.eql(approvalHerf); + expect(paypalBillingAgreementCreateStub).to.be.calledOnce; + let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; + expect(paypalBillingAgreementCreateStub).to.be.calledWith({ + name: billingPlanTitle, + description: billingPlanTitle, + start_date: moment().add({ minutes: 5 }).format(), + plan: { + id: sub.paypalKey, + }, + payer: { + payment_method: 'Paypal', + }, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/cancel-subscription.test.js b/test/api/v3/unit/libs/payments/stripe/cancel-subscription.test.js new file mode 100644 index 00000000000..2e09014a39e --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/cancel-subscription.test.js @@ -0,0 +1,143 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('cancel subscription', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let user, groupId, group; + + beforeEach(async () => { + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = 'customer-id'; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = 'customer-id'; + group.purchased.plan.planId = subKey; + await group.save(); + + groupId = group._id; + }); + + it('throws an error if there is no customer id', async () => { + user.purchased.plan.customerId = undefined; + + await expect(stripePayments.cancelSubscription({ + user, + groupId: undefined, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('throws an error if the group is not found', async () => { + await expect(stripePayments.cancelSubscription({ + user, + groupId: 'fake-group', + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + it('throws an error if user is not the group leader', async () => { + let nonLeader = new User(); + nonLeader.guilds.push(groupId); + await nonLeader.save(); + + await expect(stripePayments.cancelSubscription({ + user: nonLeader, + groupId, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + }); + }); + + describe('success', () => { + let stripeDeleteCustomerStub, paymentsCancelSubStub, stripeRetrieveStub, subscriptionId, currentPeriodEndTimeStamp; + + beforeEach(() => { + subscriptionId = 'subId'; + stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); + paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + + currentPeriodEndTimeStamp = (new Date()).getTime(); + stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve') + .returnsPromise().resolves({ + subscriptions: { + data: [{id: subscriptionId, current_period_end: currentPeriodEndTimeStamp}], // eslint-disable-line camelcase + }, + }); + }); + + afterEach(() => { + stripe.customers.del.restore(); + stripe.customers.retrieve.restore(); + payments.cancelSubscription.restore(); + }); + + it('cancels a user subscription', async () => { + await stripePayments.cancelSubscription({ + user, + groupId: undefined, + }, stripe); + + expect(stripeDeleteCustomerStub).to.be.calledOnce; + expect(stripeDeleteCustomerStub).to.be.calledWith(user.purchased.plan.customerId); + expect(stripeRetrieveStub).to.be.calledOnce; + expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); + expect(paymentsCancelSubStub).to.be.calledOnce; + expect(paymentsCancelSubStub).to.be.calledWith({ + user, + groupId: undefined, + nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds + paymentMethod: 'Stripe', + cancellationReason: undefined, + }); + }); + + it('cancels a group subscription', async () => { + await stripePayments.cancelSubscription({ + user, + groupId, + }, stripe); + + expect(stripeDeleteCustomerStub).to.be.calledOnce; + expect(stripeDeleteCustomerStub).to.be.calledWith(group.purchased.plan.customerId); + expect(stripeRetrieveStub).to.be.calledOnce; + expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); + expect(paymentsCancelSubStub).to.be.calledOnce; + expect(paymentsCancelSubStub).to.be.calledWith({ + user, + groupId, + nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds + paymentMethod: 'Stripe', + cancellationReason: undefined, + }); + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/checkout-subscription.test.js b/test/api/v3/unit/libs/payments/stripe/checkout-subscription.test.js new file mode 100644 index 00000000000..0c2683b8c22 --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/checkout-subscription.test.js @@ -0,0 +1,307 @@ +import stripeModule from 'stripe'; +import cc from 'coupon-code'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Coupon } from '../../../../../../../website/server/models/coupon'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('checkout with subscription', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let user, group, data, gift, sub, groupId, email, headers, coupon, customerIdResponse, subscriptionId, token; + let spy; + let stripeCreateCustomerSpy; + let stripePaymentsCreateSubSpy; + + beforeEach(async () => { + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = 'customer-id'; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = 'customer-id'; + group.purchased.plan.planId = subKey; + await group.save(); + + sub = { + key: 'basic_3mo', + }; + + data = { + user, + sub, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + }; + + email = 'example@example.com'; + customerIdResponse = 'test-id'; + subscriptionId = 'test-sub-id'; + token = 'test-token'; + + spy = sinon.stub(stripe.subscriptions, 'update'); + spy.returnsPromise().resolves; + + stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create'); + let stripCustomerResponse = { + id: customerIdResponse, + subscriptions: { + data: [{id: subscriptionId}], + }, + }; + stripeCreateCustomerSpy.returnsPromise().resolves(stripCustomerResponse); + + stripePaymentsCreateSubSpy = sinon.stub(payments, 'createSubscription'); + stripePaymentsCreateSubSpy.returnsPromise().resolves({}); + + data.groupId = group._id; + data.sub.quantity = 3; + }); + + afterEach(function () { + sinon.restore(stripe.subscriptions.update); + stripe.customers.create.restore(); + payments.createSubscription.restore(); + }); + + it('should throw an error if we are missing a token', async () => { + await expect(stripePayments.checkout({ + user, + gift, + sub, + groupId, + email, + headers, + coupon, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: 'Missing req.body.id', + }); + }); + + it('should throw an error when coupon code is missing', async () => { + sub.discount = 40; + + await expect(stripePayments.checkout({ + token, + user, + gift, + sub, + groupId, + email, + headers, + coupon, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: i18n.t('couponCodeRequired'), + }); + }); + + it('should throw an error when coupon code is invalid', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + await couponModel.save(); + + sinon.stub(cc, 'validate').returns('invalid'); + + await expect(stripePayments.checkout({ + token, + user, + gift, + sub, + groupId, + email, + headers, + coupon, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: i18n.t('invalidCoupon'), + }); + cc.validate.restore(); + }); + + it('subscribes with amazon with a coupon', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + let updatedCouponModel = await couponModel.save(); + + sinon.stub(cc, 'validate').returns(updatedCouponModel._id); + + await stripePayments.checkout({ + token, + user, + gift, + sub, + groupId, + email, + headers, + coupon, + }, stripe); + + expect(stripeCreateCustomerSpy).to.be.calledOnce; + expect(stripeCreateCustomerSpy).to.be.calledWith({ + email, + metadata: { uuid: user._id }, + card: token, + plan: sub.key, + }); + + expect(stripePaymentsCreateSubSpy).to.be.calledOnce; + expect(stripePaymentsCreateSubSpy).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Stripe', + sub, + headers, + groupId: undefined, + subscriptionId: undefined, + }); + + cc.validate.restore(); + }); + + it('subscribes a user', async () => { + sub = data.sub; + + await stripePayments.checkout({ + token, + user, + gift, + sub, + groupId, + email, + headers, + coupon, + }, stripe); + + expect(stripeCreateCustomerSpy).to.be.calledOnce; + expect(stripeCreateCustomerSpy).to.be.calledWith({ + email, + metadata: { uuid: user._id }, + card: token, + plan: sub.key, + }); + + expect(stripePaymentsCreateSubSpy).to.be.calledOnce; + expect(stripePaymentsCreateSubSpy).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Stripe', + sub, + headers, + groupId: undefined, + subscriptionId: undefined, + }); + }); + + it('subscribes a group', async () => { + token = 'test-token'; + sub = data.sub; + groupId = group._id; + email = 'test@test.com'; + headers = {}; + + await stripePayments.checkout({ + token, + user, + gift, + sub, + groupId, + email, + headers, + coupon, + }, stripe); + + expect(stripeCreateCustomerSpy).to.be.calledOnce; + expect(stripeCreateCustomerSpy).to.be.calledWith({ + email, + metadata: { uuid: user._id }, + card: token, + plan: sub.key, + quantity: 3, + }); + + expect(stripePaymentsCreateSubSpy).to.be.calledOnce; + expect(stripePaymentsCreateSubSpy).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Stripe', + sub, + headers, + groupId, + subscriptionId, + }); + }); + + it('subscribes a group with the correct number of group members', async () => { + token = 'test-token'; + sub = data.sub; + groupId = group._id; + email = 'test@test.com'; + headers = {}; + user = new User(); + user.guilds.push(groupId); + await user.save(); + group.memberCount = 2; + await group.save(); + + await stripePayments.checkout({ + token, + user, + gift, + sub, + groupId, + email, + headers, + coupon, + }, stripe); + + expect(stripeCreateCustomerSpy).to.be.calledOnce; + expect(stripeCreateCustomerSpy).to.be.calledWith({ + email, + metadata: { uuid: user._id }, + card: token, + plan: sub.key, + quantity: 4, + }); + + expect(stripePaymentsCreateSubSpy).to.be.calledOnce; + expect(stripePaymentsCreateSubSpy).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Stripe', + sub, + headers, + groupId, + subscriptionId, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/checkout.test.js b/test/api/v3/unit/libs/payments/stripe/checkout.test.js new file mode 100644 index 00000000000..f784bca300f --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/checkout.test.js @@ -0,0 +1,193 @@ +import stripeModule from 'stripe'; + +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('checkout', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let stripeChargeStub, paymentBuyGemsStub, paymentCreateSubscritionStub; + let user, gift, groupId, email, headers, coupon, customerIdResponse, token; + + beforeEach(() => { + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = 'customer-id'; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + + token = 'test-token'; + + customerIdResponse = 'example-customerIdResponse'; + let stripCustomerResponse = { + id: customerIdResponse, + }; + stripeChargeStub = sinon.stub(stripe.charges, 'create').returnsPromise().resolves(stripCustomerResponse); + paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); + paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + stripe.charges.create.restore(); + payments.buyGems.restore(); + payments.createSubscription.restore(); + }); + + it('should error if gem amount is too low', async () => { + let receivingUser = new User(); + receivingUser.save(); + gift = { + type: 'gems', + gems: { + amount: 0, + uuid: receivingUser._id, + }, + }; + + await expect(stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + message: 'Amount must be at least 1.', + name: 'BadRequest', + }); + }); + + + it('should error if user cannot get gems', async () => { + gift = undefined; + sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); + + await expect(stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe)).to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + message: i18n.t('groupPolicyCannotGetGems'), + name: 'NotAuthorized', + }); + }); + + it('should purchase gems', async () => { + gift = undefined; + sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); + + await stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe); + + expect(stripeChargeStub).to.be.calledOnce; + expect(stripeChargeStub).to.be.calledWith({ + amount: 500, + currency: 'usd', + card: token, + }); + + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Stripe', + gift, + }); + expect(user.canGetGems).to.be.calledOnce; + user.canGetGems.restore(); + }); + + it('should gift gems', async () => { + let receivingUser = new User(); + await receivingUser.save(); + gift = { + type: 'gems', + uuid: receivingUser._id, + gems: { + amount: 16, + }, + }; + + await stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe); + + expect(stripeChargeStub).to.be.calledOnce; + expect(stripeChargeStub).to.be.calledWith({ + amount: '400', + currency: 'usd', + card: token, + }); + + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Gift', + gift, + }); + }); + + it('should gift a subscription', async () => { + let receivingUser = new User(); + receivingUser.save(); + gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + + await stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe); + + gift.member = receivingUser; + expect(stripeChargeStub).to.be.calledOnce; + expect(stripeChargeStub).to.be.calledWith({ + amount: '1500', + currency: 'usd', + card: token, + }); + + expect(paymentCreateSubscritionStub).to.be.calledOnce; + expect(paymentCreateSubscritionStub).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Gift', + gift, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/edit-subscription.test.js b/test/api/v3/unit/libs/payments/stripe/edit-subscription.test.js new file mode 100644 index 00000000000..9c58c4bae5d --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/edit-subscription.test.js @@ -0,0 +1,147 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('edit subscription', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let user, groupId, group, token; + + beforeEach(async () => { + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = 'customer-id'; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = 'customer-id'; + group.purchased.plan.planId = subKey; + await group.save(); + + groupId = group._id; + + token = 'test-token'; + }); + + it('throws an error if there is no customer id', async () => { + user.purchased.plan.customerId = undefined; + + await expect(stripePayments.editSubscription({ + user, + groupId: undefined, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('throws an error if a token is not provided', async () => { + await expect(stripePayments.editSubscription({ + user, + groupId: undefined, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: 'Missing req.body.id', + }); + }); + + it('throws an error if the group is not found', async () => { + await expect(stripePayments.editSubscription({ + token, + user, + groupId: 'fake-group', + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + it('throws an error if user is not the group leader', async () => { + let nonLeader = new User(); + nonLeader.guilds.push(groupId); + await nonLeader.save(); + + await expect(stripePayments.editSubscription({ + token, + user: nonLeader, + groupId, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + }); + }); + + describe('success', () => { + let stripeListSubscriptionStub, stripeUpdateSubscriptionStub, subscriptionId; + + beforeEach(() => { + subscriptionId = 'subId'; + stripeListSubscriptionStub = sinon.stub(stripe.customers, 'listSubscriptions') + .returnsPromise().resolves({ + data: [{id: subscriptionId}], + }); + + stripeUpdateSubscriptionStub = sinon.stub(stripe.customers, 'updateSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + stripe.customers.listSubscriptions.restore(); + stripe.customers.updateSubscription.restore(); + }); + + it('edits a user subscription', async () => { + await stripePayments.editSubscription({ + token, + user, + groupId: undefined, + }, stripe); + + expect(stripeListSubscriptionStub).to.be.calledOnce; + expect(stripeListSubscriptionStub).to.be.calledWith(user.purchased.plan.customerId); + expect(stripeUpdateSubscriptionStub).to.be.calledOnce; + expect(stripeUpdateSubscriptionStub).to.be.calledWith( + user.purchased.plan.customerId, + subscriptionId, + { card: token } + ); + }); + + it('edits a group subscription', async () => { + await stripePayments.editSubscription({ + token, + user, + groupId, + }, stripe); + + expect(stripeListSubscriptionStub).to.be.calledOnce; + expect(stripeListSubscriptionStub).to.be.calledWith(group.purchased.plan.customerId); + expect(stripeUpdateSubscriptionStub).to.be.calledOnce; + expect(stripeUpdateSubscriptionStub).to.be.calledWith( + group.purchased.plan.customerId, + subscriptionId, + { card: token } + ); + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/handle-webhook.test.js b/test/api/v3/unit/libs/payments/stripe/handle-webhook.test.js new file mode 100644 index 00000000000..c91cb7e919a --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/handle-webhook.test.js @@ -0,0 +1,257 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; +import logger from '../../../../../../../website/server/libs/logger'; +import { v4 as uuid } from 'uuid'; +import moment from 'moment'; + +const i18n = common.i18n; + +describe('Stripe - Webhooks', () => { + const stripe = stripeModule('test'); + + describe('all events', () => { + const eventType = 'account.updated'; + const event = {id: 123}; + const eventRetrieved = {type: eventType}; + + beforeEach(() => { + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves(eventRetrieved); + sinon.stub(logger, 'error'); + }); + + afterEach(() => { + stripe.events.retrieve.restore(); + logger.error.restore(); + }); + + it('logs an error if an unsupported webhook event is passed', async () => { + const error = new Error(`Missing handler for Stripe webhook ${eventType}`); + await stripePayments.handleWebhooks({requestBody: event}, stripe); + expect(logger.error).to.have.been.called.once; + expect(logger.error).to.have.been.calledWith(error, {event: eventRetrieved}); + }); + + it('retrieves and validates the event from Stripe', async () => { + await stripePayments.handleWebhooks({requestBody: event}, stripe); + expect(stripe.events.retrieve).to.have.been.called.once; + expect(stripe.events.retrieve).to.have.been.calledWith(event.id); + }); + }); + + describe('customer.subscription.deleted', () => { + const eventType = 'customer.subscription.deleted'; + + beforeEach(() => { + sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); + sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + stripe.customers.del.restore(); + payments.cancelSubscription.restore(); + }); + + it('does not do anything if event.request is null (subscription cancelled manually)', async () => { + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + request: 123, + }); + + await stripePayments.handleWebhooks({requestBody: {}}, stripe); + + expect(stripe.events.retrieve).to.have.been.called.once; + expect(stripe.customers.del).to.not.have.been.called; + expect(payments.cancelSubscription).to.not.have.been.called; + stripe.events.retrieve.restore(); + }); + + describe('user subscription', () => { + it('throws an error if the user is not found', async () => { + const customerId = 456; + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'basic_earned', + }, + customer: customerId, + }, + }, + request: null, + }); + + await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ + message: i18n.t('userNotFound'), + httpCode: 404, + name: 'NotFound', + }); + + expect(stripe.customers.del).to.not.have.been.called; + expect(payments.cancelSubscription).to.not.have.been.called; + + stripe.events.retrieve.restore(); + }); + + it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { + const customerId = '456'; + + let subscriber = new User(); + subscriber.purchased.plan.customerId = customerId; + subscriber.purchased.plan.paymentMethod = 'Stripe'; + await subscriber.save(); + + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'basic_earned', + }, + customer: customerId, + }, + }, + request: null, + }); + + await stripePayments.handleWebhooks({requestBody: {}}, stripe); + + expect(stripe.customers.del).to.have.been.calledOnce; + expect(stripe.customers.del).to.have.been.calledWith(customerId); + expect(payments.cancelSubscription).to.have.been.calledOnce; + + let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; + expect(cancelSubscriptionOpts.user._id).to.equal(subscriber._id); + expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); + expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); + expect(cancelSubscriptionOpts.groupId).to.be.undefined; + + stripe.events.retrieve.restore(); + }); + }); + + describe('group plan subscription', () => { + it('throws an error if the group is not found', async () => { + const customerId = 456; + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'group_monthly', + }, + customer: customerId, + }, + }, + request: null, + }); + + await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ + message: i18n.t('groupNotFound'), + httpCode: 404, + name: 'NotFound', + }); + + expect(stripe.customers.del).to.not.have.been.called; + expect(payments.cancelSubscription).to.not.have.been.called; + + stripe.events.retrieve.restore(); + }); + + it('throws an error if the group leader is not found', async () => { + const customerId = 456; + + let subscriber = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: uuid(), + }); + subscriber.purchased.plan.customerId = customerId; + subscriber.purchased.plan.paymentMethod = 'Stripe'; + await subscriber.save(); + + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'group_monthly', + }, + customer: customerId, + }, + }, + request: null, + }); + + await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ + message: i18n.t('userNotFound'), + httpCode: 404, + name: 'NotFound', + }); + + expect(stripe.customers.del).to.not.have.been.called; + expect(payments.cancelSubscription).to.not.have.been.called; + + stripe.events.retrieve.restore(); + }); + + it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { + const customerId = '456'; + + let leader = new User(); + await leader.save(); + + let subscriber = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: leader._id, + }); + subscriber.purchased.plan.customerId = customerId; + subscriber.purchased.plan.paymentMethod = 'Stripe'; + await subscriber.save(); + + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'group_monthly', + }, + customer: customerId, + }, + }, + request: null, + }); + + await stripePayments.handleWebhooks({requestBody: {}}, stripe); + + expect(stripe.customers.del).to.have.been.calledOnce; + expect(stripe.customers.del).to.have.been.calledWith(customerId); + expect(payments.cancelSubscription).to.have.been.calledOnce; + + let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; + expect(cancelSubscriptionOpts.user._id).to.equal(leader._id); + expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); + expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); + expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id); + + stripe.events.retrieve.restore(); + }); + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/upgrade-group-plan.test.js b/test/api/v3/unit/libs/payments/stripe/upgrade-group-plan.test.js new file mode 100644 index 00000000000..f0126631a9e --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/upgrade-group-plan.test.js @@ -0,0 +1,66 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Group } from '../../../../../../../website/server/models/group'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; + +describe('Stripe - Upgrade Group Plan', () => { + const stripe = stripeModule('test'); + let spy, data, user, group; + + beforeEach(async function () { + user = new User(); + user.profile.name = 'sender'; + + data = { + user, + sub: { + key: 'basic_3mo', // @TODO: Validate that this is group + }, + customerId: 'customer-id', + paymentMethod: 'Payment Method', + headers: { + 'x-client': 'habitica-web', + 'user-agent': '', + }, + }; + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + await group.save(); + + spy = sinon.stub(stripe.subscriptions, 'update'); + spy.returnsPromise().resolves([]); + data.groupId = group._id; + data.sub.quantity = 3; + stripePayments.setStripeApi(stripe); + }); + + afterEach(function () { + sinon.restore(stripe.subscriptions.update); + }); + + it('updates a group plan quantity', async () => { + data.paymentMethod = 'Stripe'; + await payments.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + expect(updatedGroup.purchased.plan.quantity).to.eql(3); + + updatedGroup.memberCount += 1; + await updatedGroup.save(); + + await stripePayments.chargeForAdditionalGroupMember(updatedGroup); + + expect(spy.calledOnce).to.be.true; + expect(updatedGroup.purchased.plan.quantity).to.eql(4); + }); +}); diff --git a/test/api/v3/unit/libs/paypalPayments.test.js b/test/api/v3/unit/libs/paypalPayments.test.js deleted file mode 100644 index ae67cf3eded..00000000000 --- a/test/api/v3/unit/libs/paypalPayments.test.js +++ /dev/null @@ -1,561 +0,0 @@ -/* eslint-disable camelcase */ -import nconf from 'nconf'; -import moment from 'moment'; -import cc from 'coupon-code'; - -import payments from '../../../../../website/server/libs/payments'; -import paypalPayments from '../../../../../website/server/libs/paypalPayments'; -import { - generateGroup, -} from '../../../../helpers/api-unit.helper.js'; -import { model as User } from '../../../../../website/server/models/user'; -import { model as Coupon } from '../../../../../website/server/models/coupon'; -import common from '../../../../../website/common'; - -const BASE_URL = nconf.get('BASE_URL'); -const i18n = common.i18n; - -describe('Paypal Payments', () => { - let subKey = 'basic_3mo'; - - describe('checkout', () => { - let paypalPaymentCreateStub; - let approvalHerf; - - function getPaypalCreateOptions (description, amount) { - return { - intent: 'sale', - payer: { payment_method: 'Paypal' }, - redirect_urls: { - return_url: `${BASE_URL}/paypal/checkout/success`, - cancel_url: `${BASE_URL}`, - }, - transactions: [{ - item_list: { - items: [{ - name: description, - price: amount, - currency: 'USD', - quantity: 1, - }], - }, - amount: { - currency: 'USD', - total: amount, - }, - description, - }], - }; - } - - beforeEach(() => { - approvalHerf = 'approval_href'; - paypalPaymentCreateStub = sinon.stub(paypalPayments, 'paypalPaymentCreate') - .returnsPromise().resolves({ - links: [{ rel: 'approval_url', href: approvalHerf }], - }); - }); - - afterEach(() => { - paypalPayments.paypalPaymentCreate.restore(); - }); - - it('creates a link for gem purchases', async () => { - let link = await paypalPayments.checkout({user: new User()}); - - expect(paypalPaymentCreateStub).to.be.calledOnce; - expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 5.00)); - expect(link).to.eql(approvalHerf); - }); - - it('should error if gem amount is too low', async () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'gems', - gems: { - amount: 0, - uuid: receivingUser._id, - }, - }; - - await expect(paypalPayments.checkout({gift})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Amount must be at least 1.', - name: 'BadRequest', - }); - }); - - it('should error if the user cannot get gems', async () => { - let user = new User(); - sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); - - await expect(paypalPayments.checkout({user})).to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('groupPolicyCannotGetGems'), - name: 'NotAuthorized', - }); - }); - - it('creates a link for gifting gems', async () => { - let receivingUser = new User(); - await receivingUser.save(); - let gift = { - type: 'gems', - uuid: receivingUser._id, - gems: { - amount: 16, - }, - }; - - let link = await paypalPayments.checkout({gift}); - - expect(paypalPaymentCreateStub).to.be.calledOnce; - expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems (Gift)', '4.00')); - expect(link).to.eql(approvalHerf); - }); - - it('creates a link for gifting a subscription', async () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - - let link = await paypalPayments.checkout({gift}); - - expect(paypalPaymentCreateStub).to.be.calledOnce; - expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00')); - expect(link).to.eql(approvalHerf); - }); - }); - - describe('checkout success', () => { - let user, gift, customerId, paymentId; - let paypalPaymentExecuteStub, paymentBuyGemsStub, paymentsCreateSubscritionStub; - - beforeEach(() => { - user = new User(); - customerId = 'customerId-test'; - paymentId = 'paymentId-test'; - - paypalPaymentExecuteStub = sinon.stub(paypalPayments, 'paypalPaymentExecute').returnsPromise().resolves({}); - paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); - paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - paypalPayments.paypalPaymentExecute.restore(); - payments.buyGems.restore(); - payments.createSubscription.restore(); - }); - - it('purchases gems', async () => { - await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); - - expect(paypalPaymentExecuteStub).to.be.calledOnce; - expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId, - paymentMethod: 'Paypal', - }); - }); - - it('gifts gems', async () => { - let receivingUser = new User(); - await receivingUser.save(); - gift = { - type: 'gems', - gems: { - amount: 16, - uuid: receivingUser._id, - }, - }; - - await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); - - expect(paypalPaymentExecuteStub).to.be.calledOnce; - expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId, - paymentMethod: 'PayPal (Gift)', - gift, - }); - }); - - it('gifts subscription', async () => { - let receivingUser = new User(); - await receivingUser.save(); - gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - - await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); - - expect(paypalPaymentExecuteStub).to.be.calledOnce; - expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); - expect(paymentsCreateSubscritionStub).to.be.calledOnce; - expect(paymentsCreateSubscritionStub).to.be.calledWith({ - user, - customerId, - paymentMethod: 'PayPal (Gift)', - gift, - }); - }); - }); - - describe('subscribe', () => { - let coupon, sub, approvalHerf; - let paypalBillingAgreementCreateStub; - - beforeEach(() => { - approvalHerf = 'approvalHerf-test'; - sub = common.content.subscriptionBlocks[subKey]; - - paypalBillingAgreementCreateStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCreate') - .returnsPromise().resolves({ - links: [{ rel: 'approval_url', href: approvalHerf }], - }); - }); - - afterEach(() => { - paypalPayments.paypalBillingAgreementCreate.restore(); - }); - - it('should throw an error when coupon code is missing', async () => { - sub.discount = 40; - - await expect(paypalPayments.subscribe({sub, coupon})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: i18n.t('couponCodeRequired'), - }); - }); - - it('should throw an error when coupon code is invalid', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - await couponModel.save(); - - sinon.stub(cc, 'validate').returns('invalid'); - - await expect(paypalPayments.subscribe({sub, coupon})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('invalidCoupon'), - }); - cc.validate.restore(); - }); - - it('subscribes with amazon with a coupon', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - let updatedCouponModel = await couponModel.save(); - - sinon.stub(cc, 'validate').returns(updatedCouponModel._id); - - let link = await paypalPayments.subscribe({sub, coupon}); - - expect(link).to.eql(approvalHerf); - expect(paypalBillingAgreementCreateStub).to.be.calledOnce; - let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; - expect(paypalBillingAgreementCreateStub).to.be.calledWith({ - name: billingPlanTitle, - description: billingPlanTitle, - start_date: moment().add({ minutes: 5 }).format(), - plan: { - id: sub.paypalKey, - }, - payer: { - payment_method: 'Paypal', - }, - }); - - cc.validate.restore(); - }); - - it('creates a link for a subscription', async () => { - delete sub.discount; - - let link = await paypalPayments.subscribe({sub, coupon}); - - expect(link).to.eql(approvalHerf); - expect(paypalBillingAgreementCreateStub).to.be.calledOnce; - let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; - expect(paypalBillingAgreementCreateStub).to.be.calledWith({ - name: billingPlanTitle, - description: billingPlanTitle, - start_date: moment().add({ minutes: 5 }).format(), - plan: { - id: sub.paypalKey, - }, - payer: { - payment_method: 'Paypal', - }, - }); - }); - }); - - describe('subscribeSuccess', () => { - let user, group, block, groupId, token, headers, customerId; - let paypalBillingAgreementExecuteStub, paymentsCreateSubscritionStub; - - beforeEach(async () => { - user = new User(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - - token = 'test-token'; - headers = {}; - block = common.content.subscriptionBlocks[subKey]; - customerId = 'test-customerId'; - - paypalBillingAgreementExecuteStub = sinon.stub(paypalPayments, 'paypalBillingAgreementExecute') - .returnsPromise({}).resolves({ - id: customerId, - }); - paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - paypalPayments.paypalBillingAgreementExecute.restore(); - payments.createSubscription.restore(); - }); - - it('creates a user subscription', async () => { - await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); - - expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; - expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); - - expect(paymentsCreateSubscritionStub).to.be.calledOnce; - expect(paymentsCreateSubscritionStub).to.be.calledWith({ - user, - groupId, - customerId, - paymentMethod: 'Paypal', - sub: block, - headers, - }); - }); - - it('create a group subscription', async () => { - groupId = group._id; - - await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); - - expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; - expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); - - expect(paymentsCreateSubscritionStub).to.be.calledOnce; - expect(paymentsCreateSubscritionStub).to.be.calledWith({ - user, - groupId, - customerId, - paymentMethod: 'Paypal', - sub: block, - headers, - }); - }); - }); - - describe('subscribeCancel', () => { - let user, group, groupId, customerId, groupCustomerId, nextBillingDate; - let paymentCancelSubscriptionSpy, paypalBillingAgreementCancelStub, paypalBillingAgreementGetStub; - - beforeEach(async () => { - customerId = 'customer-id'; - groupCustomerId = 'groupCustomerId-test'; - - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = customerId; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = groupCustomerId; - group.purchased.plan.planId = subKey; - group.purchased.plan.lastBillingDate = new Date(); - await group.save(); - - nextBillingDate = new Date(); - - paypalBillingAgreementCancelStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCancel').returnsPromise().resolves({}); - paypalBillingAgreementGetStub = sinon.stub(paypalPayments, 'paypalBillingAgreementGet') - .returnsPromise().resolves({ - agreement_details: { - next_billing_date: nextBillingDate, - cycles_completed: 1, - }, - }); - paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - }); - - afterEach(function () { - paypalPayments.paypalBillingAgreementGet.restore(); - paypalPayments.paypalBillingAgreementCancel.restore(); - payments.cancelSubscription.restore(); - }); - - it('should throw an error if we are missing a subscription', async () => { - user.purchased.plan.customerId = undefined; - - await expect(paypalPayments.subscribeCancel({user})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('missingSubscription'), - }); - }); - - it('should throw an error if group is not found', async () => { - await expect(paypalPayments.subscribeCancel({user, groupId: 'fake-id'})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 404, - name: 'NotFound', - message: i18n.t('groupNotFound'), - }); - }); - - it('should throw an error if user is not group leader', async () => { - let nonLeader = new User(); - nonLeader.guilds.push(group._id); - await nonLeader.save(); - - await expect(paypalPayments.subscribeCancel({user: nonLeader, groupId: group._id})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('onlyGroupLeaderCanManageSubscription'), - }); - }); - - it('should cancel a user subscription', async () => { - await paypalPayments.subscribeCancel({user}); - - expect(paypalBillingAgreementGetStub).to.be.calledOnce; - expect(paypalBillingAgreementGetStub).to.be.calledWith(customerId); - expect(paypalBillingAgreementCancelStub).to.be.calledOnce; - expect(paypalBillingAgreementCancelStub).to.be.calledWith(customerId, { note: i18n.t('cancelingSubscription') }); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId, - paymentMethod: 'Paypal', - nextBill: nextBillingDate, - cancellationReason: undefined, - }); - }); - - it('should cancel a group subscription', async () => { - await paypalPayments.subscribeCancel({user, groupId: group._id}); - - expect(paypalBillingAgreementGetStub).to.be.calledOnce; - expect(paypalBillingAgreementGetStub).to.be.calledWith(groupCustomerId); - expect(paypalBillingAgreementCancelStub).to.be.calledOnce; - expect(paypalBillingAgreementCancelStub).to.be.calledWith(groupCustomerId, { note: i18n.t('cancelingSubscription') }); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: group._id, - paymentMethod: 'Paypal', - nextBill: nextBillingDate, - cancellationReason: undefined, - }); - }); - }); - - describe('ipn', () => { - let user, group, txn_type, userPaymentId, groupPaymentId; - let ipnVerifyAsyncStub, paymentCancelSubscriptionSpy; - - beforeEach(async () => { - txn_type = 'recurring_payment_profile_cancel'; - userPaymentId = 'userPaymentId-test'; - groupPaymentId = 'groupPaymentId-test'; - - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = userPaymentId; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - await user.save(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = groupPaymentId; - group.purchased.plan.planId = subKey; - group.purchased.plan.lastBillingDate = new Date(); - await group.save(); - - ipnVerifyAsyncStub = sinon.stub(paypalPayments, 'ipnVerifyAsync').returnsPromise().resolves({}); - paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - }); - - afterEach(function () { - paypalPayments.ipnVerifyAsync.restore(); - payments.cancelSubscription.restore(); - }); - - it('should cancel a user subscription', async () => { - await paypalPayments.ipn({txn_type, recurring_payment_id: userPaymentId}); - - expect(ipnVerifyAsyncStub).to.be.calledOnce; - expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: userPaymentId}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy.args[0][0].user._id).to.eql(user._id); - expect(paymentCancelSubscriptionSpy.args[0][0].paymentMethod).to.eql('Paypal'); - }); - - it('should cancel a group subscription', async () => { - await paypalPayments.ipn({txn_type, recurring_payment_id: groupPaymentId}); - - expect(ipnVerifyAsyncStub).to.be.calledOnce; - expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: groupPaymentId}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ groupId: group._id, paymentMethod: 'Paypal' }); - }); - }); -}); diff --git a/test/api/v3/unit/libs/stripePayments.test.js b/test/api/v3/unit/libs/stripePayments.test.js deleted file mode 100644 index 75aaffafcb8..00000000000 --- a/test/api/v3/unit/libs/stripePayments.test.js +++ /dev/null @@ -1,1059 +0,0 @@ -import stripeModule from 'stripe'; -import cc from 'coupon-code'; - -import { - generateGroup, -} from '../../../../helpers/api-unit.helper.js'; -import { model as User } from '../../../../../website/server/models/user'; -import { model as Group } from '../../../../../website/server/models/group'; -import { model as Coupon } from '../../../../../website/server/models/coupon'; -import stripePayments from '../../../../../website/server/libs/stripePayments'; -import payments from '../../../../../website/server/libs/payments'; -import common from '../../../../../website/common'; -import logger from '../../../../../website/server/libs/logger'; -import { v4 as uuid } from 'uuid'; -import moment from 'moment'; - -const i18n = common.i18n; - -describe('Stripe Payments', () => { - let subKey = 'basic_3mo'; - let stripe = stripeModule('test'); - - describe('checkout', () => { - let stripeChargeStub, paymentBuyGemsStub, paymentCreateSubscritionStub; - let user, gift, groupId, email, headers, coupon, customerIdResponse, token; - - beforeEach(() => { - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = 'customer-id'; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - token = 'test-token'; - - customerIdResponse = 'example-customerIdResponse'; - let stripCustomerResponse = { - id: customerIdResponse, - }; - stripeChargeStub = sinon.stub(stripe.charges, 'create').returnsPromise().resolves(stripCustomerResponse); - paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); - paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - stripe.charges.create.restore(); - payments.buyGems.restore(); - payments.createSubscription.restore(); - }); - - it('should error if gem amount is too low', async () => { - let receivingUser = new User(); - receivingUser.save(); - gift = { - type: 'gems', - gems: { - amount: 0, - uuid: receivingUser._id, - }, - }; - - await expect(stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe)) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Amount must be at least 1.', - name: 'BadRequest', - }); - }); - - - it('should error if user cannot get gems', async () => { - gift = undefined; - sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); - - await expect(stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe)).to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('groupPolicyCannotGetGems'), - name: 'NotAuthorized', - }); - }); - - it('should purchase gems', async () => { - gift = undefined; - sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); - - await stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: 500, - currency: 'usd', - card: token, - }); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Stripe', - gift, - }); - expect(user.canGetGems).to.be.calledOnce; - user.canGetGems.restore(); - }); - - it('should gift gems', async () => { - let receivingUser = new User(); - await receivingUser.save(); - gift = { - type: 'gems', - uuid: receivingUser._id, - gems: { - amount: 16, - }, - }; - - await stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: '400', - currency: 'usd', - card: token, - }); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Gift', - gift, - }); - }); - - it('should gift a subscription', async () => { - let receivingUser = new User(); - receivingUser.save(); - gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - - await stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe); - - gift.member = receivingUser; - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: '1500', - currency: 'usd', - card: token, - }); - - expect(paymentCreateSubscritionStub).to.be.calledOnce; - expect(paymentCreateSubscritionStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Gift', - gift, - }); - }); - }); - - describe('checkout with subscription', () => { - let user, group, data, gift, sub, groupId, email, headers, coupon, customerIdResponse, subscriptionId, token; - let spy; - let stripeCreateCustomerSpy; - let stripePaymentsCreateSubSpy; - - beforeEach(async () => { - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = 'customer-id'; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = 'customer-id'; - group.purchased.plan.planId = subKey; - await group.save(); - - sub = { - key: 'basic_3mo', - }; - - data = { - user, - sub, - customerId: 'customer-id', - paymentMethod: 'Payment Method', - }; - - email = 'example@example.com'; - customerIdResponse = 'test-id'; - subscriptionId = 'test-sub-id'; - token = 'test-token'; - - spy = sinon.stub(stripe.subscriptions, 'update'); - spy.returnsPromise().resolves; - - stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create'); - let stripCustomerResponse = { - id: customerIdResponse, - subscriptions: { - data: [{id: subscriptionId}], - }, - }; - stripeCreateCustomerSpy.returnsPromise().resolves(stripCustomerResponse); - - stripePaymentsCreateSubSpy = sinon.stub(payments, 'createSubscription'); - stripePaymentsCreateSubSpy.returnsPromise().resolves({}); - - data.groupId = group._id; - data.sub.quantity = 3; - }); - - afterEach(function () { - sinon.restore(stripe.subscriptions.update); - stripe.customers.create.restore(); - payments.createSubscription.restore(); - }); - - it('should throw an error if we are missing a token', async () => { - await expect(stripePayments.checkout({ - user, - gift, - sub, - groupId, - email, - headers, - coupon, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: 'Missing req.body.id', - }); - }); - - it('should throw an error when coupon code is missing', async () => { - sub.discount = 40; - - await expect(stripePayments.checkout({ - token, - user, - gift, - sub, - groupId, - email, - headers, - coupon, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: i18n.t('couponCodeRequired'), - }); - }); - - it('should throw an error when coupon code is invalid', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - await couponModel.save(); - - sinon.stub(cc, 'validate').returns('invalid'); - - await expect(stripePayments.checkout({ - token, - user, - gift, - sub, - groupId, - email, - headers, - coupon, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: i18n.t('invalidCoupon'), - }); - cc.validate.restore(); - }); - - it('subscribes with amazon with a coupon', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - let updatedCouponModel = await couponModel.save(); - - sinon.stub(cc, 'validate').returns(updatedCouponModel._id); - - await stripePayments.checkout({ - token, - user, - gift, - sub, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeCreateCustomerSpy).to.be.calledOnce; - expect(stripeCreateCustomerSpy).to.be.calledWith({ - email, - metadata: { uuid: user._id }, - card: token, - plan: sub.key, - }); - - expect(stripePaymentsCreateSubSpy).to.be.calledOnce; - expect(stripePaymentsCreateSubSpy).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Stripe', - sub, - headers, - groupId: undefined, - subscriptionId: undefined, - }); - - cc.validate.restore(); - }); - - it('subscribes a user', async () => { - sub = data.sub; - - await stripePayments.checkout({ - token, - user, - gift, - sub, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeCreateCustomerSpy).to.be.calledOnce; - expect(stripeCreateCustomerSpy).to.be.calledWith({ - email, - metadata: { uuid: user._id }, - card: token, - plan: sub.key, - }); - - expect(stripePaymentsCreateSubSpy).to.be.calledOnce; - expect(stripePaymentsCreateSubSpy).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Stripe', - sub, - headers, - groupId: undefined, - subscriptionId: undefined, - }); - }); - - it('subscribes a group', async () => { - token = 'test-token'; - sub = data.sub; - groupId = group._id; - email = 'test@test.com'; - headers = {}; - - await stripePayments.checkout({ - token, - user, - gift, - sub, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeCreateCustomerSpy).to.be.calledOnce; - expect(stripeCreateCustomerSpy).to.be.calledWith({ - email, - metadata: { uuid: user._id }, - card: token, - plan: sub.key, - quantity: 3, - }); - - expect(stripePaymentsCreateSubSpy).to.be.calledOnce; - expect(stripePaymentsCreateSubSpy).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Stripe', - sub, - headers, - groupId, - subscriptionId, - }); - }); - - it('subscribes a group with the correct number of group members', async () => { - token = 'test-token'; - sub = data.sub; - groupId = group._id; - email = 'test@test.com'; - headers = {}; - user = new User(); - user.guilds.push(groupId); - await user.save(); - group.memberCount = 2; - await group.save(); - - await stripePayments.checkout({ - token, - user, - gift, - sub, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeCreateCustomerSpy).to.be.calledOnce; - expect(stripeCreateCustomerSpy).to.be.calledWith({ - email, - metadata: { uuid: user._id }, - card: token, - plan: sub.key, - quantity: 4, - }); - - expect(stripePaymentsCreateSubSpy).to.be.calledOnce; - expect(stripePaymentsCreateSubSpy).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Stripe', - sub, - headers, - groupId, - subscriptionId, - }); - }); - }); - - describe('edit subscription', () => { - let user, groupId, group, token; - - beforeEach(async () => { - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = 'customer-id'; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = 'customer-id'; - group.purchased.plan.planId = subKey; - await group.save(); - - groupId = group._id; - - token = 'test-token'; - }); - - it('throws an error if there is no customer id', async () => { - user.purchased.plan.customerId = undefined; - - await expect(stripePayments.editSubscription({ - user, - groupId: undefined, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('missingSubscription'), - }); - }); - - it('throws an error if a token is not provided', async () => { - await expect(stripePayments.editSubscription({ - user, - groupId: undefined, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: 'Missing req.body.id', - }); - }); - - it('throws an error if the group is not found', async () => { - await expect(stripePayments.editSubscription({ - token, - user, - groupId: 'fake-group', - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 404, - name: 'NotFound', - message: i18n.t('groupNotFound'), - }); - }); - - it('throws an error if user is not the group leader', async () => { - let nonLeader = new User(); - nonLeader.guilds.push(groupId); - await nonLeader.save(); - - await expect(stripePayments.editSubscription({ - token, - user: nonLeader, - groupId, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('onlyGroupLeaderCanManageSubscription'), - }); - }); - - describe('success', () => { - let stripeListSubscriptionStub, stripeUpdateSubscriptionStub, subscriptionId; - - beforeEach(() => { - subscriptionId = 'subId'; - stripeListSubscriptionStub = sinon.stub(stripe.customers, 'listSubscriptions') - .returnsPromise().resolves({ - data: [{id: subscriptionId}], - }); - - stripeUpdateSubscriptionStub = sinon.stub(stripe.customers, 'updateSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - stripe.customers.listSubscriptions.restore(); - stripe.customers.updateSubscription.restore(); - }); - - it('edits a user subscription', async () => { - await stripePayments.editSubscription({ - token, - user, - groupId: undefined, - }, stripe); - - expect(stripeListSubscriptionStub).to.be.calledOnce; - expect(stripeListSubscriptionStub).to.be.calledWith(user.purchased.plan.customerId); - expect(stripeUpdateSubscriptionStub).to.be.calledOnce; - expect(stripeUpdateSubscriptionStub).to.be.calledWith( - user.purchased.plan.customerId, - subscriptionId, - { card: token } - ); - }); - - it('edits a group subscription', async () => { - await stripePayments.editSubscription({ - token, - user, - groupId, - }, stripe); - - expect(stripeListSubscriptionStub).to.be.calledOnce; - expect(stripeListSubscriptionStub).to.be.calledWith(group.purchased.plan.customerId); - expect(stripeUpdateSubscriptionStub).to.be.calledOnce; - expect(stripeUpdateSubscriptionStub).to.be.calledWith( - group.purchased.plan.customerId, - subscriptionId, - { card: token } - ); - }); - }); - }); - - describe('cancel subscription', () => { - let user, groupId, group; - - beforeEach(async () => { - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = 'customer-id'; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = 'customer-id'; - group.purchased.plan.planId = subKey; - await group.save(); - - groupId = group._id; - }); - - it('throws an error if there is no customer id', async () => { - user.purchased.plan.customerId = undefined; - - await expect(stripePayments.cancelSubscription({ - user, - groupId: undefined, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('missingSubscription'), - }); - }); - - it('throws an error if the group is not found', async () => { - await expect(stripePayments.cancelSubscription({ - user, - groupId: 'fake-group', - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 404, - name: 'NotFound', - message: i18n.t('groupNotFound'), - }); - }); - - it('throws an error if user is not the group leader', async () => { - let nonLeader = new User(); - nonLeader.guilds.push(groupId); - await nonLeader.save(); - - await expect(stripePayments.cancelSubscription({ - user: nonLeader, - groupId, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('onlyGroupLeaderCanManageSubscription'), - }); - }); - - describe('success', () => { - let stripeDeleteCustomerStub, paymentsCancelSubStub, stripeRetrieveStub, subscriptionId, currentPeriodEndTimeStamp; - - beforeEach(() => { - subscriptionId = 'subId'; - stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); - paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - - currentPeriodEndTimeStamp = (new Date()).getTime(); - stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve') - .returnsPromise().resolves({ - subscriptions: { - data: [{id: subscriptionId, current_period_end: currentPeriodEndTimeStamp}], // eslint-disable-line camelcase - }, - }); - }); - - afterEach(() => { - stripe.customers.del.restore(); - stripe.customers.retrieve.restore(); - payments.cancelSubscription.restore(); - }); - - it('cancels a user subscription', async () => { - await stripePayments.cancelSubscription({ - user, - groupId: undefined, - }, stripe); - - expect(stripeDeleteCustomerStub).to.be.calledOnce; - expect(stripeDeleteCustomerStub).to.be.calledWith(user.purchased.plan.customerId); - expect(stripeRetrieveStub).to.be.calledOnce; - expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); - expect(paymentsCancelSubStub).to.be.calledOnce; - expect(paymentsCancelSubStub).to.be.calledWith({ - user, - groupId: undefined, - nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds - paymentMethod: 'Stripe', - cancellationReason: undefined, - }); - }); - - it('cancels a group subscription', async () => { - await stripePayments.cancelSubscription({ - user, - groupId, - }, stripe); - - expect(stripeDeleteCustomerStub).to.be.calledOnce; - expect(stripeDeleteCustomerStub).to.be.calledWith(group.purchased.plan.customerId); - expect(stripeRetrieveStub).to.be.calledOnce; - expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); - expect(paymentsCancelSubStub).to.be.calledOnce; - expect(paymentsCancelSubStub).to.be.calledWith({ - user, - groupId, - nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds - paymentMethod: 'Stripe', - cancellationReason: undefined, - }); - }); - }); - }); - - describe('#upgradeGroupPlan', () => { - let spy, data, user, group; - - beforeEach(async function () { - user = new User(); - user.profile.name = 'sender'; - - data = { - user, - sub: { - key: 'basic_3mo', // @TODO: Validate that this is group - }, - customerId: 'customer-id', - paymentMethod: 'Payment Method', - headers: { - 'x-client': 'habitica-web', - 'user-agent': '', - }, - }; - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - await group.save(); - - spy = sinon.stub(stripe.subscriptions, 'update'); - spy.returnsPromise().resolves([]); - data.groupId = group._id; - data.sub.quantity = 3; - stripePayments.setStripeApi(stripe); - }); - - afterEach(function () { - sinon.restore(stripe.subscriptions.update); - }); - - it('updates a group plan quantity', async () => { - data.paymentMethod = 'Stripe'; - await payments.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - expect(updatedGroup.purchased.plan.quantity).to.eql(3); - - updatedGroup.memberCount += 1; - await updatedGroup.save(); - - await stripePayments.chargeForAdditionalGroupMember(updatedGroup); - - expect(spy.calledOnce).to.be.true; - expect(updatedGroup.purchased.plan.quantity).to.eql(4); - }); - }); - - describe('handleWebhooks', () => { - describe('all events', () => { - const eventType = 'account.updated'; - const event = {id: 123}; - const eventRetrieved = {type: eventType}; - - beforeEach(() => { - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves(eventRetrieved); - sinon.stub(logger, 'error'); - }); - - afterEach(() => { - stripe.events.retrieve.restore(); - logger.error.restore(); - }); - - it('logs an error if an unsupported webhook event is passed', async () => { - const error = new Error(`Missing handler for Stripe webhook ${eventType}`); - await stripePayments.handleWebhooks({requestBody: event}, stripe); - expect(logger.error).to.have.been.called.once; - expect(logger.error).to.have.been.calledWith(error, {event: eventRetrieved}); - }); - - it('retrieves and validates the event from Stripe', async () => { - await stripePayments.handleWebhooks({requestBody: event}, stripe); - expect(stripe.events.retrieve).to.have.been.called.once; - expect(stripe.events.retrieve).to.have.been.calledWith(event.id); - }); - }); - - describe('customer.subscription.deleted', () => { - const eventType = 'customer.subscription.deleted'; - - beforeEach(() => { - sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); - sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - stripe.customers.del.restore(); - payments.cancelSubscription.restore(); - }); - - it('does not do anything if event.request is null (subscription cancelled manually)', async () => { - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - request: 123, - }); - - await stripePayments.handleWebhooks({requestBody: {}}, stripe); - - expect(stripe.events.retrieve).to.have.been.called.once; - expect(stripe.customers.del).to.not.have.been.called; - expect(payments.cancelSubscription).to.not.have.been.called; - stripe.events.retrieve.restore(); - }); - - describe('user subscription', () => { - it('throws an error if the user is not found', async () => { - const customerId = 456; - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'basic_earned', - }, - customer: customerId, - }, - }, - request: null, - }); - - await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ - message: i18n.t('userNotFound'), - httpCode: 404, - name: 'NotFound', - }); - - expect(stripe.customers.del).to.not.have.been.called; - expect(payments.cancelSubscription).to.not.have.been.called; - - stripe.events.retrieve.restore(); - }); - - it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { - const customerId = '456'; - - let subscriber = new User(); - subscriber.purchased.plan.customerId = customerId; - subscriber.purchased.plan.paymentMethod = 'Stripe'; - await subscriber.save(); - - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'basic_earned', - }, - customer: customerId, - }, - }, - request: null, - }); - - await stripePayments.handleWebhooks({requestBody: {}}, stripe); - - expect(stripe.customers.del).to.have.been.calledOnce; - expect(stripe.customers.del).to.have.been.calledWith(customerId); - expect(payments.cancelSubscription).to.have.been.calledOnce; - - let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; - expect(cancelSubscriptionOpts.user._id).to.equal(subscriber._id); - expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); - expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); - expect(cancelSubscriptionOpts.groupId).to.be.undefined; - - stripe.events.retrieve.restore(); - }); - }); - - describe('group plan subscription', () => { - it('throws an error if the group is not found', async () => { - const customerId = 456; - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'group_monthly', - }, - customer: customerId, - }, - }, - request: null, - }); - - await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ - message: i18n.t('groupNotFound'), - httpCode: 404, - name: 'NotFound', - }); - - expect(stripe.customers.del).to.not.have.been.called; - expect(payments.cancelSubscription).to.not.have.been.called; - - stripe.events.retrieve.restore(); - }); - - it('throws an error if the group leader is not found', async () => { - const customerId = 456; - - let subscriber = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: uuid(), - }); - subscriber.purchased.plan.customerId = customerId; - subscriber.purchased.plan.paymentMethod = 'Stripe'; - await subscriber.save(); - - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'group_monthly', - }, - customer: customerId, - }, - }, - request: null, - }); - - await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ - message: i18n.t('userNotFound'), - httpCode: 404, - name: 'NotFound', - }); - - expect(stripe.customers.del).to.not.have.been.called; - expect(payments.cancelSubscription).to.not.have.been.called; - - stripe.events.retrieve.restore(); - }); - - it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { - const customerId = '456'; - - let leader = new User(); - await leader.save(); - - let subscriber = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: leader._id, - }); - subscriber.purchased.plan.customerId = customerId; - subscriber.purchased.plan.paymentMethod = 'Stripe'; - await subscriber.save(); - - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'group_monthly', - }, - customer: customerId, - }, - }, - request: null, - }); - - await stripePayments.handleWebhooks({requestBody: {}}, stripe); - - expect(stripe.customers.del).to.have.been.calledOnce; - expect(stripe.customers.del).to.have.been.calledWith(customerId); - expect(payments.cancelSubscription).to.have.been.calledOnce; - - let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; - expect(cancelSubscriptionOpts.user._id).to.equal(leader._id); - expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); - expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); - expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id); - - stripe.events.retrieve.restore(); - }); - }); - }); - }); -}); diff --git a/test/api/v3/unit/middlewares/ensureAccessRight.test.js b/test/api/v3/unit/middlewares/ensureAccessRight.test.js index f4cf909a956..4421b82a3e8 100644 --- a/test/api/v3/unit/middlewares/ensureAccessRight.test.js +++ b/test/api/v3/unit/middlewares/ensureAccessRight.test.js @@ -7,6 +7,7 @@ import { import i18n from '../../../../../website/common/script/i18n'; import { ensureAdmin, ensureSudo } from '../../../../../website/server/middlewares/ensureAccessRight'; import { NotAuthorized } from '../../../../../website/server/libs/errors'; +import apiMessages from '../../../../../website/server/libs/apiMessages'; describe('ensure access middlewares', () => { let res, req, next; @@ -42,7 +43,7 @@ describe('ensure access middlewares', () => { ensureSudo(req, res, next); - expect(next).to.be.calledWith(new NotAuthorized(i18n.t('noSudoAccess'))); + expect(next).to.be.calledWith(new NotAuthorized(apiMessages('noSudoAccess'))); }); it('passes when user is a sudo user', () => { diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index a2db74fcc20..db44574a8ba 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -391,6 +391,20 @@ describe('Group Model', () => { expect(party.quest.progress.collect.soapBars).to.eq(5); }); + it('does not drop an item if not need when on a collection quest', async () => { + party.quest.key = 'dilatoryDistress1'; + party.quest.active = false; + await party.startQuest(questLeader); + party.quest.progress.collect.fireCoral = 20; + await party.save(); + + await Group.processQuestProgress(participatingMember, progress); + + party = await Group.findOne({_id: party._id}); + + expect(party.quest.progress.collect.fireCoral).to.eq(20); + }); + it('sends a chat message about progress', async () => { await Group.processQuestProgress(participatingMember, progress); diff --git a/test/api/v3/unit/models/user.test.js b/test/api/v3/unit/models/user.test.js index 98abc11a4cf..6be822fc8da 100644 --- a/test/api/v3/unit/models/user.test.js +++ b/test/api/v3/unit/models/user.test.js @@ -323,4 +323,48 @@ describe('User Model', () => { expect(user.achievements.beastMaster).to.not.equal(true); }); }); + + context('days missed', () => { + // http://forbrains.co.uk/international_tools/earth_timezones + let user; + + beforeEach(() => { + user = new User(); + }); + + it('should not cron early when going back a timezone', () => { + const yesterday = moment('2017-12-05T00:00:00.000-06:00'); // 11 pm on 4 Texas + const timezoneOffset = moment().zone('-06:00').zone(); + user.lastCron = yesterday; + user.preferences.timezoneOffset = timezoneOffset; + + const today = moment('2017-12-06T00:00:00.000-06:00'); // 11 pm on 4 Texas + const req = {}; + req.header = () => { + return timezoneOffset + 60; + }; + + const {daysMissed} = user.daysUserHasMissed(today, req); + + expect(daysMissed).to.eql(0); + }); + + it('should not cron early when going back a timezone with a custom day start', () => { + const yesterday = moment('2017-12-05T02:00:00.000-08:00'); + const timezoneOffset = moment().zone('-08:00').zone(); + user.lastCron = yesterday; + user.preferences.timezoneOffset = timezoneOffset; + user.preferences.dayStart = 2; + + const today = moment('2017-12-06T02:00:00.000-08:00'); + const req = {}; + req.header = () => { + return timezoneOffset + 60; + }; + + const {daysMissed} = user.daysUserHasMissed(today, req); + + expect(daysMissed).to.eql(0); + }); + }); }); diff --git a/test/client/unit/specs/components/groups/membersModal.js b/test/client/unit/specs/components/groups/membersModal.js new file mode 100644 index 00000000000..79d57e70a31 --- /dev/null +++ b/test/client/unit/specs/components/groups/membersModal.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import MembersModalComponent from 'client/components/groups/membersModal.vue'; + +describe('Members Modal Component', () => { + describe('Party Sort', () => { + let CTor; + let vm; + + beforeEach(() => { + CTor = Vue.extend(MembersModalComponent); + vm = new CTor().$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should have an empty object as sort-option at start', () => { + const defaultData = vm.data(); + expect(defaultData.sortOption).to.eq({}); + }); + + it('should accept sort-option object', () => { + const sortOption = vm.data().sortOption[0]; + vm.sort(sortOption); + Vue.nextTick(() => { + expect(vm.data().sortOption).to.eq(sortOption); + }); + }); + }); +}); diff --git a/test/common/libs/shops.js b/test/common/libs/shops.js index f3b5dce7240..c04fad0bd67 100644 --- a/test/common/libs/shops.js +++ b/test/common/libs/shops.js @@ -34,6 +34,31 @@ describe('shops', () => { }); }); }); + + it('shows relevant non class gear in special category', () => { + let contributor = generateUser({ + contributor: { + level: 7, + critical: true, + }, + items: { + gear: { + owned: { + weapon_armoire_basicCrossbow: true, // eslint-disable-line camelcase + }, + }, + }, + }); + + let gearCategories = shared.shops.getMarketGearCategories(contributor); + let specialCategory = gearCategories.find(o => o.identifier === 'none'); + expect(specialCategory.items.find((item) => item.key === 'weapon_special_1')); + expect(specialCategory.items.find((item) => item.key === 'armor_special_1')); + expect(specialCategory.items.find((item) => item.key === 'head_special_1')); + expect(specialCategory.items.find((item) => item.key === 'shield_special_1')); + expect(specialCategory.items.find((item) => item.key === 'weapon_special_critical')); + expect(specialCategory.items.find((item) => item.key === 'weapon_armoire_basicCrossbow'));// eslint-disable-line camelcase + }); }); describe('questShop', () => { diff --git a/test/common/ops/addTask.js b/test/common/ops/addTask.js index d8301a4b351..9d5e1d7a8e2 100644 --- a/test/common/ops/addTask.js +++ b/test/common/ops/addTask.js @@ -38,7 +38,7 @@ describe('shared.ops.addTask', () => { expect(habit.counterDown).to.equal(0); }); - it('adds an habtit when type is invalid', () => { + it('adds a habit when type is invalid', () => { let habit = addTask(user, { body: { type: 'invalid', diff --git a/vagrant_scripts/install_node.sh b/vagrant_scripts/install_node.sh old mode 100644 new mode 100755 diff --git a/webpack/config/index.js b/webpack/config/index.js index 4a05a1d9dc0..ff54852be9d 100644 --- a/webpack/config/index.js +++ b/webpack/config/index.js @@ -3,6 +3,14 @@ const path = require('path'); const staticAssetsDirectory = './website/static/.'; // The folder where static files (not processed) live const prodEnv = require('./prod.env'); const devEnv = require('./dev.env'); +const nconf = require('nconf'); +const setupNconf = require('../../website/server/libs/setupNconf'); + +let configFile = path.join(path.resolve(__dirname, '../../config.json')); + +setupNconf(configFile); + +const DEV_BASE_URL = nconf.get('BASE_URL'); module.exports = { build: { @@ -33,25 +41,25 @@ module.exports = { assetsPublicPath: '/', staticAssetsDirectory, proxyTable: { - // proxy all requests starting with /api/v3 to localhost:3000 + // proxy all requests starting with /api/v3 to IP:PORT as specified in the top-level config '/api/v3': { - target: 'http://localhost:3000', + target: DEV_BASE_URL, changeOrigin: true, }, '/stripe': { - target: 'http://localhost:3000', + target: DEV_BASE_URL, changeOrigin: true, }, '/amazon': { - target: 'http://localhost:3000', + target: DEV_BASE_URL, changeOrigin: true, }, '/paypal': { - target: 'http://localhost:3000', + target: DEV_BASE_URL, changeOrigin: true, }, '/logout': { - target: 'http://localhost:3000', + target: DEV_BASE_URL, changeOrigin: true, }, }, diff --git a/webpack/config/prod.env.js b/webpack/config/prod.env.js index ea7ffdd0be3..ca5c502e1dc 100644 --- a/webpack/config/prod.env.js +++ b/webpack/config/prod.env.js @@ -1,17 +1,11 @@ const nconf = require('nconf'); const { join, resolve } = require('path'); +const setupNconf = require('../../website/server/libs/setupNconf'); const PATH_TO_CONFIG = join(resolve(__dirname, '../../config.json')); let configFile = PATH_TO_CONFIG; -nconf - .argv() - .env() - .file('user', configFile); - -nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production'); -nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development'); -nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test'); +setupNconf(configFile); // @TODO: Check if we can import from client. Items like admin emails can be imported // and that should be prefered @@ -40,7 +34,7 @@ let env = { }, }; -'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY GOOGLE_CLIENT_ID AMPLITUDE_KEY PUSHER:KEY PUSHER:ENABLED' +'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY GOOGLE_CLIENT_ID AMPLITUDE_KEY PUSHER:KEY PUSHER:ENABLED LOGGLY_CLIENT_TOKEN' .split(' ') .forEach(key => { env[key] = `"${nconf.get(key)}"`; diff --git a/webpack/webpack.base.conf.js b/webpack/webpack.base.conf.js index 8ad3c973e2d..773d3bbd80b 100644 --- a/webpack/webpack.base.conf.js +++ b/webpack/webpack.base.conf.js @@ -11,7 +11,7 @@ const IS_PROD = process.env.NODE_ENV === 'production'; const baseConfig = { entry: { - app: './website/client/main.js', + app: ['babel-polyfill', './website/client/main.js'], }, output: { path: config.build.assetsRoot, diff --git a/webpack/webpack.dev.conf.js b/webpack/webpack.dev.conf.js index 16e9d116c3d..75d6741d084 100644 --- a/webpack/webpack.dev.conf.js +++ b/webpack/webpack.dev.conf.js @@ -7,7 +7,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); // add hot-reload related code to entry chunks Object.keys(baseWebpackConfig.entry).forEach((name) => { - baseWebpackConfig.entry[name] = ['./webpack/dev-client'].concat(baseWebpackConfig.entry[name]); + baseWebpackConfig.entry[name] = baseWebpackConfig.entry[name].concat('./webpack/dev-client'); }); module.exports = merge(baseWebpackConfig, { diff --git a/website/client/app.vue b/website/client/app.vue index b81f340cd97..e37c6a8abb1 100644 --- a/website/client/app.vue +++ b/website/client/app.vue @@ -1,37 +1,72 @@ - diff --git a/website/client/components/challenges/challengeDetail.vue b/website/client/components/challenges/challengeDetail.vue index 392f0c3f164..01f07bcb6d1 100644 --- a/website/client/components/challenges/challengeDetail.vue +++ b/website/client/components/challenges/challengeDetail.vue @@ -4,10 +4,9 @@ leave-challenge-modal(:challengeId='challenge._id') close-challenge-modal(:members='members', :challengeId='challenge._id') challenge-member-progress-modal(:memberId='progressMemberId', :challengeId='challenge._id') - - .col-8.standard-page + .col-12.col-md-8.standard-page .row - .col-8 + .col-12.col-md-8 h1(v-markdown='challenge.name') div strong(v-once) {{$t('createdBy')}}: @@ -20,7 +19,7 @@ // span {{challenge.endDate}} .tags span.tag(v-for='tag in challenge.tags') {{tag}} - .col-4 + .col-12.col-md-4 .box(@click="showMemberModal()") .svg-icon.member-icon(v-html="icons.memberIcon") | {{challenge.memberCount}} @@ -30,13 +29,10 @@ | {{challenge.prize}} .details(v-once) {{$t('prize')}} .row.challenge-actions - .col-7.offset-5 + .col-12.col-md-7.offset-md-5 span.view-progress strong {{ $t('viewProgressOf') }} - b-dropdown.create-dropdown(text="Select a Participant") - input.form-control(type='text', v-model='searchTerm') - b-dropdown-item(v-for="member in memberResults", :key="member._id", @click="openMemberProgressModal(member._id)") - | {{ member.profile.name }} + member-search-dropdown(:text="$t('selectParticipant')", :members='members', :challengeId='challengeId', @member-selected='openMemberProgressModal') span(v-if='isLeader || isAdmin') b-dropdown.create-dropdown(:text="$t('addTaskToChallenge')", :variant="'success'") b-dropdown-item(v-for="type in columns", :key="type", @click="createTask(type)") @@ -51,7 +47,6 @@ v-on:taskEdited='taskEdited', @taskDestroyed='taskDestroyed' ) - .row task-column.col-12.col-sm-6( v-for="column in columns", @@ -60,7 +55,7 @@ :taskListOverride='tasksByType[column]', v-on:editTask="editTask", v-if='tasksByType[column].length > 0') - .col-4.sidebar.standard-page + .col-12.col-md-4.sidebar.standard-page .acitons div(v-if='canJoin') button.btn.btn-success(v-once, @click='joinChallenge()') {{$t('joinChallenge')}} @@ -185,6 +180,7 @@ import omit from 'lodash/omit'; import uuid from 'uuid'; import { mapState } from 'client/libs/store'; +import memberSearchDropdown from 'client/components/members/memberSearchDropdown'; import closeChallengeModal from './closeChallengeModal'; import Column from '../tasks/column'; import TaskModal from '../tasks/taskModal'; @@ -211,6 +207,7 @@ export default { leaveChallengeModal, challengeModal, challengeMemberProgressModal, + memberSearchDropdown, TaskColumn: Column, TaskModal, }, @@ -388,8 +385,8 @@ export default { updatedChallenge (eventData) { Object.assign(this.challenge, eventData.challenge); }, - openMemberProgressModal (memberId) { - this.progressMemberId = memberId; + openMemberProgressModal (member) { + this.progressMemberId = member._id; this.$root.$emit('bv::show::modal', 'challenge-member-modal'); }, async exportChallengeCsv () { diff --git a/website/client/components/challenges/challengeModal.vue b/website/client/components/challenges/challengeModal.vue index 872b59f808e..8f39632badd 100644 --- a/website/client/components/challenges/challengeModal.vue +++ b/website/client/components/challenges/challengeModal.vue @@ -1,5 +1,5 @@ @@ -123,7 +124,7 @@ } .category-box { - top: -120px !important; + top: 20em !important; z-index: 10; } } @@ -227,27 +228,11 @@ export default { showCategorySelect: false, categoryOptions, categoriesHashByKey, + loading: false, groups: [], }; }, - async mounted () { - this.groups = await this.$store.dispatch('guilds:getMyGuilds'); - if (this.user.party._id) { - let party = await this.$store.dispatch('guilds:getGroup', {groupId: 'party'}); - this.groups.push({ - name: party.name, - _id: party._id, - privacy: 'private', - }); - } - - this.groups.push({ - name: this.$t('publicChallengesTitle'), - _id: TAVERN_ID, - }); - - this.setUpWorkingChallenge(); - }, + async mounted () {}, watch: { user () { if (!this.challenge) this.workingChallenge.leader = this.user._id; @@ -313,6 +298,25 @@ export default { }, }, methods: { + async shown () { + this.groups = await this.$store.dispatch('guilds:getMyGuilds'); + await this.$store.dispatch('party:getParty'); + const party = this.$store.state.party.data; + if (party._id) { + this.groups.push({ + name: party.name, + _id: party._id, + privacy: 'private', + }); + } + + this.groups.push({ + name: this.$t('publicChallengesTitle'), + _id: TAVERN_ID, + }); + + this.setUpWorkingChallenge(); + }, setUpWorkingChallenge () { this.resetWorkingChallenge(); @@ -353,6 +357,7 @@ export default { this.$store.state.workingChallenge = {}; }, async createChallenge () { + this.loading = true; // @TODO: improve error handling, add it to updateChallenge, make errors translatable. Suggestion: `<% fieldName %> is required` where possible, where `fieldName` is inserted as the translatable string that's used for the field header. let errors = []; @@ -366,6 +371,7 @@ export default { if (errors.length > 0) { alert(errors.join('\n')); + this.loading = false; return; } diff --git a/website/client/components/challenges/closeChallengeModal.vue b/website/client/components/challenges/closeChallengeModal.vue index ea2d93e7568..736066ade72 100644 --- a/website/client/components/challenges/closeChallengeModal.vue +++ b/website/client/components/challenges/closeChallengeModal.vue @@ -10,10 +10,7 @@ div .col-12 strong(v-once) {{$t('selectChallengeWinnersDescription')}} .col-12 - b-dropdown.create-dropdown(:text="winnerText") - input.form-control(type='text', v-model='searchTerm') - b-dropdown-item(v-for="member in memberResults", :key="member._id", @click="selectMember(member)") - | {{ member.profile.name }} + member-search-dropdown(:text='winnerText', :members='members', :challengeId='challengeId', @member-selected='selectMember') .col-12 button.btn.btn-primary(v-once, @click='closeChallenge') {{$t('awardWinners')}} .col-12 @@ -74,16 +71,16 @@ div diff --git a/website/client/components/chat/chatMessages.vue b/website/client/components/chat/chatMessages.vue index da3c2c29449..ddd2480197e 100644 --- a/website/client/components/chat/chatMessages.vue +++ b/website/client/components/chat/chatMessages.vue @@ -2,9 +2,8 @@ .container .row .col-12 - copy-as-todo-modal(:copying-message='copyingMessage', :group-name='groupName', :group-id='groupId') + copy-as-todo-modal(:group-name='groupName', :group-id='groupId') report-flag-modal - div(v-for="(msg, index) in messages", v-if='chat && canViewFlag(msg)') // @TODO: is there a different way to do these conditionals? This creates an infinite loop //.hr(v-if='displayDivider(msg)') @@ -19,76 +18,22 @@ @click.native="showMemberModal(msg.uuid)", ) .card(:class='inbox ? "col-8" : "col-10"') - .mentioned-icon(v-if='isUserMentioned(msg)') - .message-hidden(v-if='msg.flagCount === 1 && user.contributor.admin') Message flagged once, not hidden - .message-hidden(v-if='msg.flagCount > 1 && user.contributor.admin') Message hidden - .card-body - h3.leader( - :class='userLevelStyle(cachedProfileData[msg.uuid])' - @click="showMemberModal(msg.uuid)", - ) - | {{msg.user}} - .svg-icon(v-html="icons[`tier${cachedProfileData[msg.uuid].contributor.level}`]", v-if='cachedProfileData[msg.uuid] && cachedProfileData[msg.uuid].contributor && cachedProfileData[msg.uuid].contributor.level') - p.time {{msg.timestamp | timeAgo}} - .text(v-markdown='msg.text') - hr - .action(@click='like(msg, index)', v-if='msg.likes', :class='{active: msg.likes[user._id]}') - .svg-icon(v-html="icons.like") - span(v-if='!msg.likes[user._id]') {{ $t('like') }} - span(v-if='msg.likes[user._id]') {{ $t('liked') }} - // @TODO make copyAsTodo work in Tavern, guilds, party (inbox can be done later) - span.action(v-if='!inbox', @click='copyAsTodo(msg)') - .svg-icon(v-html="icons.copy") - | {{$t('copyAsTodo')}} - // @TODO make copyAsTodo work in the inbox - span.action(v-if='!inbox && user.flags.communityGuidelinesAccepted', @click='report(msg)') - .svg-icon(v-html="icons.report") - | {{$t('report')}} - // @TODO make flagging/reporting work in the inbox. NOTE: it must work even if the communityGuidelines are not accepted and it MUST work for messages that you have SENT as well as received. -- Alys - span.action(v-if='msg.uuid === user._id || inbox || user.contributor.admin', @click='remove(msg, index)') - .svg-icon(v-html="icons.delete") - | {{$t('delete')}} - span.action.float-right.liked(v-if='likeCount(msg) > 0') - .svg-icon(v-html="icons.liked") - | + {{ likeCount(msg) }} - // @TODO can we avoid duplicating all this code? Cannot we just push everything - // to the right if the user is the author? - // Maybe we just create two sub components instead + chat-card( + :msg='msg', + :inbox='inbox', + :groupId='groupId', + @messaged-liked='messageLiked', + @message-removed='messageRemoved', + @show-member-modal='showMemberModal') .row(v-if='user._id === msg.uuid') .card(:class='inbox ? "col-8" : "col-10"') - .mentioned-icon(v-if='isUserMentioned(msg)') - .message-hidden(v-if='msg.flagCount === 1 && user.contributor.admin') Message flagged once, not hidden - .message-hidden(v-if='msg.flagCount > 1 && user.contributor.admin') Message hidden - .card-body - h3.leader( - :class='userLevelStyle(cachedProfileData[msg.uuid])', - @click="showMemberModal(msg.uuid)", - ) - | {{msg.user}} - .svg-icon(v-html="icons[`tier${cachedProfileData[msg.uuid].contributor.level}`]", v-if='cachedProfileData[msg.uuid] && cachedProfileData[msg.uuid].contributor && cachedProfileData[msg.uuid].contributor.level') - p.time {{msg.timestamp | timeAgo}} - .text(v-markdown='msg.text') - hr - .action(@click='like(msg, index)', v-if='msg.likes', :class='{active: msg.likes[user._id]}') - .svg-icon(v-html="icons.like") - span(v-if='!msg.likes[user._id]') {{ $t('like') }} - span(v-if='msg.likes[user._id]') {{ $t('liked') }} - // @TODO make copyAsTodo work in Tavern, guilds, party (inbox can be done later) - span.action(v-if='!inbox', @click='copyAsTodo(msg)') - .svg-icon(v-html="icons.copy") - | {{$t('copyAsTodo')}} - // @TODO make copyAsTodo work in the inbox - span.action(v-if='user.flags.communityGuidelinesAccepted', @click='report(msg)') - span.action(v-if='!inbox && user.flags.communityGuidelinesAccepted', @click='report(msg)') - .svg-icon(v-html="icons.report") - | {{$t('report')}} - // @TODO make flagging/reporting work in the inbox. NOTE: it must work even if the communityGuidelines are not accepted and it MUST work for messages that you have SENT as well as received. -- Alys - span.action(v-if='msg.uuid === user._id', @click='remove(msg, index)') - .svg-icon(v-html="icons.delete") - | {{$t('delete')}} - span.action.float-right.liked(v-if='likeCount(msg) > 0') - .svg-icon(v-html="icons.liked") - | + {{ likeCount(msg) }} + chat-card( + :msg='msg', + :inbox='inbox', + :groupId='groupId', + @messaged-liked='messageLiked', + @message-removed='messageRemoved', + @show-member-modal='showMemberModal') div(:class='inbox ? "col-4" : "col-2"') avatar( v-if='cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected', @@ -102,80 +47,6 @@ diff --git a/website/client/components/chat/copyAsTodoModal.vue b/website/client/components/chat/copyAsTodoModal.vue index 3cda6232c45..9aff58475f2 100644 --- a/website/client/components/chat/copyAsTodoModal.vue +++ b/website/client/components/chat/copyAsTodoModal.vue @@ -20,7 +20,7 @@ div(v-markdown='text', target='_blank') .modal-footer - button.btn.btn-default(@click='close()') {{ $t('close') }} + button.btn.btn-secondary(@click='close()') {{ $t('close') }} button.btn.btn-primary(@click='saveTodo()') {{ $t('submit') }} diff --git a/website/client/components/chat/reportFlagModal.vue b/website/client/components/chat/reportFlagModal.vue index 3c46fff59d0..78f45a1e944 100644 --- a/website/client/components/chat/reportFlagModal.vue +++ b/website/client/components/chat/reportFlagModal.vue @@ -33,12 +33,6 @@ export default { name: `${reportMessage}`, }; }, - abuseObject () { - return this.$store.state.flagChatOptions.message; - }, - groupId () { - return this.$store.state.flagChatOptions.groupId; - }, }, data () { let abuseFlagModalBody = { @@ -49,8 +43,21 @@ export default { return { abuseFlagModalBody, + abuseObject: '', + groupId: '', }; }, + created () { + this.$root.$on('habitica::report-chat', data => { + if (!data.message || !data.groupId) return; + this.abuseObject = data.message; + this.groupId = data.groupId; + this.$root.$emit('bv::show::modal', 'report-flag'); + }); + }, + destroyed () { + this.$root.$off('habitica::report-chat'); + }, methods: { close () { this.$root.$emit('bv::hide::modal', 'report-flag'); @@ -61,6 +68,7 @@ export default { groupId: this.groupId, chatId: this.abuseObject.id, }); + this.close(); }, async clearFlagCount () { diff --git a/website/client/components/creatorIntro.vue b/website/client/components/creatorIntro.vue index 0cf4b918cbc..a4a2ef4b783 100644 --- a/website/client/components/creatorIntro.vue +++ b/website/client/components/creatorIntro.vue @@ -33,7 +33,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true .svg-icon(v-html='icons.accessoriesIcon') strong(v-once) {{$t('extra')}} .menu-container.col-2(v-if='editing', :class='{active: activeTopPage === "backgrounds"}') - .menu-item(@click='changeTopPage("backgrounds", "2017")') + .menu-item(@click='changeTopPage("backgrounds", "2018")') .svg-icon(v-html='icons.backgroundsIcon') strong(v-once) {{$t('backgrounds')}} #body.section.customize-section(v-if='activeTopPage === "body"') @@ -177,6 +177,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true span 5 button.btn.btn-secondary.purchase-all(@click='unlock(`hair.beard.${baseHair5Keys.join(",hair.beard.")}`)') {{ $t('purchaseAll') }} .col-12.customize-options(v-if='editing') + .head_0.option(@click='set({"preferences.hair.mustache": 0})', :class="[{ active: user.preferences.hair.mustache === 0 }, 'hair_base_0_' + user.preferences.hair.color]") .option(v-for='option in baseHair6', :class='{active: option.active, locked: option.locked}') .base.sprite.customize-option(:class="`hair_mustache_${option.key}_${user.preferences.hair.color}`", @click='option.click') @@ -249,13 +250,15 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true .incentive-background(:class='[`background_${bg.key}`]') .small-rectangle .row.sub-menu.col-10.offset-1 + .col-3.text-center.sub-menu-item(@click='changeSubPage("2018")', :class='{active: activeSubPage === "2018"}') + strong(v-once) 2018 .col-3.text-center.sub-menu-item(@click='changeSubPage("2017")', :class='{active: activeSubPage === "2017"}') strong(v-once) 2017 - .col-3.text-center.sub-menu-item(@click='changeSubPage("2016")', :class='{active: activeSubPage === "2016"}') + .col-2.text-center.sub-menu-item(@click='changeSubPage("2016")', :class='{active: activeSubPage === "2016"}') strong(v-once) 2016 - .col-3.text-center.sub-menu-item(@click='changeSubPage("2015")', :class='{active: activeSubPage === "2015"}') + .col-2.text-center.sub-menu-item(@click='changeSubPage("2015")', :class='{active: activeSubPage === "2015"}') strong(v-once) 2015 - .col-3.text-center.sub-menu-item(@click='changeSubPage("2014")', :class='{active: activeSubPage === "2014"}') + .col-2.text-center.sub-menu-item(@click='changeSubPage("2014")', :class='{active: activeSubPage === "2014"}') strong(v-once) 2014 .row.customize-menu(v-for='(sets, key) in backgroundShopSetsByYear') .row(v-for='set in sets', v-if='activeSubPage === key') @@ -291,41 +294,34 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true .section.row .col-6 .task-option - label.custom-control.custom-checkbox - input.custom-control-input(type="checkbox", value='work', v-model='taskCategories') - span.custom-control-indicator - span.custom-control-description(v-once) {{ $t('work') }} + .custom-control.custom-checkbox + input.custom-control-input#work(type="checkbox", value='work', v-model='taskCategories') + label.custom-control-label(v-once, for="work") {{ $t('work') }} .task-option - label.custom-control.custom-checkbox - input.custom-control-input(type="checkbox", value='exercise', v-model='taskCategories') - span.custom-control-indicator - span.custom-control-description(v-once) {{ $t('exercise') }} + .custom-control.custom-checkbox + input.custom-control-input#excercise(type="checkbox", value='exercise', v-model='taskCategories') + label.custom-control-label(v-once, for="excercise") {{ $t('exercise') }} .task-option - label.custom-control.custom-checkbox - input.custom-control-input(type="checkbox", value='health_wellness', v-model='taskCategories') - span.custom-control-indicator - span.custom-control-description(v-once) {{ $t('health_wellness') }} + .custom-control.custom-checkbox + input.custom-control-input#health_wellness(type="checkbox", value='health_wellness', v-model='taskCategories') + label.custom-control-label(v-once, for="health_wellness") {{ $t('health_wellness') }} .task-option - label.custom-control.custom-checkbox - input.custom-control-input(type="checkbox", value='school', v-model='taskCategories') - span.custom-control-indicator - span.custom-control-description(v-once) {{ $t('school') }} + .custom-control.custom-checkbox + input.custom-control-input#school(type="checkbox", value='school', v-model='taskCategories') + label.custom-control-label(v-once, for="school") {{ $t('school') }} .col-6 .task-option - label.custom-control.custom-checkbox - input.custom-control-input(type="checkbox", value='chores', v-model='taskCategories') - span.custom-control-indicator - span.custom-control-description(v-once) {{ $t('chores') }} + .custom-control.custom-checkbox + input.custom-control-input#chores(type="checkbox", value='chores', v-model='taskCategories') + label.custom-control-label(v-once, for="chores") {{ $t('chores') }} .task-option - label.custom-control.custom-checkbox - input.custom-control-input(type="checkbox", value='creativity', v-model='taskCategories') - span.custom-control-indicator - span.custom-control-description(v-once) {{ $t('creativity') }} + .custom-control.custom-checkbox + input.custom-control-input#creativity(type="checkbox", value='creativity', v-model='taskCategories') + label.custom-control-label(v-once, for="creativity") {{ $t('creativity') }} .task-option - label.custom-control.custom-checkbox - input.custom-control-input(type="checkbox", value='self_care', v-model='taskCategories') - span.custom-control-indicator - span.custom-control-description(v-once) {{ $t('self_care') }} + .custom-control.custom-checkbox + input.custom-control-input#self_care(type="checkbox", value='self_care', v-model='taskCategories') + label.custom-control-label(v-once, for="self_care") {{ $t('self_care') }} .section.row.justin-message-section(:class='{top: modalPage > 1}', v-if='!editing') .col-12 @@ -538,12 +534,6 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true padding-bottom: 2em; } - .option.locked { - border-radius: 2px; - background-color: #ffffff; - box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12); - } - .option.hide { display: none !important; } @@ -554,8 +544,17 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true padding: .5em; height: 90px; width: 90px; - margin-bottom: .5em; - margin-right: .5em; + margin: 1em .5em .5em 0; + border: 4px solid $gray-700; + border-radius: 4px; + + &.locked { + border: none; + border-radius: 2px; + background-color: #ffffff; + box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12); + margin-top: 0; + } .sprite.customize-option { margin: 0 auto; @@ -587,9 +586,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true } .option.active { - border: 4px solid $purple-200; - border-radius: 4px; - margin-top: 1em; + border-color: $purple-200; } .option:hover { @@ -1023,7 +1020,8 @@ export default { option.key = key; option.active = this.user.preferences.costume ? this.user.items.gear.costume.eyewear === newKey : this.user.items.gear.equipped.eyewear === newKey; option.click = () => { - return this.equip(newKey); + let type = this.user.preferences.costume ? 'costume' : 'equipped'; + return this.equip(newKey, type); }; return option; }); @@ -1047,7 +1045,6 @@ export default { return own; }, animalEars () { - // @TODO: This is not like other purchase items // @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line let keys = this.animalEarsKeys; @@ -1061,7 +1058,8 @@ export default { option.active = this.user.preferences.costume ? this.user.items.gear.costume.headAccessory === newKey : this.user.items.gear.equipped.headAccessory === newKey; option.locked = locked; option.click = () => { - return locked ? this.purchase('gear', newKey) : this.equip(newKey); + let type = this.user.preferences.costume ? 'costume' : 'equipped'; + return locked ? this.unlock(`items.gear.owned.${newKey}`) : this.equip(newKey, type); }; return option; }); @@ -1225,6 +1223,7 @@ export default { 2015: [], 2016: [], 2017: [], + 2018: [], }; // Hack to force update for now until we restructure the data @@ -1306,9 +1305,8 @@ export default { set (settings) { this.$store.dispatch('user:set', settings); }, - equip (key) { - this.$store.dispatch('common:equip', {key, type: 'equipped'}); - this.user.items.gear.equipped[key] = !this.user.items.gear.equipped[key]; + equip (key, type) { + this.$store.dispatch('common:equip', {key, type}); }, async done () { this.loading = true; diff --git a/website/client/components/group-plans/taskInformation.vue b/website/client/components/group-plans/taskInformation.vue index d71374b7746..2535712b79a 100644 --- a/website/client/components/group-plans/taskInformation.vue +++ b/website/client/components/group-plans/taskInformation.vue @@ -3,7 +3,7 @@ .row.tasks-navigation .col-4 h1 Group's Tasks - // @TODO: Abstract to component? + // @TODO: Abstract to component! .col-4 .input-group input.form-control.input-search(type="text", :placeholder="$t('search')", v-model="searchText") @@ -18,20 +18,20 @@ .col-6(v-for="(tag, tagIndex) in tagsSnap") .inline-edit-input-group.tag-edit-item.input-group input.tag-edit-input.inline-edit-input.form-control(type="text", :value="tag.name") - span.input-group-btn(@click="removeTag(tagIndex)") + .input-group-append(@click="removeTag(tagIndex)") .svg-icon.destroy-icon(v-html="icons.destroy") .col-6 input.new-tag-item.edit-tag-item.inline-edit-input.form-control(type="text", :placeholder="$t('newTag')", @keydown.enter="addTag($event)", v-model="newTag") template(v-else) .col-6(v-for="(tag, tagIndex) in tagsType.tags") - label.custom-control.custom-checkbox + .custom-control.custom-checkbox input.custom-control-input( type="checkbox", :checked="isTagSelected(tag)", @change="toggleTag(tag)", + :id="`tag-${tagIndex}`", ) - span.custom-control-indicator - span.custom-control-description {{ tag.name }} + label.custom-control-label(:for="`tag-${tagIndex}`") {{ tag.name }} .filter-panel-footer.clearfix template(v-if="editingTags === true") @@ -44,7 +44,7 @@ .float-right a.mr-3.btn-filters-primary(@click="applyFilters()", v-once) {{ $t('applyFilters') }} a.btn-filters-secondary(@click="closeFilterPanel()", v-once) {{ $t('cancel') }} - span.input-group-btn + span.input-group-append button.btn.btn-secondary.filter-button( type="button", @click="toggleFilterPanel()", @@ -173,7 +173,7 @@ .tag-edit-input { border-bottom: 1px solid $gray-500 !important; - &:focus, &:focus ~ .input-group-btn { + &:focus, &:focus ~ .input-group-append { border-color: $purple-500 !important; } } @@ -188,7 +188,7 @@ background-image: url(~client/assets/svg/for-css/positive.svg); } - .tag-edit-item .input-group-btn { + .tag-edit-item .input-group-append { border-bottom: 1px solid $gray-500 !important; &:focus { @@ -196,7 +196,7 @@ } } - .custom-control-description { + .custom-control-label { margin-left: 10px; } diff --git a/website/client/components/groups/createPartyModal.vue b/website/client/components/groups/createPartyModal.vue index 5860a3441c3..74beb78f0c9 100644 --- a/website/client/components/groups/createPartyModal.vue +++ b/website/client/components/groups/createPartyModal.vue @@ -57,6 +57,7 @@ b-modal#create-party-modal(title="Empty", size='lg', hide-footer=true) .header-wrap { padding: 0; color: #4e4a57; + width: 100%; .quest_screen { background-image: url('~client/assets/images/quest_screen.png'); diff --git a/website/client/components/groups/discovery.vue b/website/client/components/groups/discovery.vue index 7a833d77673..602a330df28 100644 --- a/website/client/components/groups/discovery.vue +++ b/website/client/components/groups/discovery.vue @@ -137,6 +137,7 @@ export default { // Reset the page when filters are updated this.lastPageLoaded = 0; + this.hasLoadedAllGuilds = false; this.queryFilters.page = this.lastPageLoaded; this.queryFilters.categories = eventData.categories.join(','); @@ -173,10 +174,11 @@ export default { }, async fetchGuilds () { // We have the data cached - if (this.lastPageLoaded === 0 && this.guilds.length > 0) return; + if (this.lastPageLoaded === 0 && this.guilds.length > 0) { + this.lastPageLoaded += 1; + } this.loading = true; - this.queryFilters.page = this.lastPageLoaded; let guilds = await this.$store.dispatch('guilds:getPublicGuilds', this.queryFilters); if (guilds.length === 0) this.hasLoadedAllGuilds = true; diff --git a/website/client/components/groups/group.vue b/website/client/components/groups/group.vue index 357388dbe9a..3a51f7ee342 100644 --- a/website/client/components/groups/group.vue +++ b/website/client/components/groups/group.vue @@ -7,11 +7,11 @@ group-gems-modal .col-12.col-sm-8.standard-page .row - .col-6.title-details + .col-12.col-md-6.title-details h1 {{group.name}} strong.float-left(v-once) {{$t('groupLeader')}} span.leader.float-left(v-if='group.leader.profile', @click='showMemberProfile(group.leader)') : {{group.leader.profile.name}} - .col-6 + .col-12.col-md-6 .row.icon-row .col-4.offset-4(v-bind:class="{ 'offset-8': isParty }") .item-with-icon(@click="showMemberModal()") @@ -28,23 +28,19 @@ .row.chat-row .col-12 h3(v-once) {{ $t('chat') }} - .row.new-message-row - textarea(:placeholder="!isParty ? $t('chatPlaceholder') : $t('partyChatPlaceholder')", v-model='newMessage', @keydown='updateCarretPosition') + textarea(:placeholder="!isParty ? $t('chatPlaceholder') : $t('partyChatPlaceholder')", v-model='newMessage', @keydown='updateCarretPosition', @keyup.ctrl.enter='sendMessage()') autocomplete(:text='newMessage', v-on:select="selectedAutocomplete", :coords='coords', :chat='group.chat') - .row .col-6 button.btn.btn-secondary.float-left.fetch(v-once, @click='fetchRecentMessages()') {{ $t('fetchRecentMessages') }} button.btn.btn-secondary.float-left(v-once, @click='reverseChat()') {{ $t('reverseChat') }} .col-6 button.btn.btn-secondary.send-chat.float-right(v-once, @click='sendMessage()') {{ $t('send') }} - .row.community-guidelines(v-if='!communityGuidelinesAccepted') div.col-8(v-once, v-html="$t('communityGuidelinesIntro')") div.col-4 button.btn.btn-info(@click='acceptCommunityGuidelines()', v-once) {{ $t('acceptCommunityGuidelines') }} - .row .col-12.hr chat-message(:chat.sync='group.chat', :group-id='group._id', group-name='group.name') @@ -66,75 +62,8 @@ // @TODO: V2 button.btn.btn-primary(v-once, v-if='!isLeader') {{$t('messageGuildLeader')}} // Suggest making the button visible to the leader too - useful for them to test how the feature works or to send a note to themself. -- Alys .button-container // @TODO: V2 button.btn.btn-primary(v-once, v-if='isMember && !isParty') {{$t('donateGems')}} // Suggest removing the isMember restriction - it's okay if non-members donate to a public guild. Also probably allow it for parties if parties can buy imagery. -- Alys - .section-header(v-if='isParty') - .row - .col-10 - h3(v-once) {{ $t('questDetailsTitle') }} - .col-2 - .toggle-up(@click="sections.quest = !sections.quest", v-if="sections.quest") - .svg-icon(v-html="icons.upIcon") - .toggle-down(@click="sections.quest = !sections.quest", v-if="!sections.quest") - .svg-icon(v-html="icons.downIcon") - .section(v-if="sections.quest") - .row.no-quest-section(v-if='isParty && !onPendingQuest && !onActiveQuest') - .col-12.text-center - .svg-icon(v-html="icons.questIcon") - h4(v-once) {{ $t('youAreNotOnQuest') }} - p(v-once) {{ $t('questDescription') }} - button.btn.btn-secondary(v-once, @click="openStartQuestModal()") {{ $t('startAQuest') }} - .row.quest-active-section(v-if='isParty && onPendingQuest && !onActiveQuest') - .col-2 - .quest(:class='`inventory_quest_scroll_${questData.key}`') - .col-6.titles - strong {{ questData.text() }} - p {{acceptedCount}} / {{group.memberCount}} - .col-4 - button.btn.btn-secondary(@click="openQuestDetails()") {{ $t('details') }} - .row.quest-active-section.quest-invite(v-if='user.party.quest && user.party.quest.RSVPNeeded') - span {{ $t('wouldYouParticipate') }} - button.btn.btn-primary.accept(@click='questAccept(group._id)') {{$t('accept')}} - button.btn.btn-primary.reject(@click='questReject(group._id)') {{$t('reject')}} - .row.quest-active-section(v-if='isParty && !onPendingQuest && onActiveQuest') - .col-12.text-center - .quest-boss(:class="'quest_' + questData.key") - h3(v-once) {{ questData.text() }} - .quest-box - .collect-info(v-if='questData.collect') - .row(v-for='(value, key) in questData.collect') - .col-2 - div(:class="'quest_' + questData.key + '_' + key") - .col-10 - strong {{value.text()}} - .grey-progress-bar - .collect-progress-bar(:style="{width: (group.quest.progress.collect[key] / value.count) * 100 + '%'}") - strong {{group.quest.progress.collect[key]}} / {{value.count}} - .boss-info(v-if='questData.boss') - .row - .col-6 - h4.float-left(v-once) {{ questData.boss.name() }} - .col-6 - span.float-right(v-once) {{ $t('participantsTitle') }} - .row - .col-12 - .grey-progress-bar - .boss-health-bar(:style="{width: (group.quest.progress.hp / questData.boss.hp) * 100 + '%'}") - .row.boss-details - .col-6 - span.float-left - | {{parseFloat(group.quest.progress.hp).toFixed(2)}} / {{parseFloat(questData.boss.hp).toFixed(2)}} - .col-6 - // @TODO: Why do we not sync quset progress on the group doc? Each user could have different progress - span.float-right {{parseFloat(user.party.quest.progress.up).toFixed(1) || 0}} pending damage - .row.rage-bar-row(v-if='questData.boss.rage') - .col-12 - .grey-progress-bar - .boss-health-bar.rage-bar(:style="{width: (group.quest.progress.rage / questData.boss.rage.value) * 100 + '%'}") - .row.boss-details.rage-details(v-if='questData.boss.rage') - .col-6 - span.float-left {{ $t('rage') }} {{ parseFloat(group.quest.progress.rage).toFixed(2) }} / {{ questData.boss.rage.value }} - button.btn.btn-secondary(v-once, @click="questAbort()", v-if='canEditQuest') {{ $t('abort') }} - + quest-sidebar-section(@toggle='toggleQuestSection', :show='sections.quest', :group='group') .section-header(v-if='!isParty') .row .col-10 @@ -146,7 +75,6 @@ .svg-icon(v-html="icons.downIcon") .section(v-if="sections.summary") p(v-markdown='group.summary') - .section-header .row .col-10 @@ -158,7 +86,6 @@ .svg-icon(v-html="icons.downIcon") .section(v-if="sections.description") p(v-markdown='group.description') - .section-header.challenge .row .col-10.information-header @@ -184,6 +111,16 @@ diff --git a/website/client/components/groups/myGuilds.vue b/website/client/components/groups/myGuilds.vue index 68ca0edf150..a75aaf71b40 100644 --- a/website/client/components/groups/myGuilds.vue +++ b/website/client/components/groups/myGuilds.vue @@ -60,6 +60,12 @@ } } } + + @media only screen and (max-width: 768px) { + .no-guilds-wrapper { + width: 100% !important; + } + } diff --git a/website/client/components/groups/sidebar.vue b/website/client/components/groups/sidebar.vue index ee7a0428053..cd1a35ebf9a 100644 --- a/website/client/components/groups/sidebar.vue +++ b/website/client/components/groups/sidebar.vue @@ -1,5 +1,5 @@ diff --git a/website/client/components/header/menu.vue b/website/client/components/header/menu.vue index 2d0a7ee2f03..1fdca4a76a8 100644 --- a/website/client/components/header/menu.vue +++ b/website/client/components/header/menu.vue @@ -3,15 +3,14 @@ div inbox-modal creator-intro profile - b-navbar.navbar.navbar-inverse.fixed-top.navbar-expand-md(type="dark") + b-navbar.navbar.navbar-inverse.fixed-top.navbar-expand-lg(type="dark") .navbar-header .logo.svg-icon.d-none.d-xl-block(v-html="icons.logo") .svg-icon.gryphon.d-md-block.d-none.d-xl-none .svg-icon.gryphon.d-sm-block.d-lg-none.d-md-none - b-nav-toggle(target='nav_collapse') - b-collapse#nav_collapse.collapse.navbar-collapse(is-nav) - ul.navbar-nav.mr-auto + b-collapse#nav_collapse.collapse.navbar-collapse.justify-content-between.flex-wrap(is-nav) + .ul.navbar-nav router-link.nav-item(tag="li", :to="{name: 'tasks'}", exact) a.nav-link(v-once) {{ $t('tasks') }} router-link.nav-item.dropdown(tag="li", :to="{name: 'items'}", :class="{'active': $route.path.startsWith('/inventory')}") @@ -46,7 +45,7 @@ div .dropdown-menu router-link.dropdown-item(:to="{name: 'myChallenges'}") {{ $t('myChallenges') }} router-link.dropdown-item(:to="{name: 'findChallenges'}") {{ $t('findChallenges') }} - router-link.nav-item.dropdown(tag="li", to="/help", :class="{'active': $route.path.startsWith('/help')}", :to="{name: 'faq'}") + router-link.nav-item.dropdown(tag="li", :class="{'active': $route.path.startsWith('/help')}", :to="{name: 'faq'}") a.nav-link(v-once) {{ $t('help') }} .dropdown-menu router-link.dropdown-item(:to="{name: 'faq'}") {{ $t('faq') }} @@ -56,7 +55,7 @@ div a.dropdown-item(href="https://trello.com/c/odmhIqyW/440-read-first-table-of-contents", target='_blank') {{ $t('requestAF') }} a.dropdown-item(href="http://habitica.wikia.com/wiki/Contributing_to_Habitica", target='_blank') {{ $t('contributing') }} a.dropdown-item(href="http://habitica.wikia.com/wiki/Habitica_Wiki", target='_blank') {{ $t('wiki') }} - .d-flex.align-items-center + .user-menu.d-flex.align-items-center .item-with-icon(v-if="userHourglasses > 0") .svg-icon(v-html="icons.hourglasses", v-b-tooltip.hover.bottom="$t('mysticHourglassesTooltip')") span {{ userHourglasses }} @@ -76,17 +75,17 @@ div @import '~client/assets/scss/colors.scss'; @import '~client/assets/scss/utils.scss'; - @media only screen and (max-width: 1280px) { + @media only screen and (max-width: 1305px) { .nav-link { padding: .8em 1em !important; } - } - @media only screen and (max-width: 1200px) { .navbar-header { - margin-right: 0px; + margin-right: 5px !important; } + } + @media only screen and (max-width: 1200px) { .gryphon { background-image: url('~assets/images/melior@3x.png'); width: 30px; @@ -98,18 +97,45 @@ div } .svg-icon.gryphon.d-sm-block { - position: absolute; left: calc(50% - 30px); top: 1em; } + + .nav-item .nav-link { + font-size: 14px !important; + padding: 16px 12px !important; + } } @media only screen and (max-width: 990px) { #nav_collapse { margin-top: 0.6em; - background-color: $purple-200; + flex-direction: row !important; + max-height: 650px; + overflow: auto; } + + .navbar-nav { + width: 100%; + background: $purple-100; + } + + .user-menu { + flex-direction: column !important; + align-items: left !important; + background: $purple-100; + width: 100%; + + .item-with-icon { + width: 100%; + padding-bottom: 1em; + } + } + } + + #nav_collapse { + display: flex; } nav.navbar { @@ -135,7 +161,7 @@ div color: $white !important; font-weight: bold; line-height: 1.5; - padding: 16px 24px; + padding: 16px 20px; transition: none; } diff --git a/website/client/components/header/notificationsDropdown.vue b/website/client/components/header/notificationsDropdown.vue index 4bbd1ae3132..2b29937a91e 100644 --- a/website/client/components/header/notificationsDropdown.vue +++ b/website/client/components/header/notificationsDropdown.vue @@ -15,7 +15,7 @@ menu-dropdown.item-notifications(:right="true") a.dropdown-item(v-if='user.purchased.plan.mysteryItems.length', @click='go("/inventory/items")') span.glyphicon.glyphicon-gift span {{ $t('newSubscriberItem') }} - a.dropdown-item(v-for='(party, index) in user.invitations.parties') + a.dropdown-item(v-for='(party, index) in user.invitations.parties', :key='party.id') div span.glyphicon.glyphicon-user span {{ $t('invitedTo', {name: party.name}) }} @@ -26,7 +26,7 @@ menu-dropdown.item-notifications(:right="true") span.glyphicon.glyphicon-envelope span {{ $t('cardReceived') }} a.dropdown-item(@click.stop='clearCards()') - a.dropdown-item(v-for='(guild, index) in user.invitations.guilds') + a.dropdown-item(v-for='(guild, index) in user.invitations.guilds', :key='guild.id') div span.glyphicon.glyphicon-user span {{ $t('invitedTo', {name: guild.name}) }} @@ -34,10 +34,10 @@ menu-dropdown.item-notifications(:right="true") button.btn.btn-primary(@click.stop='accept(guild, index, "guild")') Accept button.btn.btn-primary(@click.stop='reject(guild, index, "guild")') Reject a.dropdown-item(v-if='user.flags.classSelected && !user.preferences.disableClasses && user.stats.points', - @click='go("/user/profile")') + @click='showProfile()') span.glyphicon.glyphicon-plus-sign span {{ $t('haveUnallocated', {points: user.stats.points}) }} - a.dropdown-item(v-for='message in userNewMessages') + a.dropdown-item(v-for='message in userNewMessages', :key='message.key') span(@click='navigateToGroup(message.key)') span.glyphicon.glyphicon-comment span {{message.name}} @@ -279,6 +279,12 @@ export default { let quest = await this.$store.dispatch('quests:sendAction', {groupId: partyId, action: 'quests/reject'}); this.user.party.quest = quest; }, + showProfile () { + this.$root.$emit('habitica:show-profile', { + user: this.user, + startingPage: 'stats', + }); + }, }, }; diff --git a/website/client/components/header/userDropdown.vue b/website/client/components/header/userDropdown.vue index 60ead51a3a0..be1d7567562 100644 --- a/website/client/components/header/userDropdown.vue +++ b/website/client/components/header/userDropdown.vue @@ -11,7 +11,7 @@ menu-dropdown.item-user(:right="true") a.nav-link.dropdown-item.dropdown-separated(@click.prevent='showInbox()') | {{ $t('messages') }} message-count(v-if='user.inbox.newMessages > 0', :count="user.inbox.newMessages") - a.dropdown-item(@click='showAvatar("backgrounds", "2017")') {{ $t('backgrounds') }} + a.dropdown-item(@click='showAvatar("backgrounds", "2018")') {{ $t('backgrounds') }} a.dropdown-item(@click='showProfile("stats")') {{ $t('stats') }} a.dropdown-item(@click='showProfile("achievements")') {{ $t('achievements') }} a.dropdown-item.dropdown-separated(@click='showProfile("profile")') {{ $t('profile') }} @@ -98,9 +98,10 @@ export default { this.$root.$emit('bv::show::modal', 'inbox-modal'); }, showProfile (startingPage) { - this.$store.state.profileUser = this.user; - this.$store.state.profileOptions.startingPage = startingPage; - this.$root.$emit('bv::show::modal', 'profile'); + this.$root.$emit('habitica:show-profile', { + user: this.user, + startingPage, + }); }, showBuyGemsModal (startingPage) { this.$store.state.gemModalOptions.startingPage = startingPage; diff --git a/website/client/components/inventory/equipment/equipGearModal.vue b/website/client/components/inventory/equipment/equipGearModal.vue index 213c63683c3..d54390f70a6 100644 --- a/website/client/components/inventory/equipment/equipGearModal.vue +++ b/website/client/components/inventory/equipment/equipGearModal.vue @@ -17,6 +17,7 @@ :withBackground="true", :overrideAvatarGear="memberOverrideAvatarGear(item)", :spritesMargin='"0px auto auto -1px"', + :showVisualBuffs="false", ) h4.title {{ itemText }} diff --git a/website/client/components/inventory/equipment/index.vue b/website/client/components/inventory/equipment/index.vue index 41cf7480699..301de48008a 100644 --- a/website/client/components/inventory/equipment/index.vue +++ b/website/client/components/inventory/equipment/index.vue @@ -1,6 +1,6 @@ @@ -242,6 +257,8 @@ export default { groups, sortBy: 'quantity', // or 'AZ' + currentDraggingEgg: null, + eggClickMode: false, currentDraggingPotion: null, potionClickMode: false, cardOptions: { @@ -252,7 +269,7 @@ export default { }, watch: { searchText: throttle(function throttleSearch () { - this.searchTextThrottled = this.searchText; + this.searchTextThrottled = this.searchText.toLowerCase(); }, 250), }, computed: { @@ -343,6 +360,7 @@ export default { this.currentDraggingPotion = null; }, onDragStart ($event, potion) { + // Dragging needs to be added for egg items this.currentDraggingPotion = potion; let itemRef = this.$refs.draggingPotionInfo; @@ -351,19 +369,19 @@ export default { dragEvent.dataTransfer.setDragImage(itemRef, -20, -20); }, - isHatchable (potion, eggKey) { - if (potion === null) + isHatchable (potion, egg) { + if (potion === null || egg === null) return false; - let petKey = `${eggKey}-${potion.key}`; + let petKey = `${egg.key}-${potion.key}`; if (!this.content.petInfo[petKey]) return false; - return !this.userHasPet(potion.key, eggKey); + return !this.userHasPet(potion.key, egg.key); }, onDragOver ($event, egg) { - if (this.isHatchable(this.currentDraggingPotion, egg.key)) { + if (this.isHatchable(this.currentDraggingPotion, egg)) { $event.dropable = false; } }, @@ -373,18 +391,38 @@ export default { onDragLeave () { }, onEggClicked ($event, egg) { - if (this.currentDraggingPotion === null) { + if (this.currentDraggingPotion !== null) { + if (this.isHatchable(this.currentDraggingPotion, egg)) { + this.hatchPet(this.currentDraggingPotion, egg); + } + + this.currentDraggingPotion = null; + this.potionClickMode = false; return; } - if (this.isHatchable(this.currentDraggingPotion, egg.key)) { - this.hatchPet(this.currentDraggingPotion, egg); - } + if (this.currentDraggingEgg === null || this.currentDraggingEgg !== egg) { + this.currentDraggingEgg = egg; + this.eggClickMode = true; - this.currentDraggingPotion = null; - this.potionClickMode = false; + this.$nextTick(() => { + this.mouseMoved(lastMouseMoveEvent); + }); + } else { + this.currentDraggingEgg = null; + this.eggClickMode = false; + } }, onPotionClicked ($event, potion) { + if (this.currentDraggingEgg !== null) { + if (this.isHatchable(potion, this.currentDraggingEgg)) { + this.hatchPet(potion, this.currentDraggingEgg); + } + + this.currentDraggingEgg = null; + this.eggClickMode = false; + return; + } if (this.currentDraggingPotion === null || this.currentDraggingPotion !== potion) { this.currentDraggingPotion = potion; this.potionClickMode = true; @@ -437,6 +475,10 @@ export default { // dragging potioninfo is 180px wide (90 would be centered) this.$refs.clickPotionInfo.style.left = `${$event.x - 70}px`; this.$refs.clickPotionInfo.style.top = `${$event.y}px`; + } else if (this.eggClickMode) { + // dragging eggInfo is 180px wide (90 would be centered) + this.$refs.clickEggInfo.style.left = `${$event.x - 70}px`; + this.$refs.clickEggInfo.style.top = `${$event.y}px`; } else { lastMouseMoveEvent = $event; } diff --git a/website/client/components/inventory/stable/index.vue b/website/client/components/inventory/stable/index.vue index 191995b0ebe..eaf9132f2a7 100644 --- a/website/client/components/inventory/stable/index.vue +++ b/website/client/components/inventory/stable/index.vue @@ -1,7 +1,7 @@