diff --git a/.github/workflows/run-tests-bundled.yml b/.github/workflows/run-tests-bundled.yml index 79d1ad28..24cfe00d 100644 --- a/.github/workflows/run-tests-bundled.yml +++ b/.github/workflows/run-tests-bundled.yml @@ -25,4 +25,5 @@ jobs: run: npm run test:bundled:parallel env: TEST_REQUEST_API_KEY: ${{ secrets.TEST_REQUEST_API_KEY }} + TEST_MEDIA_REQUEST_API_KEY: ${{ secrets.TEST_MEDIA_REQUEST_API_KEY }} SKIP_NETWORK_TIMEOUT_TESTS: true diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 448d80d0..2fbac4f6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -25,4 +25,5 @@ jobs: run: npm run test:parallel env: TEST_REQUEST_API_KEY: ${{ secrets.TEST_REQUEST_API_KEY }} + TEST_MEDIA_REQUEST_API_KEY: ${{ secrets.TEST_MEDIA_REQUEST_API_KEY }} SKIP_NETWORK_TIMEOUT_TESTS: true diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index 6b01874b..746e3ed5 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -17,6 +17,7 @@ chai.use(sinonChai); dotenv.config(); const testApiKey = process.env.TEST_REQUEST_API_KEY; +const testApiKeyWithAdPlacements = process.env.TEST_MEDIA_REQUEST_API_KEY; const clientVersion = 'cio-mocha'; const delayBetweenTests = 50; const bundled = process.env.BUNDLED === 'true'; @@ -28,6 +29,7 @@ const utmParameters = 'utm_source=attentive&utm_medium=sms&utm_campaign=campaign const url = `http://localhost.test/path/name?query=term&category=cat&${utmParameters}`; const referrer = 'https://www.google.com/'; const canonicalUrl = 'https://localhost/'; +const testPlacementId = 'home'; function validateOriginReferrer(requestParams) { expect(requestParams).to.have.property('origin_referrer').to.contain('localhost.test/path/name'); @@ -15512,18 +15514,25 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { }); describe('trackMediaImpressionView', () => { - const requiredParameters = { - bannerAdId: 'banner_ad_id', - placementId: 'placement_id', - }; + let requiredParameters; const optionalParameters = { analyticsTags: testAnalyticsTag, }; + before(async () => { + const response = await fetch(`https://display.media-cnstrc.com/display-ads?key=${testApiKeyWithAdPlacements}&placement_ids=${testPlacementId}`); + const data = await response.json(); + const ad = data.display_ads[testPlacementId]; + requiredParameters = { + bannerAdId: ad.banner_ad_id, + placementId: testPlacementId, + }; + }); + it('Should respond with a valid response when required parameters are provided', (done) => { const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, fetch: fetchSpy, mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, @@ -15550,7 +15559,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { // Response expect(responseParams).to.have.property('method').to.equal('POST'); - expect(responseParams).to.have.property('message'); + expect(responseParams).to.have.property('message').to.equal('ok'); done(); }); @@ -15562,7 +15571,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { it('Should respond with a valid response when required and optional parameters are provided', (done) => { const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, fetch: fetchSpy, mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, @@ -15579,7 +15588,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { // Response expect(responseParams).to.have.property('method').to.equal('POST'); - expect(responseParams).to.have.property('message'); + expect(responseParams).to.have.property('message').to.equal('ok'); done(); }); @@ -15592,20 +15601,20 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { }); it('Should throw an error when invalid parameters are provided', () => { - const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { tracker } = new ConstructorIO({ apiKey: testApiKeyWithAdPlacements }); expect(tracker.trackMediaImpressionView([])).to.be.an('error'); }); it('Should throw an error when no parameters are provided', () => { - const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { tracker } = new ConstructorIO({ apiKey: testApiKeyWithAdPlacements }); expect(tracker.trackMediaImpressionView()).to.be.an('error'); }); it('Should send along origin_referrer query param if sendReferrerWithTrackingEvents is true', (done) => { const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, fetch: fetchSpy, sendReferrerWithTrackingEvents: true, mediaServiceUrl: 'https://media-cnstrc.com', @@ -15633,7 +15642,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { it('Should not send along origin_referrer query param if sendReferrerWithTrackingEvents is false', (done) => { const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, fetch: fetchSpy, sendReferrerWithTrackingEvents: false, mediaServiceUrl: 'https://media-cnstrc.com', @@ -15659,10 +15668,40 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { ); }); + it('Should not encode body parameters', (done) => { + const specialCharacters = '+[]&'; + const userId = `user-id ${specialCharacters}`; + const { tracker } = new ConstructorIO({ + apiKey: testApiKeyWithAdPlacements, + userId, + mediaServiceUrl: 'https://media-cnstrc.com', + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('ui').to.equal(userId); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackMediaImpressionView(requiredParameters)).to.equal( + true, + ); + }); + if (!skipNetworkTimeoutTests) { it('Should be rejected when network request timeout is provided and reached', (done) => { const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, }); @@ -15679,7 +15718,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { it('Should be rejected when global network request timeout is provided and reached', (done) => { const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, mediaServiceUrl: 'https://media-cnstrc.com', networkParameters: { timeout: 20, @@ -15697,16 +15736,172 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { ); }); } + }); + + describe('trackMediaImpressionClick', () => { + let requiredParameters; + + const optionalParameters = { + analyticsTags: testAnalyticsTag, + }; + + before(async () => { + const response = await fetch(`https://display.media-cnstrc.com/display-ads?key=${testApiKeyWithAdPlacements}&placement_ids=${testPlacementId}`); + const data = await response.json(); + const ad = data.display_ads[testPlacementId]; + + requiredParameters = { + bannerAdId: ad.banner_ad_id, + placementId: testPlacementId, + }; + }); + + it('Should respond with a valid response when required parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKeyWithAdPlacements, + fetch: fetchSpy, + mediaServiceUrl: 'https://media-cnstrc.com', + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('key'); + expect(requestParams).to.have.property('i'); + expect(requestParams).to.have.property('s'); + expect(requestParams).to.have.property('c').to.equal(clientVersion); + expect(requestParams).to.have.property('_dt'); + expect(requestParams).to.have.property('canonical_url').to.equal(canonicalUrl); + expect(requestParams).to.have.property('document_referrer').to.equal(referrer); + expect(requestParams) + .to.have.property('banner_ad_id') + .to.equal(requiredParameters.bannerAdId); + expect(requestParams) + .to.have.property('placement_id') + .to.equal(requiredParameters.placementId); + validateOriginReferrer(requestParams); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackMediaImpressionClick(requiredParameters)).to.equal( + true, + ); + }); + + it('Should respond with a valid response when required and optional parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKeyWithAdPlacements, + fetch: fetchSpy, + mediaServiceUrl: 'https://media-cnstrc.com', + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams) + .to.have.property('analytics_tags') + .to.deep.equal(testAnalyticsTag); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect( + tracker.trackMediaImpressionClick( + { ...requiredParameters, ...optionalParameters }, + ), + ).to.equal(true); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKeyWithAdPlacements }); + + expect(tracker.trackMediaImpressionClick([])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKeyWithAdPlacements }); + + expect(tracker.trackMediaImpressionClick()).to.be.an('error'); + }); + + it('Should send along origin_referrer query param if sendReferrerWithTrackingEvents is true', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKeyWithAdPlacements, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: true, + mediaServiceUrl: 'https://media-cnstrc.com', + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + validateOriginReferrer(requestParams); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackMediaImpressionClick(requiredParameters)).to.equal( + true, + ); + }); + + it('Should not send along origin_referrer query param if sendReferrerWithTrackingEvents is false', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKeyWithAdPlacements, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: false, + mediaServiceUrl: 'https://media-cnstrc.com', + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.not.have.property('origin_referrer'); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackMediaImpressionClick(requiredParameters)).to.equal( + true, + ); + }); it('Should not encode body parameters', (done) => { const specialCharacters = '+[]&'; const userId = `user-id ${specialCharacters}`; - const bannerAdId = `banner_ad_id ${specialCharacters}`; const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, userId, - fetch: fetchSpy, mediaServiceUrl: 'https://media-cnstrc.com', + fetch: fetchSpy, ...requestQueueOptions, }); @@ -15716,9 +15911,6 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { // Request expect(fetchSpy).to.have.been.called; expect(requestParams).to.have.property('ui').to.equal(userId); - expect(requestParams) - .to.have.property('banner_ad_id') - .to.equal(bannerAdId); // Response expect(responseParams).to.have.property('method').to.equal('POST'); @@ -15727,19 +15919,56 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { done(); }); - expect( - tracker.trackMediaImpressionView({ ...requiredParameters, bannerAdId }), - ).to.equal(true); + expect(tracker.trackMediaImpressionClick(requiredParameters)).to.equal( + true, + ); }); + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKeyWithAdPlacements, + mediaServiceUrl: 'https://media-cnstrc.com', + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect( + tracker.trackMediaImpressionClick(requiredParameters, { timeout: 10 }), + ).to.equal(true); + }); + + it('Should be rejected when global network request timeout is provided and reached', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKeyWithAdPlacements, + mediaServiceUrl: 'https://media-cnstrc.com', + networkParameters: { + timeout: 20, + }, + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect(tracker.trackMediaImpressionClick(requiredParameters)).to.equal( + true, + ); + }); + } + it('Should properly transform non-breaking spaces in parameters', (done) => { const breakingSpaces = '   '; const userId = `user-id ${breakingSpaces} user-id`; - const bannerAdId = `banner_ad_id ${breakingSpaces} banner_ad_id`; - const bannerAdIdExpected = 'banner_ad_id banner_ad_id'; const userIdExpected = 'user-id user-id'; const { tracker } = new ConstructorIO({ - apiKey: testApiKey, + apiKey: testApiKeyWithAdPlacements, userId, mediaServiceUrl: 'https://media-cnstrc.com', fetch: fetchSpy, @@ -15752,9 +15981,6 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { // Request expect(fetchSpy).to.have.been.called; expect(requestParams).to.have.property('ui').to.equal(userIdExpected); - expect(requestParams) - .to.have.property('banner_ad_id') - .to.equal(bannerAdIdExpected); // Response expect(responseParams).to.have.property('method').to.equal('POST'); @@ -15764,10 +15990,9 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { }); expect( - tracker.trackMediaImpressionView({ + tracker.trackMediaImpressionClick({ ...requiredParameters, userId, - bannerAdId, }), ).to.equal(true); }); diff --git a/src/modules/tracker.js b/src/modules/tracker.js index 60da242a..a95f9bb1 100644 --- a/src/modules/tracker.js +++ b/src/modules/tracker.js @@ -1369,13 +1369,75 @@ class Tracker { trackMediaImpressionView(parameters, networkParameters = {}) { // Ensure parameters are provided (required) if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { - const baseUrl = new URL(this.options.mediaServiceUrl); + const baseUrl = helpers.getBehaviorUrl(this.options.mediaServiceUrl); - if (!baseUrl.hostname.startsWith('behavior')) { - baseUrl.hostname = `behavior.${baseUrl.hostname}`; + const requestPath = `${baseUrl.toString()}v2/ad_behavioral_action/display_ad_view?`; + + const bodyParams = {}; + const { + bannerAdId, + placementId, + analyticsTags, + } = parameters; + + if (!helpers.isNil(bannerAdId)) { + bodyParams.banner_ad_id = bannerAdId; } - const requestPath = `${baseUrl.toString()}v2/ad_behavioral_action/display_ad_view?`; + if (!helpers.isNil(placementId)) { + bodyParams.placement_id = placementId; + } + + if (!helpers.isNil(analyticsTags)) { + bodyParams.analytics_tags = analyticsTags; + } + + const requestURL = `${requestPath}${applyParamsAsString({}, this.options)}`; + const requestMethod = 'POST'; + const requestBody = applyParams(bodyParams, { ...this.options, requestMethod }); + + this.requests.queue( + requestURL, + requestMethod, + requestBody, + networkParameters, + ); + this.requests.send(); + + return true; + } + + this.requests.send(); + + return new Error('parameters are required of type object'); + } + + /** + * Send media impression click event to API + * + * @function trackMediaImpressionClick + * @param {object} parameters - Additional parameters to be sent with request + * @param {string} parameters.bannerAdId - Banner ad identifier + * @param {string} parameters.placementId - Placement identifier + * @param {object} [parameters.analyticsTags] - Pass additional analytics data + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {(true|Error)} + * @description User clicked a media banner + * @example + * constructorio.tracker.trackMediaImpressionClick( + * { + * bannerAdId: 'banner_ad_id', + * placementId: 'placement_id', + * }, + * ); + */ + trackMediaImpressionClick(parameters, networkParameters = {}) { + // Ensure required parameters are provided + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const baseUrl = helpers.getBehaviorUrl(this.options.mediaServiceUrl); + + const requestPath = `${baseUrl.toString()}v2/ad_behavioral_action/display_ad_click?`; const bodyParams = {}; const { diff --git a/src/types/tracker.d.ts b/src/types/tracker.d.ts index c43e1429..b159ec8e 100644 --- a/src/types/tracker.d.ts +++ b/src/types/tracker.d.ts @@ -444,5 +444,12 @@ declare class Tracker { }, networkParameters?: NetworkParameters ): true | Error; + trackMediaImpressionClick(parameters: { + bannerAdId: string; + placementId: string; + analyticsTags?: Record; + }, networkParameters?: NetworkParameters + ): true | Error; + on(messageType: string, callback: Function): true | Error; } diff --git a/src/utils/helpers.js b/src/utils/helpers.js index ed26f398..5a34c6c1 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -372,6 +372,16 @@ const utils = { }, truncateString: (string, maxLength) => string.slice(0, maxLength), + + getBehaviorUrl: (mediaServiceUrl) => { + const baseUrl = new URL(mediaServiceUrl); + + if (!baseUrl.hostname.startsWith('behavior')) { + baseUrl.hostname = `behavior.${baseUrl.hostname}`; + } + + return baseUrl; + }, }; module.exports = utils;