diff --git a/test/common/ops/buy/buyQuestGems.js b/test/common/ops/buy/buyQuestGems.js new file mode 100644 index 0000000000..3bb19841e4 --- /dev/null +++ b/test/common/ops/buy/buyQuestGems.js @@ -0,0 +1,94 @@ +import pinnedGearUtils from '../../../../website/common/script/ops/pinnedGearUtils'; +import { + NotAuthorized, +} from '../../../../website/common/script/libs/errors'; +import i18n from '../../../../website/common/script/i18n'; +import { + generateUser, +} from '../../../helpers/common.helper'; +import {BuyQuestWithGemOperation} from '../../../../website/common/script/ops/buy/buyQuestGem'; + +describe('shared.ops.buyQuestGems', () => { + let user; + let goldPoints = 40; + let analytics = {track () {}}; + + function buyQuest (_user, _req, _analytics) { + const buyOp = new BuyQuestWithGemOperation(_user, _req, _analytics); + + return buyOp.purchase(); + } + + before(() => { + user = generateUser({'stats.class': 'rogue'}); + }); + + beforeEach(() => { + sinon.stub(analytics, 'track'); + sinon.spy(pinnedGearUtils, 'removeItemByPath'); + }); + + afterEach(() => { + analytics.track.restore(); + pinnedGearUtils.removeItemByPath.restore(); + }); + + context('successful purchase', () => { + let userGemAmount = 10; + + before(() => { + user.balance = userGemAmount; + user.stats.gp = goldPoints; + user.purchased.plan.gemsBought = 0; + user.purchased.plan.customerId = 'customer-id'; + user.pinnedItems.push({type: 'quests', key: 'gryphon'}); + }); + + it('purchases quests', () => { + let key = 'gryphon'; + + buyQuest(user, {params: {key}}); + + expect(user.items.quests[key]).to.equal(1); + expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true); + }); + }); + + context('bulk purchase', () => { + let userGemAmount = 10; + + beforeEach(() => { + user.balance = userGemAmount; + user.stats.gp = goldPoints; + user.purchased.plan.gemsBought = 0; + user.purchased.plan.customerId = 'customer-id'; + }); + + it('errors when user does not have enough gems', (done) => { + user.balance = 1; + let key = 'gryphon'; + + try { + buyQuest(user, { + params: {key}, + quantity: 2, + }); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.equal(i18n.t('notEnoughGems')); + done(); + } + }); + + it('makes bulk purchases of quests', () => { + let key = 'gryphon'; + + buyQuest(user, { + params: {key}, + quantity: 3, + }); + + expect(user.items.quests[key]).to.equal(4); + }); + }); +}); diff --git a/test/common/ops/buy/purchase.js b/test/common/ops/buy/purchase.js index 900011daa5..fd1ab65b48 100644 --- a/test/common/ops/buy/purchase.js +++ b/test/common/ops/buy/purchase.js @@ -121,7 +121,6 @@ describe('shared.ops.purchase', () => { user.pinnedItems.push({type: 'eggs', key: 'Wolf'}); user.pinnedItems.push({type: 'hatchingPotions', key: 'Base'}); user.pinnedItems.push({type: 'food', key: SEASONAL_FOOD}); - user.pinnedItems.push({type: 'quests', key: 'gryphon'}); user.pinnedItems.push({type: 'gear', key: 'headAccessory_special_tigerEars'}); user.pinnedItems.push({type: 'bundles', key: 'featheredFriends'}); }); @@ -157,16 +156,6 @@ describe('shared.ops.purchase', () => { expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true); }); - it('purchases quests', () => { - let type = 'quests'; - let key = 'gryphon'; - - purchase(user, {params: {type, key}}); - - expect(user.items[type][key]).to.equal(1); - expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true); - }); - it('purchases gear', () => { let type = 'gear'; let key = 'headAccessory_special_tigerEars'; diff --git a/website/common/locales/en/quests.json b/website/common/locales/en/quests.json index a67a4b97b4..058e26cf15 100644 --- a/website/common/locales/en/quests.json +++ b/website/common/locales/en/quests.json @@ -98,6 +98,7 @@ "guildQuestsNotSupported": "Guilds cannot be invited on quests.", "questNotOwned": "You don't own that quest scroll.", "questNotGoldPurchasable": "Quest \"<%= key %>\" is not a Gold-purchasable quest.", + "questNotGemPurchasable": "Quest \"<%= key %>\" is not a Gem-purchasable quest.", "questLevelTooHigh": "You must be level <%= level %> to begin this quest.", "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.", "questAlreadyAccepted": "You already accepted the quest invitation.", diff --git a/website/common/script/ops/buy/abstractBuyOperation.js b/website/common/script/ops/buy/abstractBuyOperation.js index 9b0a2d9fe2..78256d21c3 100644 --- a/website/common/script/ops/buy/abstractBuyOperation.js +++ b/website/common/script/ops/buy/abstractBuyOperation.js @@ -24,6 +24,24 @@ export class AbstractBuyOperation { if (isNaN(this.quantity)) throw new BadRequest(this.i18n('invalidQuantity')); } + /** + * Returns the item value + * @param item + * @returns {number} + */ + getItemValue (item) { + return item.value; + } + + /** + * Returns the item key + * @param item + * @returns {String} + */ + getIemKey (item) { + return item.key; + } + /** * Shortcut to get the translated string without passing `req.language` * @param {String} key - translation key @@ -100,14 +118,6 @@ export class AbstractGoldItemOperation extends AbstractBuyOperation { super(user, req, analytics); } - getItemValue (item) { - return item.value; - } - - getIemKey (item) { - return item.key; - } - canUserPurchase (user, item) { this.item = item; let itemValue = this.getItemValue(item); @@ -138,3 +148,37 @@ export class AbstractGoldItemOperation extends AbstractBuyOperation { }; } } + +export class AbstractGemItemOperation extends AbstractBuyOperation { + constructor (user, req, analytics) { + super(user, req, analytics); + } + + canUserPurchase (user, item) { + this.item = item; + let itemValue = this.getItemValue(item); + + if (!item.canBuy(user)) { + throw new NotAuthorized(this.i18n('messageNotAvailable')); + } + + if (!user.balance || user.balance < itemValue * this.quantity) { + throw new NotAuthorized(this.i18n('notEnoughGems')); + } + } + + subtractCurrency (user, item) { + let itemValue = this.getItemValue(item); + + user.balance -= itemValue * this.quantity; + } + + analyticsData () { + return { + itemKey: this.getIemKey(this.item), + itemType: 'Market', + acquireMethod: 'Gems', + gemCost: this.getItemValue(this.item) * 4, + }; + } +} diff --git a/website/common/script/ops/buy/buy.js b/website/common/script/ops/buy/buy.js index 413ca79e7d..f564af45a5 100644 --- a/website/common/script/ops/buy/buy.js +++ b/website/common/script/ops/buy/buy.js @@ -12,6 +12,7 @@ import purchaseOp from './purchase'; import hourglassPurchase from './hourglassPurchase'; import errorMessage from '../../libs/errorMessage'; import {BuyGemOperation} from './buyGem'; +import {BuyQuestWithGemOperation} from './buyQuestGem'; // @TODO: remove the req option style. Dependency on express structure is an anti-pattern // We should either have more parms or a set structure validated by a Type checker @@ -52,10 +53,15 @@ module.exports = function buy (user, req = {}, analytics) { buyRes = buyOp.purchase(); break; } + case 'quests': { + const buyOp = new BuyQuestWithGemOperation(user, req, analytics); + + buyRes = buyOp.purchase(); + break; + } case 'eggs': case 'hatchingPotions': case 'food': - case 'quests': case 'gear': case 'bundles': buyRes = purchaseOp(user, req, analytics); diff --git a/website/common/script/ops/buy/buyQuestGem.js b/website/common/script/ops/buy/buyQuestGem.js new file mode 100644 index 0000000000..d3dae03502 --- /dev/null +++ b/website/common/script/ops/buy/buyQuestGem.js @@ -0,0 +1,57 @@ +import { + BadRequest, + NotAuthorized, + NotFound, +} from '../../libs/errors'; +import content from '../../content/index'; +import get from 'lodash/get'; + +import errorMessage from '../../libs/errorMessage'; +import {AbstractGemItemOperation} from './abstractBuyOperation'; + +export class BuyQuestWithGemOperation extends AbstractGemItemOperation { + constructor (user, req, analytics) { + super(user, req, analytics); + } + + multiplePurchaseAllowed () { + return true; + } + + getItemKey () { + return this.key; + } + + getItemValue (item) { + return item.value / 4; + } + + extractAndValidateParams (user, req) { + let key = this.key = get(req, 'params.key'); + if (!key) throw new BadRequest(errorMessage('missingKeyParam')); + + let item = content.quests[key]; + + if (!item) throw new NotFound(errorMessage('questNotFound', {key})); + + if (item.category === 'gold') { + throw new NotAuthorized(this.i18n('questNotGemPurchasable', {key})); + } + + this.canUserPurchase(user, item); + } + + executeChanges (user, item, req) { + user.items.quests[item.key] = user.items.quests[item.key] || 0; + user.items.quests[item.key] += this.quantity; + + this.subtractCurrency(user, item, this.quantity); + + return [ + user.items.quests, + this.i18n('messageBought', { + itemText: item.text(req.language), + }), + ]; + } +} diff --git a/website/common/script/ops/buy/purchase.js b/website/common/script/ops/buy/purchase.js index eb48ee72e8..4904b74fe3 100644 --- a/website/common/script/ops/buy/purchase.js +++ b/website/common/script/ops/buy/purchase.js @@ -63,7 +63,7 @@ function purchaseItem (user, item, price, type, key) { } } -const acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'gear', 'bundles']; +const acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'gear', 'bundles']; const singlePurchaseTypes = ['gear']; module.exports = function purchase (user, req = {}, analytics) { let type = get(req.params, 'type');