diff --git a/libraries/pubstackUtils/index.ts b/libraries/pubstackUtils/index.ts new file mode 100644 index 0000000000..6b78f97afa --- /dev/null +++ b/libraries/pubstackUtils/index.ts @@ -0,0 +1,50 @@ +import { canAccessWindowTop, getWindowSelf, getWindowTop } from "../../src/utils.js"; +import { getBoundingClientRect } from "../boundingClientRect/boundingClientRect.js"; +import { getGptSlotInfoForAdUnitCode } from "../gptUtils/gptUtils.js"; + +export const getElementForAdUnitCode = (adUnitCode: string): HTMLElement | undefined => { + if (!adUnitCode) return; + const win = canAccessWindowTop() ? getWindowTop() : getWindowSelf(); + const doc = win.document; + let element = doc?.getElementById(adUnitCode); + if (element) return element; + const divId = getGptSlotInfoForAdUnitCode(adUnitCode)?.divId; + element = doc?.getElementById(divId); + if (element) return element; +}; + +export const getViewportDistance = (adUnitCode?: string): number | undefined => { + try { + const round = (value: number) => Number(value.toFixed(2)); + const element = getElementForAdUnitCode(adUnitCode); + if (!element) return; + const rect = getBoundingClientRect(element); + if (!rect) return; + + const win = canAccessWindowTop() ? getWindowTop() : getWindowSelf(); + const doc = win.document; + + const viewportHeight = win.innerHeight || + doc?.documentElement?.clientHeight || + doc?.body?.clientHeight || + 0; + + if (!viewportHeight) return; + + if (rect.top > viewportHeight) { + return round((rect.top - viewportHeight) / viewportHeight); + } + if (rect.bottom < 0) { + return round(rect.bottom / viewportHeight); + } + if (rect.top < 0) { + return round(rect.top / viewportHeight); + } + if (rect.bottom > viewportHeight) { + return round((rect.bottom - viewportHeight) / viewportHeight); + } + return 0; + } catch (_) {} +}; + +export const isPageVisible = (): boolean => document.visibilityState === "visible"; diff --git a/metadata/modules.json b/metadata/modules.json index 6040fedcd6..6475cb0199 100644 --- a/metadata/modules.json +++ b/metadata/modules.json @@ -4439,6 +4439,13 @@ "gvlid": 104, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "pubstack", + "aliasOf": null, + "gvlid": 1408, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "sovrn", diff --git a/modules/pubstackBidAdapter.md b/modules/pubstackBidAdapter.md new file mode 100644 index 0000000000..dc3df3b8ee --- /dev/null +++ b/modules/pubstackBidAdapter.md @@ -0,0 +1,30 @@ +# Overview +``` +Module Name: Pubstack Bidder Adapter +Module Type: Bidder Adapter +Maintainer: prebid@pubstack.io +``` + +# Description +Connects to Pubstack exchange for bids. + +Pubstack bid adapter supports all media type including video, banner and native. + +# Test Parameters +``` +var adUnits = [{ + code: 'adunit-1', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bids: [{ + bidder: 'pubstack', + params: { + siteId: 'your-site-id', + adUnitName: 'adunit-1' + } + }] +}]; +``` \ No newline at end of file diff --git a/modules/pubstackBidAdapter.ts b/modules/pubstackBidAdapter.ts new file mode 100644 index 0000000000..35d1de2e36 --- /dev/null +++ b/modules/pubstackBidAdapter.ts @@ -0,0 +1,136 @@ +import { deepSetValue, logError } from '../src/utils.js'; +import { AdapterRequest, BidderSpec, registerBidder, ServerResponse } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { getElementForAdUnitCode, getViewportDistance, isPageVisible } from '../libraries/pubstackUtils/index.js'; +import { BidRequest, ClientBidderRequest } from '../src/adapterManager.js'; +import { ORTBRequest } from '../src/prebid.public.js'; +import { config } from '../src/config.js'; +import { SyncType } from '../src/userSync.js'; +import { ConsentData, CONSENT_GDPR, CONSENT_USP, CONSENT_GPP } from '../src/consentHandler.js'; +import { getGlobal } from '../src/prebidGlobal.js'; + +const BIDDER_CODE = 'pubstack'; +const GVLID = 1408; +const REQUEST_URL = 'https://node.pbstck.com/openrtb2/auction'; +const COOKIESYNC_IFRAME_URL = 'https://cdn.pbstck.com/async_usersync.html'; +const COOKIESYNC_PIXEL_URL = 'https://cdn.pbstck.com/async_usersync.png'; + +declare module '../src/adUnits' { + interface BidderParams { + [BIDDER_CODE]: { + siteId: string; + adUnitName: string; + }; + } +} + +type GetUserSyncFn = ( + syncOptions: { + iframeEnabled: boolean; + pixelEnabled: boolean; + }, + responses: ServerResponse[], + gdprConsent: null | ConsentData[typeof CONSENT_GDPR], + uspConsent: null | ConsentData[typeof CONSENT_USP], + gppConsent: null | ConsentData[typeof CONSENT_GPP]) => ({ type: SyncType, url: string })[] + +const siteIds: Set = new Set(); +let cntRequest = 0; +let cntImp = 0; +const uStart = performance.now(); + +const converter = ortbConverter({ + imp(buildImp, bidRequest: BidRequest, context) { + cntImp++; + const imp = buildImp(bidRequest, context); + deepSetValue(imp, `ext.prebid.bidder.${BIDDER_CODE}.adUnitName`, bidRequest.params.adUnitName); + deepSetValue(imp, `ext.prebid.bidder.${BIDDER_CODE}.adUnitCode`, bidRequest.adUnitCode); + deepSetValue(imp, `ext.prebid.bidder.${BIDDER_CODE}.divId`, getElementForAdUnitCode(bidRequest.adUnitCode)?.id); + deepSetValue(imp, `ext.prebid.bidder.${BIDDER_CODE}.vpl`, getViewportDistance(bidRequest.adUnitCode)); + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + cntRequest++; + const request = buildRequest(imps, bidderRequest, context); + const siteId = bidderRequest.bids[0].params.siteId; + siteIds.add(siteId); + deepSetValue(request, 'site.publisher.id', siteId); + deepSetValue(request, 'test', config.getConfig('debug') ? 1 : 0); + deepSetValue(request, 'ext.prebid.version', getGlobal()?.version ?? 'unknown'); + deepSetValue(request, `ext.prebid.cntRequest`, cntRequest); + deepSetValue(request, `ext.prebid.cntImp`, cntImp); + deepSetValue(request, `ext.prebid.pVisible`, isPageVisible()); + deepSetValue(request, `ext.prebid.uStart`, Math.trunc((performance.now() - uStart) / 1000)); + return request; + }, +}); + +const isBidRequestValid = (bid: BidRequest): boolean => { + if (!bid.params.siteId || typeof bid.params.siteId !== 'string') { + logError('bid.params.siteId needs to be a string'); + if (config.getConfig('debug') === false) return false; + } + if (!bid.params.adUnitName || typeof bid.params.adUnitName !== 'string') { + logError('bid.params.adUnitName needs to be a string'); + if (config.getConfig('debug') === false) return false; + } + return true; +}; + +const buildRequests = ( + bidRequests: BidRequest[], + bidderRequest: ClientBidderRequest, +): AdapterRequest => { + const data: ORTBRequest = converter.toORTB({ bidRequests, bidderRequest }); + const siteId = data.site.publisher.id; + return { + method: 'POST', + url: `${REQUEST_URL}?siteId=${siteId}`, + data, + }; +}; + +const interpretResponse = (serverResponse, bidRequest) => { + if (!serverResponse?.body) { + return []; + } + return converter.fromORTB({ request: bidRequest.data, response: serverResponse.body }); +}; + +const getUserSyncs: GetUserSyncFn = (syncOptions, _serverResponses, gdprConsent, uspConsent, gppConsent) => { + const isIframeEnabled = syncOptions.iframeEnabled; + const isPixelEnabled = syncOptions.pixelEnabled; + + if (!isIframeEnabled && !isPixelEnabled) { + return []; + } + + const payload = btoa(JSON.stringify({ + gdprConsentString: gdprConsent?.consentString, + gdprApplies: gdprConsent?.gdprApplies, + uspConsent, + gpp: gppConsent?.gppString, + gpp_sid: gppConsent?.applicableSections + + })); + const syncUrl = isIframeEnabled ? COOKIESYNC_IFRAME_URL : COOKIESYNC_PIXEL_URL; + + return Array.from(siteIds).map(siteId => ({ + type: isIframeEnabled ? 'iframe' : 'image', + url: `${syncUrl}?consent=${payload}&siteId=${siteId}`, + })); +}; + +export const spec: BidderSpec = { + code: BIDDER_CODE, + aliases: [{code: `${BIDDER_CODE}_server`, gvlid: GVLID}], + gvlid: GVLID, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, +}; + +registerBidder(spec); diff --git a/test/spec/libraries/pubstackUtils_spec.js b/test/spec/libraries/pubstackUtils_spec.js new file mode 100644 index 0000000000..3ff518eb2a --- /dev/null +++ b/test/spec/libraries/pubstackUtils_spec.js @@ -0,0 +1,182 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as boundingClientRectLib from '../../../libraries/boundingClientRect/boundingClientRect.js'; +import * as gptUtils from '../../../libraries/gptUtils/gptUtils.js'; +import { getElementForAdUnitCode, getViewportDistance } from '../../../libraries/pubstackUtils/index.js'; +import * as utils from '../../../src/utils.js'; + +describe('pubstackUtils', function () { + let sandbox; + let topDocument; + let selfDocument; + let topWindow; + let selfWindow; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + topDocument = { + getElementById: sandbox.stub(), + documentElement: { clientHeight: 1000 }, + body: { clientHeight: 1000 } + }; + selfDocument = { + getElementById: sandbox.stub(), + documentElement: { clientHeight: 1000 }, + body: { clientHeight: 1000 } + }; + topWindow = { + innerHeight: 1000, + document: topDocument + }; + selfWindow = { + innerHeight: 1000, + document: selfDocument + }; + + sandbox.stub(utils, 'canAccessWindowTop').returns(true); + sandbox.stub(utils, 'getWindowTop').returns(topWindow); + sandbox.stub(utils, 'getWindowSelf').returns(selfWindow); + sandbox.stub(gptUtils, 'getGptSlotInfoForAdUnitCode').returns({}); + sandbox.stub(boundingClientRectLib, 'getBoundingClientRect'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('getElementForAdUnitCode', function () { + it('returns undefined when adUnitCode is not provided', function () { + expect(getElementForAdUnitCode()).to.equal(undefined); + }); + + it('returns the element that matches the adUnitCode', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + + const result = getElementForAdUnitCode('ad-unit-code'); + + expect(result).to.equal(element); + expect(gptUtils.getGptSlotInfoForAdUnitCode.called).to.equal(false); + }); + + it('falls back to GPT divId when adUnitCode does not match a DOM element', function () { + const element = { id: 'gpt-div-id' }; + gptUtils.getGptSlotInfoForAdUnitCode.withArgs('ad-unit-code').returns({ divId: 'gpt-div-id' }); + topDocument.getElementById.withArgs('gpt-div-id').returns(element); + + const result = getElementForAdUnitCode('ad-unit-code'); + + expect(result).to.equal(element); + expect(gptUtils.getGptSlotInfoForAdUnitCode.calledOnceWith('ad-unit-code')).to.equal(true); + }); + + it('uses window.self when top window access is unavailable', function () { + utils.canAccessWindowTop.returns(false); + const element = { id: 'self-div' }; + selfDocument.getElementById.withArgs('self-div').returns(element); + + const result = getElementForAdUnitCode('self-div'); + + expect(result).to.equal(element); + expect(topDocument.getElementById.called).to.equal(false); + }); + }); + + describe('getViewportDistance', function () { + it('returns undefined when no adUnitCode is provided', function () { + expect(getViewportDistance()).to.equal(undefined); + }); + + it('returns undefined when no matching element exists', function () { + expect(getViewportDistance('missing-ad-unit')).to.equal(undefined); + }); + + it('returns undefined when bounding rect is not available', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns(undefined); + + expect(getViewportDistance('ad-unit-code')).to.equal(undefined); + }); + + it('computes positive distance when element is below the viewport', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: 1400, bottom: 1700 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(0.4); + }); + + it('computes negative distance when element is above the viewport', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: -350, bottom: -200 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(-0.2); + }); + + it('computes distance from top when element starts above the viewport', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: -250, bottom: 100 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(-0.25); + }); + + it('computes distance from bottom when element extends below the viewport', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: 900, bottom: 1250 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(0.25); + }); + + it('returns 0 when the element is inside the viewport', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: 100, bottom: 200 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(0); + }); + + it('falls back to documentElement height when innerHeight is unavailable', function () { + topWindow.innerHeight = 0; + topDocument.documentElement.clientHeight = 800; + topDocument.body.clientHeight = 700; + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: 1000, bottom: 1200 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(0.25); + }); + + it('returns undefined when viewport height is unavailable', function () { + topWindow.innerHeight = 0; + topDocument.documentElement.clientHeight = 0; + topDocument.body.clientHeight = 0; + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: 1000, bottom: 1200 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(undefined); + }); + + it('uses GPT div id fallback when adUnitCode does not match an element', function () { + const element = { id: 'gpt-div-id' }; + gptUtils.getGptSlotInfoForAdUnitCode.withArgs('ad-unit-code').returns({ divId: 'gpt-div-id' }); + topDocument.getElementById.withArgs('gpt-div-id').returns(element); + boundingClientRectLib.getBoundingClientRect.returns({ top: 100, bottom: 200 }); + + expect(getViewportDistance('ad-unit-code')).to.equal(0); + }); + + it('returns undefined when an exception is thrown', function () { + const element = { id: 'ad-unit-code' }; + topDocument.getElementById.withArgs('ad-unit-code').returns(element); + boundingClientRectLib.getBoundingClientRect.throws(new Error('unexpected error')); + + expect(getViewportDistance('ad-unit-code')).to.equal(undefined); + }); + }); +}); diff --git a/test/spec/modules/pubstackBidAdapter_spec.js b/test/spec/modules/pubstackBidAdapter_spec.js new file mode 100644 index 0000000000..fed5e40663 --- /dev/null +++ b/test/spec/modules/pubstackBidAdapter_spec.js @@ -0,0 +1,333 @@ +import { expect } from 'chai'; +import { spec } from 'modules/pubstackBidAdapter'; +import * as utils from 'src/utils.js'; +import { config } from 'src/config.js'; +import { hook } from 'src/hook.js'; +import 'src/prebid.js'; +import 'modules/consentManagementTcf.js'; +import 'modules/consentManagementUsp.js'; +import 'modules/consentManagementGpp.js'; + +describe('pubstackBidAdapter', function () { + const baseBidRequest = { + adUnitCode: 'adunit-code', + auctionId: 'auction-1', + bidId: 'bid-1', + bidder: 'pubstack', + bidderRequestId: 'request-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: { + siteId: 'site-123', + adUnitName: 'adunit-1' + }, + sizes: [[300, 250]], + transactionId: 'transaction-1' + }; + + const baseBidderRequest = { + gdprConsent: { + gdprApplies: true, + consentString: 'consent-string', + vendorData: { + purpose: { + consents: { 1: true } + } + } + }, + uspConsent: '1YYN', + gppConsent: { + gppString: 'gpp-string', + applicableSections: [7, 8] + }, + refererInfo: { + referer: 'https://example.com' + } + }; + + const clone = (obj) => JSON.parse(JSON.stringify(obj)); + + const createBidRequest = (overrides = {}) => { + const bidRequest = clone(baseBidRequest); + const { params = {}, ...otherOverrides } = overrides; + Object.assign(bidRequest, otherOverrides); + bidRequest.params = { + ...bidRequest.params, + ...params + }; + return bidRequest; + }; + + const createBidderRequest = (bidRequest, overrides = {}) => ({ + ...clone(baseBidderRequest), + bids: [bidRequest], + ...overrides + }); + + const extractBids = (result) => Array.isArray(result) ? result : result?.bids; + + const findSyncForSite = (syncs, siteId) => + syncs.find((sync) => new URL(sync.url).searchParams.get('siteId') === siteId); + + const getDecodedSyncPayload = (sync) => + JSON.parse(atob(new URL(sync.url).searchParams.get('consent'))); + + before(() => { + hook.ready(); + }); + + beforeEach(function () { + config.resetConfig(); + }); + + afterEach(function () { + config.resetConfig(); + }); + + describe('isBidRequestValid', function () { + it('returns true when required params are present', function () { + expect(spec.isBidRequestValid(createBidRequest())).to.equal(true); + }); + + it('returns false for invalid params when debug is disabled', function () { + config.setConfig({ debug: false }); + expect(spec.isBidRequestValid(createBidRequest({ params: { siteId: undefined } }))).to.equal(false); + expect(spec.isBidRequestValid(createBidRequest({ params: { adUnitName: undefined } }))).to.equal(false); + }); + + it('returns true for invalid params when debug is enabled', function () { + config.setConfig({ debug: true }); + expect(spec.isBidRequestValid(createBidRequest({ params: { siteId: undefined } }))).to.equal(true); + expect(spec.isBidRequestValid(createBidRequest({ params: { adUnitName: undefined } }))).to.equal(true); + }); + }); + + describe('buildRequests', function () { + it('builds a POST request with ORTB data and bidder extensions', function () { + const bidRequest = createBidRequest(); + const bidderRequest = createBidderRequest(bidRequest); + const request = spec.buildRequests([bidRequest], bidderRequest); + + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://node.pbstck.com/openrtb2/auction?siteId=site-123'); + expect(utils.deepAccess(request, 'data.site.publisher.id')).to.equal('site-123'); + expect(utils.deepAccess(request, 'data.test')).to.equal(0); + expect(request.data.imp).to.have.lengthOf(1); + expect(utils.deepAccess(request, 'data.imp.0.id')).to.equal('bid-1'); + expect(utils.deepAccess(request, 'data.imp.0.ext.prebid.bidder.pubstack.adUnitName')).to.equal('adunit-1'); + expect(utils.deepAccess(request, 'data.imp.0.ext.prebid.bidder.pubstack.adUnitCode')).to.equal('adunit-code'); + expect(utils.deepAccess(request, 'data.ext.prebid.version')).to.be.a('string'); + expect(utils.deepAccess(request, 'data.ext.prebid.cntRequest')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.ext.prebid.cntImp')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.ext.prebid.pVisible')).to.be.a('boolean'); + expect(utils.deepAccess(request, 'data.ext.prebid.uStart')).to.be.a('number'); + }); + + it('sets test to 1 when prebid debug mode is enabled', function () { + config.setConfig({ debug: true }); + const bidRequest = createBidRequest({ bidId: 'bid-debug' }); + const bidderRequest = createBidderRequest(bidRequest); + const request = spec.buildRequests([bidRequest], bidderRequest); + + expect(utils.deepAccess(request, 'data.test')).to.equal(1); + }); + + it('increments request and imp counters for each call', function () { + const firstBidRequest = createBidRequest({ bidId: 'bid-counter-1' }); + const firstRequest = spec.buildRequests([firstBidRequest], createBidderRequest(firstBidRequest)); + const secondBidRequest = createBidRequest({ + bidId: 'bid-counter-2', + adUnitCode: 'adunit-code-2', + params: { adUnitName: 'adunit-2' } + }); + const secondRequest = spec.buildRequests([secondBidRequest], createBidderRequest(secondBidRequest)); + + expect(utils.deepAccess(secondRequest, 'data.ext.prebid.cntRequest')) + .to.equal(utils.deepAccess(firstRequest, 'data.ext.prebid.cntRequest') + 1); + expect(utils.deepAccess(secondRequest, 'data.ext.prebid.cntImp')) + .to.equal(utils.deepAccess(firstRequest, 'data.ext.prebid.cntImp') + 1); + }); + }); + + describe('interpretResponse', function () { + it('returns empty array when response has no body', function () { + const bidRequest = createBidRequest(); + const request = spec.buildRequests([bidRequest], createBidderRequest(bidRequest)); + const bids = spec.interpretResponse({ body: null }, request); + expect(bids).to.be.an('array'); + expect(bids).to.have.lengthOf(0); + }); + + it('maps ORTB bid responses into prebid bids', function () { + const bidRequest = createBidRequest(); + const request = spec.buildRequests([bidRequest], createBidderRequest(bidRequest)); + const serverResponse = { + body: { + id: 'resp-1', + cur: 'USD', + seatbid: [ + { + bid: [ + { + impid: 'bid-1', + mtype: 1, + price: 1.23, + w: 300, + h: 250, + adm: '
ad
', + crid: 'creative-1' + } + ] + } + ] + } + }; + + const result = spec.interpretResponse(serverResponse, request); + const bids = extractBids(result); + expect(bids).to.have.lengthOf(1); + expect(bids[0]).to.include({ + requestId: 'bid-1', + cpm: 1.23, + width: 300, + height: 250, + ad: '
ad
', + creativeId: 'creative-1' + }); + expect(bids[0]).to.have.property('currency', 'USD'); + }); + + it('returns no bids when ORTB response impid does not match request imp ids', function () { + const bidRequest = createBidRequest({ bidId: 'bid-match-required' }); + const request = spec.buildRequests([bidRequest], createBidderRequest(bidRequest)); + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: 'unknown-imp-id', + price: 2.5, + w: 300, + h: 250, + adm: '
ad
', + crid: 'creative-unknown' + }] + }] + } + }; + + expect(extractBids(spec.interpretResponse(serverResponse, request))).to.deep.equal([]); + }); + }); + + describe('getUserSyncs', function () { + it('returns iframe sync with encoded consent payload and site id', function () { + const bidRequest = createBidRequest(); + const bidderRequest = createBidderRequest(bidRequest); + spec.buildRequests([bidRequest], bidderRequest); + + const syncs = spec.getUserSyncs( + { iframeEnabled: true, pixelEnabled: true }, + [], + bidderRequest.gdprConsent, + bidderRequest.uspConsent, + bidderRequest.gppConsent + ); + + const siteSync = findSyncForSite(syncs, 'site-123'); + expect(siteSync).to.not.equal(undefined); + expect(siteSync.type).to.equal('iframe'); + expect(siteSync.url).to.include('https://cdn.pbstck.com/async_usersync.html'); + + const consentPayload = getDecodedSyncPayload(siteSync); + expect(consentPayload).to.deep.equal({ + gdprConsentString: 'consent-string', + gdprApplies: true, + uspConsent: '1YYN', + gpp: 'gpp-string', + gpp_sid: [7, 8] + }); + }); + + it('returns image sync when iframe sync is disabled', function () { + const bidRequest = createBidRequest({ bidId: 'bid-pixel' }); + const bidderRequest = createBidderRequest(bidRequest); + spec.buildRequests([bidRequest], bidderRequest); + + const syncs = spec.getUserSyncs( + { iframeEnabled: false, pixelEnabled: true }, + [], + bidderRequest.gdprConsent, + bidderRequest.uspConsent, + bidderRequest.gppConsent + ); + + const siteSync = findSyncForSite(syncs, 'site-123'); + expect(siteSync).to.not.equal(undefined); + expect(siteSync.type).to.equal('image'); + expect(siteSync.url).to.include('https://cdn.pbstck.com/async_usersync.png'); + }); + + it('returns no syncs when both iframe and pixel sync are disabled', function () { + const bidRequest = createBidRequest({ bidId: 'bid-disabled-syncs' }); + const bidderRequest = createBidderRequest(bidRequest); + spec.buildRequests([bidRequest], bidderRequest); + + const syncs = spec.getUserSyncs( + { iframeEnabled: false, pixelEnabled: false }, + [], + bidderRequest.gdprConsent, + bidderRequest.uspConsent, + bidderRequest.gppConsent + ); + + expect(syncs).to.deep.equal([]); + }); + + it('includes sync entries for each seen site id', function () { + const bidA = createBidRequest({ + bidId: 'bid-site-a', + adUnitCode: 'ad-site-a', + params: { siteId: 'site-a', adUnitName: 'adunit-a' } + }); + const bidB = createBidRequest({ + bidId: 'bid-site-b', + adUnitCode: 'ad-site-b', + params: { siteId: 'site-b', adUnitName: 'adunit-b' } + }); + + spec.buildRequests([bidA], createBidderRequest(bidA)); + spec.buildRequests([bidB], createBidderRequest(bidB)); + + const syncs = spec.getUserSyncs( + { iframeEnabled: true, pixelEnabled: false }, + [], + baseBidderRequest.gdprConsent, + baseBidderRequest.uspConsent, + baseBidderRequest.gppConsent + ); + const siteIds = syncs.map((sync) => new URL(sync.url).searchParams.get('siteId')); + + expect(siteIds).to.include('site-a'); + expect(siteIds).to.include('site-b'); + }); + + it('supports null consent objects in the sync payload', function () { + const bidRequest = createBidRequest({ + bidId: 'bid-null-consent', + params: { siteId: 'site-null-consent', adUnitName: 'adunit-null-consent' } + }); + spec.buildRequests([bidRequest], createBidderRequest(bidRequest)); + + const syncs = spec.getUserSyncs( + { iframeEnabled: true, pixelEnabled: false }, + [], + null, + null, + null + ); + + const siteSync = findSyncForSite(syncs, 'site-null-consent'); + expect(siteSync).to.not.equal(undefined); + expect(getDecodedSyncPayload(siteSync)).to.deep.equal({ uspConsent: null }); + }); + }); +});