diff --git a/common/script/content/appearance.js b/common/script/content/appearance.js new file mode 100644 index 0000000000..659de8be95 --- /dev/null +++ b/common/script/content/appearance.js @@ -0,0 +1,62 @@ +export let defaultAppearancePreferences = { + background: {}, + hair: { + bangs: { + 0: true, + 1: true, + 2: true, + 3: true, + }, + base: { + 0: true, + 1: true, + 3: true, + }, + beard: { + 0: true, + }, + color: { + white: true, + brown: true, + blond: true, + red: true, + black: true, + }, + flower: { + 0: true, + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + 6: true, + }, + mustache: { + 0: true, + }, + }, + shirt: { + black: true, + blue: true, + green: true, + pink: true, + white: true, + yellow: true, + }, + size: { + slim: true, + broad: true, + }, + skin: { + /* eslint-disable quote-props */ + 'ddc994': true, + 'f5a76e': true, + 'ea8349': true, + 'c06534': true, + '98461a': true, + '915533': true, + 'c3e1dc': true, + '6bd049': true, + /* eslint-enable quote-props */ + }, +}; diff --git a/common/script/content/index.js b/common/script/content/index.js index d9a574be3f..c81656e306 100644 --- a/common/script/content/index.js +++ b/common/script/content/index.js @@ -19,6 +19,7 @@ import { import mysterySets from './mystery-sets'; import gear from './gear'; +import { defaultAppearancePreferences } from './appearance'; api.mystery = mysterySets; @@ -3010,6 +3011,8 @@ api.questsByLevel = _.sortBy(api.quests, function(quest) { return quest.lvl || 0; }); +api.defaultAppearancePreferences = defaultAppearancePreferences; + api.backgrounds = { backgrounds062014: { beach: { diff --git a/test/api/v2/user/PUT-user.test.js b/test/api/v2/user/PUT-user.test.js index 2e50a37650..68664e6ed3 100644 --- a/test/api/v2/user/PUT-user.test.js +++ b/test/api/v2/user/PUT-user.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../helpers/api-integration.helper'; -import { each } from 'lodash'; +import { each, get } from 'lodash'; describe('PUT /user', () => { let user; @@ -12,7 +12,7 @@ describe('PUT /user', () => { user = await generateUser(); }); - context('allowed operations', () => { + context('Allowed Operations', () => { it('updates the user', async () => { let updatedUser = await user.put('/user', { 'profile.name': 'Frodo', @@ -26,7 +26,7 @@ describe('PUT /user', () => { }); }); - context('top level protected operations', () => { + context('Top Level Protected Operations', () => { let protectedOperations = { 'gem balance': {balance: 100}, auth: {'auth.blocked': true, 'auth.timestamps.created': new Date()}, @@ -52,7 +52,7 @@ describe('PUT /user', () => { }); }); - context('sub-level protected operations', () => { + context('Sub-Level Protected Operations', () => { let protectedOperations = { 'class stat': {'stats.class': 'wizard'}, }; @@ -71,4 +71,106 @@ describe('PUT /user', () => { }); }); }); + + context('Default Appearance Preferences', () => { + let testCases = { + shirt: 'yellow', + skin: 'ddc994', + 'hair.color': 'blond', + 'hair.bangs': 2, + 'hair.base': 1, + 'hair.flower': 4, + size: 'broad', + }; + + each(testCases, (item, type) => { + const update = {}; + update[`preferences.${type}`] = item; + + it(`updates user with ${type} that is a default`, async () => { + let dbUpdate = {}; + dbUpdate[`purchased.${type}.${item}`] = true; + await user.update(dbUpdate); + + // Sanity checks to make sure user is not already equipped with item + expect(get(user.preferences, type)).to.not.eql(item); + + let updatedUser = await user.put('/user', update); + + expect(get(updatedUser.preferences, type)).to.eql(item); + }); + }); + + it('returns an error if user tries to update body size with invalid type', async () => { + await expect(user.put('/user', { + 'preferences.size': 'round', + })).to.eventually.be.rejected.and.eql({ + code: 401, + text: ['Must purchase round to set it on preferences.size'], + }); + }); + + it('can set beard to default', async () => { + await user.update({ + 'purchased.hair.beard': 3, + 'preferences.hair.beard': 3, + }); + + let updatedUser = await user.put('/user', { + 'preferences.hair.beard': 0, + }); + + expect(updatedUser.preferences.hair.beard).to.eql(0); + }); + + it('can set mustache to default', async () => { + await user.update({ + 'purchased.hair.mustache': 2, + 'preferences.hair.mustache': 2, + }); + + let updatedUser = await user.put('/user', { + 'preferences.hair.mustache': 0, + }); + + expect(updatedUser.preferences.hair.mustache).to.eql(0); + }); + }); + + context('Purchasable Appearance Preferences', () => { + let testCases = { + background: 'volcano', + shirt: 'convict', + skin: 'cactus', + 'hair.base': 7, + 'hair.beard': 2, + 'hair.color': 'rainbow', + 'hair.mustache': 2, + }; + + each(testCases, (item, type) => { + const update = {}; + update[`preferences.${type}`] = item; + + it(`returns an error if user tries to update ${type} with ${type} the user does not own`, async () => { + await expect(user.put('/user', update)).to.eventually.be.rejected.and.eql({ + code: 401, + text: [`Must purchase ${item} to set it on preferences.${type}`], + }); + }); + + it(`updates user with ${type} user does own`, async () => { + let dbUpdate = {}; + dbUpdate[`purchased.${type}.${item}`] = true; + await user.update(dbUpdate); + + // Sanity check to make sure user is not already equipped with item + expect(get(user.preferences, type)).to.not.eql(item); + + let updatedUser = await user.put('/user', update); + + expect(get(updatedUser.preferences, type)).to.eql(item); + }); + }); + }); }); diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 5e40f8162f..d472d14df8 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -11,7 +11,9 @@ var Group = require('./../../models/group').model; var Challenge = require('./../../models/challenge').model; var moment = require('moment'); var logging = require('./../../libs/logging'); -var acceptablePUTPaths; +let acceptablePUTPaths; +let restrictedPUTSubPaths; + var api = module.exports; var qs = require('qs'); var firebase = require('../../libs/firebase'); @@ -284,17 +286,44 @@ api.getUserAnonymized = function(req, res, next) { * The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations */ -acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, function(m,v,leaf){ - var found= _.find('achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '), function(root){ - return leaf.indexOf(root) == 0; +acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (m, v, leaf) => { + let updatablePaths = 'achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '); + let found = _.find(updatablePaths, (rootPath) => { + return leaf.indexOf(rootPath) === 0; }); - if (found) m[leaf]=true; - return m; -}, {}) -_.each('stats.class'.split(' '), function(removePath){ + if (found) m[leaf] = true; + + return m; +}, {}); + +restrictedPUTSubPaths = 'stats.class'.split(' '); + +_.each(restrictedPUTSubPaths, (removePath) => { delete acceptablePUTPaths[removePath]; -}) +}); + +let requiresPurchase = { + 'preferences.background': 'background', + 'preferences.shirt': 'shirt', + 'preferences.size': 'size', + 'preferences.skin': 'skin', + 'preferences.hair.bangs': 'hair.bangs', + 'preferences.hair.base': 'hair.base', + 'preferences.hair.beard': 'hair.beard', + 'preferences.hair.color': 'hair.color', + 'preferences.hair.flower': 'hair.flower', + 'preferences.hair.mustache': 'hair.mustache', +}; + +let checkPreferencePurchase = (user, path, item) => { + let itemPath = `${path}.${item}`; + let isDefaultPreference = _.get(shared.content.defaultAppearancePreferences, itemPath); + + if (isDefaultPreference) return true; + + return _.get(user.purchased, itemPath); +}; /** * Update user @@ -302,21 +331,31 @@ _.each('stats.class'.split(' '), function(removePath){ * PUT /user {'stats.hp':50, 'tasks.TASK_ID.repeat.m':false} * See acceptablePUTPaths for which user paths are supported */ -api.update = function(req, res, next) { - var user = res.locals.user; - var errors = []; +api.update = (req, res, next) => { + let user = res.locals.user; + let errors = []; + if (_.isEmpty(req.body)) return res.json(200, user); - _.each(req.body, function(v, k) { - if (acceptablePUTPaths[k]) + _.each(req.body, (v, k) => { + let purchasable = requiresPurchase[k]; + + if (purchasable && !checkPreferencePurchase(user, purchasable, v)) { + return errors.push(`Must purchase ${v} to set it on ${k}`); + } + + if (acceptablePUTPaths[k]) { user.fns.dotSet(k, v); - else + } else { errors.push(shared.i18n.t('messageUserOperationProtected', { operation: k })); + } return true; }); - user.save(function(err) { + + user.save((err) => { if (!_.isEmpty(errors)) return res.json(401, {err: errors}); if (err) return next(err); + res.json(200, user); user = errors = null; });