From 50f8a40c678433fa7dce75fcaec71e91adeded5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 9 Feb 2026 13:35:02 +0100 Subject: [PATCH 1/7] Add user sync and reporting urls --- modules/performaxBidAdapter.js | 135 ++++++++++++- test/spec/modules/performaxBidAdapter_spec.js | 184 ++++++++++++++++-- 2 files changed, 298 insertions(+), 21 deletions(-) diff --git a/modules/performaxBidAdapter.js b/modules/performaxBidAdapter.js index 48dd4366f1d..f02344057a2 100644 --- a/modules/performaxBidAdapter.js +++ b/modules/performaxBidAdapter.js @@ -1,12 +1,43 @@ -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"; + +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); +} + export const converter = ortbConverter({ imp(buildImp, bidRequest, context) { @@ -28,6 +59,51 @@ export const converter = ortbConverter({ } }) +/** + * 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 const spec = { code: BIDDER_CODE, aliases: [BIDDER_SHORT_CODE], @@ -39,7 +115,13 @@ export const spec = { }, buildRequests: function (bidRequests, bidderRequest) { - const data = converter.toORTB({bidderRequest, bidRequests}) + let data = converter.toORTB({bidderRequest, bidRequests}) + + const uids = readData(UIDS_STORAGE_KEY, {}); + if (Object.keys(uids).length > 0) { + deepSetValue(data, 'user.ext.uids', uids); + } + return [{ method: 'POST', url: ENDPOINT, @@ -71,7 +153,56 @@ 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 + }); + 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); + }); + + 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 218f9402e75..cf1f4c93998 100644 --- a/test/spec/modules/performaxBidAdapter_spec.js +++ b/test/spec/modules/performaxBidAdapter_spec.js @@ -1,8 +1,11 @@ import { expect } from 'chai'; -import { spec, converter } from 'modules/performaxBidAdapter.js'; +import { spec, converter, storeData, readData, storage } 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 = [{ + let bids = [{ bidder: 'performax', params: { tagid: 'sample' @@ -67,7 +70,7 @@ describe('Performax adapter', function () { device: {} }}]; - const bidderRequest = { + let bidderRequest = { bidderCode: 'performax2', auctionId: 'acd97e55-01e1-45ad-813c-67fa27fc5c1b', id: 'acd97e55-01e1-45ad-813c-67fa27fc5c1b', @@ -87,7 +90,7 @@ describe('Performax adapter', function () { device: {} }}; - const serverResponse = { + let serverResponse = { body: { cur: 'CZK', seatbid: [ @@ -105,7 +108,7 @@ describe('Performax adapter', function () { } describe('isBidRequestValid', function () { - const bid = {}; + let bid = {}; it('should return false when missing "tagid" param', function() { bid.params = {slotId: 'param'}; expect(spec.isBidRequestValid(bid)).to.equal(false); @@ -121,47 +124,47 @@ describe('Performax adapter', function () { describe('buildRequests', function () { it('should set correct request method and url', function () { - const requests = spec.buildRequests([bids[0]], bidderRequest); + let requests = spec.buildRequests([bids[0]], bidderRequest); expect(requests).to.be.an('array').that.has.lengthOf(1); - const request = requests[0]; + let request = requests[0]; expect(request.method).to.equal('POST'); expect(request.url).to.equal('https://dale.performax.cz/ortb'); expect(request.data).to.be.an('object'); }); it('should pass correct imp', function () { - const requests = spec.buildRequests([bids[0]], bidderRequest); - const {data} = requests[0]; - const {imp} = data; + let requests = spec.buildRequests([bids[0]], bidderRequest); + let {data} = requests[0]; + let {imp} = data; expect(imp).to.be.an('array').that.has.lengthOf(1); expect(imp[0]).to.be.an('object'); - const bid = imp[0]; + let bid = imp[0]; expect(bid.id).to.equal('2bc545c347dbbe'); expect(bid.banner).to.deep.equal({topframe: 0, format: [{w: 300, h: 300}]}); }); it('should process multiple bids', function () { - const requests = spec.buildRequests(bids, bidderRequest); + let requests = spec.buildRequests(bids, bidderRequest); expect(requests).to.be.an('array').that.has.lengthOf(1); - const {data} = requests[0]; - const {imp} = data; + let {data} = requests[0]; + let {imp} = data; expect(imp).to.be.an('array').that.has.lengthOf(bids.length); - const bid1 = imp[0]; + let bid1 = imp[0]; expect(bid1.banner).to.deep.equal({topframe: 0, format: [{w: 300, h: 300}]}); - const bid2 = imp[1]; + let bid2 = imp[1]; expect(bid2.banner).to.deep.equal({topframe: 0, format: [{w: 300, h: 600}]}); }); }); describe('interpretResponse', function () { it('should map params correctly', function () { - const ortbRequest = {data: converter.toORTB({bidderRequest, bids})}; + let ortbRequest = {data: converter.toORTB({bidderRequest, bids})}; serverResponse.body.id = ortbRequest.data.id; serverResponse.body.seatbid[0].bid[0].imp_id = ortbRequest.data.imp[0].id; - const result = spec.interpretResponse(serverResponse, ortbRequest); + let result = spec.interpretResponse(serverResponse, ortbRequest); expect(result).to.be.an('array').that.has.lengthOf(1); - const bid = result[0]; + let bid = result[0]; expect(bid.cpm).to.equal(20); expect(bid.ad).to.equal('My ad'); @@ -172,4 +175,147 @@ 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; + }); + }); }); From e9bca8971908e4175b560ba0025bb6f06ecdde41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 9 Feb 2026 13:48:23 +0100 Subject: [PATCH 2/7] add tests, minor refactor --- modules/performaxBidAdapter.js | 58 +++--- test/spec/modules/performaxBidAdapter_spec.js | 182 ++++++++++++++++-- 2 files changed, 193 insertions(+), 47 deletions(-) diff --git a/modules/performaxBidAdapter.js b/modules/performaxBidAdapter.js index f02344057a2..826d81afd00 100644 --- a/modules/performaxBidAdapter.js +++ b/modules/performaxBidAdapter.js @@ -12,11 +12,11 @@ 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_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"; +const LOG_EVENT_TYPE_BIDDER_ERROR = 'bidderError'; +const LOG_EVENT_TYPE_INTERVENTION = 'intervention'; +const LOG_EVENT_TYPE_TIMEOUT = 'timeout'; export const storage = getStorageManager({ bidderCode: BIDDER_CODE }); @@ -33,32 +33,11 @@ function logEvent(type, payload, sampleRate = LOG_EVENT_SAMPLE_RATE) { } const data = { type, payload }; - const options = { method: 'POST', withCredentials: true, contentType: "application/json" }; + const options = { method: 'POST', withCredentials: true, contentType: 'application/json' }; ajax(LOG_EVENT_URL, undefined, safeJSONEncode(data), options); } -export const converter = ortbConverter({ - - imp(buildImp, bidRequest, context) { - const imp = buildImp(bidRequest, context); - deepSetValue(imp, 'tagid', bidRequest.params.tagid); - return imp; - }, - - bidResponse(buildBidResponse, bid, context) { - context.netRevenue = deepAccess(bid, 'netRevenue'); - context.mediaType = deepAccess(bid, 'mediaType'); - context.currency = deepAccess(bid, 'currency'); - - return buildBidResponse(bid, context) - }, - - context: { - ttl: 360, - } -}) - /** * Serializes and stores data. * @param {string} key - The unique identifier @@ -67,7 +46,7 @@ export const converter = ortbConverter({ */ export function storeData(key, value) { if (!storage.localStorageIsEnabled()) { - logWarn("Local Storage is not enabled"); + logWarn('Local Storage is not enabled'); return; } @@ -86,7 +65,7 @@ export function storeData(key, value) { */ export function readData(key, defaultValue) { if (!storage.localStorageIsEnabled()) { - logWarn("Local Storage is not enabled"); + logWarn('Local Storage is not enabled'); return defaultValue; } @@ -104,6 +83,27 @@ export function readData(key, defaultValue) { } } +export const converter = ortbConverter({ + + imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + deepSetValue(imp, 'tagid', bidRequest.params.tagid); + return imp; + }, + + bidResponse(buildBidResponse, bid, context) { + context.netRevenue = deepAccess(bid, 'netRevenue'); + context.mediaType = deepAccess(bid, 'mediaType'); + context.currency = deepAccess(bid, 'currency'); + + return buildBidResponse(bid, context) + }, + + context: { + ttl: 360, + } +}) + export const spec = { code: BIDDER_CODE, aliases: [BIDDER_SHORT_CODE], @@ -115,7 +115,7 @@ export const spec = { }, buildRequests: function (bidRequests, bidderRequest) { - let data = converter.toORTB({bidderRequest, bidRequests}) + const data = converter.toORTB({bidderRequest, bidRequests}) const uids = readData(UIDS_STORAGE_KEY, {}); if (Object.keys(uids).length > 0) { diff --git a/test/spec/modules/performaxBidAdapter_spec.js b/test/spec/modules/performaxBidAdapter_spec.js index cf1f4c93998..779a5e49e28 100644 --- a/test/spec/modules/performaxBidAdapter_spec.js +++ b/test/spec/modules/performaxBidAdapter_spec.js @@ -5,7 +5,7 @@ import * as ajax from 'src/ajax.js'; import sinon from 'sinon'; describe('Performax adapter', function () { - let bids = [{ + const bids = [{ bidder: 'performax', params: { tagid: 'sample' @@ -70,7 +70,7 @@ describe('Performax adapter', function () { device: {} }}]; - let bidderRequest = { + const bidderRequest = { bidderCode: 'performax2', auctionId: 'acd97e55-01e1-45ad-813c-67fa27fc5c1b', id: 'acd97e55-01e1-45ad-813c-67fa27fc5c1b', @@ -90,7 +90,7 @@ describe('Performax adapter', function () { device: {} }}; - let serverResponse = { + const serverResponse = { body: { cur: 'CZK', seatbid: [ @@ -108,7 +108,7 @@ describe('Performax adapter', function () { } describe('isBidRequestValid', function () { - let bid = {}; + const bid = {}; it('should return false when missing "tagid" param', function() { bid.params = {slotId: 'param'}; expect(spec.isBidRequestValid(bid)).to.equal(false); @@ -123,48 +123,77 @@ 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.equal({ someVendor: '12345' }); + }); + it('should set correct request method and url', function () { - let requests = spec.buildRequests([bids[0]], bidderRequest); + const requests = spec.buildRequests([bids[0]], bidderRequest); expect(requests).to.be.an('array').that.has.lengthOf(1); - let request = requests[0]; + const request = requests[0]; expect(request.method).to.equal('POST'); expect(request.url).to.equal('https://dale.performax.cz/ortb'); expect(request.data).to.be.an('object'); }); it('should pass correct imp', function () { - let requests = spec.buildRequests([bids[0]], bidderRequest); - let {data} = requests[0]; - let {imp} = data; + const requests = spec.buildRequests([bids[0]], bidderRequest); + const {data} = requests[0]; + const {imp} = data; expect(imp).to.be.an('array').that.has.lengthOf(1); expect(imp[0]).to.be.an('object'); - let bid = imp[0]; + const bid = imp[0]; expect(bid.id).to.equal('2bc545c347dbbe'); expect(bid.banner).to.deep.equal({topframe: 0, format: [{w: 300, h: 300}]}); }); it('should process multiple bids', function () { - let requests = spec.buildRequests(bids, bidderRequest); + const requests = spec.buildRequests(bids, bidderRequest); expect(requests).to.be.an('array').that.has.lengthOf(1); - let {data} = requests[0]; - let {imp} = data; + const {data} = requests[0]; + const {imp} = data; expect(imp).to.be.an('array').that.has.lengthOf(bids.length); - let bid1 = imp[0]; + const bid1 = imp[0]; expect(bid1.banner).to.deep.equal({topframe: 0, format: [{w: 300, h: 300}]}); - let bid2 = imp[1]; + const bid2 = imp[1]; expect(bid2.banner).to.deep.equal({topframe: 0, format: [{w: 300, h: 600}]}); }); }); 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 () { - let ortbRequest = {data: converter.toORTB({bidderRequest, bids})}; + const ortbRequest = {data: converter.toORTB({bidderRequest, bids})}; serverResponse.body.id = ortbRequest.data.id; serverResponse.body.seatbid[0].bid[0].imp_id = ortbRequest.data.imp[0].id; - let result = spec.interpretResponse(serverResponse, ortbRequest); + const result = spec.interpretResponse(serverResponse, ortbRequest); expect(result).to.be.an('array').that.has.lengthOf(1); - let bid = result[0]; + const bid = result[0]; expect(bid.cpm).to.equal(20); expect(bid.ad).to.equal('My ad'); @@ -317,5 +346,122 @@ describe('Performax adapter', function () { 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); + }); + + 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; + }); + }); }); }); From 9667e44c7c1af0c330323a03e827fb94107ccb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 9 Feb 2026 16:12:25 +0100 Subject: [PATCH 3/7] add window.addEventListener only once --- modules/performaxBidAdapter.js | 33 ++++++++++++------- test/spec/modules/performaxBidAdapter_spec.js | 13 +++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/modules/performaxBidAdapter.js b/modules/performaxBidAdapter.js index 826d81afd00..11548475c98 100644 --- a/modules/performaxBidAdapter.js +++ b/modules/performaxBidAdapter.js @@ -18,6 +18,8 @@ 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 }); /** @@ -83,6 +85,10 @@ export function readData(key, defaultValue) { } } +export function resetUserSyncsInit() { + isUserSyncsInit = false; +} + export const converter = ortbConverter({ imp(buildImp, bidRequest, context) { @@ -176,21 +182,24 @@ export const spec = { url: url }); - window.addEventListener('message', function (event) { - if (!event.data || event.origin !== USER_SYNC_ORIGIN || !event.data.flexo_sync_cookie) { - return; - } + 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; + const { uid, vendor } = event.data.flexo_sync_cookie; - if (!uid || !vendor) { - return; - } + if (!uid || !vendor) { + return; + } - const uids = readData(UIDS_STORAGE_KEY, {}); - uids[vendor] = uid; - storeData(UIDS_STORAGE_KEY, uids); - }); + const uids = readData(UIDS_STORAGE_KEY, {}); + uids[vendor] = uid; + storeData(UIDS_STORAGE_KEY, uids); + }); + isUserSyncsInit = true; + } return syncs; }, diff --git a/test/spec/modules/performaxBidAdapter_spec.js b/test/spec/modules/performaxBidAdapter_spec.js index 779a5e49e28..510396b7984 100644 --- a/test/spec/modules/performaxBidAdapter_spec.js +++ b/test/spec/modules/performaxBidAdapter_spec.js @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { spec, converter, storeData, readData, storage } 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'; @@ -370,6 +370,7 @@ describe('Performax adapter', function () { sandbox.stub(storage, 'localStorageIsEnabled').returns(true); sandbox.stub(storage, 'setDataInLocalStorage'); sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + resetUserSyncsInit(); }); afterEach(() => { @@ -462,6 +463,16 @@ describe('Performax adapter', function () { 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; + }); }); }); }); From 3d654bf1ee01c99598f34997d909d0cc2a26a99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 9 Feb 2026 16:35:57 +0100 Subject: [PATCH 4/7] fix JSON.parse can return null --- modules/performaxBidAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/performaxBidAdapter.js b/modules/performaxBidAdapter.js index 11548475c98..8aeb2a9a312 100644 --- a/modules/performaxBidAdapter.js +++ b/modules/performaxBidAdapter.js @@ -78,7 +78,7 @@ export function readData(key, defaultValue) { } try { - return JSON.parse(rawData); + return JSON.parse(rawData) || {}; } catch (err) { logError(`Error parsing data for key "${key}": `, err); return defaultValue; From 6a8af495d00f923c63eaf0ce9e99484cb07455b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Wed, 11 Feb 2026 15:00:52 +0100 Subject: [PATCH 5/7] Fix unconditional setting user.ext.uids --- modules/performaxBidAdapter.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/modules/performaxBidAdapter.js b/modules/performaxBidAdapter.js index 8aeb2a9a312..e7dd7009576 100644 --- a/modules/performaxBidAdapter.js +++ b/modules/performaxBidAdapter.js @@ -125,7 +125,18 @@ export const spec = { const uids = readData(UIDS_STORAGE_KEY, {}); if (Object.keys(uids).length > 0) { - deepSetValue(data, 'user.ext.uids', uids); + if (!data.user) { + data.user = {}; + } + + if (!data.user.ext) { + data.user.ext = {}; + } + + data.user.ext.uids = { + ...(data.user.ext.uids ?? {}), + ...uids + }; } return [{ From baf7d6e029b2c381108aaed7954c30a6ceae30a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Wed, 11 Feb 2026 15:47:51 +0100 Subject: [PATCH 6/7] Add test --- test/spec/modules/performaxBidAdapter_spec.js | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/test/spec/modules/performaxBidAdapter_spec.js b/test/spec/modules/performaxBidAdapter_spec.js index 510396b7984..5d072705d0f 100644 --- a/test/spec/modules/performaxBidAdapter_spec.js +++ b/test/spec/modules/performaxBidAdapter_spec.js @@ -144,7 +144,33 @@ describe('Performax adapter', function () { expect(data.user).to.exist; expect(data.user.ext).to.exist; - expect(data.user.ext.uids).to.deep.equal({ someVendor: '12345' }); + 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 () { From 902b5d6a37be4e2473da57f44c975e8601f78caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Thu, 12 Feb 2026 21:50:50 +0100 Subject: [PATCH 7/7] swap uids from storage and original user.ext.uids --- modules/performaxBidAdapter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/performaxBidAdapter.js b/modules/performaxBidAdapter.js index e7dd7009576..147d5a8aeb7 100644 --- a/modules/performaxBidAdapter.js +++ b/modules/performaxBidAdapter.js @@ -134,8 +134,8 @@ export const spec = { } data.user.ext.uids = { - ...(data.user.ext.uids ?? {}), - ...uids + ...uids, + ...(data.user.ext.uids ?? {}) }; }