diff --git a/package.json b/package.json index a9f3464726..5912055010 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,6 @@ "mocha": "^2.3.3", "mongodb": "^2.0.46", "mongoskin": "~2.1.0", - "nock": "^2.17.0", "phantomjs": "^1.9", "protractor": "^3.1.1", "require-again": "^1.0.1", diff --git a/test/api/v3/unit/libs/analyticsService.test.js b/test/api/v3/unit/libs/analyticsService.test.js index 8eb7f9c05e..ffba0c8144 100644 --- a/test/api/v3/unit/libs/analyticsService.test.js +++ b/test/api/v3/unit/libs/analyticsService.test.js @@ -1,33 +1,30 @@ -// TODO These tests are pretty brittle -// rewrite them to not depend on nock -// Trust that the amplitude module works as intended and sends the requests +/* eslint-disable camelcase */ import analyticsService from '../../../../../website/server/libs/analyticsService'; - -import nock from 'nock'; +import Amplitude from 'amplitude'; +import { Visitor } from 'universal-analytics'; describe('analyticsService', () => { - let amplitudeNock, gaNock; - beforeEach(() => { - amplitudeNock = nock('https://api.amplitude.com') - .filteringPath(/httpapi.*/g, '') - .post('/') - .reply(200, {status: 'OK'}); + sandbox.stub(Amplitude.prototype, 'track').returns(Promise.resolve()); - gaNock = nock('http://www.google-analytics.com'); + sandbox.stub(Visitor.prototype, 'event'); + sandbox.stub(Visitor.prototype, 'transaction'); }); describe('#track', () => { let eventType, data; beforeEach(() => { + Visitor.prototype.event.yields(); + eventType = 'Cron'; data = { category: 'behavior', uuid: 'unique-user-id', resting: true, cronCount: 5, - headers: {'x-client': 'habitica-web', + headers: { + 'x-client': 'habitica-web', 'user-agent': '', }, }; @@ -37,188 +34,231 @@ describe('analyticsService', () => { it('calls out to amplitude', () => { return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledOnce; }); }); it('uses a dummy user id if none is provided', () => { delete data.uuid; - amplitudeNock - .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + user_id: 'no-user-id-was-provided', + }); }); }); context('platform', () => { it('logs web platform', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*Web.*/g, ''); - data.headers = {'x-client': 'habitica-web'}; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'Web', + }); }); }); it('logs iOS platform', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*iOS.*/g, ''); - data.headers = {'x-client': 'habitica-ios'}; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'iOS', + }); }); }); it('logs Android platform', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*Android.*/g, ''); - data.headers = {'x-client': 'habitica-android'}; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'Android', + }); }); }); it('logs 3rd Party platform', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*3rd\%20Party.*/g, ''); - - data.headers = {}; + data.headers = {'x-client': 'some-third-party'}; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: '3rd Party', + }); + }); + }); + + it('logs unknown if headers are not passed in', () => { + delete data.headers; + + return analyticsService.track(eventType, data) + .then(() => { + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'Unknown', + }); }); }); }); context('Operating System', () => { it('sets default', () => { - amplitudeNock - .filteringPath(/httpapi.*os.*name.*Other.*/g, ''); + data.headers = { + 'x-client': 'third-party', + 'user-agent': 'foo', + }; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: 'Other', + os_version: '0', + }); }); }); it('sets iOS', () => { - amplitudeNock - .filteringPath(/httpapi.*os.*name.*iOS.*/g, ''); - - data.headers = {'x-client': 'habitica-ios', - 'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)'}; + data.headers = { + 'x-client': 'habitica-ios', + 'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)', + }; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: 'iOS', + os_version: '9.3.0', + }); }); }); it('sets Android', () => { - amplitudeNock - .filteringPath(/httpapi.*os.*name.*Android.*/g, ''); - - data.headers = {'x-client': 'habitica-android'}; + data.headers = { + 'x-client': 'habitica-android', + 'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19', + }; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: 'Android', + os_version: '4.0.4', + }); + }); + }); + + it('sets Unkown if headers are not passed in', () => { + delete data.headers; + + return analyticsService.track(eventType, data) + .then(() => { + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: undefined, + os_version: undefined, + }); }); }); }); it('sends details about event', () => { - amplitudeNock - .filteringPath(/httpapi.*event_properties%22%3A%7B%22category%22%3A%22behavior%22%2C%22resting%22%3Atrue%2C%22cronCount%22%3A5%7D%2C%22.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + category: 'behavior', + resting: true, + cronCount: 5, + }, + }); }); }); it('sends english item name for gear if itemKey is provided', () => { data.itemKey = 'headAccessory_special_foxEars'; - amplitudeNock - .filteringPath(/httpapi.*itemName.*Fox%20Ears.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + itemKey: data.itemKey, + itemName: 'Fox Ears', + }, + }); }); }); it('sends english item name for egg if itemKey is provided', () => { data.itemKey = 'Wolf'; - amplitudeNock - .filteringPath(/httpapi.*itemName.*Wolf%20Egg.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + itemKey: data.itemKey, + itemName: 'Wolf Egg', + }, + }); }); }); it('sends english item name for food if itemKey is provided', () => { data.itemKey = 'Cake_Skeleton'; - amplitudeNock - .filteringPath(/httpapi.*itemName.*Bare%20Bones%20Cake.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + itemKey: data.itemKey, + itemName: 'Bare Bones Cake', + }, + }); }); }); it('sends english item name for hatching potion if itemKey is provided', () => { data.itemKey = 'Golden'; - amplitudeNock - .filteringPath(/httpapi.*itemName.*Golden%20Hatching%20Potion.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + itemKey: data.itemKey, + itemName: 'Golden Hatching Potion', + }, + }); }); }); - xit('sends english item name for quest if itemKey is provided', () => { + it('sends english item name for quest if itemKey is provided', () => { data.itemKey = 'atom1'; - amplitudeNock - .filteringPath(/httpapi.*itemName.*Attack%20of%20the%20Mundane%2C%20Part%201%3A%20Dish%20Disaster!.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + itemKey: data.itemKey, + itemName: 'Attack of the Mundane, Part 1: Dish Disaster!', + }, + }); }); }); it('sends english item name for purchased spell if itemKey is provided', () => { data.itemKey = 'seafoam'; - amplitudeNock - .filteringPath(/httpapi.*itemName.*Seafoam.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + itemKey: data.itemKey, + itemName: 'Seafoam', + }, + }); }); }); @@ -237,45 +277,65 @@ describe('analyticsService', () => { data.user = user; - amplitudeNock - .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); - return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + user_properties: { + Class: 'wizard', + Experience: 5, + Gold: 23, + Health: 10, + Level: 4, + Mana: 30, + tutorialComplete: true, + 'Number Of Tasks': { + habits: 1, + dailys: 1, + todos: 1, + rewards: 1, + }, + contributorLevel: 1, + subscription: 'foo-plan', + }, + }); }); }); }); context('GA', () => { it('calls out to GA', () => { - gaNock - .post('/collect') - .reply(200, {status: 'OK'}); - return analyticsService.track(eventType, data) .then(() => { - gaNock.done(); + expect(Visitor.prototype.event).to.be.calledOnce; }); }); it('sends details about event', () => { - gaNock - .post('/collect', /ec=behavior&ea=Cron&v=1&tid=GA_ID&cid=.*&t=event/) - .reply(200, {status: 'OK'}); - return analyticsService.track(eventType, data) .then(() => { - gaNock.done(); + expect(Visitor.prototype.event).to.be.calledWith({ + ea: 'Cron', + ec: 'behavior', + }); }); }); }); }); describe('#trackPurchase', () => { - let data; + let data, itemSpy; beforeEach(() => { + itemSpy = sandbox.stub().returnsThis(); + + Visitor.prototype.event.returns({ + send: sandbox.stub(), + }); + Visitor.prototype.transaction.returns({ + item: itemSpy, + send: sandbox.stub().returnsThis(), + }); + data = { uuid: 'user-id', sku: 'paypal-checkout', @@ -295,117 +355,150 @@ describe('analyticsService', () => { it('calls out to amplitude', () => { return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledOnce; }); }); it('uses a dummy user id if none is provided', () => { delete data.uuid; - amplitudeNock - .filteringPath(/httpapi.*user_id.*no-user-id-was-provided.*/g, ''); - return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + user_id: 'no-user-id-was-provided', + }); }); }); - context('sets platform as', () => { - it('Web', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*Web.*/g, ''); - + context('platform', () => { + it('logs web platform', () => { data.headers = {'x-client': 'habitica-web'}; return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'Web', + }); }); }); - it('iOS', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*iOS.*/g, ''); - + it('logs iOS platform', () => { data.headers = {'x-client': 'habitica-ios'}; return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'iOS', + }); }); }); - it('Android', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*Android.*/g, ''); - + it('logs Android platform', () => { data.headers = {'x-client': 'habitica-android'}; return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'Android', + }); }); }); - it('3rd Party', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*3rd\%20Party.*/g, ''); - - data.headers = {}; + it('logs 3rd Party platform', () => { + data.headers = {'x-client': 'some-third-party'}; return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: '3rd Party', + }); + }); + }); + + it('logs unknown if headers are not passed in', () => { + delete data.headers; + + return analyticsService.trackPurchase(data) + .then(() => { + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + platform: 'Unknown', + }); }); }); }); - context('sets os for', () => { - it('Default', () => { - amplitudeNock - .filteringPath(/httpapi.*os.*name.*Other.*/g, ''); + context('Operating System', () => { + it('sets default', () => { + data.headers = { + 'x-client': 'third-party', + 'user-agent': 'foo', + }; return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: 'Other', + os_version: '0', + }); }); }); - it('iOS', () => { - amplitudeNock - .filteringPath(/httpapi.*os.*name.*iOS.*/g, ''); - - data.headers = {'x-client': 'habitica-ios', - 'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)'}; + it('sets iOS', () => { + data.headers = { + 'x-client': 'habitica-ios', + 'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)', + }; return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: 'iOS', + os_version: '9.3.0', + }); }); }); - it('Android', () => { - amplitudeNock - .filteringPath(/httpapi.*os.*name.*Android.*/g, ''); - - data.headers = {'x-client': 'habitica-android', - 'user-agent': ''}; + it('sets Android', () => { + data.headers = { + 'x-client': 'habitica-android', + 'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19', + }; return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: 'Android', + os_version: '4.0.4', + }); + }); + }); + + it('sets Unkown if headers are not passed in', () => { + delete data.headers; + + return analyticsService.trackPurchase(data) + .then(() => { + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + os_name: undefined, + os_version: undefined, + }); }); }); }); it('sends details about purchase', () => { - amplitudeNock - .filteringPath(/httpapi.*aypal-checkout%22%2C%22paymentMethod%22%3A%22PayPal%22%2C%22itemPurchased%22%3A%22Gems%22%2C%22purchaseType%22%3A%22checkout%22%2C%22gift%22%3Afalse%2C%22quantity%22%3A1%7D%2C%22event_type%22%3A%22purchase%22%2C%22revenue.*/g, ''); - return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + event_properties: { + gift: false, + itemPurchased: 'Gems', + paymentMethod: 'PayPal', + purchaseType: 'checkout', + quantity: 1, + sku: 'paypal-checkout', + }, + }); }); }); @@ -424,38 +517,51 @@ describe('analyticsService', () => { data.user = user; - amplitudeNock - .filteringPath(/httpapi.*user_properties%22%3A%7B%22Class%22%3A%22wizard%22%2C%22Experience%22%3A5%2C%22Gold%22%3A23%2C%22Health%22%3A10%2C%22Level%22%3A4%2C%22Mana%22%3A30%2C%22tutorialComplete%22%3Atrue%2C%22Number%20Of%20Tasks%22%3A%7B%22habits%22%3A1%2C%22dailys%22%3A1%2C%22todos%22%3A1%2C%22rewards%22%3A1%7D%2C%22contributorLevel%22%3A1%2C%22subscription%22%3A%22foo-plan%22%7D%2C%22.*/g, ''); - return analyticsService.trackPurchase(data) .then(() => { - amplitudeNock.done(); + expect(Amplitude.prototype.track).to.be.calledWithMatch({ + user_properties: { + Class: 'wizard', + Experience: 5, + Gold: 23, + Health: 10, + Level: 4, + Mana: 30, + tutorialComplete: true, + 'Number Of Tasks': { + habits: 1, + dailys: 1, + todos: 1, + rewards: 1, + }, + contributorLevel: 1, + subscription: 'foo-plan', + }, + }); }); }); }); context('GA', () => { it('calls out to GA', () => { - gaNock - .post('/collect') - .reply(200, {status: 'OK'}); - return analyticsService.trackPurchase(data) .then(() => { - gaNock.done(); + expect(Visitor.prototype.event).to.be.calledOnce; + expect(Visitor.prototype.transaction).to.be.calledOnce; }); }); it('sends details about purchase', () => { - gaNock - .post('/collect', /ti=user-id&tr=8&v=1&tid=GA_ID&cid=.*&t=transaction/) - .reply(200, {status: 'OK'}) - .post('/collect', /ec=commerce&ea=checkout&el=PayPal&ev=8&v=1&tid=GA_ID&cid=.*&t=event/) - .reply(200, {status: 'OK'}); - return analyticsService.trackPurchase(data) .then(() => { - gaNock.done(); + expect(Visitor.prototype.event).to.be.calledWith({ + ea: 'checkout', + ec: 'commerce', + el: 'PayPal', + ev: 8, + }); + expect(Visitor.prototype.transaction).to.be.calledWith('user-id', 8); + expect(itemSpy).to.be.calledWith(8, 1, 'paypal-checkout', 'Gems', 'checkout'); }); }); }); diff --git a/website/server/controllers/top-level/payments/amazon.js b/website/server/controllers/top-level/payments/amazon.js index a0522b2b32..0efa50690d 100644 --- a/website/server/controllers/top-level/payments/amazon.js +++ b/website/server/controllers/top-level/payments/amazon.js @@ -131,7 +131,11 @@ api.checkout = { // execute payment let method = 'buyGems'; - let data = { user, paymentMethod: 'Amazon Payments' }; + let data = { + user, + paymentMethod: 'Amazon Payments', + headers: req.headers, + }; if (gift) { if (gift.type === 'subscription') method = 'createSubscription'; @@ -247,6 +251,7 @@ api.subscribeCancel = { user, nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), paymentMethod: 'Amazon Payments', + headers: req.headers, }); if (req.query.noRedirect) { diff --git a/website/server/libs/analyticsService.js b/website/server/libs/analyticsService.js index 6996d565c0..eda6a2a561 100644 --- a/website/server/libs/analyticsService.js +++ b/website/server/libs/analyticsService.js @@ -89,6 +89,10 @@ let _formatUserData = (user) => { }; let _formatPlatformForAmplitude = (platform) => { + if (!platform) { + return 'Unknown'; + } + if (platform in PLATFORM_MAP) { return PLATFORM_MAP[platform]; } @@ -97,6 +101,10 @@ let _formatPlatformForAmplitude = (platform) => { }; let _formatUserAgentForAmplitude = (platform, agentString) => { + if (!agentString) { + return 'Unknown'; + } + let agent = useragent.lookup(agentString).toJSON(); let formattedAgent = {}; if (platform === 'iOS' || platform === 'Android') { @@ -115,8 +123,8 @@ let _formatUserAgentForAmplitude = (platform, agentString) => { let _formatDataForAmplitude = (data) => { let event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB); - let platform = _formatPlatformForAmplitude(data.headers['x-client']); - let agent = _formatUserAgentForAmplitude(platform, data.headers['user-agent']); + let platform = _formatPlatformForAmplitude(data.headers && data.headers['x-client']); + let agent = _formatUserAgentForAmplitude(platform, data.headers && data.headers['user-agent']); let ampData = { user_id: data.uuid || 'no-user-id-was-provided', platform, diff --git a/website/server/libs/payments.js b/website/server/libs/payments.js index 3f5b1c5e3b..f5ba7faad3 100644 --- a/website/server/libs/payments.js +++ b/website/server/libs/payments.js @@ -146,6 +146,7 @@ api.cancelSubscription = async function cancelSubscription (data) { gaCategory: 'commerce', gaLabel: data.paymentMethod, paymentMethod: data.paymentMethod, + headers: data.headers, }); };