diff --git a/modules/performaxBidAdapter.js b/modules/performaxBidAdapter.js index 48dd4366f1..147d5a8aeb 100644 --- a/modules/performaxBidAdapter.js +++ b/modules/performaxBidAdapter.js @@ -1,12 +1,94 @@ -import { deepSetValue, deepAccess } from '../src/utils.js'; +import { logWarn, logError, deepSetValue, deepAccess, safeJSONEncode } from '../src/utils.js'; import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER } from '../src/mediaTypes.js' import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { ajax } from '../src/ajax.js'; const BIDDER_CODE = 'performax'; const BIDDER_SHORT_CODE = 'px'; const GVLID = 732 const ENDPOINT = 'https://dale.performax.cz/ortb' +const USER_SYNC_URL = 'https://cdn.performax.cz/px2/cookie_sync_bundle.html'; +const USER_SYNC_ORIGIN = 'https://cdn.performax.cz'; +const UIDS_STORAGE_KEY = BIDDER_SHORT_CODE + '_uids'; +const LOG_EVENT_URL = 'https://chip.performax.cz/error'; +const LOG_EVENT_SAMPLE_RATE = 1; +const LOG_EVENT_TYPE_BIDDER_ERROR = 'bidderError'; +const LOG_EVENT_TYPE_INTERVENTION = 'intervention'; +const LOG_EVENT_TYPE_TIMEOUT = 'timeout'; + +let isUserSyncsInit = false; + +export const storage = getStorageManager({ bidderCode: BIDDER_CODE }); + +/** + * Sends diagnostic events. + * @param {string} type - The category of the event + * @param {Object|Array|string} payload - The data to be logged + * @param {number} [sampleRate=LOG_EVENT_SAMPLE_RATE] - The probability of logging the event + * @returns {void} + */ +function logEvent(type, payload, sampleRate = LOG_EVENT_SAMPLE_RATE) { + if (sampleRate <= Math.random()) { + return; + } + + const data = { type, payload }; + const options = { method: 'POST', withCredentials: true, contentType: 'application/json' }; + + ajax(LOG_EVENT_URL, undefined, safeJSONEncode(data), options); +} + +/** + * Serializes and stores data. + * @param {string} key - The unique identifier + * @param {any} value - The data to store + * @returns {void} + */ +export function storeData(key, value) { + if (!storage.localStorageIsEnabled()) { + logWarn('Local Storage is not enabled'); + return; + } + + try { + storage.setDataInLocalStorage(key, JSON.stringify(value)); + } catch (err) { + logError('Failed to store data: ', err); + } +} + +/** + * Retrieves and parses data. + * @param {string} key - The unique identifier + * @param {any} defaultValue - The value to return if the key is missing or parsing fails. + * @returns {any} The parsed data + */ +export function readData(key, defaultValue) { + if (!storage.localStorageIsEnabled()) { + logWarn('Local Storage is not enabled'); + return defaultValue; + } + + let rawData = storage.getDataFromLocalStorage(key); + + if (rawData === null) { + return defaultValue; + } + + try { + return JSON.parse(rawData) || {}; + } catch (err) { + logError(`Error parsing data for key "${key}": `, err); + return defaultValue; + } +} + +export function resetUserSyncsInit() { + isUserSyncsInit = false; +} + export const converter = ortbConverter({ imp(buildImp, bidRequest, context) { @@ -40,6 +122,23 @@ export const spec = { buildRequests: function (bidRequests, bidderRequest) { const data = converter.toORTB({bidderRequest, bidRequests}) + + const uids = readData(UIDS_STORAGE_KEY, {}); + if (Object.keys(uids).length > 0) { + if (!data.user) { + data.user = {}; + } + + if (!data.user.ext) { + data.user.ext = {}; + } + + data.user.ext.uids = { + ...uids, + ...(data.user.ext.uids ?? {}) + }; + } + return [{ method: 'POST', url: ENDPOINT, @@ -71,7 +170,59 @@ export const spec = { }; return converter.fromORTB({ response: data, request: request.data }).bids }, + getUserSyncs: function(syncOptions, serverResponses, gdprConsent) { + const syncs = []; + + if (!syncOptions.iframeEnabled) { + logWarn('Please enable iframe based user sync.'); + return syncs; + } + + let url = USER_SYNC_URL; + + if (gdprConsent) { + if (typeof gdprConsent.gdprApplies === 'boolean') { + url += `?gdpr=${Number(gdprConsent.gdprApplies)}&gdpr_consent=${gdprConsent.consentString}`; + } else { + url += `?gdpr_consent=${gdprConsent.consentString}`; + } + } + syncs.push({ + type: 'iframe', + url: url + }); + + if (!isUserSyncsInit) { + window.addEventListener('message', function (event) { + if (!event.data || event.origin !== USER_SYNC_ORIGIN || !event.data.flexo_sync_cookie) { + return; + } + + const { uid, vendor } = event.data.flexo_sync_cookie; + + if (!uid || !vendor) { + return; + } + + const uids = readData(UIDS_STORAGE_KEY, {}); + uids[vendor] = uid; + storeData(UIDS_STORAGE_KEY, uids); + }); + isUserSyncsInit = true; + } + + return syncs; + }, + onTimeout: function(timeoutData) { + logEvent(LOG_EVENT_TYPE_TIMEOUT, timeoutData); + }, + onBidderError: function({ bidderRequest }) { + logEvent(LOG_EVENT_TYPE_BIDDER_ERROR, bidderRequest); + }, + onIntervention: function({ bid }) { + logEvent(LOG_EVENT_TYPE_INTERVENTION, bid); + } } registerBidder(spec); diff --git a/test/spec/modules/performaxBidAdapter_spec.js b/test/spec/modules/performaxBidAdapter_spec.js index 218f9402e7..5d072705d0 100644 --- a/test/spec/modules/performaxBidAdapter_spec.js +++ b/test/spec/modules/performaxBidAdapter_spec.js @@ -1,5 +1,8 @@ import { expect } from 'chai'; -import { spec, converter } from 'modules/performaxBidAdapter.js'; +import { spec, converter, storeData, readData, storage, resetUserSyncsInit } from 'modules/performaxBidAdapter.js'; +import * as utils from '../../../src/utils.js'; +import * as ajax from 'src/ajax.js'; +import sinon from 'sinon'; describe('Performax adapter', function () { const bids = [{ @@ -120,6 +123,56 @@ describe('Performax adapter', function () { }) describe('buildRequests', function () { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should inject stored UIDs into user.ext.uids if they exist', function() { + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'getDataFromLocalStorage') + .withArgs('px_uids') // BIDDER_SHORT_CODE + '_uids' + .returns(JSON.stringify({ someVendor: '12345' })); + + const requests = spec.buildRequests(bids, bidderRequest); + const data = requests[0].data; + + expect(data.user).to.exist; + expect(data.user.ext).to.exist; + expect(data.user.ext.uids).to.deep.include({ someVendor: '12345' }); + }); + + it('should merge stored UIDs with existing user.ext.uids (preserving existing)', function() { + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'getDataFromLocalStorage') + .withArgs('px_uids') + .returns(JSON.stringify({ storedVendor: 'storedId' })); + + const requestWithUids = { + ...bidderRequest, + ortb2: { + user: { + ext: { + uids: { existingVendor: 'existingId' } + } + } + } + }; + + const requests = spec.buildRequests(bids, requestWithUids); + const data = requests[0].data; + + expect(data.user.ext.uids).to.deep.equal({ + existingVendor: 'existingId', + storedVendor: 'storedId' + }); + }); + it('should set correct request method and url', function () { const requests = spec.buildRequests([bids[0]], bidderRequest); expect(requests).to.be.an('array').that.has.lengthOf(1); @@ -154,6 +207,11 @@ describe('Performax adapter', function () { }); describe('interpretResponse', function () { + it('should return an empty array if the response body is missing', function () { + const result = spec.interpretResponse({}, {}); + expect(result).to.deep.equal([]); + }); + it('should map params correctly', function () { const ortbRequest = {data: converter.toORTB({bidderRequest, bids})}; serverResponse.body.id = ortbRequest.data.id; @@ -172,4 +230,275 @@ describe('Performax adapter', function () { expect(bid.creativeId).to.equal('sample'); }); }); + + describe('Storage Helpers', () => { + let sandbox; + let logWarnSpy; + let logErrorSpy; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(storage, 'localStorageIsEnabled'); + sandbox.stub(storage, 'setDataInLocalStorage'); + sandbox.stub(storage, 'getDataFromLocalStorage'); + + logWarnSpy = sandbox.stub(utils, 'logWarn'); + logErrorSpy = sandbox.stub(utils, 'logError'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('storeData', () => { + it('should store serialized data when local storage is enabled', () => { + storage.localStorageIsEnabled.returns(true); + const testData = { foo: 'bar' }; + + storeData('testKey', testData); + + sandbox.assert.calledWithExactly( + storage.setDataInLocalStorage, + 'testKey', + JSON.stringify(testData) + ); + }); + + it('should log a warning and exit if local storage is disabled', () => { + storage.localStorageIsEnabled.returns(false); + + storeData('testKey', { foo: 'bar' }); + + expect(storage.setDataInLocalStorage.called).to.be.false; + sandbox.assert.calledOnce(logWarnSpy); + }); + + it('should log an error if setDataInLocalStorage throws', () => { + storage.localStorageIsEnabled.returns(true); + storage.setDataInLocalStorage.throws(new Error('QuotaExceeded')); + + storeData('testKey', 'someValue'); + + sandbox.assert.calledOnce(logErrorSpy); + }); + }); + + describe('readData', () => { + it('should return parsed data when it exists in storage', () => { + storage.localStorageIsEnabled.returns(true); + const mockValue = { id: 123 }; + storage.getDataFromLocalStorage.withArgs('myKey').returns(JSON.stringify(mockValue)); + + const result = readData('myKey', {}); + + expect(result).to.deep.equal(mockValue); + }); + + it('should return defaultValue if local storage is disabled', () => { + storage.localStorageIsEnabled.returns(false); + const defaultValue = { status: 'default' }; + + const result = readData('myKey', defaultValue); + + expect(result).to.equal(defaultValue); + sandbox.assert.calledOnce(logWarnSpy); + }); + + it('should return defaultValue if the key does not exist (returns null)', () => { + storage.localStorageIsEnabled.returns(true); + storage.getDataFromLocalStorage.returns(null); + + const result = readData('missingKey', 'fallback'); + + expect(result).to.equal('fallback'); + }); + + it('should return defaultValue and log an error if JSON is malformed', () => { + storage.localStorageIsEnabled.returns(true); + storage.getDataFromLocalStorage.returns('not-valid-json{'); + + const result = readData('badKey', { error: true }); + + expect(result).to.deep.equal({ error: true }); + sandbox.assert.calledOnce(logErrorSpy); + }); + }); + }); + + describe('logging', function () { + let ajaxStub; + let randomStub; + + beforeEach(() => { + ajaxStub = sinon.stub(ajax, 'ajax'); + randomStub = sinon.stub(Math, 'random').returns(0); + }); + + afterEach(() => { + ajaxStub.restore(); + randomStub.restore(); + }); + + it('should call ajax when onTimeout is triggered', function () { + const timeoutData = [{ bidId: '123' }]; + spec.onTimeout(timeoutData); + + expect(ajaxStub.calledOnce).to.be.true; + + const [url, callback, data, options] = ajaxStub.firstCall.args; + const parsedData = JSON.parse(data); + + expect(parsedData.type).to.equal('timeout'); + expect(parsedData.payload).to.deep.equal(timeoutData); + expect(options.method).to.equal('POST'); + }); + + it('should call ajax when onBidderError is triggered', function () { + const errorData = { bidderRequest: { some: 'data' } }; + spec.onBidderError(errorData); + + expect(ajaxStub.calledOnce).to.be.true; + + const [url, callback, data] = ajaxStub.firstCall.args; + const parsedData = JSON.parse(data); + + expect(parsedData.type).to.equal('bidderError'); + expect(parsedData.payload).to.deep.equal(errorData.bidderRequest); + }); + + it('should NOT call ajax if sampling logic fails', function () { + randomStub.returns(1.1); + + spec.onTimeout({}); + expect(ajaxStub.called).to.be.false; + }); + + it('should call ajax with correct type "intervention"', function () { + const bidData = { bidId: 'abc' }; + spec.onIntervention({ bid: bidData }); + + expect(ajaxStub.calledOnce).to.be.true; + const [url, callback, data] = ajaxStub.firstCall.args; + const parsed = JSON.parse(data); + + expect(parsed.type).to.equal('intervention'); + expect(parsed.payload).to.deep.equal(bidData); + }); + }); + + describe('getUserSyncs', function () { + let sandbox; + let logWarnSpy; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + logWarnSpy = sandbox.stub(utils, 'logWarn'); + sandbox.stub(storage, 'localStorageIsEnabled').returns(true); + sandbox.stub(storage, 'setDataInLocalStorage'); + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + resetUserSyncsInit(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return empty array and log warning if iframeEnabled is false', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: false }); + expect(syncs).to.deep.equal([]); + expect(logWarnSpy.calledOnce).to.be.true; + }); + + it('should return correct iframe sync url without GDPR', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true }); + expect(syncs).to.have.lengthOf(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.equal('https://cdn.performax.cz/px2/cookie_sync_bundle.html'); + }); + + it('should append GDPR params when gdprApplies is a boolean', function () { + const consent = { gdprApplies: true, consentString: 'abc' }; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], consent); + + expect(syncs[0].url).to.include('?gdpr=1&gdpr_consent=abc'); + }); + + it('should append GDPR params when gdprApplies is undefined/non-boolean', function () { + const consent = { gdprApplies: undefined, consentString: 'abc' }; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], consent); + + expect(syncs[0].url).to.include('?gdpr_consent=abc'); + }); + + describe('PostMessage Listener', function () { + it('should store data when valid message is received', function () { + const addEventListenerStub = sandbox.stub(window, 'addEventListener'); + spec.getUserSyncs({ iframeEnabled: true }); + expect(addEventListenerStub.calledWith('message')).to.be.true; + const callback = addEventListenerStub.args.find(arg => arg[0] === 'message')[1]; + + const mockEvent = { + origin: 'https://cdn.performax.cz', + data: { + flexo_sync_cookie: { + uid: 'user123', + vendor: 'vendorXYZ' + } + } + }; + + callback(mockEvent); + + expect(storage.setDataInLocalStorage.calledOnce).to.be.true; + + const [key, value] = storage.setDataInLocalStorage.firstCall.args; + expect(key).to.equal('px_uids'); + expect(JSON.parse(value)).to.deep.equal({ + vendorXYZ: 'user123' + }); + }); + + it('should ignore messages from invalid origins', function () { + const addEventListenerStub = sandbox.stub(window, 'addEventListener'); + spec.getUserSyncs({ iframeEnabled: true }); + + const callback = addEventListenerStub.args.find(arg => arg[0] === 'message')[1]; + + const mockEvent = { + origin: 'https://not.cdn.performax.cz', + data: { flexo_sync_cookie: { uid: '1', vendor: '2' } } + }; + + callback(mockEvent); + + expect(storage.setDataInLocalStorage.called).to.be.false; + }); + + it('should ignore messages with missing structure', function () { + const addEventListenerStub = sandbox.stub(window, 'addEventListener'); + spec.getUserSyncs({ iframeEnabled: true }); + + const callback = addEventListenerStub.args.find(arg => arg[0] === 'message')[1]; + + const mockEvent = { + origin: 'https://cdn.performax.cz', + data: { wrong_key: 123 } // Missing flexo_sync_cookie + }; + + callback(mockEvent); + + expect(storage.setDataInLocalStorage.called).to.be.false; + }); + + it('should not register duplicate listeners on multiple calls', function () { + const addEventListenerStub = sandbox.stub(window, 'addEventListener'); + + spec.getUserSyncs({ iframeEnabled: true }); + expect(addEventListenerStub.calledOnce).to.be.true; + + spec.getUserSyncs({ iframeEnabled: true }); + expect(addEventListenerStub.calledOnce).to.be.true; + }); + }); + }); });