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 7efe32f864..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,104 +34,95 @@ 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 = {'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', () => { - amplitudeNock - .filteringPath(/httpapi.*platform.*Unknown.*/g, ''); - delete data.headers; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + 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': 'thrid-party', + '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)', @@ -142,14 +130,14 @@ describe('analyticsService', () => { 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', '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', @@ -157,102 +145,120 @@ describe('analyticsService', () => { 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', () => { - amplitudeNock - .filteringPath(/httpapi.*Unknown.*/g, ''); - delete data.headers; return analyticsService.track(eventType, data) .then(() => { - amplitudeNock.done(); + 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', + }, + }); }); }); @@ -271,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', @@ -329,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', + }, + }); }); }); @@ -458,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'); }); }); });