diff --git a/modules/qortexRtdProvider.js b/modules/qortexRtdProvider.js index aaf9b983c78..79f2c400dfd 100644 --- a/modules/qortexRtdProvider.js +++ b/modules/qortexRtdProvider.js @@ -6,10 +6,9 @@ import * as events from '../src/events.js'; import { EVENTS } from '../src/constants.js'; import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; -let requestUrl; -let bidderArray; -let impressionIds; -let currentSiteContext; +const DEFAULT_API_URL = 'https://demand.qortex.ai'; + +const qortexSessionInfo = {} /** * Init if module configuration is valid @@ -22,11 +21,34 @@ function init (config) { return false; } else { initializeModuleData(config); + if (config?.params?.enableBidEnrichment) { + logMessage('Requesting Qortex group configuration') + getGroupConfig() + .then(groupConfig => { + logMessage(['Received response for qortex group config', groupConfig]) + if (groupConfig?.active === true && groupConfig?.prebidBidEnrichment === true) { + setGroupConfigData(groupConfig); + initializeBidEnrichment(); + } else { + logWarn('Group config is not configured for qortex bid enrichment') + setGroupConfigData(groupConfig); + } + }) + .catch((e) => { + const errorStatus = e.message; + logWarn('Returned error status code: ' + errorStatus); + if (errorStatus == 404) { + logWarn('No Group Config found'); + } + }); + } else { + logWarn('Bid Enrichment Function has been disabled in module configuration') + } + if (config?.params?.tagConfig) { + loadScriptTag(config) + } + return true; } - if (config?.params?.tagConfig) { - loadScriptTag(config) - } - return true; } /** @@ -35,62 +57,161 @@ function init (config) { * @param {Function} callback Called on completion */ function getBidRequestData (reqBidsConfig, callback) { - if (reqBidsConfig?.adUnits?.length > 0) { + if (reqBidsConfig?.adUnits?.length > 0 && shouldAllowBidEnrichment()) { getContext() .then(contextData => { setContextData(contextData) addContextToRequests(reqBidsConfig) callback(); }) - .catch((e) => { - logWarn(e?.message); + .catch(e => { + logWarn('Returned error status code: ' + e.message); callback(); }); } else { - logWarn('No adunits found on request bids configuration: ' + JSON.stringify(reqBidsConfig)) + logWarn('Module function is paused due to configuration \n Module Config: ' + JSON.stringify(reqBidsConfig) + `\n Group Config: ${JSON.stringify(qortexSessionInfo.groupConfig) ?? 'NO GROUP CONFIG'}`) callback(); } } +/** + * Processess auction end events for Qortex reporting + * @param {Object} data Auction end object + */ +function onAuctionEndEvent (data, config, t) { + if (shouldAllowBidEnrichment()) { + sendAnalyticsEvent('AUCTION', 'AUCTION_END', attachContextAnalytics(data)) + .then(result => { + logMessage('Qortex analytics event sent') + }) + .catch(e => logWarn(e?.message)) + } +} + /** * determines whether to send a request to context api and does so if necessary * @returns {Promise} ortb Content object */ export function getContext () { - if (!currentSiteContext) { + if (qortexSessionInfo.currentSiteContext === null) { + const pageUrlObject = { pageUrl: qortexSessionInfo.indexData?.pageUrl ?? '' } logMessage('Requesting new context data'); return new Promise((resolve, reject) => { const callbacks = { success(text, data) { - const result = data.status === 200 ? JSON.parse(data.response)?.content : null; + const responseStatus = data.status; + let result = null; + if (responseStatus === 200) { + qortexSessionInfo.pageAnalysisData.contextRetrieved = true + result = JSON.parse(data.response)?.content; + } resolve(result); }, - error(error) { - reject(new Error(error)); + error(e, x) { + const responseStatus = x.status; + reject(new Error(responseStatus)); } } - ajax(requestUrl, callbacks) + ajax(qortexSessionInfo.contextUrl, callbacks, JSON.stringify(pageUrlObject), {contentType: 'application/json'}) }) } else { logMessage('Adding Content object from existing context data'); - return new Promise(resolve => resolve(currentSiteContext)); + return new Promise((resolve, reject) => resolve(qortexSessionInfo.currentSiteContext)); + } +} + +/** + * Requests Qortex group configuration using group id + * @returns {Promise} Qortex group configuration + */ +export function getGroupConfig () { + return new Promise((resolve, reject) => { + const callbacks = { + success(text, data) { + const result = data.status === 200 ? JSON.parse(data.response) : null; + resolve(result); + }, + error(e, x) { + reject(new Error(x.status)); + } + } + ajax(qortexSessionInfo.groupConfigUrl, callbacks) + }) +} + +/** + * Sends analytics events to Qortex + * @returns {Promise} + */ +export function sendAnalyticsEvent(eventType, subType, data) { + if (qortexSessionInfo.analyticsUrl !== null) { + if (shouldSendAnalytics()) { + const analtyicsEventObject = generateAnalyticsEventObject(eventType, subType, data) + logMessage('Sending qortex analytics event'); + return new Promise((resolve, reject) => { + const callbacks = { + success() { + resolve(); + }, + error(error) { + reject(new Error(error)); + } + } + ajax(qortexSessionInfo.analyticsUrl, callbacks, JSON.stringify(analtyicsEventObject), {contentType: 'application/json'}) + }) + } else { + return new Promise((resolve, reject) => reject(new Error('Current request did not meet analytics percentage threshold, cancelling sending event'))); + } + } else { + return new Promise((resolve, reject) => reject(new Error('Analytics host not initialized'))); + } +} + +/** + * Creates analytics object for Qortex + * @returns {Object} analytics object + */ +export function generateAnalyticsEventObject(eventType, subType, data) { + return { + sessionId: qortexSessionInfo.sessionId, + groupId: qortexSessionInfo.groupId, + eventType: eventType, + subType: subType, + eventOriginSource: 'RTD', + data: data + } +} + +/** + * Creates page index data for Qortex analysis + * @param qortexUrlBase api url from config or default + * @returns {string} Qortex analytics host url + */ +export function generateAnalyticsHostUrl(qortexUrlBase) { + if (qortexUrlBase === DEFAULT_API_URL) { + return 'https://events.qortex.ai/api/v1/player-event'; + } else if (qortexUrlBase.includes('stg-demand')) { + return 'https://stg-events.qortex.ai/api/v1/player-event'; + } else { + return 'https://dev-events.qortex.ai/api/v1/player-event'; } } /** * Updates bidder configs with the response from Qortex context services * @param {Object} reqBidsConfig Bid request configuration object - * @param {string[]} bidders Bidders specified in module's configuration */ export function addContextToRequests (reqBidsConfig) { - if (currentSiteContext === null) { + if (qortexSessionInfo.currentSiteContext === null) { logWarn('No context data received at this time'); } else { - const fragment = { site: {content: currentSiteContext} } - if (bidderArray?.length > 0) { - bidderArray.forEach(bidder => mergeDeep(reqBidsConfig.ortb2Fragments.bidder, {[bidder]: fragment})) - } else if (!bidderArray) { + const fragment = { site: {content: qortexSessionInfo.currentSiteContext} } + if (qortexSessionInfo.bidderArray?.length > 0) { + qortexSessionInfo.bidderArray.forEach(bidder => mergeDeep(reqBidsConfig.ortb2Fragments.bidder, {[bidder]: fragment})) + saveContextAdded(reqBidsConfig, qortexSessionInfo.bidderArray); + } else if (!qortexSessionInfo.bidderArray) { mergeDeep(reqBidsConfig.ortb2Fragments.global, fragment); + saveContextAdded(reqBidsConfig); } else { logWarn('Config contains an empty bidders array, unable to determine which bids to enrich'); } @@ -122,45 +243,119 @@ export function loadScriptTag(config) { switch (e?.detail?.type) { case 'qx-impression': const {uid} = e.detail; - if (!uid || impressionIds.has(uid)) { - logWarn(`received invalid billable event due to ${!uid ? 'missing' : 'duplicate'} uid: qx-impression`) + if (!uid || qortexSessionInfo.impressionIds.has(uid)) { + logWarn(`Received invalid billable event due to ${!uid ? 'missing' : 'duplicate'} uid: qx-impression`) return; } else { - logMessage('received billable event: qx-impression') - impressionIds.add(uid) + logMessage('Received billable event: qx-impression') + qortexSessionInfo.impressionIds.add(uid) billableEvent.transactionId = e.detail.uid; events.emit(EVENTS.BILLABLE_EVENT, billableEvent); break; } default: - logWarn(`received invalid billable event: ${e.detail?.type}`) + logWarn(`Received invalid billable event: ${e.detail?.type}`) } }) loadExternalScript(src, MODULE_TYPE_RTD, code, undefined, undefined, attr); } +export function initializeBidEnrichment() { + if (shouldAllowBidEnrichment()) { + getContext() + .then(contextData => { + if (qortexSessionInfo.pageAnalysisData.contextRetrieved) { + logMessage('Contextual record Received from Qortex API') + setContextData(contextData) + } else { + logWarn('Contexual record is not yet complete at this time') + } + }) + .catch((e) => { + const errorStatus = e.message; + logWarn('Returned error status code: ' + errorStatus) + }) + } +} /** * Helper function to set initial values when they are obtained by init * @param {Object} config module config obtained during init */ export function initializeModuleData(config) { - const DEFAULT_API_URL = 'https://demand.qortex.ai'; - const {apiUrl, groupId, bidders} = config.params; - requestUrl = `${apiUrl || DEFAULT_API_URL}/api/v1/analyze/${groupId}/prebid`; - bidderArray = bidders; - impressionIds = new Set(); - currentSiteContext = null; + const {apiUrl, groupId, bidders, enableBidEnrichment} = config.params; + const qortexUrlBase = apiUrl || DEFAULT_API_URL; + const windowUrl = window.top.location.host; + qortexSessionInfo.bidEnrichmentDisabled = enableBidEnrichment !== null ? !enableBidEnrichment : true; + qortexSessionInfo.bidderArray = bidders; + qortexSessionInfo.impressionIds = new Set(); + qortexSessionInfo.currentSiteContext = null; + qortexSessionInfo.pageAnalysisData = { + contextRetrieved: false, + contextAdded: {} + }; + qortexSessionInfo.sessionId = generateSessionId(); + qortexSessionInfo.groupId = groupId; + qortexSessionInfo.groupConfigUrl = `${qortexUrlBase}/api/v1/prebid/group/configs/${groupId}/${windowUrl}`; + qortexSessionInfo.contextUrl = `${qortexUrlBase}/api/v1/prebid/${groupId}/page/lookup`; + qortexSessionInfo.analyticsUrl = generateAnalyticsHostUrl(qortexUrlBase); + return qortexSessionInfo; +} + +export function saveContextAdded(reqBids, bidders = null) { + const id = reqBids.auctionId; + const contextBidders = bidders ?? Array.from(new Set(reqBids.adUnits.flatMap(adunit => adunit.bids.map(bid => bid.bidder)))) + qortexSessionInfo.pageAnalysisData.contextAdded[id] = contextBidders; } export function setContextData(value) { - currentSiteContext = value + qortexSessionInfo.currentSiteContext = value +} + +export function setGroupConfigData(value) { + qortexSessionInfo.groupConfig = value +} + +function generateSessionId() { + const randomInt = window.crypto.getRandomValues(new Uint32Array(1)); + const currentDateTime = Math.floor(Date.now() / 1000); + return 'QX' + randomInt.toString() + 'X' + currentDateTime.toString() +} + +function attachContextAnalytics (data) { + let qxData = {}; + let qxDataAdded = false; + if (qortexSessionInfo?.pageAnalysisData?.contextAdded[data.auctionId]) { + qxData = qortexSessionInfo.currentSiteContext; + qxDataAdded = true; + } + data.qortexData = qxData; + data.qortexDataAdded = qxDataAdded; + return data; +} + +function shouldSendAnalytics() { + const analyticsPercentage = qortexSessionInfo.groupConfig?.prebidReportingPercentage ?? 0; + const randomInt = Math.random().toFixed(5) * 100; + return analyticsPercentage > randomInt; +} + +function shouldAllowBidEnrichment() { + if (qortexSessionInfo.bidEnrichmentDisabled) { + logWarn('Bid enrichment disabled at prebid config') + return false; + } else if (!qortexSessionInfo.groupConfig?.prebidBidEnrichment) { + logWarn('Bid enrichment disabled at group config') + return false; + } + return true } export const qortexSubmodule = { name: 'qortex', init, - getBidRequestData + getBidRequestData, + onAuctionEndEvent } submodule('realTimeData', qortexSubmodule); diff --git a/modules/qortexRtdProvider.md b/modules/qortexRtdProvider.md index 312696068cd..b9a08eb817a 100644 --- a/modules/qortexRtdProvider.md +++ b/modules/qortexRtdProvider.md @@ -12,7 +12,7 @@ Maintainer: mannese@qortex.ai The Qortex RTD module appends contextual segments to the bidding object based on the content of a page using the Qortex API. -Upon load, the Qortex context API will analyze the bidder page (video, text, image, etc.) and will return a [Content object](https://www.iab.com/wp-content/uploads/2016/03/OpenRTB-API-Specification-Version-2-5-FINAL.pdf#page=26). The module will then merge that object into the appropriate bidders' `ortb2.site.content`, which can be used by prebid adapters that use `site.content` data. +If the `Qortex Group Id` and module parameters provided during configuration is active, the Qortex context API will attempt to generate and return a [Content object](https://www.iab.com/wp-content/uploads/2016/03/OpenRTB-API-Specification-Version-2-5-FINAL.pdf#page=26) using indexed data from provided page content. The module will then merge that object into the appropriate bidders' `ortb2.site.content`, which can be used by prebid adapters that use `site.content` data. ## Build @@ -40,13 +40,8 @@ pbjs.setConfig({ params: { groupId: 'ABC123', //required bidders: ['qortex', 'adapter2'], //optional (see below) - tagConfig: { // optional, please reach out to your account manager for configuration reccommendation - videoContainer: 'string', - htmlContainer: 'string', - attachToTop: 'string', - esm6Mod: 'string', - continuousLoad: 'string' - } + enableBidEnrichment: true, //optional (see below) + tagConfig: { } // optional, please reach out to your account manager for configuration reccommendation } }] } @@ -63,7 +58,11 @@ pbjs.setConfig({ - If this parameter is omitted, the RTD module will default to updating `ortb2.site.content` on *all* bid adapters being used on the page +#### `enableBidEnrichment` - optional +- This optional parameter allows a publisher to opt-in to the features of the RTD module that use our API to enrich bids with first party data for contextuality. Enabling this feature will allow this module to interact with the Qortex AI contextuality server for indexing and analysis. Please use caution when adding this module to pages that may contain personal user data or proprietary information. + #### `tagConfig` - optional - This optional parameter is an object containing the config settings that could be usedto initialize the Qortex integration on your page. A preconfigured object for this step will be provided to you by the Qortex team. -- If this parameter is not present, the Qortex integration can still be configured and loaded manually on your page outside of prebid. The RTD module will continue to initialize and operate as normal. \ No newline at end of file +- If this parameter is not present, the Qortex integration can still be configured and loaded manually on your page outside of prebid. The RTD module will continue to initialize and operate as normal. + \ No newline at end of file diff --git a/test/spec/modules/qortexRtdProvider_spec.js b/test/spec/modules/qortexRtdProvider_spec.js index c9f92e8af67..b1a4195fb37 100644 --- a/test/spec/modules/qortexRtdProvider_spec.js +++ b/test/spec/modules/qortexRtdProvider_spec.js @@ -1,21 +1,27 @@ -import * as utils from 'src/utils'; -import * as ajax from 'src/ajax.js'; +import * as utils from 'src/utils.js'; import * as events from 'src/events.js'; import { EVENTS } from '../../../src/constants.js'; import {loadExternalScript} from 'src/adloader.js'; import { qortexSubmodule as module, getContext, + getGroupConfig, + generateAnalyticsEventObject, + generateAnalyticsHostUrl, addContextToRequests, setContextData, + loadScriptTag, initializeModuleData, - loadScriptTag + setGroupConfigData, + saveContextAdded, + initializeBidEnrichment } from '../../../modules/qortexRtdProvider'; import {server} from '../../mocks/xhr.js'; import { cloneDeep } from 'lodash'; describe('qortexRtdProvider', () => { let logWarnSpy; + let logMessageSpy; let ortb2Stub; const defaultApiHost = 'https://demand.qortex.ai'; @@ -27,45 +33,53 @@ describe('qortexRtdProvider', () => { } const validModuleConfig = { - params: { - groupId: defaultGroupId, - apiUrl: defaultApiHost, - bidders: validBidderArray - } - }, - emptyModuleConfig = { - params: {} + params: { + groupId: defaultGroupId, + apiUrl: defaultApiHost, + bidders: validBidderArray, + enableBidEnrichment: true + } + } + const bidEnrichmentDisabledModuleConfig = { + params: { + groupId: defaultGroupId, + apiUrl: defaultApiHost, + bidders: validBidderArray } + } + const emptyModuleConfig = { + params: {} + } const validImpressionEvent = { - detail: { - uid: 'uid123', - type: 'qx-impression' - } - }, - validImpressionEvent2 = { - detail: { - uid: 'uid1234', - type: 'qx-impression' - } - }, - missingIdImpressionEvent = { - detail: { - type: 'qx-impression' - } - }, - invalidTypeQortexEvent = { - detail: { - type: 'invalid-type' - } + detail: { + uid: 'uid123', + type: 'qx-impression' + } + } + const validImpressionEvent2 = { + detail: { + uid: 'uid1234', + type: 'qx-impression' } + } + const missingIdImpressionEvent = { + detail: { + type: 'qx-impression' + } + } + const invalidTypeQortexEvent = { + detail: { + type: 'invalid-type' + } + } const responseHeaders = { 'content-type': 'application/json', 'access-control-allow-origin': '*' }; - const responseObj = { + const contextResponseObj = { content: { id: '123456', episode: 15, @@ -74,11 +88,27 @@ describe('qortexRtdProvider', () => { season: '1', url: 'https://example.com/file.mp4' } - }; + } + const contextResponse = JSON.stringify(contextResponseObj); - const apiResponse = JSON.stringify(responseObj); + const validGroupConfigResponseObj = { + groupId: defaultGroupId, + active: true, + prebidBidEnrichment: true, + prebidReportingPercentage: 100 + } + const validGroupConfigResponse = JSON.stringify(validGroupConfigResponseObj); + + const inactiveGroupConfigResponseObj = { + groupId: defaultGroupId, + active: false, + PrebidBidEnrichment: true, + PrebidReportingPercentage: 100 + } + const inactiveGroupConfigResponse = JSON.stringify(inactiveGroupConfigResponseObj); const reqBidsConfig = { + auctionId: '1234', adUnits: [{ bids: [ { bidder: 'qortex' } @@ -93,17 +123,51 @@ describe('qortexRtdProvider', () => { beforeEach(() => { ortb2Stub = sinon.stub(reqBidsConfig, 'ortb2Fragments').value({bidder: {}, global: {}}) logWarnSpy = sinon.spy(utils, 'logWarn'); + logMessageSpy = sinon.spy(utils, 'logMessage'); }) afterEach(() => { logWarnSpy.restore(); + logMessageSpy.restore(); ortb2Stub.restore(); setContextData(null); }) describe('init', () => { - it('returns true for valid config object', () => { - expect(module.init(validModuleConfig)).to.be.true; + it('returns true for valid config object', (done) => { + const result = module.init(validModuleConfig); + expect(server.requests.length).to.be.eql(1) + const groupConfigReq = server.requests[0]; + groupConfigReq.respond(200, responseHeaders, validGroupConfigResponse); + setTimeout(() => { + expect(result).to.be.true; + done() + }, 500) + }) + + it('logs warning when group config does not pass setup conditions', (done) => { + const result = module.init(validModuleConfig); + expect(server.requests.length).to.be.eql(1) + const groupConfigReq = server.requests[0]; + groupConfigReq.respond(200, responseHeaders, inactiveGroupConfigResponse); + setTimeout(() => { + expect(logWarnSpy.calledWith('Group config is not configured for qortex bid enrichment')).to.be.true; + done() + }, 500) + }) + + it('logs warning when group config request errors', (done) => { + const result = module.init(validModuleConfig); + server.requests[0].respond(404, responseHeaders, inactiveGroupConfigResponse); + setTimeout(() => { + expect(logWarnSpy.calledWith('No Group Config found')).to.be.true; + done() + }, 500) + }) + + it('will not initialize bid enrichment if it is disabled', () => { + module.init(bidEnrichmentDisabledModuleConfig); + expect(logWarnSpy.calledWith('Bid Enrichment Function has been disabled in module configuration')).to.be.true; }) it('returns false and logs error for missing groupId', () => { @@ -168,21 +232,21 @@ describe('qortexRtdProvider', () => { dispatchEvent(new CustomEvent('qortex-rtd', validImpressionEvent)); dispatchEvent(new CustomEvent('qortex-rtd', validImpressionEvent)); expect(billableEvents.length).to.be.equal(1); - expect(logWarnSpy.calledWith('received invalid billable event due to duplicate uid: qx-impression')).to.be.ok; + expect(logWarnSpy.calledWith('Received invalid billable event due to duplicate uid: qx-impression')).to.be.ok; }) it('will not allow events with missing uid', () => { loadScriptTag(config); dispatchEvent(new CustomEvent('qortex-rtd', missingIdImpressionEvent)); expect(billableEvents.length).to.be.equal(0); - expect(logWarnSpy.calledWith('received invalid billable event due to missing uid: qx-impression')).to.be.ok; + expect(logWarnSpy.calledWith('Received invalid billable event due to missing uid: qx-impression')).to.be.ok; }) it('will not allow events with unavailable type', () => { loadScriptTag(config); dispatchEvent(new CustomEvent('qortex-rtd', invalidTypeQortexEvent)); expect(billableEvents.length).to.be.equal(0); - expect(logWarnSpy.calledWith('received invalid billable event: invalid-type')).to.be.ok; + expect(logWarnSpy.calledWith('Received invalid billable event: invalid-type')).to.be.ok; }) }) @@ -191,7 +255,9 @@ describe('qortexRtdProvider', () => { beforeEach(() => { initializeModuleData(validModuleConfig); + setGroupConfigData(validGroupConfigResponseObj); callbackSpy = sinon.spy(); + server.reset(); }) afterEach(() => { @@ -203,26 +269,85 @@ describe('qortexRtdProvider', () => { const reqBidsConfigNoBids = { adUnits: [] }; module.getBidRequestData(reqBidsConfigNoBids, callbackSpy); expect(callbackSpy.calledOnce).to.be.true; - expect(logWarnSpy.calledWith('No adunits found on request bids configuration: ' + JSON.stringify(reqBidsConfigNoBids))).to.be.ok; + expect(logWarnSpy.calledOnce).to.be.true; }) - it('will call callback if getContext does not throw', () => { + it('will call callback if getContext does not throw', (done) => { const cb = function () { expect(logWarnSpy.calledOnce).to.be.false; done(); } module.getBidRequestData(reqBidsConfig, cb); - server.requests[0].respond(200, responseHeaders, apiResponse); + server.requests[0].respond(200, responseHeaders, contextResponse); }) it('will catch and log error and fire callback', (done) => { - const a = sinon.stub(ajax, 'ajax').throws(new Error('test')); + module.getBidRequestData(reqBidsConfig, callbackSpy); + server.requests[0].respond(404, responseHeaders, JSON.stringify({})); + setTimeout(() => { + expect(logWarnSpy.calledWith('Returned error status code: 404')).to.be.eql(true); + expect(callbackSpy.calledOnce).to.be.true; + done(); + }, 250) + }) + + it('will not request context if group config toggle is false', (done) => { + setGroupConfigData(inactiveGroupConfigResponseObj); + const cb = function () { + expect(server.requests.length).to.be.eql(0); + expect(logWarnSpy.called).to.be.true; + expect(logWarnSpy.calledWith('Bid enrichment disabled at group config')).to.be.true; + done(); + } + module.getBidRequestData(reqBidsConfig, cb); + }) + it('will not request context if prebid disable toggle is true', (done) => { + initializeModuleData(bidEnrichmentDisabledModuleConfig); const cb = function () { - expect(logWarnSpy.calledWith('test')).to.be.eql(true); + expect(server.requests.length).to.be.eql(0); + expect(logWarnSpy.called).to.be.true; + expect(logWarnSpy.calledWith('Bid enrichment disabled at prebid config')).to.be.true; done(); } module.getBidRequestData(reqBidsConfig, cb); - a.restore(); + }) + }) + + describe('onAuctionEndEvent', () => { + beforeEach(() => { + initializeModuleData(validModuleConfig); + setGroupConfigData(validGroupConfigResponseObj); + }) + + afterEach(() => { + initializeModuleData(emptyModuleConfig); + setGroupConfigData(null); + }) + + it('Properly sends analytics event with valid config', (done) => { + saveContextAdded(reqBidsConfig); + const testData = {auctionId: reqBidsConfig.auctionId, data: 'data'}; + module.onAuctionEndEvent(testData); + const request = server.requests[0]; + expect(request.url).to.be.eql('https://events.qortex.ai/api/v1/player-event'); + server.requests[0].respond(200, responseHeaders, JSON.stringify({})); + setTimeout(() => { + expect(logMessageSpy.calledWith('Qortex analytics event sent')).to.be.true + done(); + }, 200) + }) + + it('Logs warning for rejected analytics request', (done) => { + const invalidPercentageConfig = cloneDeep(validGroupConfigResponseObj); + invalidPercentageConfig.prebidReportingPercentage = -1; + setGroupConfigData(invalidPercentageConfig); + const testData = {data: 'data'}; + module.onAuctionEndEvent(testData); + expect(server.requests.length).to.be.eql(0); + setTimeout(() => { + expect(logWarnSpy.calledWith('Current request did not meet analytics percentage threshold, cancelling sending event')).to.be.true + done(); + }, 200) }) }) @@ -235,38 +360,27 @@ describe('qortexRtdProvider', () => { initializeModuleData(emptyModuleConfig); }) - it('returns a promise', (done) => { + it('returns a promise', () => { const result = getContext(); expect(result).to.be.a('promise'); - done(); }) it('uses request url generated from initialize function in config and resolves to content object data', (done) => { - let requestUrl = `${validModuleConfig.params.apiUrl}/api/v1/analyze/${validModuleConfig.params.groupId}/prebid`; + let requestUrl = `${validModuleConfig.params.apiUrl}/api/v1/prebid/${validModuleConfig.params.groupId}/page/lookup`; const ctx = getContext() - expect(server.requests.length).to.be.eql(1); - expect(server.requests[0].url).to.be.eql(requestUrl); - server.requests[0].respond(200, responseHeaders, apiResponse); + const request = server.requests[0] + request.respond(200, responseHeaders, contextResponse); ctx.then(response => { - expect(response).to.be.eql(responseObj.content); - done(); - }); - }) - - it('will return existing context data instead of ajax call if the source was not updated', (done) => { - setContextData(responseObj.content); - const ctx = getContext(); - expect(server.requests.length).to.be.eql(0); - ctx.then(response => { - expect(response).to.be.eql(responseObj.content); + expect(server.requests.length).to.be.eql(1); + expect(request.url).to.be.eql(requestUrl); + expect(response).to.be.eql(contextResponseObj.content); done(); }); }) - it('returns null for non erroring api responses other than 200', (done) => { - const nullContentResponse = { content: null } + it('returns null when necessary', (done) => { const ctx = getContext() - server.requests[0].respond(200, responseHeaders, JSON.stringify(nullContentResponse)) + server.requests[0].respond(202, responseHeaders, JSON.stringify({})) ctx.then(response => { expect(response).to.be.null; expect(server.requests.length).to.be.eql(1); @@ -276,7 +390,7 @@ describe('qortexRtdProvider', () => { }) }) - describe(' addContextToRequests', () => { + describe('addContextToRequests', () => { it('logs error if no data was retrieved from get context call', () => { initializeModuleData(validModuleConfig); addContextToRequests(reqBidsConfig); @@ -290,30 +404,30 @@ describe('qortexRtdProvider', () => { const omittedBidderArrayConfig = cloneDeep(validModuleConfig); delete omittedBidderArrayConfig.params.bidders; initializeModuleData(omittedBidderArrayConfig); - setContextData(responseObj.content); + setContextData(contextResponseObj.content); addContextToRequests(reqBidsConfig); expect(reqBidsConfig.ortb2Fragments.global).to.have.property('site'); expect(reqBidsConfig.ortb2Fragments.global.site).to.have.property('content'); - expect(reqBidsConfig.ortb2Fragments.global.site.content).to.be.eql(responseObj.content); + expect(reqBidsConfig.ortb2Fragments.global.site.content).to.be.eql(contextResponseObj.content); expect(reqBidsConfig.ortb2Fragments.bidder).to.be.eql({}); }) it('adds site.content only to bidder ortb2 when bidders array is included', () => { initializeModuleData(validModuleConfig); - setContextData(responseObj.content); + setContextData(contextResponseObj.content); addContextToRequests(reqBidsConfig); const qortexOrtb2Fragment = reqBidsConfig.ortb2Fragments.bidder['qortex'] expect(qortexOrtb2Fragment).to.not.be.null; expect(qortexOrtb2Fragment).to.have.property('site'); expect(qortexOrtb2Fragment.site).to.have.property('content'); - expect(qortexOrtb2Fragment.site.content).to.be.eql(responseObj.content); + expect(qortexOrtb2Fragment.site.content).to.be.eql(contextResponseObj.content); const testOrtb2Fragment = reqBidsConfig.ortb2Fragments.bidder['test'] expect(testOrtb2Fragment).to.not.be.null; expect(testOrtb2Fragment).to.have.property('site'); expect(testOrtb2Fragment.site).to.have.property('content'); - expect(testOrtb2Fragment.site.content).to.be.eql(responseObj.content); + expect(testOrtb2Fragment.site.content).to.be.eql(contextResponseObj.content); expect(reqBidsConfig.ortb2Fragments.global).to.be.eql({}); }) @@ -322,7 +436,7 @@ describe('qortexRtdProvider', () => { const invalidBidderArrayConfig = cloneDeep(validModuleConfig); invalidBidderArrayConfig.params.bidders = []; initializeModuleData(invalidBidderArrayConfig); - setContextData(responseObj.content) + setContextData(contextResponseObj.content) addContextToRequests(reqBidsConfig); expect(logWarnSpy.calledWith('Config contains an empty bidders array, unable to determine which bids to enrich')).to.be.ok; @@ -330,4 +444,117 @@ describe('qortexRtdProvider', () => { expect(reqBidsConfig.ortb2Fragments.bidder).to.be.eql({}); }) }) + + describe('generateAnalyticsEventObject', () => { + let qortexSessionInfo; + beforeEach(() => { + qortexSessionInfo = initializeModuleData(validModuleConfig); + setGroupConfigData(validGroupConfigResponseObj); + }) + + afterEach(() => { + initializeModuleData(emptyModuleConfig); + setGroupConfigData(null); + }) + + it('returns expected object', () => { + const testEventType = 'TEST'; + const testSubType = 'TEST_SUBTYPE'; + const testData = {data: 'data'}; + + const result = generateAnalyticsEventObject(testEventType, testSubType, testData); + + expect(result.sessionId).to.be.eql(qortexSessionInfo.sessionId); + expect(result.groupId).to.be.eql(qortexSessionInfo.groupId); + expect(result.eventType).to.be.eql(testEventType); + expect(result.subType).to.be.eql(testSubType); + expect(result.eventOriginSource).to.be.eql('RTD'); + expect(result.data).to.be.eql(testData); + }) + }) + + describe('generateAnalyticsHostUrl', () => { + it('will use qortex analytics host when appropriate', () => { + const hostUrl = generateAnalyticsHostUrl(defaultApiHost); + expect(hostUrl).to.be.eql('https://events.qortex.ai/api/v1/player-event'); + }) + + it('will use qortex stage analytics host when appropriate', () => { + const hostUrl = generateAnalyticsHostUrl('https://stg-demand.qortex.ai'); + expect(hostUrl).to.be.eql('https://stg-events.qortex.ai/api/v1/player-event'); + }) + + it('will default to dev analytics host when appropriate', () => { + const hostUrl = generateAnalyticsHostUrl('https://dev-demand.qortex.ai'); + expect(hostUrl).to.be.eql('https://dev-events.qortex.ai/api/v1/player-event'); + }) + }) + + describe('getGroupConfig', () => { + let sessionInfo; + + beforeEach(() => { + sessionInfo = initializeModuleData(validModuleConfig); + }) + + afterEach(() => { + initializeModuleData(emptyModuleConfig); + setGroupConfigData(null); + setContextData(null); + server.reset(); + }) + + it('returns a promise', () => { + const result = getGroupConfig(); + expect(result).to.be.a('promise'); + }) + + it('processes group config response in valid conditions', (done) => { + const result = getGroupConfig(); + const request = server.requests[0] + request.respond(200, responseHeaders, validGroupConfigResponse); + result.then(response => { + expect(request.url).to.be.eql(sessionInfo.groupConfigUrl); + expect(response.groupId).to.be.eql(validGroupConfigResponseObj.groupId); + expect(response.active).to.be.eql(validGroupConfigResponseObj.active); + expect(response.prebidBidEnrichment).to.be.eql(validGroupConfigResponseObj.prebidBidEnrichment); + expect(response.prebidReportingPercentage).to.be.eql(validGroupConfigResponseObj.prebidReportingPercentage); + done(); + }) + }) + }) + + describe('initializeBidEnrichment', () => { + beforeEach(() => { + initializeModuleData(validModuleConfig); + setGroupConfigData(validGroupConfigResponseObj); + setContextData(null); + server.reset(); + }) + + afterEach(() => { + initializeModuleData(emptyModuleConfig); + setGroupConfigData(null); + setContextData(null); + server.reset(); + }) + + it('sets context data if applicable', (done) => { + initializeBidEnrichment(); + server.requests[0].respond(200, responseHeaders, contextResponse); + setTimeout(() => { + expect(logMessageSpy.calledWith('Contextual record Received from Qortex API')).to.be.true; + done() + }, 250) + }) + + it('logs warning if no record has been made', (done) => { + initializeBidEnrichment(); + server.requests[0].respond(202, responseHeaders, JSON.stringify({})); + setTimeout(() => { + expect(logWarnSpy.calledWith('Contexual record is not yet complete at this time')).to.be.true; + done(); + }, 250) + }) + }) })