From 0409a376fd6e876913508ae0aa4fd69f2006c96e Mon Sep 17 00:00:00 2001 From: Viktor Zavala Date: Fri, 10 Oct 2025 16:15:13 +0200 Subject: [PATCH 1/3] [REM-1773] Add mediaServiceUrl to js module, add tracking event for media impression view and tests --- spec/src/modules/tracker.js | 303 ++++++++++++++++++++++++++++++++++++ src/constructorio.js | 3 + src/modules/tracker.js | 72 +++++++++ src/types/index.d.ts | 1 + 4 files changed, 379 insertions(+) diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index fce802ad..8d660b41 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -14539,4 +14539,307 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { expect(tracker.trackProductInsightsAgentAnswerFeedback(requiredParameters)).to.equal(true); }); }); + + describe('trackMediaImpressionView', () => { + const requiredParameters = { + bannerAdId: 'banner_ad_id', + placementId: 'placement_id', + }; + + const optionalParameters = { + analyticsTags: testAnalyticsTag, + }; + + it('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + mediaServiceUrl: 'https://behavior.media-cnstrc.com', + ...requestQueueOptions, + }); + + const snakeCaseParameters = { + banner_ad_id: 'banner_ad_id', + placement_id: 'placement_id', + }; + + 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('banner_ad_id') + .to.equal(snakeCaseParameters.banner_ad_id); + expect(requestParams) + .to.have.property('placement_id') + .to.equal(snakeCaseParameters.placement_id); + validateOriginReferrer(requestParams); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message'); + + done(); + }); + + expect(tracker.trackMediaImpressionView(snakeCaseParameters)).to.equal( + true, + ); + }); + + it('Should respond with a valid response when required parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + mediaServiceUrl: 'https://behavior.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('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'); + + done(); + }); + + expect(tracker.trackMediaImpressionView(requiredParameters)).to.equal( + true, + ); + }); + + it('Should respond with a valid response when required and optional parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + mediaServiceUrl: 'https://behavior.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'); + + done(); + }); + + expect( + tracker.trackMediaImpressionView( + Object.assign(requiredParameters, optionalParameters), + ), + ).to.equal(true); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackMediaImpressionView([])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + 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, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: true, + mediaServiceUrl: 'https://behavior.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.trackMediaImpressionView(requiredParameters)).to.equal( + true, + ); + }); + + it('Should not send along origin_referrer query param if sendReferrerWithTrackingEvents is false', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: false, + mediaServiceUrl: 'https://behavior.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.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, + mediaServiceUrl: 'https://behavior.media-cnstrc.com', + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect( + tracker.trackMediaImpressionView(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: testApiKey, + mediaServiceUrl: 'https://behavior.media-cnstrc.com', + networkParameters: { + timeout: 20, + }, + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect(tracker.trackMediaImpressionView(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, + userId, + fetch: fetchSpy, + mediaServiceUrl: 'https://behavior.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('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'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect( + tracker.trackMediaImpressionView({ ...requiredParameters, bannerAdId }), + ).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, + userId, + mediaServiceUrl: 'https://behavior.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(userIdExpected); + expect(requestParams) + .to.have.property('banner_ad_id') + .to.equal(bannerAdIdExpected); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect( + tracker.trackMediaImpressionView({ + ...requiredParameters, + userId, + bannerAdId, + }), + ).to.equal(true); + }); + }); }); diff --git a/src/constructorio.js b/src/constructorio.js index 958f94cd..a8e5a11f 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -40,6 +40,7 @@ class ConstructorIO { * @param {string} [parameters.serviceUrl='https://ac.cnstrc.com'] - API URL endpoint * @param {string} [parameters.quizzesServiceUrl='https://quizzes.cnstrc.com'] - Quizzes API URL endpoint * @param {string} [parameters.agentServiceUrl='https://agent.cnstrc.com'] - AI Shopping Agent API URL endpoint + * @param {string} [parameters.mediaServiceUrl='https://behavior.media-cnstrc.com'] - Media API URL endpoint * @param {string} [parameters.assistantServiceUrl='https://assistant.cnstrc.com'] - AI Shopping Assistant API URL endpoint @deprecated This parameter is deprecated and will be removed in a future version. Use parameters.agentServiceUrl instead. * @param {array} [parameters.segments] - User segments * @param {object} [parameters.testCells] - User test cells @@ -74,6 +75,7 @@ class ConstructorIO { quizzesServiceUrl, agentServiceUrl, assistantServiceUrl, + mediaServiceUrl, segments, testCells, clientId, @@ -121,6 +123,7 @@ class ConstructorIO { quizzesServiceUrl: (quizzesServiceUrl && quizzesServiceUrl.replace(/\/$/, '')) || 'https://quizzes.cnstrc.com', agentServiceUrl: (agentServiceUrl && agentServiceUrl.replace(/\/$/, '')) || 'https://agent.cnstrc.com', assistantServiceUrl: (assistantServiceUrl && assistantServiceUrl.replace(/\/$/, '')) || 'https://assistant.cnstrc.com', + mediaServiceUrl: (mediaServiceUrl && mediaServiceUrl.replace(/\/$/, '')) || 'https://behavior.media-cnstrc.com', sessionId: sessionId || session_id, clientId: clientId || client_id, userId, diff --git a/src/modules/tracker.js b/src/modules/tracker.js index 702e3a05..1beea421 100644 --- a/src/modules/tracker.js +++ b/src/modules/tracker.js @@ -1336,6 +1336,78 @@ class Tracker { return new Error('parameters are required of type object'); } + /** + * Send media impression view event to API + * + * @function trackMediaImpressionView + * @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 viewed a media banner + * @example + * constructorio.tracker.trackMediaImpressionView( + * { + * bannerAdId: 'banner_ad_id', + * placementId: 'placement_id', + * }, + * ); + */ + trackMediaImpressionView(parameters, networkParameters = {}) { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + + const requestPath = `${this.options.mediaServiceUrl}/v2/ad_behavioral_action/display_ad_view?`; + const bodyParams = {}; + const { + banner_ad_id, + bannerAdId = banner_ad_id, + placement_id, + placementId = placement_id, + result_id, + resultId = result_id, + analyticsTags, + } = parameters; + + if (!helpers.isNil(bannerAdId)) { + bodyParams.banner_ad_id = bannerAdId; + } + + if (!helpers.isNil(placementId)) { + bodyParams.placement_id = placementId; + } + + if (!helpers.isNil(resultId)) { + bodyParams.result_id = resultId; + } + + 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 recommendation click event to API * diff --git a/src/types/index.d.ts b/src/types/index.d.ts index ce217566..f28d9461 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -47,6 +47,7 @@ export interface ConstructorClientOptions { serviceUrl?: string; quizzesServiceUrl?: string; agentServiceUrl?: string; + mediaServiceUrl?: string; assistantServiceUrl?: string; sessionId?: number; clientId?: string; From 270a53b14209771133e7041e58e3cf3999b282f8 Mon Sep 17 00:00:00 2001 From: Viktor Zavala Date: Wed, 22 Oct 2025 09:18:35 +0200 Subject: [PATCH 2/3] [REM-1773] Remove subdomain from base mediaURL and add it on the behavior call. Remove snake case params handling --- spec/src/modules/tracker.js | 59 +++++-------------------------------- src/constructorio.js | 4 +-- src/modules/tracker.js | 17 ++++++----- 3 files changed, 20 insertions(+), 60 deletions(-) diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index 8d660b41..518f3743 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -14550,54 +14550,11 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { analyticsTags: testAnalyticsTag, }; - it('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { - const { tracker } = new ConstructorIO({ - apiKey: testApiKey, - fetch: fetchSpy, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', - ...requestQueueOptions, - }); - - const snakeCaseParameters = { - banner_ad_id: 'banner_ad_id', - placement_id: 'placement_id', - }; - - 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('banner_ad_id') - .to.equal(snakeCaseParameters.banner_ad_id); - expect(requestParams) - .to.have.property('placement_id') - .to.equal(snakeCaseParameters.placement_id); - validateOriginReferrer(requestParams); - - // Response - expect(responseParams).to.have.property('method').to.equal('POST'); - expect(responseParams).to.have.property('message'); - - done(); - }); - - expect(tracker.trackMediaImpressionView(snakeCaseParameters)).to.equal( - true, - ); - }); - it('Should respond with a valid response when required parameters are provided', (done) => { const { tracker } = new ConstructorIO({ apiKey: testApiKey, fetch: fetchSpy, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, }); @@ -14634,7 +14591,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { const { tracker } = new ConstructorIO({ apiKey: testApiKey, fetch: fetchSpy, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, }); @@ -14678,7 +14635,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { apiKey: testApiKey, fetch: fetchSpy, sendReferrerWithTrackingEvents: true, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, }); @@ -14706,7 +14663,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { apiKey: testApiKey, fetch: fetchSpy, sendReferrerWithTrackingEvents: false, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, }); @@ -14733,7 +14690,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { it('Should be rejected when network request timeout is provided and reached', (done) => { const { tracker } = new ConstructorIO({ apiKey: testApiKey, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, }); @@ -14750,7 +14707,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, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', networkParameters: { timeout: 20, }, @@ -14776,7 +14733,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { apiKey: testApiKey, userId, fetch: fetchSpy, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', ...requestQueueOptions, }); @@ -14811,7 +14768,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { const { tracker } = new ConstructorIO({ apiKey: testApiKey, userId, - mediaServiceUrl: 'https://behavior.media-cnstrc.com', + mediaServiceUrl: 'https://media-cnstrc.com', fetch: fetchSpy, ...requestQueueOptions, }); diff --git a/src/constructorio.js b/src/constructorio.js index a8e5a11f..24ce07d8 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -40,7 +40,7 @@ class ConstructorIO { * @param {string} [parameters.serviceUrl='https://ac.cnstrc.com'] - API URL endpoint * @param {string} [parameters.quizzesServiceUrl='https://quizzes.cnstrc.com'] - Quizzes API URL endpoint * @param {string} [parameters.agentServiceUrl='https://agent.cnstrc.com'] - AI Shopping Agent API URL endpoint - * @param {string} [parameters.mediaServiceUrl='https://behavior.media-cnstrc.com'] - Media API URL endpoint + * @param {string} [parameters.mediaServiceUrl='https://media-cnstrc.com'] - Media API URL endpoint * @param {string} [parameters.assistantServiceUrl='https://assistant.cnstrc.com'] - AI Shopping Assistant API URL endpoint @deprecated This parameter is deprecated and will be removed in a future version. Use parameters.agentServiceUrl instead. * @param {array} [parameters.segments] - User segments * @param {object} [parameters.testCells] - User test cells @@ -123,7 +123,7 @@ class ConstructorIO { quizzesServiceUrl: (quizzesServiceUrl && quizzesServiceUrl.replace(/\/$/, '')) || 'https://quizzes.cnstrc.com', agentServiceUrl: (agentServiceUrl && agentServiceUrl.replace(/\/$/, '')) || 'https://agent.cnstrc.com', assistantServiceUrl: (assistantServiceUrl && assistantServiceUrl.replace(/\/$/, '')) || 'https://assistant.cnstrc.com', - mediaServiceUrl: (mediaServiceUrl && mediaServiceUrl.replace(/\/$/, '')) || 'https://behavior.media-cnstrc.com', + mediaServiceUrl: (mediaServiceUrl && mediaServiceUrl.replace(/\/$/, '')) || 'https://media-cnstrc.com', sessionId: sessionId || session_id, clientId: clientId || client_id, userId, diff --git a/src/modules/tracker.js b/src/modules/tracker.js index 1beea421..198d5862 100644 --- a/src/modules/tracker.js +++ b/src/modules/tracker.js @@ -1359,16 +1359,19 @@ 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); + + if (!baseUrl.hostname.startsWith('behavior')) { + baseUrl.hostname = `behavior.${baseUrl.hostname}`; + } + + const requestPath = `${baseUrl.toString()}v2/ad_behavioral_action/display_ad_view?`; - const requestPath = `${this.options.mediaServiceUrl}/v2/ad_behavioral_action/display_ad_view?`; const bodyParams = {}; const { - banner_ad_id, - bannerAdId = banner_ad_id, - placement_id, - placementId = placement_id, - result_id, - resultId = result_id, + bannerAdId, + placementId, + resultId, analyticsTags, } = parameters; From e600ef8b123d6592ece73f8f64e9e33a9f501f7a Mon Sep 17 00:00:00 2001 From: Viktor Zavala Date: Wed, 22 Oct 2025 18:06:45 +0200 Subject: [PATCH 3/3] [REM-1773] Add types to tracker.d.ts and remove resultId as its not used --- src/modules/tracker.js | 5 ----- src/types/tracker.d.ts | 7 +++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/modules/tracker.js b/src/modules/tracker.js index 198d5862..376b4385 100644 --- a/src/modules/tracker.js +++ b/src/modules/tracker.js @@ -1371,7 +1371,6 @@ class Tracker { const { bannerAdId, placementId, - resultId, analyticsTags, } = parameters; @@ -1383,10 +1382,6 @@ class Tracker { bodyParams.placement_id = placementId; } - if (!helpers.isNil(resultId)) { - bodyParams.result_id = resultId; - } - if (!helpers.isNil(analyticsTags)) { bodyParams.analytics_tags = analyticsTags; } diff --git a/src/types/tracker.d.ts b/src/types/tracker.d.ts index 60a21793..cf508543 100644 --- a/src/types/tracker.d.ts +++ b/src/types/tracker.d.ts @@ -436,5 +436,12 @@ declare class Tracker { networkParameters?: NetworkParameters ): true | Error; + trackMediaImpressionView(parameters: { + bannerAdId: string; + placementId: string; + analyticsTags?: Record; + }, networkParameters?: NetworkParameters + ): true | Error; + on(messageType: string, callback: Function): true | Error; }