diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index a4fd69f0a3..7c2f5c1001 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -154,5 +154,8 @@ "mountsReleased": "Mounts released", "typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>", "userItemsKeyNotFound": "Key not found for user.items <%= type %>", - "sold": "You sold a <%= key %> <%= type %>" + "sold": "You sold a <%= key %> <%= type %>", + "pathRequired": "Path string is required", + "unlocked": "Items have been unlocked", + "alreadyUnlocked": "Item already unlocked" } diff --git a/common/script/index.js b/common/script/index.js index c55ac24c9f..3b4c5769cd 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -122,6 +122,7 @@ import releasePets from './ops/releasePets'; import releaseBoth from './ops/releaseBoth'; import releaseMounts from './ops/releaseMounts'; import sell from './ops/sell'; +import unlock from './ops/unlock'; api.ops = { scoreTask, @@ -145,6 +146,7 @@ api.ops = { releaseBoth, releaseMounts, sell, + unlock, }; import handleTwoHanded from './fns/handleTwoHanded'; diff --git a/common/script/ops/unlock.js b/common/script/ops/unlock.js index c53a5997ec..bbd3cb0e39 100644 --- a/common/script/ops/unlock.js +++ b/common/script/ops/unlock.js @@ -1,63 +1,85 @@ import i18n from '../i18n'; import _ from 'lodash'; import splitWhitespace from '../libs/splitWhitespace'; +import dotSet from '../libs/dotSet'; +import { + NotAuthorized, + BadRequest, +} from '../libs/errors'; -module.exports = function(user, req, cb, analytics) { - var alreadyOwns, analyticsData, cost, fullSet, k, path, split, v; - path = req.query.path; - fullSet = ~path.indexOf(","); - cost = ~path.indexOf('background.') ? fullSet ? 3.75 : 1.75 : fullSet ? 1.25 : 0.5; - alreadyOwns = !fullSet && user.fns.dotGet("purchased." + path) === true; - if ((user.balance < cost || !user.balance) && !alreadyOwns) { - return typeof cb === "function" ? cb({ - code: 401, - message: i18n.t('notEnoughGems', req.language) - }) : void 0; +module.exports = function unlock (user, req = {}, analytics) { + let path = _.get(req.query, 'path'); + + if (!path) { + throw new BadRequest(i18n.t('pathRequired', req.language)); } - if (fullSet) { - _.each(path.split(","), function(p) { - if (~path.indexOf('gear.')) { - user.fns.dotSet("" + p, true); - true; - } else { + let isFullSet = path.indexOf(',') !== -1; + let cost; + let isBackground = path.indexOf('background.') !== -1; + + if (isBackground && isFullSet) { + cost = 3.75; + } else if (isBackground) { + cost = 1.75; + } else if (isFullSet) { + cost = 1.25; + } else { + cost = 0.5; + } + + let alreadyOwns = !isFullSet && user.fns.dotGet(`purchased.${path}`) === true; + + if ((!user.balance || user.balance < cost) && !alreadyOwns) { + throw new NotAuthorized(i18n.t('notEnoughGems', req.language)); + } + + if (isFullSet) { + _.each(path.split(','), function markItemsAsPurchased (pathPart) { + if (path.indexOf('gear.') !== -1) { + dotSet(user, pathPart, true); + return true; } - user.fns.dotSet("purchased." + p, true); + + dotSet(user, `purchased.${pathPart}`, true); return true; }); } else { if (alreadyOwns) { - split = path.split('.'); - v = split.pop(); - k = split.join('.'); - if (k === 'background' && v === user.preferences.background) { - v = ''; + let split = path.split('.'); + let value = split.pop(); + let key = split.join('.'); + if (key === 'background' && value === user.preferences.background) { + value = ''; } - user.fns.dotSet("preferences." + k, v); - return typeof cb === "function" ? cb(null, req) : void 0; + dotSet(user, `preferences.${key}`, value); + + throw new NotAuthorized(i18n.t('alreadyUnlocked', req.language)); } - user.fns.dotSet("purchased." + path, true); + dotSet(user, `purchased.${path}`, true); } + + if (path.indexOf('gear.') === -1) { + user.markModified('purchased'); + } + user.balance -= cost; - if (~path.indexOf('gear.')) { - if (typeof user.markModified === "function") { - user.markModified('gear.owned'); - } - } else { - if (typeof user.markModified === "function") { - user.markModified('purchased'); - } + + if (analytics) { + analytics.track('acquire item', { + uuid: user._id, + itemKey: path, + itemType: 'customization', + acquireMethod: 'Gems', + gemCost: cost / 0.25, + category: 'behavior', + }); } - analyticsData = { - uuid: user._id, - itemKey: path, - itemType: 'customization', - acquireMethod: 'Gems', - gemCost: cost / .25, - category: 'behavior' + + let response = { + data: _.pick(user, splitWhitespace('purchased preferences items')), + message: i18n.t('unlocked'), }; - if (analytics != null) { - analytics.track('acquire item', analyticsData); - } - return typeof cb === "function" ? cb(null, _.pick(user, splitWhitespace('purchased preferences items'))) : void 0; + + return response; }; diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js index 6ed2b567a9..4376bd07f8 100644 --- a/tasks/gulp-eslint.js +++ b/tasks/gulp-eslint.js @@ -32,7 +32,6 @@ const COMMON_FILES = [ '!./common/script/ops/revive.js', '!./common/script/ops/sortTag.js', '!./common/script/ops/sortTask.js', - '!./common/script/ops/unlock.js', '!./common/script/ops/update.js', '!./common/script/ops/updateTag.js', '!./common/script/ops/updateTask.js', diff --git a/test/api/v3/integration/user/POST-user_unlock.js b/test/api/v3/integration/user/POST-user_unlock.js new file mode 100644 index 0000000000..6dbdb3c1b1 --- /dev/null +++ b/test/api/v3/integration/user/POST-user_unlock.js @@ -0,0 +1,37 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /user/unlock', () => { + let user; + let unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; + let unlockCost = 1.25; + let usersStartingGems = 5; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('returns an error when user balance is too low', async () => { + await expect(user.post(`/user/unlock?path=${unlockPath}`)) + .to.eventually.be.rejected.and.to.eql({ + code: 401, + error: 'NotAuthorized', + message: t('notEnoughGems'), + }); + }); + + // More tests in common code unit tests + + it('reduces a user\'s balance', async () => { + await user.update({ + balance: usersStartingGems, + }); + let response = await user.post(`/user/unlock?path=${unlockPath}`); + await user.sync(); + + expect(response.message).to.equal(t('unlocked')); + expect(user.balance).to.equal(usersStartingGems - unlockCost); + }); +}); diff --git a/test/common/ops/unlock.js b/test/common/ops/unlock.js new file mode 100644 index 0000000000..b7d7bda370 --- /dev/null +++ b/test/common/ops/unlock.js @@ -0,0 +1,84 @@ +import unlock from '../../../common/script/ops/unlock'; +import i18n from '../../../common/script/i18n'; +import { + generateUser, +} from '../../helpers/common.helper'; +import { + NotAuthorized, + BadRequest, +} from '../../../common/script/libs/errors'; + +describe('shared.ops.unlock', () => { + let user; + let unlockPath = 'shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie'; + let unlockGearSetPath = 'items.gear.owned.headAccessory_special_bearEars,items.gear.owned.headAccessory_special_cactusEars,items.gear.owned.headAccessory_special_foxEars,items.gear.owned.headAccessory_special_lionEars,items.gear.owned.headAccessory_special_pandaEars,items.gear.owned.headAccessory_special_pigEars,items.gear.owned.headAccessory_special_tigerEars,items.gear.owned.headAccessory_special_wolfEars'; + let backgroundUnlockPath = 'background.giant_florals'; + let unlockCost = 1.25; + let usersStartingGems = 5; + + beforeEach(() => { + user = generateUser(); + user.balance = usersStartingGems; + }); + + it('returns an error when path is not provided', (done) => { + try { + unlock(user); + } catch (err) { + expect(err).to.be.an.instanceof(BadRequest); + expect(err.message).to.equal(i18n.t('pathRequired')); + done(); + } + }); + + it('returns an error when user balance is too low', (done) => { + user.balance = 0; + + try { + unlock(user, {query: {path: unlockPath}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('returns an error when user already owns an item', (done) => { + try { + unlock(user, {query: {path: backgroundUnlockPath}}); + unlock(user, {query: {path: backgroundUnlockPath}}); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('alreadyUnlocked')); + done(); + } + }); + + it('unlocks a full set', () => { + let response = unlock(user, {query: {path: unlockPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.purchased.shirt.convict).to.be.true; + }); + + it('unlocks a full set of gear', () => { + let response = unlock(user, {query: {path: unlockGearSetPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.items.gear.owned.headAccessory_special_wolfEars).to.be.true; + }); + + it('unlocks a an item', () => { + let response = unlock(user, {query: {path: backgroundUnlockPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.purchased.background.giant_florals).to.be.true; + }); + + it('reduces a user\'s balance', () => { + let response = unlock(user, {query: {path: unlockPath}}); + + expect(response.message).to.equal(i18n.t('unlocked')); + expect(user.balance).to.equal(usersStartingGems - unlockCost); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index c60b2a3eef..bfa4ca8dff 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -848,4 +848,24 @@ api.userSell = { }, }; +/* +* @api {post} /user/unlock Unlocks items by purchase. +* @apiVersion 3.0.0 +* @apiName UserUnlock +* @apiGroup User +* +* @apiSuccess {Object} data `purchased preferences items` +*/ +api.userUnlock = { + method: 'POST', + middlewares: [authWithHeaders(), cron], + url: '/user/unlock', + async handler (req, res) { + let user = res.locals.user; + let unlockResponse = common.ops.unlock(user, req); + await user.save(); + res.respond(200, unlockResponse); + }, +}; + module.exports = api;