diff --git a/.github/actions/load/action.yml b/.github/actions/load/action.yml index 0102608dbd1..e3fc00dc6ae 100644 --- a/.github/actions/load/action.yml +++ b/.github/actions/load/action.yml @@ -24,10 +24,14 @@ runs: rm -r "$(pwd)"/* - name: Download artifact - uses: actions/download-artifact@v5 + uses: Wandalen/wretry.action@v3.8.0 with: - path: '${{ runner.temp }}' - name: '${{ inputs.name }}' + action: actions/download-artifact@v7 + attempt_limit: 2 + attempt_delay: 10000 + with: | + path: '${{ runner.temp }}' + name: '${{ inputs.name }}' - name: 'Untar working directory' shell: bash diff --git a/.github/actions/unzip-artifact/action.yml b/.github/actions/unzip-artifact/action.yml index 672fae7af85..a05c15a00cd 100644 --- a/.github/actions/unzip-artifact/action.yml +++ b/.github/actions/unzip-artifact/action.yml @@ -11,6 +11,9 @@ outputs: runs: using: 'composite' steps: + - name: 'Delay waiting for artifacts to be ready' + shell: bash + run: sleep 10 - name: 'Download artifact' id: download uses: actions/github-script@v8 diff --git a/.github/codeql/queries/autogen_fpDOMMethod.qll b/.github/codeql/queries/autogen_fpDOMMethod.qll index 61555d5852f..164a699e97b 100644 --- a/.github/codeql/queries/autogen_fpDOMMethod.qll +++ b/.github/codeql/queries/autogen_fpDOMMethod.qll @@ -7,9 +7,9 @@ class DOMMethod extends string { DOMMethod() { - ( this = "toDataURL" and weight = 32.78 and type = "HTMLCanvasElement" ) + ( this = "toDataURL" and weight = 32.64 and type = "HTMLCanvasElement" ) or - ( this = "getChannelData" and weight = 1033.52 and type = "AudioBuffer" ) + ( this = "getChannelData" and weight = 1009.41 and type = "AudioBuffer" ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpEventProperty.qll b/.github/codeql/queries/autogen_fpEventProperty.qll index a102dc216aa..25ecd018f0f 100644 --- a/.github/codeql/queries/autogen_fpEventProperty.qll +++ b/.github/codeql/queries/autogen_fpEventProperty.qll @@ -7,21 +7,21 @@ class EventProperty extends string { EventProperty() { - ( this = "accelerationIncludingGravity" and weight = 195.95 and event = "devicemotion" ) + ( this = "candidate" and weight = 54.73 and event = "icecandidate" ) or - ( this = "beta" and weight = 889.02 and event = "deviceorientation" ) + ( this = "rotationRate" and weight = 63.55 and event = "devicemotion" ) or - ( this = "gamma" and weight = 318.9 and event = "deviceorientation" ) + ( this = "accelerationIncludingGravity" and weight = 205.08 and event = "devicemotion" ) or - ( this = "alpha" and weight = 748.66 and event = "deviceorientation" ) + ( this = "acceleration" and weight = 64.53 and event = "devicemotion" ) or - ( this = "candidate" and weight = 48.4 and event = "icecandidate" ) + ( this = "alpha" and weight = 784.67 and event = "deviceorientation" ) or - ( this = "acceleration" and weight = 59.13 and event = "devicemotion" ) + ( this = "beta" and weight = 801.42 and event = "deviceorientation" ) or - ( this = "rotationRate" and weight = 58.73 and event = "devicemotion" ) + ( this = "gamma" and weight = 300.01 and event = "deviceorientation" ) or - ( this = "absolute" and weight = 480.46 and event = "deviceorientation" ) + ( this = "absolute" and weight = 281.45 and event = "deviceorientation" ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpGlobalConstructor.qll b/.github/codeql/queries/autogen_fpGlobalConstructor.qll index 1bd3776448a..8feceaae940 100644 --- a/.github/codeql/queries/autogen_fpGlobalConstructor.qll +++ b/.github/codeql/queries/autogen_fpGlobalConstructor.qll @@ -6,15 +6,15 @@ class GlobalConstructor extends string { GlobalConstructor() { - ( this = "OfflineAudioContext" and weight = 1249.69 ) + ( this = "SharedWorker" and weight = 74.12 ) or - ( this = "SharedWorker" and weight = 78.96 ) + ( this = "OfflineAudioContext" and weight = 1062.83 ) or - ( this = "RTCPeerConnection" and weight = 36.22 ) + ( this = "RTCPeerConnection" and weight = 36.17 ) or - ( this = "Gyroscope" and weight = 94.31 ) + ( this = "Gyroscope" and weight = 100.27 ) or - ( this = "AudioWorkletNode" and weight = 106.77 ) + ( this = "AudioWorkletNode" and weight = 145.12 ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpGlobalObjectProperty0.qll b/.github/codeql/queries/autogen_fpGlobalObjectProperty0.qll index 622b4097377..19489a50149 100644 --- a/.github/codeql/queries/autogen_fpGlobalObjectProperty0.qll +++ b/.github/codeql/queries/autogen_fpGlobalObjectProperty0.qll @@ -7,59 +7,57 @@ class GlobalObjectProperty0 extends string { GlobalObjectProperty0() { - ( this = "availWidth" and weight = 62.91 and global0 = "screen" ) + ( this = "availHeight" and weight = 65.33 and global0 = "screen" ) or - ( this = "availHeight" and weight = 66.51 and global0 = "screen" ) + ( this = "availWidth" and weight = 61.95 and global0 = "screen" ) or - ( this = "colorDepth" and weight = 36.87 and global0 = "screen" ) + ( this = "colorDepth" and weight = 38.5 and global0 = "screen" ) or - ( this = "pixelDepth" and weight = 43.1 and global0 = "screen" ) + ( this = "availTop" and weight = 1305.37 and global0 = "screen" ) or - ( this = "availLeft" and weight = 730.43 and global0 = "screen" ) + ( this = "plugins" and weight = 15.16 and global0 = "navigator" ) or - ( this = "availTop" and weight = 1485.89 and global0 = "screen" ) + ( this = "deviceMemory" and weight = 64.15 and global0 = "navigator" ) or - ( this = "orientation" and weight = 33.81 and global0 = "screen" ) + ( this = "getBattery" and weight = 41.16 and global0 = "navigator" ) or - ( this = "vendorSub" and weight = 1822.98 and global0 = "navigator" ) + ( this = "webdriver" and weight = 27.64 and global0 = "navigator" ) or - ( this = "productSub" and weight = 381.55 and global0 = "navigator" ) + ( this = "permission" and weight = 24.67 and global0 = "Notification" ) or - ( this = "plugins" and weight = 15.37 and global0 = "navigator" ) + ( this = "storage" and weight = 35.77 and global0 = "navigator" ) or - ( this = "mimeTypes" and weight = 15.39 and global0 = "navigator" ) + ( this = "onLine" and weight = 18.84 and global0 = "navigator" ) or - ( this = "webkitTemporaryStorage" and weight = 32.87 and global0 = "navigator" ) + ( this = "pixelDepth" and weight = 45.77 and global0 = "screen" ) or - ( this = "hardwareConcurrency" and weight = 55.54 and global0 = "navigator" ) + ( this = "availLeft" and weight = 624.44 and global0 = "screen" ) or - ( this = "appCodeName" and weight = 167.7 and global0 = "navigator" ) + ( this = "orientation" and weight = 34.16 and global0 = "screen" ) or - ( this = "onLine" and weight = 18.14 and global0 = "navigator" ) + ( this = "vendorSub" and weight = 1873.27 and global0 = "navigator" ) or - ( this = "webdriver" and weight = 28.99 and global0 = "navigator" ) + ( this = "productSub" and weight = 381.87 and global0 = "navigator" ) or - ( this = "keyboard" and weight = 5673.26 and global0 = "navigator" ) + ( this = "webkitTemporaryStorage" and weight = 37.97 and global0 = "navigator" ) or - ( this = "mediaDevices" and weight = 123.32 and global0 = "navigator" ) + ( this = "hardwareConcurrency" and weight = 51.78 and global0 = "navigator" ) or - ( this = "storage" and weight = 30.23 and global0 = "navigator" ) + ( this = "appCodeName" and weight = 173.35 and global0 = "navigator" ) or - ( this = "deviceMemory" and weight = 62.29 and global0 = "navigator" ) + ( this = "keyboard" and weight = 1722.82 and global0 = "navigator" ) or - ( this = "mediaCapabilities" and weight = 148.31 and global0 = "navigator" ) + ( this = "mediaDevices" and weight = 149.07 and global0 = "navigator" ) or - ( this = "permissions" and weight = 92.01 and global0 = "navigator" ) + ( this = "mediaCapabilities" and weight = 142.34 and global0 = "navigator" ) or - ( this = "permission" and weight = 25.87 and global0 = "Notification" ) + ( this = "permissions" and weight = 89.71 and global0 = "navigator" ) or - ( this = "getBattery" and weight = 40.45 and global0 = "navigator" ) + ( this = "webkitPersistentStorage" and weight = 134.12 and global0 = "navigator" ) or - ( this = "webkitPersistentStorage" and weight = 121.43 and global0 = "navigator" ) + ( this = "requestMediaKeySystemAccess" and weight = 18.22 and global0 = "navigator" ) or - ( this = "requestMediaKeySystemAccess" and weight = 22.53 and global0 = "navigator" ) - or - ( this = "getGamepads" and weight = 275.28 and global0 = "navigator" ) + ( this = "getGamepads" and weight = 209.55 and global0 = "navigator" ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpGlobalObjectProperty1.qll b/.github/codeql/queries/autogen_fpGlobalObjectProperty1.qll index 3be175f2c11..4ba664c998f 100644 --- a/.github/codeql/queries/autogen_fpGlobalObjectProperty1.qll +++ b/.github/codeql/queries/autogen_fpGlobalObjectProperty1.qll @@ -8,7 +8,7 @@ class GlobalObjectProperty1 extends string { GlobalObjectProperty1() { - ( this = "enumerateDevices" and weight = 361.7 and global0 = "navigator" and global1 = "mediaDevices" ) + ( this = "enumerateDevices" and weight = 595.56 and global0 = "navigator" and global1 = "mediaDevices" ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpGlobalTypeProperty0.qll b/.github/codeql/queries/autogen_fpGlobalTypeProperty0.qll index 489d3f0f3ae..b26e3689251 100644 --- a/.github/codeql/queries/autogen_fpGlobalTypeProperty0.qll +++ b/.github/codeql/queries/autogen_fpGlobalTypeProperty0.qll @@ -7,11 +7,11 @@ class GlobalTypeProperty0 extends string { GlobalTypeProperty0() { - ( this = "x" and weight = 5673.26 and global0 = "Gyroscope" ) + ( this = "x" and weight = 4255.55 and global0 = "Gyroscope" ) or - ( this = "y" and weight = 5673.26 and global0 = "Gyroscope" ) + ( this = "y" and weight = 4255.55 and global0 = "Gyroscope" ) or - ( this = "z" and weight = 5673.26 and global0 = "Gyroscope" ) + ( this = "z" and weight = 4255.55 and global0 = "Gyroscope" ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpGlobalTypeProperty1.qll b/.github/codeql/queries/autogen_fpGlobalTypeProperty1.qll index 2f290d30132..084e91305b6 100644 --- a/.github/codeql/queries/autogen_fpGlobalTypeProperty1.qll +++ b/.github/codeql/queries/autogen_fpGlobalTypeProperty1.qll @@ -8,7 +8,7 @@ class GlobalTypeProperty1 extends string { GlobalTypeProperty1() { - ( this = "resolvedOptions" and weight = 18.94 and global0 = "Intl" and global1 = "DateTimeFormat" ) + ( this = "resolvedOptions" and weight = 19.01 and global0 = "Intl" and global1 = "DateTimeFormat" ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpGlobalVar.qll b/.github/codeql/queries/autogen_fpGlobalVar.qll index debc39522ee..a28f1c7772c 100644 --- a/.github/codeql/queries/autogen_fpGlobalVar.qll +++ b/.github/codeql/queries/autogen_fpGlobalVar.qll @@ -6,23 +6,23 @@ class GlobalVar extends string { GlobalVar() { - ( this = "devicePixelRatio" and weight = 18.84 ) + ( this = "devicePixelRatio" and weight = 18.39 ) or - ( this = "outerWidth" and weight = 104.3 ) + ( this = "screenX" and weight = 366.36 ) or - ( this = "outerHeight" and weight = 177.3 ) + ( this = "screenY" and weight = 320.66 ) or - ( this = "indexedDB" and weight = 21.68 ) + ( this = "outerWidth" and weight = 104.67 ) or - ( this = "screenX" and weight = 411.93 ) + ( this = "outerHeight" and weight = 154.1 ) or - ( this = "screenY" and weight = 369.99 ) + ( this = "screenLeft" and weight = 321.49 ) or - ( this = "screenLeft" and weight = 344.06 ) + ( this = "screenTop" and weight = 322.32 ) or - ( this = "screenTop" and weight = 343.13 ) + ( this = "indexedDB" and weight = 23.36 ) or - ( this = "openDatabase" and weight = 128.91 ) + ( this = "openDatabase" and weight = 146.11 ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpRenderingContextProperty.qll b/.github/codeql/queries/autogen_fpRenderingContextProperty.qll index 1f23b1a5057..e508d42520b 100644 --- a/.github/codeql/queries/autogen_fpRenderingContextProperty.qll +++ b/.github/codeql/queries/autogen_fpRenderingContextProperty.qll @@ -7,35 +7,35 @@ class RenderingContextProperty extends string { RenderingContextProperty() { - ( this = "getImageData" and weight = 55.51 and contextType = "2d" ) + ( this = "getExtension" and weight = 24.59 and contextType = "webgl" ) or - ( this = "getParameter" and weight = 30.58 and contextType = "webgl" ) + ( this = "getParameter" and weight = 28.11 and contextType = "webgl" ) or - ( this = "measureText" and weight = 46.82 and contextType = "2d" ) + ( this = "getImageData" and weight = 62.25 and contextType = "2d" ) or - ( this = "getParameter" and weight = 70.22 and contextType = "webgl2" ) + ( this = "measureText" and weight = 43.06 and contextType = "2d" ) or - ( this = "getShaderPrecisionFormat" and weight = 128.74 and contextType = "webgl2" ) + ( this = "getParameter" and weight = 67.61 and contextType = "webgl2" ) or - ( this = "getExtension" and weight = 71.78 and contextType = "webgl2" ) + ( this = "getShaderPrecisionFormat" and weight = 138.74 and contextType = "webgl2" ) or - ( this = "getContextAttributes" and weight = 190.28 and contextType = "webgl2" ) + ( this = "getExtension" and weight = 69.66 and contextType = "webgl2" ) or - ( this = "getSupportedExtensions" and weight = 560.85 and contextType = "webgl2" ) + ( this = "getContextAttributes" and weight = 201.04 and contextType = "webgl2" ) or - ( this = "getExtension" and weight = 26.27 and contextType = "webgl" ) + ( this = "getSupportedExtensions" and weight = 360.36 and contextType = "webgl2" ) or - ( this = "getShaderPrecisionFormat" and weight = 1175.17 and contextType = "webgl" ) + ( this = "readPixels" and weight = 24.33 and contextType = "webgl" ) or - ( this = "getContextAttributes" and weight = 1998.53 and contextType = "webgl" ) + ( this = "getShaderPrecisionFormat" and weight = 1347.35 and contextType = "webgl" ) or - ( this = "getSupportedExtensions" and weight = 1388.64 and contextType = "webgl" ) + ( this = "getContextAttributes" and weight = 2411.38 and contextType = "webgl" ) or - ( this = "readPixels" and weight = 22.43 and contextType = "webgl" ) + ( this = "getSupportedExtensions" and weight = 1484.82 and contextType = "webgl" ) or - ( this = "isPointInPath" and weight = 5210.68 and contextType = "2d" ) + ( this = "isPointInPath" and weight = 4255.55 and contextType = "2d" ) or - ( this = "readPixels" and weight = 610.19 and contextType = "webgl2" ) + ( this = "readPixels" and weight = 1004.16 and contextType = "webgl2" ) } float getWeight() { diff --git a/.github/codeql/queries/autogen_fpSensorProperty.qll b/.github/codeql/queries/autogen_fpSensorProperty.qll index 74bf3e4f988..bfc5c329068 100644 --- a/.github/codeql/queries/autogen_fpSensorProperty.qll +++ b/.github/codeql/queries/autogen_fpSensorProperty.qll @@ -6,7 +6,7 @@ class SensorProperty extends string { SensorProperty() { - ( this = "start" and weight = 92.53 ) + ( this = "start" and weight = 105.54 ) } float getWeight() { diff --git a/.github/workflows/PR-assignment-deps.yml b/.github/workflows/PR-assignment-deps.yml index 587555b705c..2d7fce88837 100644 --- a/.github/workflows/PR-assignment-deps.yml +++ b/.github/workflows/PR-assignment-deps.yml @@ -20,7 +20,7 @@ jobs: run: | npx gulp build - name: Upload dependencies.json - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dependencies.json path: ./build/dist/dependencies.json @@ -28,7 +28,7 @@ jobs: run: | echo '{ "prNo": ${{ github.event.pull_request.number }} }' >> ${{ runner.temp}}/prInfo.json - name: Upload PR info - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: prInfo path: ${{ runner.temp}}/prInfo.json diff --git a/.github/workflows/jscpd.yml b/.github/workflows/jscpd.yml index c3021b2ced7..a4b2a14861f 100644 --- a/.github/workflows/jscpd.yml +++ b/.github/workflows/jscpd.yml @@ -56,7 +56,7 @@ jobs: - name: Upload unfiltered jscpd report if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: unfiltered-jscpd-report path: ./jscpd-report.json @@ -89,7 +89,7 @@ jobs: - name: Upload filtered jscpd report if: env.filtered_report_exists == 'true' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: filtered-jscpd-report path: ./filtered-jscpd-report.json @@ -119,7 +119,7 @@ jobs: - name: Upload comment data if: env.filtered_report_exists == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: comment path: ${{ runner.temp }}/comment.json diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 39fdcc4067b..e4a736c0b25 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -117,7 +117,7 @@ jobs: - name: Upload comment data if: ${{ steps.comment.outputs.result == 'true' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: comment path: ${{ runner.temp }}/comment.json diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1cc6808f29f..924683ec0e0 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -71,6 +71,11 @@ on: BROWSERSTACK_ACCESS_KEY: description: "Browserstack access key" + +permissions: + contents: read + actions: read + jobs: checkout: name: "Define chunks" @@ -201,7 +206,7 @@ jobs: - name: 'Save coverage result' if: ${{ steps.coverage.outputs.coverage }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: coverage-partial-${{inputs.test-cmd}}-${{ matrix.chunk-no }} path: ./build/coverage @@ -224,7 +229,7 @@ jobs: name: ${{ needs.build.outputs.built-key }} - name: Download coverage results - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: ./build/coverage pattern: coverage-partial-${{ inputs.test-cmd }}-* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1209ca2057d..ed12de000c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,10 @@ on: pull_request: types: [opened, synchronize, reopened] +permissions: + contents: read + actions: read + concurrency: group: test-${{ github.head_ref || github.ref }} cancel-in-progress: true diff --git a/AGENTS.md b/AGENTS.md index ce4e1353a9a..c340fee56ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,3 +45,6 @@ This file contains instructions for the Codex agent and its friends when working - Avoid running Babel over the entire project for incremental test runs. - Use `gulp serve-and-test --file ` or `gulp test --file` so Babel processes only the specified files. - Do not invoke commands that rebuild all modules when only a subset are changed. + +## Additional context +- for additional context on repo history, consult https://github.com/prebid/github-activity-db/blob/main/CLAUDE.md on how to download and access repo history in a database you can search locally. diff --git a/eslint.config.js b/eslint.config.js index a9b7fe04153..46d0eb86da4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,6 +9,7 @@ const path = require('path'); const _ = require('lodash'); const tseslint = require('typescript-eslint'); const {getSourceFolders} = require('./gulpHelpers.js'); +const APPROVED_LOAD_EXTERNAL_SCRIPT_PATHS = require('./plugins/eslint/approvedLoadExternalScriptPaths.js'); function jsPattern(name) { return [`${name}/**/*.js`, `${name}/**/*.mjs`] @@ -122,6 +123,13 @@ module.exports = [ message: "Assigning a function to 'logResult, 'logMessage', 'logInfo', 'logWarn', or 'logError' is not allowed." }, ], + 'no-restricted-imports': [ + 'error', { + patterns: [ + '**/src/adloader.js' + ] + } + ], // Exceptions below this line are temporary (TM), so that eslint can be added into the CI process. // Violations of these styles should be fixed, and the exceptions removed over time. @@ -273,4 +281,22 @@ module.exports = [ '@typescript-eslint/no-require-imports': 'off' } }, -] + // Override: allow loadExternalScript import in approved files (excluding BidAdapters) + { + files: APPROVED_LOAD_EXTERNAL_SCRIPT_PATHS.filter(p => !p.includes('BidAdapter')).map(p => { + // If path doesn't end with .js/.ts/.mjs, treat as folder pattern + if (!p.match(/\.(js|ts|mjs)$/)) { + return `${p}/**/*.{js,ts,mjs}`; + } + return p; + }), + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [] + } + ], + } + }, + ] diff --git a/gulpfile.js b/gulpfile.js index e5a4db41884..cc543ad73ec 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -53,7 +53,7 @@ function bundleToStdout() { bundleToStdout.displayName = 'bundle-to-stdout'; function clean() { - return gulp.src(['build', 'dist'], { + return gulp.src(['.cache', 'build', 'dist'], { read: false, allowEmpty: true }) diff --git a/integrationExamples/gpt/adcluster_banner_example.html b/integrationExamples/gpt/adcluster_banner_example.html new file mode 100644 index 00000000000..4f7bf646bb9 --- /dev/null +++ b/integrationExamples/gpt/adcluster_banner_example.html @@ -0,0 +1,115 @@ + + + + + Adcluster Adapter Test + + + + + + + + + + +

Prebid.js Live Adapter Test

+
+ + + + diff --git a/integrationExamples/gpt/adcluster_video_example.html b/integrationExamples/gpt/adcluster_video_example.html new file mode 100644 index 00000000000..0a309b24749 --- /dev/null +++ b/integrationExamples/gpt/adcluster_video_example.html @@ -0,0 +1,291 @@ + + + + + Adcluster Adapter – Outstream Test with Fallback + + + + + + + + + +

Adcluster Adapter – Outstream Test (AN renderer + IMA fallback)

+
+ +
+ + + + diff --git a/integrationExamples/gpt/insurads.html b/integrationExamples/gpt/insurads.html new file mode 100644 index 00000000000..92f6b7df8b2 --- /dev/null +++ b/integrationExamples/gpt/insurads.html @@ -0,0 +1,121 @@ + + + + + + + + + + + + + +

Prebid.js Test

+
Div-1
+
+ +
+ + + + diff --git a/integrationExamples/gpt/neuwoRtdProvider_example.html b/integrationExamples/gpt/neuwoRtdProvider_example.html index 3d6fef98995..68c95fe8b4f 100644 --- a/integrationExamples/gpt/neuwoRtdProvider_example.html +++ b/integrationExamples/gpt/neuwoRtdProvider_example.html @@ -26,28 +26,28 @@ var adUnits = [ { - code: '/19968336/header-bid-tag-1', + code: "/19968336/header-bid-tag-1", mediaTypes: { banner: { sizes: div_1_sizes } }, bids: [{ - bidder: 'appnexus', + bidder: "appnexus", params: { placementId: 13144370 } }] }, { - code: '/19968336/header-bid-tag-1', + code: "/19968336/header-bid-tag-1", mediaTypes: { banner: { sizes: div_2_sizes } }, bids: [{ - bidder: 'appnexus', + bidder: "appnexus", params: { placementId: 13144370 } @@ -86,12 +86,12 @@ // Custom Timeout logic in onSettingsUpdate() googletag.cmd.push(function () { - googletag.defineSlot('/19968336/header-bid-tag-1', div_1_sizes, 'div-1').addService(googletag.pubads()); + googletag.defineSlot("/19968336/header-bid-tag-1", div_1_sizes, "div-1").addService(googletag.pubads()); googletag.pubads().enableSingleRequest(); googletag.enableServices(); }); googletag.cmd.push(function () { - googletag.defineSlot('/19968336/header-bid-tag-1', div_2_sizes, 'div-2').addService(googletag.pubads()); + googletag.defineSlot("/19968336/header-bid-tag-1", div_2_sizes, "div-2").addService(googletag.pubads()); googletag.pubads().enableSingleRequest(); googletag.enableServices(); }); @@ -99,47 +99,65 @@ // 3. User Triggered Setup (RTD Module) function onSettingsUpdate() { - const inputNeuwoApiToken = document.getElementById('neuwo-api-token'); - const neuwoApiToken = inputNeuwoApiToken ? inputNeuwoApiToken.value : ''; + const inputNeuwoApiToken = document.getElementById("neuwo-api-token"); + const neuwoApiToken = inputNeuwoApiToken ? inputNeuwoApiToken.value : ""; if (!neuwoApiToken) { - alert('Please enter your token for Neuwo AI API to the field'); + alert("Please enter your token for Neuwo AI API to the field"); if (inputNeuwoApiToken) inputNeuwoApiToken.focus(); return; } - const inputNeuwoApiUrl = document.getElementById('neuwo-api-url'); - const neuwoApiUrl = inputNeuwoApiUrl ? inputNeuwoApiUrl.value : ''; + const inputNeuwoApiUrl = document.getElementById("neuwo-api-url"); + const neuwoApiUrl = inputNeuwoApiUrl ? inputNeuwoApiUrl.value : ""; if (!neuwoApiUrl) { - alert('Please enter Neuwo AI API url to the field'); + alert("Please enter Neuwo AI API url to the field"); if (inputNeuwoApiUrl) inputNeuwoApiUrl.focus(); return; } - const inputWebsiteToAnalyseUrl = document.getElementById('website-to-analyse-url'); + const inputWebsiteToAnalyseUrl = document.getElementById("website-to-analyse-url"); const websiteToAnalyseUrl = inputWebsiteToAnalyseUrl ? inputWebsiteToAnalyseUrl.value : undefined; - const inputIabContentTaxonomyVersion = document.getElementById('iab-content-taxonomy-version'); + const inputIabContentTaxonomyVersion = document.getElementById("iab-content-taxonomy-version"); const iabContentTaxonomyVersion = inputIabContentTaxonomyVersion ? inputIabContentTaxonomyVersion.value : undefined; // Cache Option - const inputEnableCache = document.getElementById('enable-cache'); + const inputEnableCache = document.getElementById("enable-cache"); const enableCache = inputEnableCache ? inputEnableCache.checked : undefined; + // OpenRTB 2.5 Category Fields Option + const inputEnableOrtb25Fields = document.getElementById("enable-ortb25-fields"); + const enableOrtb25Fields = inputEnableOrtb25Fields ? inputEnableOrtb25Fields.checked : true; + // URL Stripping Options - const inputStripAllQueryParams = document.getElementById('strip-all-query-params'); + const inputStripAllQueryParams = document.getElementById("strip-all-query-params"); const stripAllQueryParams = inputStripAllQueryParams ? inputStripAllQueryParams.checked : undefined; - const inputStripQueryParamsForDomains = document.getElementById('strip-query-params-for-domains'); - const stripQueryParamsForDomainsValue = inputStripQueryParamsForDomains ? inputStripQueryParamsForDomains.value.trim() : ''; - const stripQueryParamsForDomains = stripQueryParamsForDomainsValue ? stripQueryParamsForDomainsValue.split(',').map(d => d.trim()).filter(d => d) : undefined; + const inputStripQueryParamsForDomains = document.getElementById("strip-query-params-for-domains"); + const stripQueryParamsForDomainsValue = inputStripQueryParamsForDomains ? inputStripQueryParamsForDomains.value.trim() : ""; + const stripQueryParamsForDomains = stripQueryParamsForDomainsValue ? stripQueryParamsForDomainsValue.split(",").map(d => d.trim()).filter(d => d) : undefined; - const inputStripQueryParams = document.getElementById('strip-query-params'); - const stripQueryParamsValue = inputStripQueryParams ? inputStripQueryParams.value.trim() : ''; - const stripQueryParams = stripQueryParamsValue ? stripQueryParamsValue.split(',').map(p => p.trim()).filter(p => p) : undefined; + const inputStripQueryParams = document.getElementById("strip-query-params"); + const stripQueryParamsValue = inputStripQueryParams ? inputStripQueryParams.value.trim() : ""; + const stripQueryParams = stripQueryParamsValue ? stripQueryParamsValue.split(",").map(p => p.trim()).filter(p => p) : undefined; - const inputStripFragments = document.getElementById('strip-fragments'); + const inputStripFragments = document.getElementById("strip-fragments"); const stripFragments = inputStripFragments ? inputStripFragments.checked : undefined; + // IAB Taxonomy Filtering Option + const inputEnableFiltering = document.getElementById("enable-iab-filtering"); + const enableIabFiltering = inputEnableFiltering ? inputEnableFiltering.checked : false; + + // Build iabTaxonomyFilters object only if filtering is enabled + const iabTaxonomyFilters = enableIabFiltering ? { + ContentTier1: { limit: 1, threshold: 0.1 }, + ContentTier2: { limit: 2, threshold: 0.1 }, + ContentTier3: { limit: 3, threshold: 0.15 }, + AudienceTier3: { limit: 3, threshold: 0.2 }, + AudienceTier4: { limit: 5, threshold: 0.2 }, + AudienceTier5: { limit: 7, threshold: 0.3 }, + } : undefined; + pbjs.que.push(function () { pbjs.setConfig({ debug: true, @@ -155,10 +173,12 @@ websiteToAnalyseUrl, iabContentTaxonomyVersion, enableCache, + enableOrtb25Fields, stripAllQueryParams, stripQueryParamsForDomains, stripQueryParams, - stripFragments + stripFragments, + iabTaxonomyFilters, } } ] @@ -185,7 +205,7 @@

Basic Prebid.js Example using Neuwo Rtd Provider

- Looks like you're not following the testing environment setup, try accessing + Looks like you"re not following the testing environment setup, try accessing http://localhost:9999/integrationExamples/gpt/neuwoRtdProvider_example.html @@ -215,12 +235,14 @@

Neuwo Rtd Provider Configuration

- +

IAB Content Taxonomy Options

- +

Cache Options

@@ -231,6 +253,14 @@

Cache Options

+

OpenRTB 2.5 Category Fields

+
+ +
+

URL Cleaning Options

- +
- +
+

IAB Taxonomy Filtering Options

+
+ +
+ When enabled, uses these hardcoded filters:
+ • ContentTier1: top 1 (≥10% relevance)
+ • ContentTier2: top 2 (≥10% relevance)
+ • ContentTier3: top 3 (≥15% relevance)
+ • AudienceTier3: top 3 (≥20% relevance)
+ • AudienceTier4: top 5 (≥20% relevance)
+ • AudienceTier5: top 7 (≥30% relevance) +
+
+ @@ -259,10 +309,10 @@

Ad Examples

Div-1

-
- Ad spot div-1: This content will be replaced by prebid.js and/or related components once you click @@ -272,10 +322,10 @@

Div-1

Div-2

-
- Ad spot div-2: This content will be replaced by prebid.js and/or related components once you click @@ -286,82 +336,327 @@

Div-2

Neuwo Data in Bid Request

-

The retrieved data from Neuwo API is injected into the bid request as OpenRTB (ORTB2)`site.content.data` and - `user.data`. Full bid request can be inspected in Developer Tools Console under +

The retrieved data from Neuwo API is injected into the bid request as OpenRTB (ORTB2) + site.content.data and + user.data. Full bid request can be inspected in Developer Tools Console under INFO: NeuwoRTDModule injectIabCategories: post-injection bidsConfig

+

Neuwo Site Content Data

+
No data yet. Click "Update" to fetch data.
+

Neuwo User Data

+
No data yet. Click "Update" to fetch data.
+

Neuwo OpenRTB 2.5 Category Fields (IAB Content Taxonomy 1.0) Data

+
No data yet. Click "Update" to fetch data (requires enableOrtb25Fields and /v1/iab endpoint).
+
+ +
+

Accessing Neuwo Data in JavaScript

+

Listen to the bidRequested event to access the enriched ORTB2 data:

+
+pbjs.onEvent("bidRequested", function(bidRequest) {
+    const ortb2 = bidRequest.ortb2;
+    const neuwoSiteData = ortb2?.site?.content?.data?.find(d => d.name === "www.neuwo.ai");
+    const neuwoUserData = ortb2?.user?.data?.find(d => d.name === "www.neuwo.ai");
+    console.log("Neuwo data:", { siteContent: neuwoSiteData, user: neuwoUserData });
+});
+        
+

After clicking "Update", the Neuwo data is stored in the global neuwoData variable. Open + Developer Tools Console to see the logged data.

+

Note: Event timing tests for multiple Prebid.js events (auctionInit, bidRequested, + beforeBidderHttp, bidResponse, auctionEnd) are available in the page source code but are commented out. To + enable them, uncomment the timing test section in the JavaScript code.

+
+ +
+

For more information about Neuwo RTD Module configuration and accessing data retrieved from Neuwo API, see modules/neuwoRtdProvider.md.

+ + + + + + + +

Prebid Test Bidder Example

+
Banner ad
+ + + diff --git a/libraries/devicePixelRatio/devicePixelRatio.js b/libraries/devicePixelRatio/devicePixelRatio.js index 8bfe23bcb3d..c206ed053b3 100644 --- a/libraries/devicePixelRatio/devicePixelRatio.js +++ b/libraries/devicePixelRatio/devicePixelRatio.js @@ -1,14 +1,10 @@ -import {canAccessWindowTop, internal as utilsInternals} from '../../src/utils.js'; - -function getFallbackWindow(win) { - if (win) { - return win; - } - - return canAccessWindowTop() ? utilsInternals.getWindowTop() : utilsInternals.getWindowSelf(); -} +import {isFingerprintingApiDisabled} from '../fingerprinting/fingerprinting.js'; +import {getFallbackWindow} from '../../src/utils.js'; export function getDevicePixelRatio(win) { + if (isFingerprintingApiDisabled('devicepixelratio')) { + return 1; + } try { return getFallbackWindow(win).devicePixelRatio; } catch (e) { diff --git a/libraries/fingerprinting/fingerprinting.js b/libraries/fingerprinting/fingerprinting.js new file mode 100644 index 00000000000..aba529d0c8f --- /dev/null +++ b/libraries/fingerprinting/fingerprinting.js @@ -0,0 +1,12 @@ +import {config} from '../../src/config.js'; + +/** + * Returns true if the given fingerprinting API is disabled via setConfig({ disableFingerprintingApis: [...] }). + * Comparison is case-insensitive. Use for 'devicepixelratio', 'webdriver', 'resolvedoptions', 'screen'. + * @param {string} apiName + * @returns {boolean} + */ +export function isFingerprintingApiDisabled(apiName) { + const list = config.getConfig('disableFingerprintingApis'); + return Array.isArray(list) && list.some((item) => String(item).toLowerCase() === apiName.toLowerCase()); +} diff --git a/libraries/objectGuard/objectGuard.js b/libraries/objectGuard/objectGuard.js index 973e56aad5f..ff3f4b78c34 100644 --- a/libraries/objectGuard/objectGuard.js +++ b/libraries/objectGuard/objectGuard.js @@ -139,14 +139,40 @@ export function objectGuard(rules) { return true; } + const TARGET = Symbol('TARGET'); + function mkGuard(obj, tree, final, applies, cache = new WeakMap()) { // If this object is already proxied, return the cached proxy if (cache.has(obj)) { return cache.get(obj); } + /** + * Dereference (possibly nested) proxies to their underlying objects. + * + * This is to accommodate usage patterns like: + * + * guardedObject.property = [...guardedObject.property, additionalData]; + * + * where the `set` proxy trap would get an already proxied object as argument. + */ + function deref(obj, visited = new Set()) { + if (cache.has(obj?.[TARGET])) return obj[TARGET]; + if (obj == null || typeof obj !== 'object') return obj; + if (visited.has(obj)) return obj; + visited.add(obj); + Object.keys(obj).forEach(k => { + const sub = deref(obj[k], visited); + if (sub !== obj[k]) { + obj[k] = sub; + } + }) + return obj; + } + const proxy = new Proxy(obj, { get(target, prop, receiver) { + if (prop === TARGET) return target; const val = Reflect.get(target, prop, receiver); if (final && val != null && typeof val === 'object') { // a parent property has write protect rules, keep guarding @@ -175,6 +201,7 @@ export function objectGuard(rules) { return true; } } + newValue = deref(newValue); if (tree.children?.hasOwnProperty(prop)) { // apply all (possibly nested) write protect rules const curValue = Reflect.get(target, prop, receiver); diff --git a/libraries/omsUtils/index.js b/libraries/omsUtils/index.js index b2523749080..733b257a4c8 100644 --- a/libraries/omsUtils/index.js +++ b/libraries/omsUtils/index.js @@ -1,4 +1,4 @@ -import {getWindowSelf, getWindowTop, isFn, isPlainObject} from '../../src/utils.js'; +import {createTrackPixelHtml, getWindowSelf, getWindowTop, isArray, isFn, isPlainObject} from '../../src/utils.js'; export function getBidFloor(bid) { if (!isFn(bid.getFloor)) { @@ -21,3 +21,28 @@ export function isIframe() { return true; } } + +export function getProcessedSizes(sizes = []) { + const bidSizes = ((isArray(sizes) && isArray(sizes[0])) ? sizes : [sizes]).filter(size => isArray(size)); + return bidSizes.map(size => ({w: parseInt(size[0], 10), h: parseInt(size[1], 10)})); +} + +export function getDeviceType(ua = navigator.userAgent, sua) { + if (sua?.mobile || (/(ios|ipod|ipad|iphone|android)/i).test(ua)) { + return 1; + } + + if ((/(smart[-]?tv|hbbtv|appletv|googletv|hdmi|netcast\.tv|viera|nettv|roku|\bdtv\b|sonydtv|inettvbrowser|\btv\b)/i).test(ua)) { + return 3; + } + + return 2; +} + +export function getAdMarkup(bid) { + let adm = bid.adm; + if ('nurl' in bid) { + adm += createTrackPixelHtml(bid.nurl); + } + return adm; +} diff --git a/libraries/omsUtils/viewability.js b/libraries/omsUtils/viewability.js new file mode 100644 index 00000000000..ce62f74b841 --- /dev/null +++ b/libraries/omsUtils/viewability.js @@ -0,0 +1,19 @@ +import {getWindowTop} from '../../src/utils.js'; +import {percentInView} from '../percentInView/percentInView.js'; +import {getMinSize} from '../sizeUtils/sizeUtils.js'; +import {isIframe} from './index.js'; + +export function getRoundedViewability(adUnitCode, processedSizes) { + const element = document.getElementById(adUnitCode); + const minSize = getMinSize(processedSizes); + const viewabilityAmount = isViewabilityMeasurable(element) ? getViewability(element, minSize) : 'na'; + return isNaN(viewabilityAmount) ? viewabilityAmount : Math.round(viewabilityAmount); +} + +function isViewabilityMeasurable(element) { + return !isIframe() && element !== null; +} + +function getViewability(element, {w, h} = {}) { + return getWindowTop().document.visibilityState === 'visible' ? percentInView(element, {w, h}) : 0; +} diff --git a/libraries/ortbConverter/README.md b/libraries/ortbConverter/README.md index c67533ae1de..691ff7bceb7 100644 --- a/libraries/ortbConverter/README.md +++ b/libraries/ortbConverter/README.md @@ -378,7 +378,7 @@ For ease of use, the conversion logic gives special meaning to some context prop ## Prebid Server extensions -If your endpoint is a Prebid Server instance, you may take advantage of the `pbsExtension` companion library, which adds a number of processors that can populate and parse PBS-specific extensions (typically prefixed `ext.prebid`); these include bidder params (with `transformBidParams`), bidder aliases, targeting keys, and others. +If your endpoint is a Prebid Server instance, you may take advantage of the `pbsExtension` companion library, which adds a number of processors that can populate and parse PBS-specific extensions (typically prefixed `ext.prebid`); these include bidder params, bidder aliases, targeting keys, and others. ```javascript import {pbsExtensions} from '../../libraries/pbsExtensions/pbsExtensions.js' diff --git a/libraries/ortbConverter/processors/default.js b/libraries/ortbConverter/processors/default.js index b1fb5be77a5..001d0d808bf 100644 --- a/libraries/ortbConverter/processors/default.js +++ b/libraries/ortbConverter/processors/default.js @@ -111,6 +111,9 @@ export const DEFAULT_PROCESSORS = { if (bid.ext?.eventtrackers) { bidResponse.eventtrackers = (bidResponse.eventtrackers ?? []).concat(bid.ext.eventtrackers); } + if (bid.cattax) { + bidResponse.meta.cattax = bid.cattax; + } } } } diff --git a/libraries/pbsExtensions/processors/pbs.js b/libraries/pbsExtensions/processors/pbs.js index 3fa97ae674b..82a99954646 100644 --- a/libraries/pbsExtensions/processors/pbs.js +++ b/libraries/pbsExtensions/processors/pbs.js @@ -30,7 +30,7 @@ export const PBS_PROCESSORS = { }, [IMP]: { params: { - // sets bid ext.prebid.bidder.[bidderCode] with bidRequest.params, passed through transformBidParams if necessary + // sets bid ext.prebid.bidder.[bidderCode] with bidRequest.params fn: setImpBidParams }, adUnitCode: { diff --git a/libraries/percentInView/percentInView.js b/libraries/percentInView/percentInView.js index 27148e40941..5b9dfa10503 100644 --- a/libraries/percentInView/percentInView.js +++ b/libraries/percentInView/percentInView.js @@ -1,6 +1,29 @@ import { getWinDimensions, inIframe } from '../../src/utils.js'; import { getBoundingClientRect } from '../boundingClientRect/boundingClientRect.js'; +/** + * return the offset between the given window's viewport and the top window's. + */ +export function getViewportOffset(win = window) { + let x = 0; + let y = 0; + try { + while (win?.frameElement != null) { + const rect = getBoundingClientRect(win.frameElement); + x += rect.left; + y += rect.top; + win = win.parent; + } + } catch (e) { + // offset cannot be calculated as some parents are cross-frame + // fallback to 0,0 + x = 0; + y = 0; + } + + return {x, y}; +} + export function getBoundingBox(element, {w, h} = {}) { let {width, height, left, top, right, bottom, x, y} = getBoundingClientRect(element); @@ -44,14 +67,24 @@ function getIntersectionOfRects(rects) { export const percentInView = (element, {w, h} = {}) => { const elementBoundingBox = getBoundingBox(element, {w, h}); - const { innerHeight, innerWidth } = getWinDimensions(); + // when in an iframe, the bounding box is relative to the iframe's viewport + // since we are intersecting it with the top window's viewport, attempt to + // compensate for the offset between them + + const offset = getViewportOffset(element?.ownerDocument?.defaultView); + elementBoundingBox.left += offset.x; + elementBoundingBox.right += offset.x; + elementBoundingBox.top += offset.y; + elementBoundingBox.bottom += offset.y; + + const dims = getWinDimensions(); // Obtain the intersection of the element and the viewport const elementInViewBoundingBox = getIntersectionOfRects([{ left: 0, top: 0, - right: innerWidth, - bottom: innerHeight + right: dims.document.documentElement.clientWidth, + bottom: dims.document.documentElement.clientHeight }, elementBoundingBox]); let elementInViewArea, elementTotalArea; diff --git a/libraries/placementPositionInfo/placementPositionInfo.js b/libraries/placementPositionInfo/placementPositionInfo.js new file mode 100644 index 00000000000..19014632a23 --- /dev/null +++ b/libraries/placementPositionInfo/placementPositionInfo.js @@ -0,0 +1,87 @@ +import {getBoundingClientRect} from '../boundingClientRect/boundingClientRect.js'; +import {canAccessWindowTop, cleanObj, getWinDimensions, getWindowSelf, getWindowTop} from '../../src/utils.js'; +import {getViewability, getViewportOffset} from '../percentInView/percentInView.js'; + +export function getPlacementPositionUtils() { + const topWin = canAccessWindowTop() ? getWindowTop() : getWindowSelf(); + const selfWin = getWindowSelf(); + + const getViewportHeight = () => { + const dim = getWinDimensions(); + return dim.innerHeight || dim.document.documentElement.clientHeight || dim.document.body.clientHeight || 0; + }; + + const getPageHeight = () => { + const dim = getWinDimensions(); + const body = dim.document.body; + const html = dim.document.documentElement; + if (!body || !html) return 0; + + return Math.max( + body.scrollHeight, + body.offsetHeight, + html.clientHeight, + html.scrollHeight, + html.offsetHeight + ); + }; + + const getViewableDistance = (element, frameOffset) => { + if (!element) return {distanceToView: 0, elementHeight: 0}; + + const elementRect = getBoundingClientRect(element); + if (!elementRect) return {distanceToView: 0, elementHeight: 0}; + + const elementTop = elementRect.top + frameOffset.y; + const elementBottom = elementRect.bottom + frameOffset.y; + const viewportHeight = getViewportHeight(); + + let distanceToView; + if (elementTop - viewportHeight <= 0 && elementBottom >= 0) { + distanceToView = 0; + } else if (elementTop - viewportHeight > 0) { + distanceToView = Math.round(elementTop - viewportHeight); + } else { + distanceToView = Math.round(elementBottom); + } + + return {distanceToView, elementHeight: elementRect.height}; + }; + + function getPlacementInfo(bidReq) { + const element = selfWin.document.getElementById(bidReq.adUnitCode); + const frameOffset = getViewportOffset(); + const {distanceToView, elementHeight} = getViewableDistance(element, frameOffset); + + const sizes = (bidReq.sizes || []).map(size => ({ + w: Number.parseInt(size[0], 10), + h: Number.parseInt(size[1], 10) + })); + const size = sizes.length > 0 + ? sizes.reduce((min, size) => size.h * size.w < min.h * min.w ? size : min, sizes[0]) + : {}; + + const placementPercentView = element ? getViewability(element, topWin, size) : 0; + + return cleanObj({ + AuctionsCount: bidReq.auctionsCount, + DistanceToView: distanceToView, + PlacementPercentView: Math.round(placementPercentView), + ElementHeight: Math.round(elementHeight) || 1 + }); + } + + function getPlacementEnv() { + return cleanObj({ + TimeFromNavigation: Math.floor(performance.now()), + TabActive: topWin.document.visibilityState === 'visible', + PageHeight: getPageHeight(), + ViewportHeight: getViewportHeight() + }); + } + + return { + getPlacementInfo, + getPlacementEnv + }; +} diff --git a/libraries/teqblazeUtils/bidderUtils.js b/libraries/teqblazeUtils/bidderUtils.js index f310f2304d2..2c07c8ea288 100644 --- a/libraries/teqblazeUtils/bidderUtils.js +++ b/libraries/teqblazeUtils/bidderUtils.js @@ -231,8 +231,8 @@ export const getUserSyncs = (syncUrl) => (syncOptions, serverResponses, gdprCons } } - if (uspConsent && uspConsent.consentString) { - url += `&ccpa_consent=${uspConsent.consentString}`; + if (uspConsent) { + url += `&ccpa_consent=${uspConsent}`; } if (gppConsent?.gppString && gppConsent?.applicableSections?.length) { diff --git a/libraries/timezone/timezone.js b/libraries/timezone/timezone.js index e4ef39f28ef..8c09295d70f 100644 --- a/libraries/timezone/timezone.js +++ b/libraries/timezone/timezone.js @@ -1,3 +1,8 @@ +import {isFingerprintingApiDisabled} from '../fingerprinting/fingerprinting.js'; + export function getTimeZone() { + if (isFingerprintingApiDisabled('resolvedoptions')) { + return ''; + } return Intl.DateTimeFormat().resolvedOptions().timeZone; } diff --git a/libraries/webdriver/webdriver.js b/libraries/webdriver/webdriver.js index 957fea62ed8..8e10e4d0374 100644 --- a/libraries/webdriver/webdriver.js +++ b/libraries/webdriver/webdriver.js @@ -1,9 +1,49 @@ -import {canAccessWindowTop, internal as utilsInternals} from '../../src/utils.js'; +import {isFingerprintingApiDisabled} from '../fingerprinting/fingerprinting.js'; +import {getFallbackWindow} from '../../src/utils.js'; /** * Warning: accessing navigator.webdriver may impact fingerprinting scores when this API is included in the built script. + * @param {Window} [win] Window to check (defaults to top or self) + * @returns {boolean} */ -export function isWebdriverEnabled() { - const win = canAccessWindowTop() ? utilsInternals.getWindowTop() : utilsInternals.getWindowSelf(); - return win.navigator?.webdriver === true; +export function isWebdriverEnabled(win) { + if (isFingerprintingApiDisabled('webdriver')) { + return false; + } + return getFallbackWindow(win).navigator?.webdriver === true; +} + +/** + * Detects Selenium/WebDriver via document/window properties (e.g. __webdriver_script_fn, attributes). + * @param {Window} [win] Window to check + * @param {Document} [doc] Document to check (defaults to win.document) + * @returns {boolean} + */ +export function isSeleniumDetected(win, doc) { + if (isFingerprintingApiDisabled('webdriver')) { + return false; + } + const _win = win || (typeof window !== 'undefined' ? window : undefined); + const _doc = doc || (_win?.document); + if (!_win || !_doc) return false; + const checks = [ + 'webdriver' in _win, + '_Selenium_IDE_Recorder' in _win, + 'callSelenium' in _win, + '_selenium' in _win, + '__webdriver_script_fn' in _doc, + '__driver_evaluate' in _doc, + '__webdriver_evaluate' in _doc, + '__selenium_evaluate' in _doc, + '__fxdriver_evaluate' in _doc, + '__driver_unwrapped' in _doc, + '__webdriver_unwrapped' in _doc, + '__selenium_unwrapped' in _doc, + '__fxdriver_unwrapped' in _doc, + '__webdriver_script_func' in _doc, + _doc.documentElement?.getAttribute('selenium') !== null, + _doc.documentElement?.getAttribute('webdriver') !== null, + _doc.documentElement?.getAttribute('driver') !== null + ]; + return checks.some(Boolean); } diff --git a/metadata/core.json b/metadata/core.json index 1e43a89e586..faacd3ceed4 100644 --- a/metadata/core.json +++ b/metadata/core.json @@ -11,6 +11,12 @@ "moduleName": "prebid-core", "disclosureURL": "local://prebid/probes.json" }, + { + "componentType": "prebid", + "componentName": "storage", + "moduleName": "prebid-core", + "disclosureURL": "local://prebid/probes.json" + }, { "componentType": "prebid", "componentName": "debugging", diff --git a/metadata/disclosures/prebid/probes.json b/metadata/disclosures/prebid/probes.json index c371cef1d4e..16cc5dec160 100644 --- a/metadata/disclosures/prebid/probes.json +++ b/metadata/disclosures/prebid/probes.json @@ -22,7 +22,7 @@ "domains": [ { "domain": "*", - "use": "Temporary 'probing' cookies are written (and deleted) to determine the top-level domain; likewise, probes are temporarily written to local and sessionStorage to determine their availability" + "use": "Temporary 'probing' cookies are written (and deleted) to determine the top-level domain and availability of cookies; likewise, probes are temporarily written to local and sessionStorage to determine their availability" } ] } diff --git a/metadata/modules.json b/metadata/modules.json index b078a117e6e..60c9f672a99 100644 --- a/metadata/modules.json +++ b/metadata/modules.json @@ -43,6 +43,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "aceex", + "aliasOf": null, + "gvlid": 1387, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "acuityads", @@ -106,6 +113,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "adcluster", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "addefend", @@ -519,6 +533,13 @@ "gvlid": 1283, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "intlscoop", + "aliasOf": "adkernel", + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "admaru", @@ -568,6 +589,13 @@ "gvlid": 779, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "adrubi", + "aliasOf": "admatic", + "gvlid": 779, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "yobee", @@ -638,6 +666,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "adnimation", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "adnow", @@ -1163,6 +1198,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "apester", + "aliasOf": null, + "gvlid": 354, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "appStockSSP", @@ -2101,6 +2143,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "dpai", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "driftpixel", @@ -2290,6 +2339,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "floxis", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "fluct", @@ -2479,6 +2535,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "harion", + "aliasOf": null, + "gvlid": 1406, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "holid", @@ -2584,6 +2647,13 @@ "gvlid": 910, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "insurads", + "aliasOf": null, + "gvlid": 596, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "integr8", @@ -2738,6 +2808,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "leagueM", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "lemmadigital", @@ -2780,13 +2857,6 @@ "gvlid": 1358, "disclosureURL": null }, - { - "componentType": "bidder", - "componentName": "apester", - "aliasOf": "limelightDigital", - "gvlid": null, - "disclosureURL": null - }, { "componentType": "bidder", "componentName": "adsyield", @@ -2850,13 +2920,6 @@ "gvlid": null, "disclosureURL": null }, - { - "componentType": "bidder", - "componentName": "adnimation", - "aliasOf": "limelightDigital", - "gvlid": null, - "disclosureURL": null - }, { "componentType": "bidder", "componentName": "rtbdemand", @@ -3158,6 +3221,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "mile", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "minutemedia", @@ -3802,6 +3872,20 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "pubstack", + "aliasOf": null, + "gvlid": 1408, + "disclosureURL": null + }, + { + "componentType": "bidder", + "componentName": "pubstack_server", + "aliasOf": "pubstack", + "gvlid": 1408, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "pubx", @@ -3970,6 +4054,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "revantage", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "revcontent", @@ -4579,6 +4670,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "teqBlazeSalesAgent", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "theadx", @@ -4747,6 +4845,13 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "verben", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "viant", @@ -4985,6 +5090,13 @@ "gvlid": 25, "disclosureURL": null }, + { + "componentType": "bidder", + "componentName": "yaleo", + "aliasOf": null, + "gvlid": 783, + "disclosureURL": null + }, { "componentType": "bidder", "componentName": "yandex", @@ -5364,6 +5476,12 @@ "gvlid": null, "disclosureURL": null }, + { + "componentType": "rtd", + "componentName": "panxo", + "gvlid": null, + "disclosureURL": null + }, { "componentType": "rtd", "componentName": "permutive", @@ -5678,6 +5796,20 @@ "disclosureURL": null, "aliasOf": null }, + { + "componentType": "userId", + "componentName": "locId", + "gvlid": null, + "disclosureURL": null, + "aliasOf": null + }, + { + "componentType": "userId", + "componentName": "locid", + "gvlid": null, + "disclosureURL": null, + "aliasOf": "locId" + }, { "componentType": "userId", "componentName": "lockrAIMId", diff --git a/metadata/modules/33acrossBidAdapter.json b/metadata/modules/33acrossBidAdapter.json index 7c784ce23b2..79ecf25a705 100644 --- a/metadata/modules/33acrossBidAdapter.json +++ b/metadata/modules/33acrossBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://platform.33across.com/disclosures.json": { - "timestamp": "2026-01-28T16:29:58.218Z", + "timestamp": "2026-03-02T14:44:46.319Z", "disclosures": [] } }, diff --git a/metadata/modules/33acrossIdSystem.json b/metadata/modules/33acrossIdSystem.json index 08c9e5fdb3c..3adce4bba03 100644 --- a/metadata/modules/33acrossIdSystem.json +++ b/metadata/modules/33acrossIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://platform.33across.com/disclosures.json": { - "timestamp": "2026-01-28T16:29:58.314Z", + "timestamp": "2026-03-02T14:44:46.422Z", "disclosures": [] } }, diff --git a/metadata/modules/aceexBidAdapter.json b/metadata/modules/aceexBidAdapter.json new file mode 100644 index 00000000000..f443e609ec4 --- /dev/null +++ b/metadata/modules/aceexBidAdapter.json @@ -0,0 +1,18 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": { + "https://aceex.io/tcf.json": { + "timestamp": "2026-03-02T14:44:46.425Z", + "disclosures": [] + } + }, + "components": [ + { + "componentType": "bidder", + "componentName": "aceex", + "aliasOf": null, + "gvlid": 1387, + "disclosureURL": "https://aceex.io/tcf.json" + } + ] +} \ No newline at end of file diff --git a/metadata/modules/acuityadsBidAdapter.json b/metadata/modules/acuityadsBidAdapter.json index 5c1719c2ce1..01786a901ea 100644 --- a/metadata/modules/acuityadsBidAdapter.json +++ b/metadata/modules/acuityadsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://privacy.acuityads.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:29:58.317Z", + "timestamp": "2026-03-02T14:44:46.471Z", "disclosures": [] } }, diff --git a/metadata/modules/adagioBidAdapter.json b/metadata/modules/adagioBidAdapter.json index b490d929e1a..728761cdcc3 100644 --- a/metadata/modules/adagioBidAdapter.json +++ b/metadata/modules/adagioBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adagio.io/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:29:58.354Z", + "timestamp": "2026-03-02T14:44:46.503Z", "disclosures": [] } }, diff --git a/metadata/modules/adagioRtdProvider.json b/metadata/modules/adagioRtdProvider.json index e604c3b2d9c..01aba8c973b 100644 --- a/metadata/modules/adagioRtdProvider.json +++ b/metadata/modules/adagioRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adagio.io/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:29:58.504Z", + "timestamp": "2026-03-02T14:44:46.575Z", "disclosures": [] } }, diff --git a/metadata/modules/adbroBidAdapter.json b/metadata/modules/adbroBidAdapter.json index 6b21766ca1d..c1607d5b2ea 100644 --- a/metadata/modules/adbroBidAdapter.json +++ b/metadata/modules/adbroBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tag.adbro.me/privacy/devicestorage.json": { - "timestamp": "2026-01-28T16:29:58.504Z", + "timestamp": "2026-03-02T14:44:46.575Z", "disclosures": [] } }, diff --git a/metadata/modules/adclusterBidAdapter.json b/metadata/modules/adclusterBidAdapter.json new file mode 100644 index 00000000000..72d44231bb6 --- /dev/null +++ b/metadata/modules/adclusterBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "adcluster", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/addefendBidAdapter.json b/metadata/modules/addefendBidAdapter.json index 0d880b30acc..1cd4c70fc97 100644 --- a/metadata/modules/addefendBidAdapter.json +++ b/metadata/modules/addefendBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.addefend.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:29:58.826Z", + "timestamp": "2026-03-02T14:44:46.894Z", "disclosures": [] } }, diff --git a/metadata/modules/adfBidAdapter.json b/metadata/modules/adfBidAdapter.json index fb6d19f8ad4..8e01eb95878 100644 --- a/metadata/modules/adfBidAdapter.json +++ b/metadata/modules/adfBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://site.adform.com/assets/devicestorage.json": { - "timestamp": "2026-01-28T16:29:59.445Z", + "timestamp": "2026-03-02T14:45:00.675Z", "disclosures": [] } }, diff --git a/metadata/modules/adfusionBidAdapter.json b/metadata/modules/adfusionBidAdapter.json index 57df8f4f68b..bec524db96d 100644 --- a/metadata/modules/adfusionBidAdapter.json +++ b/metadata/modules/adfusionBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://spicyrtb.com/static/iab-disclosure.json": { - "timestamp": "2026-01-28T16:29:59.445Z", + "timestamp": "2026-03-02T14:45:00.676Z", "disclosures": [] } }, diff --git a/metadata/modules/adheseBidAdapter.json b/metadata/modules/adheseBidAdapter.json index 907ed2be2df..341d5b1c818 100644 --- a/metadata/modules/adheseBidAdapter.json +++ b/metadata/modules/adheseBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adhese.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:29:59.885Z", + "timestamp": "2026-03-02T14:45:01.041Z", "disclosures": [] } }, diff --git a/metadata/modules/adipoloBidAdapter.json b/metadata/modules/adipoloBidAdapter.json index 88388af4def..54ee67e7037 100644 --- a/metadata/modules/adipoloBidAdapter.json +++ b/metadata/modules/adipoloBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adipolo.com/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:00.155Z", + "timestamp": "2026-03-02T14:45:01.314Z", "disclosures": [] } }, diff --git a/metadata/modules/adkernelAdnBidAdapter.json b/metadata/modules/adkernelAdnBidAdapter.json index 29fbe50a428..0516f01d293 100644 --- a/metadata/modules/adkernelAdnBidAdapter.json +++ b/metadata/modules/adkernelAdnBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://static.adkernel.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:00.288Z", + "timestamp": "2026-03-02T14:45:01.470Z", "disclosures": [ { "identifier": "adk_rtb_conv_id", diff --git a/metadata/modules/adkernelBidAdapter.json b/metadata/modules/adkernelBidAdapter.json index 3cefa08504a..8b11e6d7d24 100644 --- a/metadata/modules/adkernelBidAdapter.json +++ b/metadata/modules/adkernelBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://static.adkernel.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:00.508Z", + "timestamp": "2026-03-02T14:45:01.554Z", "disclosures": [ { "identifier": "adk_rtb_conv_id", @@ -17,19 +17,19 @@ ] }, "https://data.converge-digital.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:00.508Z", + "timestamp": "2026-03-02T14:45:01.554Z", "disclosures": [] }, "https://spinx.biz/tcf-spinx.json": { - "timestamp": "2026-01-28T16:30:00.562Z", + "timestamp": "2026-03-02T14:45:01.604Z", "disclosures": [] }, "https://gdpr.memob.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:01.305Z", + "timestamp": "2026-03-02T14:45:02.320Z", "disclosures": [] }, "https://appmonsta.ai/DeviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:01.323Z", + "timestamp": "2026-03-02T14:45:02.337Z", "disclosures": [] } }, @@ -348,6 +348,13 @@ "aliasOf": "adkernel", "gvlid": 1283, "disclosureURL": "https://appmonsta.ai/DeviceStorageDisclosure.json" + }, + { + "componentType": "bidder", + "componentName": "intlscoop", + "aliasOf": "adkernel", + "gvlid": null, + "disclosureURL": null } ] } \ No newline at end of file diff --git a/metadata/modules/admaticBidAdapter.json b/metadata/modules/admaticBidAdapter.json index f44a99df523..ded364cbc31 100644 --- a/metadata/modules/admaticBidAdapter.json +++ b/metadata/modules/admaticBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://static.admatic.de/iab-europe/tcfv2/disclosure.json": { - "timestamp": "2026-01-28T16:30:03.161Z", + "timestamp": "2026-03-02T14:45:02.895Z", "disclosures": [ { "identifier": "px_pbjs", @@ -12,7 +12,7 @@ ] }, "https://adtarget.com.tr/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:02.786Z", + "timestamp": "2026-03-02T14:45:02.895Z", "disclosures": [ { "identifier": "adt_pbjs", @@ -65,6 +65,13 @@ "gvlid": 779, "disclosureURL": "https://adtarget.com.tr/.well-known/deviceStorage.json" }, + { + "componentType": "bidder", + "componentName": "adrubi", + "aliasOf": "admatic", + "gvlid": 779, + "disclosureURL": "https://adtarget.com.tr/.well-known/deviceStorage.json" + }, { "componentType": "bidder", "componentName": "yobee", diff --git a/metadata/modules/admixerBidAdapter.json b/metadata/modules/admixerBidAdapter.json index f4aceb4271e..8f6d6f99397 100644 --- a/metadata/modules/admixerBidAdapter.json +++ b/metadata/modules/admixerBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://admixer.com/tcf.json": { - "timestamp": "2026-01-28T16:30:03.161Z", + "timestamp": "2026-03-02T14:45:02.896Z", "disclosures": [] } }, diff --git a/metadata/modules/admixerIdSystem.json b/metadata/modules/admixerIdSystem.json index 9e56d98f973..f8bd80f3bca 100644 --- a/metadata/modules/admixerIdSystem.json +++ b/metadata/modules/admixerIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://admixer.com/tcf.json": { - "timestamp": "2026-01-28T16:30:03.552Z", + "timestamp": "2026-03-02T14:45:03.276Z", "disclosures": [] } }, diff --git a/metadata/modules/adnimationBidAdapter.json b/metadata/modules/adnimationBidAdapter.json new file mode 100644 index 00000000000..7bb7fce194e --- /dev/null +++ b/metadata/modules/adnimationBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "adnimation", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/adnowBidAdapter.json b/metadata/modules/adnowBidAdapter.json index 85b183c233f..cb5aa129b20 100644 --- a/metadata/modules/adnowBidAdapter.json +++ b/metadata/modules/adnowBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adnow.com/vdsod.json": { - "timestamp": "2026-01-28T16:30:03.552Z", + "timestamp": "2026-03-02T14:45:03.276Z", "disclosures": [ { "identifier": "SC_unique_*", diff --git a/metadata/modules/adnuntiusBidAdapter.json b/metadata/modules/adnuntiusBidAdapter.json index faa59453320..c2a643ef408 100644 --- a/metadata/modules/adnuntiusBidAdapter.json +++ b/metadata/modules/adnuntiusBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://delivery.adnuntius.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:03.784Z", + "timestamp": "2026-03-02T14:45:16.546Z", "disclosures": [ { "identifier": "adn.metaData", diff --git a/metadata/modules/adnuntiusRtdProvider.json b/metadata/modules/adnuntiusRtdProvider.json index 88033c0bd3a..9c3af875f20 100644 --- a/metadata/modules/adnuntiusRtdProvider.json +++ b/metadata/modules/adnuntiusRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://delivery.adnuntius.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:04.118Z", + "timestamp": "2026-03-02T14:45:16.877Z", "disclosures": [ { "identifier": "adn.metaData", diff --git a/metadata/modules/adoceanBidAdapter.json b/metadata/modules/adoceanBidAdapter.json index 434185cfb11..c97b093663c 100644 --- a/metadata/modules/adoceanBidAdapter.json +++ b/metadata/modules/adoceanBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://gemius.com/media/documents/Gemius_SA_Vendor_Device_Storage.json": { - "timestamp": "2026-01-28T16:30:04.118Z", + "timestamp": "2026-03-02T14:45:16.878Z", "disclosures": [ { "identifier": "__gsyncs_gdpr", diff --git a/metadata/modules/adotBidAdapter.json b/metadata/modules/adotBidAdapter.json index 7e1e6a0aff6..9a38b9d66e3 100644 --- a/metadata/modules/adotBidAdapter.json +++ b/metadata/modules/adotBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://assets.adotmob.com/tcf/tcf.json": { - "timestamp": "2026-01-28T16:30:04.676Z", + "timestamp": "2026-03-02T14:45:17.430Z", "disclosures": [] } }, diff --git a/metadata/modules/adponeBidAdapter.json b/metadata/modules/adponeBidAdapter.json index ffa30b608f0..7022a062e25 100644 --- a/metadata/modules/adponeBidAdapter.json +++ b/metadata/modules/adponeBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adserver.adpone.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:04.714Z", + "timestamp": "2026-03-02T14:45:17.575Z", "disclosures": [] } }, diff --git a/metadata/modules/adqueryBidAdapter.json b/metadata/modules/adqueryBidAdapter.json index 9525503e59b..6ee1ee7d899 100644 --- a/metadata/modules/adqueryBidAdapter.json +++ b/metadata/modules/adqueryBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://api.adquery.io/tcf/adQuery.json": { - "timestamp": "2026-01-28T16:30:04.745Z", + "timestamp": "2026-03-02T14:45:17.832Z", "disclosures": [] } }, diff --git a/metadata/modules/adqueryIdSystem.json b/metadata/modules/adqueryIdSystem.json index cc0f9edaf73..d97e79287da 100644 --- a/metadata/modules/adqueryIdSystem.json +++ b/metadata/modules/adqueryIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://api.adquery.io/tcf/adQuery.json": { - "timestamp": "2026-01-28T16:30:05.099Z", + "timestamp": "2026-03-02T14:45:18.165Z", "disclosures": [] } }, diff --git a/metadata/modules/adrinoBidAdapter.json b/metadata/modules/adrinoBidAdapter.json index 1b11ddd4322..1683ae8d010 100644 --- a/metadata/modules/adrinoBidAdapter.json +++ b/metadata/modules/adrinoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.adrino.cloud/iab/device-storage.json": { - "timestamp": "2026-01-28T16:30:05.100Z", + "timestamp": "2026-03-02T14:45:18.166Z", "disclosures": [] } }, diff --git a/metadata/modules/ads_interactiveBidAdapter.json b/metadata/modules/ads_interactiveBidAdapter.json index 186d5188d3b..c0bcc3c0438 100644 --- a/metadata/modules/ads_interactiveBidAdapter.json +++ b/metadata/modules/ads_interactiveBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adsinteractive.com/vendor.json": { - "timestamp": "2026-01-28T16:30:05.144Z", + "timestamp": "2026-03-02T14:45:18.257Z", "disclosures": [] } }, diff --git a/metadata/modules/adtargetBidAdapter.json b/metadata/modules/adtargetBidAdapter.json index 5ca832a5cfb..643548e6a49 100644 --- a/metadata/modules/adtargetBidAdapter.json +++ b/metadata/modules/adtargetBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adtarget.com.tr/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:05.446Z", + "timestamp": "2026-03-02T14:45:18.549Z", "disclosures": [ { "identifier": "adt_pbjs", diff --git a/metadata/modules/adtelligentBidAdapter.json b/metadata/modules/adtelligentBidAdapter.json index 5df3a013203..68c149946c6 100644 --- a/metadata/modules/adtelligentBidAdapter.json +++ b/metadata/modules/adtelligentBidAdapter.json @@ -2,11 +2,11 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adtelligent.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:05.446Z", + "timestamp": "2026-03-02T14:45:18.549Z", "disclosures": [] }, "https://www.selectmedia.asia/gdpr/devicestorage.json": { - "timestamp": "2026-01-28T16:30:05.465Z", + "timestamp": "2026-03-02T14:45:18.568Z", "disclosures": [ { "identifier": "waterFallCacheAnsKey_*", @@ -81,7 +81,7 @@ ] }, "https://orangeclickmedia.com/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:05.651Z", + "timestamp": "2026-03-02T14:45:31.321Z", "disclosures": [] } }, diff --git a/metadata/modules/adtelligentIdSystem.json b/metadata/modules/adtelligentIdSystem.json index 4793bd2971a..63773fb0465 100644 --- a/metadata/modules/adtelligentIdSystem.json +++ b/metadata/modules/adtelligentIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adtelligent.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:05.794Z", + "timestamp": "2026-03-02T14:45:31.395Z", "disclosures": [] } }, diff --git a/metadata/modules/aduptechBidAdapter.json b/metadata/modules/aduptechBidAdapter.json index 763a2faa1b7..63a0f554c7f 100644 --- a/metadata/modules/aduptechBidAdapter.json +++ b/metadata/modules/aduptechBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://s.d.adup-tech.com/gdpr/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:05.795Z", + "timestamp": "2026-03-02T14:45:31.395Z", "disclosures": [] } }, diff --git a/metadata/modules/adyoulikeBidAdapter.json b/metadata/modules/adyoulikeBidAdapter.json index ad36769e5f0..80d9bc14b7e 100644 --- a/metadata/modules/adyoulikeBidAdapter.json +++ b/metadata/modules/adyoulikeBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adyoulike.com/deviceStorageDisclosureURL.json": { - "timestamp": "2026-01-28T16:30:05.815Z", + "timestamp": "2026-03-02T14:45:31.420Z", "disclosures": [] } }, diff --git a/metadata/modules/airgridRtdProvider.json b/metadata/modules/airgridRtdProvider.json index d490880b416..e127fd2d00b 100644 --- a/metadata/modules/airgridRtdProvider.json +++ b/metadata/modules/airgridRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.wearemiq.com/privacy-and-compliance/devicestoragedisclosures.json": { - "timestamp": "2026-01-28T16:30:06.273Z", + "timestamp": "2026-03-02T14:45:31.871Z", "disclosures": [] } }, diff --git a/metadata/modules/alkimiBidAdapter.json b/metadata/modules/alkimiBidAdapter.json index f80e84a7d63..a9638ab0a47 100644 --- a/metadata/modules/alkimiBidAdapter.json +++ b/metadata/modules/alkimiBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://d1xjh92lb8fey3.cloudfront.net/tcf/alkimi_exchange_tcf.json": { - "timestamp": "2026-01-28T16:30:06.319Z", + "timestamp": "2026-03-02T14:45:31.902Z", "disclosures": [] } }, diff --git a/metadata/modules/allegroBidAdapter.json b/metadata/modules/allegroBidAdapter.json index 0bb0131e96f..0d631041437 100644 --- a/metadata/modules/allegroBidAdapter.json +++ b/metadata/modules/allegroBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://assets.allegrostatic.com/dsp-tcf-external/device-storage.json": { - "timestamp": "2026-01-28T16:30:06.612Z", + "timestamp": "2026-03-02T14:45:32.202Z", "disclosures": [] } }, diff --git a/metadata/modules/amxBidAdapter.json b/metadata/modules/amxBidAdapter.json index 8de34154ecc..8a4900eb3fb 100644 --- a/metadata/modules/amxBidAdapter.json +++ b/metadata/modules/amxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://assets.a-mo.net/tcf/device-storage.json": { - "timestamp": "2026-01-28T16:30:07.054Z", + "timestamp": "2026-03-02T14:45:32.667Z", "disclosures": [] } }, diff --git a/metadata/modules/amxIdSystem.json b/metadata/modules/amxIdSystem.json index 1a8270a07dd..a204078442a 100644 --- a/metadata/modules/amxIdSystem.json +++ b/metadata/modules/amxIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://assets.a-mo.net/tcf/device-storage.json": { - "timestamp": "2026-01-28T16:30:07.197Z", + "timestamp": "2026-03-02T14:45:32.757Z", "disclosures": [] } }, diff --git a/metadata/modules/aniviewBidAdapter.json b/metadata/modules/aniviewBidAdapter.json index e2d0cb92554..0bc1a7c7752 100644 --- a/metadata/modules/aniviewBidAdapter.json +++ b/metadata/modules/aniviewBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://player.aniview.com/gdpr/gdpr.json": { - "timestamp": "2026-01-28T16:30:07.197Z", + "timestamp": "2026-03-02T14:45:32.758Z", "disclosures": [ { "identifier": "av_*", diff --git a/metadata/modules/anonymisedRtdProvider.json b/metadata/modules/anonymisedRtdProvider.json index 3da9ac7b85c..7593b8bcb11 100644 --- a/metadata/modules/anonymisedRtdProvider.json +++ b/metadata/modules/anonymisedRtdProvider.json @@ -1,8 +1,8 @@ { "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { - "https://static.anonymised.io/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:07.304Z", + "https://cdn1.anonymised.io/deviceStorage.json": { + "timestamp": "2026-03-02T14:45:32.828Z", "disclosures": [ { "identifier": "oidc.user*", @@ -67,7 +67,7 @@ "componentType": "rtd", "componentName": "anonymised", "gvlid": 1116, - "disclosureURL": "https://static.anonymised.io/deviceStorage.json" + "disclosureURL": "https://cdn1.anonymised.io/deviceStorage.json" } ] } \ No newline at end of file diff --git a/metadata/modules/apesterBidAdapter.json b/metadata/modules/apesterBidAdapter.json new file mode 100644 index 00000000000..372e0ba6a78 --- /dev/null +++ b/metadata/modules/apesterBidAdapter.json @@ -0,0 +1,18 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": { + "https://apester.com/deviceStorage.json": { + "timestamp": "2026-03-02T14:45:33.047Z", + "disclosures": [] + } + }, + "components": [ + { + "componentType": "bidder", + "componentName": "apester", + "aliasOf": null, + "gvlid": 354, + "disclosureURL": "https://apester.com/deviceStorage.json" + } + ] +} \ No newline at end of file diff --git a/metadata/modules/appStockSSPBidAdapter.json b/metadata/modules/appStockSSPBidAdapter.json index 8bd90c00e58..8d113568028 100644 --- a/metadata/modules/appStockSSPBidAdapter.json +++ b/metadata/modules/appStockSSPBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://app-stock.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:07.420Z", + "timestamp": "2026-03-02T14:45:33.167Z", "disclosures": [] } }, diff --git a/metadata/modules/appierBidAdapter.json b/metadata/modules/appierBidAdapter.json index 2f0f4655a71..bc805982843 100644 --- a/metadata/modules/appierBidAdapter.json +++ b/metadata/modules/appierBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.appier.com/deviceStorage2025.json": { - "timestamp": "2026-01-28T16:30:07.455Z", + "timestamp": "2026-03-02T14:45:33.203Z", "disclosures": [ { "identifier": "_atrk_ssid", diff --git a/metadata/modules/appnexusBidAdapter.json b/metadata/modules/appnexusBidAdapter.json index ca815263571..59027a399d7 100644 --- a/metadata/modules/appnexusBidAdapter.json +++ b/metadata/modules/appnexusBidAdapter.json @@ -2,19 +2,19 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://acdn.adnxs.com/gvl/1d/xandrdevicestoragedisclosures.json": { - "timestamp": "2026-01-28T16:30:08.134Z", + "timestamp": "2026-03-02T14:45:33.960Z", "disclosures": [] }, "https://beintoo-support.b-cdn.net/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:07.591Z", + "timestamp": "2026-03-02T14:45:33.279Z", "disclosures": [] }, "https://projectagora.net/1032_deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:07.694Z", + "timestamp": "2026-03-02T14:45:33.420Z", "disclosures": [] }, "https://adzymic.com/tcf.json": { - "timestamp": "2026-01-28T16:30:08.134Z", + "timestamp": "2026-03-02T14:45:33.960Z", "disclosures": [] } }, diff --git a/metadata/modules/appushBidAdapter.json b/metadata/modules/appushBidAdapter.json index b74c3d9b2e8..15019d11110 100644 --- a/metadata/modules/appushBidAdapter.json +++ b/metadata/modules/appushBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.thebiding.com/disclosures.json": { - "timestamp": "2026-01-28T16:30:08.156Z", + "timestamp": "2026-03-02T14:45:33.989Z", "disclosures": [] } }, diff --git a/metadata/modules/apsBidAdapter.json b/metadata/modules/apsBidAdapter.json index 3ddca493ec1..7132ee483e9 100644 --- a/metadata/modules/apsBidAdapter.json +++ b/metadata/modules/apsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://m.media-amazon.com/images/G/01/adprefs/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:08.218Z", + "timestamp": "2026-03-02T14:45:34.055Z", "disclosures": [ { "identifier": "vendor-id", diff --git a/metadata/modules/apstreamBidAdapter.json b/metadata/modules/apstreamBidAdapter.json index b45081cbc64..b7a2fb2671d 100644 --- a/metadata/modules/apstreamBidAdapter.json +++ b/metadata/modules/apstreamBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://sak.userreport.com/tcf.json": { - "timestamp": "2026-01-28T16:30:08.230Z", + "timestamp": "2026-03-02T14:45:34.071Z", "disclosures": [ { "identifier": "apr_dsu", diff --git a/metadata/modules/audiencerunBidAdapter.json b/metadata/modules/audiencerunBidAdapter.json index 561717d5ac0..ad7d2d71fc5 100644 --- a/metadata/modules/audiencerunBidAdapter.json +++ b/metadata/modules/audiencerunBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.audiencerun.com/tcf.json": { - "timestamp": "2026-01-28T16:30:08.251Z", + "timestamp": "2026-03-02T14:45:34.115Z", "disclosures": [] } }, diff --git a/metadata/modules/axisBidAdapter.json b/metadata/modules/axisBidAdapter.json index 97d8889684e..33ade7a617c 100644 --- a/metadata/modules/axisBidAdapter.json +++ b/metadata/modules/axisBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://axis-marketplace.com/tcf.json": { - "timestamp": "2026-01-28T16:30:08.288Z", + "timestamp": "2026-03-02T14:45:34.167Z", "disclosures": [] } }, diff --git a/metadata/modules/azerionedgeRtdProvider.json b/metadata/modules/azerionedgeRtdProvider.json index 138bb4cf730..198266cb6ac 100644 --- a/metadata/modules/azerionedgeRtdProvider.json +++ b/metadata/modules/azerionedgeRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://sellers.improvedigital.com/tcf-cookies.json": { - "timestamp": "2026-01-28T16:30:08.325Z", + "timestamp": "2026-03-02T14:45:34.210Z", "disclosures": [ { "identifier": "tuuid", diff --git a/metadata/modules/beachfrontBidAdapter.json b/metadata/modules/beachfrontBidAdapter.json index d9ea10bb0eb..b7f18cad4eb 100644 --- a/metadata/modules/beachfrontBidAdapter.json +++ b/metadata/modules/beachfrontBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.seedtag.com/vendor.json": { - "timestamp": "2026-01-28T16:30:08.359Z", + "timestamp": "2026-03-02T14:45:34.236Z", "disclosures": [] } }, diff --git a/metadata/modules/beopBidAdapter.json b/metadata/modules/beopBidAdapter.json index 7de553c3356..84b4ecc7f33 100644 --- a/metadata/modules/beopBidAdapter.json +++ b/metadata/modules/beopBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://beop.io/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:08.381Z", + "timestamp": "2026-03-02T14:45:34.257Z", "disclosures": [] } }, diff --git a/metadata/modules/betweenBidAdapter.json b/metadata/modules/betweenBidAdapter.json index cdeb282e76d..b48dadd84d0 100644 --- a/metadata/modules/betweenBidAdapter.json +++ b/metadata/modules/betweenBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://en.betweenx.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:08.501Z", + "timestamp": "2026-03-02T14:45:34.379Z", "disclosures": [] } }, diff --git a/metadata/modules/bidfuseBidAdapter.json b/metadata/modules/bidfuseBidAdapter.json index e9f10a2af75..cb5dc975164 100644 --- a/metadata/modules/bidfuseBidAdapter.json +++ b/metadata/modules/bidfuseBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bidfuse.com/disclosure.json": { - "timestamp": "2026-01-28T16:30:08.568Z", + "timestamp": "2026-03-02T14:45:34.436Z", "disclosures": [] } }, diff --git a/metadata/modules/bidmaticBidAdapter.json b/metadata/modules/bidmaticBidAdapter.json index c698dd1c7e1..320c69b6e66 100644 --- a/metadata/modules/bidmaticBidAdapter.json +++ b/metadata/modules/bidmaticBidAdapter.json @@ -2,8 +2,8 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bidmatic.io/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:09.193Z", - "disclosures": [] + "timestamp": "2026-03-02T14:45:34.611Z", + "disclosures": null } }, "components": [ diff --git a/metadata/modules/bidtheatreBidAdapter.json b/metadata/modules/bidtheatreBidAdapter.json index f8747a74d23..987e79d876e 100644 --- a/metadata/modules/bidtheatreBidAdapter.json +++ b/metadata/modules/bidtheatreBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://privacy.bidtheatre.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:09.243Z", + "timestamp": "2026-03-02T14:45:34.658Z", "disclosures": [] } }, diff --git a/metadata/modules/bliinkBidAdapter.json b/metadata/modules/bliinkBidAdapter.json index 2daef968201..9ae146cb5de 100644 --- a/metadata/modules/bliinkBidAdapter.json +++ b/metadata/modules/bliinkBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bliink.io/disclosures.json": { - "timestamp": "2026-01-28T16:30:09.554Z", + "timestamp": "2026-03-02T14:45:34.957Z", "disclosures": [] } }, diff --git a/metadata/modules/blockthroughBidAdapter.json b/metadata/modules/blockthroughBidAdapter.json index b6e60f793e3..b3c53c81f31 100644 --- a/metadata/modules/blockthroughBidAdapter.json +++ b/metadata/modules/blockthroughBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://blockthrough.com/tcf_disclosures.json": { - "timestamp": "2026-01-28T16:30:09.898Z", + "timestamp": "2026-03-02T14:45:35.252Z", "disclosures": [ { "identifier": "BT_AA_DETECTION", diff --git a/metadata/modules/blueBidAdapter.json b/metadata/modules/blueBidAdapter.json index d5459ddd030..fdefc12b8e7 100644 --- a/metadata/modules/blueBidAdapter.json +++ b/metadata/modules/blueBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://getblue.io/iab/iab.json": { - "timestamp": "2026-01-28T16:30:10.177Z", + "timestamp": "2026-03-02T14:45:35.438Z", "disclosures": [] } }, diff --git a/metadata/modules/bmsBidAdapter.json b/metadata/modules/bmsBidAdapter.json index a7f11357ec6..59d3a1914dc 100644 --- a/metadata/modules/bmsBidAdapter.json +++ b/metadata/modules/bmsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bluems.com/iab.json": { - "timestamp": "2026-01-28T16:30:10.533Z", + "timestamp": "2026-03-02T14:45:35.784Z", "disclosures": [] } }, diff --git a/metadata/modules/boldwinBidAdapter.json b/metadata/modules/boldwinBidAdapter.json index 2e6fbbf4f05..e8f694fd2b9 100644 --- a/metadata/modules/boldwinBidAdapter.json +++ b/metadata/modules/boldwinBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://magav.videowalldirect.com/iab/videowalldirectiab.json": { - "timestamp": "2026-01-28T16:30:10.546Z", + "timestamp": "2026-03-02T14:45:35.801Z", "disclosures": [] } }, diff --git a/metadata/modules/bridBidAdapter.json b/metadata/modules/bridBidAdapter.json index 3141704c3cf..4475c15ed3a 100644 --- a/metadata/modules/bridBidAdapter.json +++ b/metadata/modules/bridBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://target-video.com/vendors-device-storage-and-operational-disclosures.json": { - "timestamp": "2026-01-28T16:30:10.571Z", + "timestamp": "2026-03-02T14:45:35.828Z", "disclosures": [ { "identifier": "brid_location", diff --git a/metadata/modules/browsiBidAdapter.json b/metadata/modules/browsiBidAdapter.json index 4983eb994cd..ddc532d4e32 100644 --- a/metadata/modules/browsiBidAdapter.json +++ b/metadata/modules/browsiBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.browsiprod.com/ads/tcf.json": { - "timestamp": "2026-01-28T16:30:10.712Z", + "timestamp": "2026-03-02T14:45:35.966Z", "disclosures": [] } }, diff --git a/metadata/modules/bucksenseBidAdapter.json b/metadata/modules/bucksenseBidAdapter.json index 5756973a60a..571e8dac8d9 100644 --- a/metadata/modules/bucksenseBidAdapter.json +++ b/metadata/modules/bucksenseBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://j.bksnimages.com/iab/devsto02.json": { - "timestamp": "2026-01-28T16:30:10.730Z", + "timestamp": "2026-03-02T14:45:36.025Z", "disclosures": [] } }, diff --git a/metadata/modules/carodaBidAdapter.json b/metadata/modules/carodaBidAdapter.json index bf06a9d157f..06cedda2cbb 100644 --- a/metadata/modules/carodaBidAdapter.json +++ b/metadata/modules/carodaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn2.caroda.io/tcfvds/2022-05-17/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:10.790Z", + "timestamp": "2026-03-02T14:45:36.084Z", "disclosures": [] } }, diff --git a/metadata/modules/categoryTranslation.json b/metadata/modules/categoryTranslation.json index 62d8ea2cf73..1b8ecd5185e 100644 --- a/metadata/modules/categoryTranslation.json +++ b/metadata/modules/categoryTranslation.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/categoryTranslation.json": { - "timestamp": "2026-01-28T16:29:58.215Z", + "timestamp": "2026-03-02T14:44:46.317Z", "disclosures": [ { "identifier": "iabToFwMappingkey", diff --git a/metadata/modules/ceeIdSystem.json b/metadata/modules/ceeIdSystem.json index 9d6ee591a78..414cf9f61c3 100644 --- a/metadata/modules/ceeIdSystem.json +++ b/metadata/modules/ceeIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ssp.wp.pl/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:11.083Z", + "timestamp": "2026-03-02T14:45:36.379Z", "disclosures": null } }, diff --git a/metadata/modules/chromeAiRtdProvider.json b/metadata/modules/chromeAiRtdProvider.json index f01c1535e6c..0374f04c2e3 100644 --- a/metadata/modules/chromeAiRtdProvider.json +++ b/metadata/modules/chromeAiRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/modules/chromeAiRtdProvider.json": { - "timestamp": "2026-01-28T16:30:11.410Z", + "timestamp": "2026-03-02T14:45:36.729Z", "disclosures": [ { "identifier": "chromeAi_detected_data", diff --git a/metadata/modules/clickioBidAdapter.json b/metadata/modules/clickioBidAdapter.json index 098d2ef463b..ec57e47521f 100644 --- a/metadata/modules/clickioBidAdapter.json +++ b/metadata/modules/clickioBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://o.clickiocdn.com/tcf_storage_info.json": { - "timestamp": "2026-01-28T16:30:11.411Z", + "timestamp": "2026-03-02T14:45:36.730Z", "disclosures": [] } }, diff --git a/metadata/modules/compassBidAdapter.json b/metadata/modules/compassBidAdapter.json index 0e34d3b794e..145961029d1 100644 --- a/metadata/modules/compassBidAdapter.json +++ b/metadata/modules/compassBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.marphezis.com/tcf-vendor-disclosures.json": { - "timestamp": "2026-01-28T16:30:11.830Z", + "timestamp": "2026-03-02T14:45:37.148Z", "disclosures": [] } }, diff --git a/metadata/modules/conceptxBidAdapter.json b/metadata/modules/conceptxBidAdapter.json index a8dc49de2f9..41d01c918e0 100644 --- a/metadata/modules/conceptxBidAdapter.json +++ b/metadata/modules/conceptxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cncptx.com/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:11.853Z", + "timestamp": "2026-03-02T14:45:37.166Z", "disclosures": [] } }, diff --git a/metadata/modules/connatixBidAdapter.json b/metadata/modules/connatixBidAdapter.json index ceff6354d6a..a1b7a772220 100644 --- a/metadata/modules/connatixBidAdapter.json +++ b/metadata/modules/connatixBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://connatix.com/iab-tcf-disclosure.json": { - "timestamp": "2026-01-28T16:30:11.876Z", + "timestamp": "2026-03-02T14:45:37.190Z", "disclosures": [ { "identifier": "cnx_userId", diff --git a/metadata/modules/connectIdSystem.json b/metadata/modules/connectIdSystem.json index fc825b87474..0cd76902e9b 100644 --- a/metadata/modules/connectIdSystem.json +++ b/metadata/modules/connectIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://meta.legal.yahoo.com/iab-tcf/v2/device-storage-disclosure.json": { - "timestamp": "2026-01-28T16:30:11.964Z", + "timestamp": "2026-03-02T14:45:37.270Z", "disclosures": [ { "identifier": "vmcid", diff --git a/metadata/modules/connectadBidAdapter.json b/metadata/modules/connectadBidAdapter.json index 449b80e8442..96bc8e141d6 100644 --- a/metadata/modules/connectadBidAdapter.json +++ b/metadata/modules/connectadBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.connectad.io/tcf_storage_info.json": { - "timestamp": "2026-01-28T16:30:11.985Z", + "timestamp": "2026-03-02T14:45:37.290Z", "disclosures": [] } }, diff --git a/metadata/modules/contentexchangeBidAdapter.json b/metadata/modules/contentexchangeBidAdapter.json index 2fe9de26b24..7b5a61930c9 100644 --- a/metadata/modules/contentexchangeBidAdapter.json +++ b/metadata/modules/contentexchangeBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://hb.contentexchange.me/template/device_storage.json": { - "timestamp": "2026-01-28T16:30:12.429Z", + "timestamp": "2026-03-02T14:45:37.325Z", "disclosures": null } }, diff --git a/metadata/modules/conversantBidAdapter.json b/metadata/modules/conversantBidAdapter.json index 61dd65570fe..5999b1b9c9a 100644 --- a/metadata/modules/conversantBidAdapter.json +++ b/metadata/modules/conversantBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://s-usweb.dotomi.com/assets/js/taggy-js/2.18.9/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:12.505Z", + "timestamp": "2026-03-02T14:45:37.366Z", "disclosures": [ { "identifier": "dtm_status", diff --git a/metadata/modules/copper6sspBidAdapter.json b/metadata/modules/copper6sspBidAdapter.json index 962b5946cd8..1bc771d2901 100644 --- a/metadata/modules/copper6sspBidAdapter.json +++ b/metadata/modules/copper6sspBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ssp.copper6.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:12.542Z", + "timestamp": "2026-03-02T14:45:37.458Z", "disclosures": [] } }, diff --git a/metadata/modules/cpmstarBidAdapter.json b/metadata/modules/cpmstarBidAdapter.json index 253fd1b3b2d..255976fa486 100644 --- a/metadata/modules/cpmstarBidAdapter.json +++ b/metadata/modules/cpmstarBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.aditude.com/storageaccess.json": { - "timestamp": "2026-01-28T16:30:12.576Z", + "timestamp": "2026-03-02T14:45:37.500Z", "disclosures": [] } }, diff --git a/metadata/modules/criteoBidAdapter.json b/metadata/modules/criteoBidAdapter.json index 8d62de75bc0..fb94d78e961 100644 --- a/metadata/modules/criteoBidAdapter.json +++ b/metadata/modules/criteoBidAdapter.json @@ -1,8 +1,8 @@ { "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { - "https://privacy.criteo.com/iab-europe/tcfv2/disclosure": { - "timestamp": "2026-01-28T16:30:12.617Z", + "https://privacy.criteo.com/iab-europe/tcfv2/disclosure.json": { + "timestamp": "2026-03-02T14:45:37.599Z", "disclosures": [ { "identifier": "criteo_fast_bid", @@ -69,7 +69,7 @@ "componentName": "criteo", "aliasOf": null, "gvlid": 91, - "disclosureURL": "https://privacy.criteo.com/iab-europe/tcfv2/disclosure" + "disclosureURL": "https://privacy.criteo.com/iab-europe/tcfv2/disclosure.json" } ] } \ No newline at end of file diff --git a/metadata/modules/criteoIdSystem.json b/metadata/modules/criteoIdSystem.json index 6a64ec8205c..d0e56b65ba1 100644 --- a/metadata/modules/criteoIdSystem.json +++ b/metadata/modules/criteoIdSystem.json @@ -1,8 +1,8 @@ { "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { - "https://privacy.criteo.com/iab-europe/tcfv2/disclosure": { - "timestamp": "2026-01-28T16:30:12.634Z", + "https://privacy.criteo.com/iab-europe/tcfv2/disclosure.json": { + "timestamp": "2026-03-02T14:45:37.624Z", "disclosures": [ { "identifier": "criteo_fast_bid", @@ -68,7 +68,7 @@ "componentType": "userId", "componentName": "criteo", "gvlid": 91, - "disclosureURL": "https://privacy.criteo.com/iab-europe/tcfv2/disclosure", + "disclosureURL": "https://privacy.criteo.com/iab-europe/tcfv2/disclosure.json", "aliasOf": null } ] diff --git a/metadata/modules/cwireBidAdapter.json b/metadata/modules/cwireBidAdapter.json index c7a9dc12ec0..265357a9c7f 100644 --- a/metadata/modules/cwireBidAdapter.json +++ b/metadata/modules/cwireBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.cwi.re/artifacts/iab/iab.json": { - "timestamp": "2026-01-28T16:30:12.634Z", + "timestamp": "2026-03-02T14:45:37.625Z", "disclosures": [] } }, diff --git a/metadata/modules/czechAdIdSystem.json b/metadata/modules/czechAdIdSystem.json index b6bd886fdd5..7a945949fe0 100644 --- a/metadata/modules/czechAdIdSystem.json +++ b/metadata/modules/czechAdIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cpex.cz/storagedisclosure.json": { - "timestamp": "2026-01-28T16:30:12.660Z", + "timestamp": "2026-03-02T14:45:37.988Z", "disclosures": [] } }, diff --git a/metadata/modules/dailymotionBidAdapter.json b/metadata/modules/dailymotionBidAdapter.json index bccf9e38e58..6779bc9b8a9 100644 --- a/metadata/modules/dailymotionBidAdapter.json +++ b/metadata/modules/dailymotionBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://statics.dmcdn.net/a/vds.json": { - "timestamp": "2026-01-28T16:30:13.069Z", + "timestamp": "2026-03-02T14:45:38.394Z", "disclosures": [ { "identifier": "uid_dm", diff --git a/metadata/modules/debugging.json b/metadata/modules/debugging.json index 63533ff21b1..d8035174738 100644 --- a/metadata/modules/debugging.json +++ b/metadata/modules/debugging.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/debugging.json": { - "timestamp": "2026-01-28T16:29:58.214Z", + "timestamp": "2026-03-02T14:44:46.316Z", "disclosures": [ { "identifier": "__*_debugging__", diff --git a/metadata/modules/deepintentBidAdapter.json b/metadata/modules/deepintentBidAdapter.json index 0328b249ca0..f9698c4095a 100644 --- a/metadata/modules/deepintentBidAdapter.json +++ b/metadata/modules/deepintentBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.deepintent.com/iabeurope_vendor_disclosures.json": { - "timestamp": "2026-01-28T16:30:13.171Z", + "timestamp": "2026-02-23T16:45:55.384Z", "disclosures": [] } }, diff --git a/metadata/modules/defineMediaBidAdapter.json b/metadata/modules/defineMediaBidAdapter.json index 4bdab1c5991..cc0c299d4f7 100644 --- a/metadata/modules/defineMediaBidAdapter.json +++ b/metadata/modules/defineMediaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://definemedia.de/tcf/deviceStorageDisclosureURL.json": { - "timestamp": "2026-01-28T16:30:13.310Z", + "timestamp": "2026-03-02T14:45:38.686Z", "disclosures": [ { "identifier": "conative$dataGathering$Adex", diff --git a/metadata/modules/deltaprojectsBidAdapter.json b/metadata/modules/deltaprojectsBidAdapter.json index 421bf1dc518..ca2f76b4695 100644 --- a/metadata/modules/deltaprojectsBidAdapter.json +++ b/metadata/modules/deltaprojectsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.de17a.com/policy/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:13.748Z", + "timestamp": "2026-03-02T14:45:39.098Z", "disclosures": [] } }, diff --git a/metadata/modules/dianomiBidAdapter.json b/metadata/modules/dianomiBidAdapter.json index 8a1c226d253..1ee3130984b 100644 --- a/metadata/modules/dianomiBidAdapter.json +++ b/metadata/modules/dianomiBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.dianomi.com/device_storage.json": { - "timestamp": "2026-01-28T16:30:14.164Z", + "timestamp": "2026-03-02T14:45:44.672Z", "disclosures": [] } }, diff --git a/metadata/modules/digitalMatterBidAdapter.json b/metadata/modules/digitalMatterBidAdapter.json index 3f71ed4c5e4..8d8e30c4102 100644 --- a/metadata/modules/digitalMatterBidAdapter.json +++ b/metadata/modules/digitalMatterBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://digitalmatter.ai/disclosures.json": { - "timestamp": "2026-01-28T16:30:14.165Z", + "timestamp": "2026-03-02T14:45:44.672Z", "disclosures": [] } }, diff --git a/metadata/modules/distroscaleBidAdapter.json b/metadata/modules/distroscaleBidAdapter.json index 7bc7a6fbaa2..b19604b5704 100644 --- a/metadata/modules/distroscaleBidAdapter.json +++ b/metadata/modules/distroscaleBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://a.jsrdn.com/tcf/tcf-vendor-disclosure.json": { - "timestamp": "2026-01-28T16:30:14.541Z", + "timestamp": "2026-03-02T14:45:45.048Z", "disclosures": [] } }, diff --git a/metadata/modules/docereeAdManagerBidAdapter.json b/metadata/modules/docereeAdManagerBidAdapter.json index 2ef6917eac2..676eab6e3c8 100644 --- a/metadata/modules/docereeAdManagerBidAdapter.json +++ b/metadata/modules/docereeAdManagerBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://doceree.com/.well-known/iab/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:14.843Z", + "timestamp": "2026-03-02T14:45:45.334Z", "disclosures": [] } }, diff --git a/metadata/modules/docereeBidAdapter.json b/metadata/modules/docereeBidAdapter.json index f24747edfe1..14a0769efca 100644 --- a/metadata/modules/docereeBidAdapter.json +++ b/metadata/modules/docereeBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://doceree.com/.well-known/iab/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:15.632Z", + "timestamp": "2026-03-02T14:45:46.086Z", "disclosures": [] } }, diff --git a/metadata/modules/dpaiBidAdapter.json b/metadata/modules/dpaiBidAdapter.json new file mode 100644 index 00000000000..901b09a3355 --- /dev/null +++ b/metadata/modules/dpaiBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "dpai", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/dspxBidAdapter.json b/metadata/modules/dspxBidAdapter.json index 18aa462c75c..579ccaef7ac 100644 --- a/metadata/modules/dspxBidAdapter.json +++ b/metadata/modules/dspxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.adtech.app/gen/deviceStorageDisclosure/os.json": { - "timestamp": "2026-01-28T16:30:15.633Z", + "timestamp": "2026-03-02T14:45:46.087Z", "disclosures": [] } }, diff --git a/metadata/modules/e_volutionBidAdapter.json b/metadata/modules/e_volutionBidAdapter.json index 9b0da76504c..1410a3e4917 100644 --- a/metadata/modules/e_volutionBidAdapter.json +++ b/metadata/modules/e_volutionBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://e-volution.ai/file.json": { - "timestamp": "2026-01-28T16:30:16.296Z", + "timestamp": "2026-03-02T14:45:46.760Z", "disclosures": [] } }, diff --git a/metadata/modules/edge226BidAdapter.json b/metadata/modules/edge226BidAdapter.json index e222f7a1ae2..58cc40d3ace 100644 --- a/metadata/modules/edge226BidAdapter.json +++ b/metadata/modules/edge226BidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.serveteck.com/cdn_storage/tcf/tcf.json?a=1.io": { - "timestamp": "2026-01-28T16:30:16.642Z", + "timestamp": "2026-03-02T14:45:47.084Z", "disclosures": [] } }, diff --git a/metadata/modules/empowerBidAdapter.json b/metadata/modules/empowerBidAdapter.json index b437207d105..5b8c1e60d93 100644 --- a/metadata/modules/empowerBidAdapter.json +++ b/metadata/modules/empowerBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.empower.net/vendor/vendor.json": { - "timestamp": "2026-01-28T16:30:16.693Z", + "timestamp": "2026-03-02T14:45:47.136Z", "disclosures": [] } }, diff --git a/metadata/modules/equativBidAdapter.json b/metadata/modules/equativBidAdapter.json index 66796571f18..a618d1b2dd7 100644 --- a/metadata/modules/equativBidAdapter.json +++ b/metadata/modules/equativBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://apps.smartadserver.com/device-storage-disclosures/equativDeviceStorageDisclosures.json": { - "timestamp": "2026-01-28T16:30:16.716Z", + "timestamp": "2026-03-02T14:45:47.169Z", "disclosures": [] } }, diff --git a/metadata/modules/eskimiBidAdapter.json b/metadata/modules/eskimiBidAdapter.json index 22dc25ff479..8908f454cef 100644 --- a/metadata/modules/eskimiBidAdapter.json +++ b/metadata/modules/eskimiBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://dsp-media.eskimi.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:16.770Z", + "timestamp": "2026-03-02T14:45:47.201Z", "disclosures": [] } }, diff --git a/metadata/modules/etargetBidAdapter.json b/metadata/modules/etargetBidAdapter.json index 5efd286beb6..db71948ec37 100644 --- a/metadata/modules/etargetBidAdapter.json +++ b/metadata/modules/etargetBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.etarget.sk/cookies3.json": { - "timestamp": "2026-01-28T16:30:16.842Z", + "timestamp": "2026-03-02T14:45:47.226Z", "disclosures": [] } }, diff --git a/metadata/modules/euidIdSystem.json b/metadata/modules/euidIdSystem.json index 4117148b7b5..5d3d333813e 100644 --- a/metadata/modules/euidIdSystem.json +++ b/metadata/modules/euidIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ttd-misc-public-assets.s3.us-west-2.amazonaws.com/deviceStorageDisclosureURL.json": { - "timestamp": "2026-01-28T16:30:17.398Z", + "timestamp": "2026-03-02T14:45:47.806Z", "disclosures": [] } }, diff --git a/metadata/modules/exadsBidAdapter.json b/metadata/modules/exadsBidAdapter.json index 1bf45f51e78..4f78604a882 100644 --- a/metadata/modules/exadsBidAdapter.json +++ b/metadata/modules/exadsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://a.native7.com/tcf/deviceStorage.php": { - "timestamp": "2026-01-28T16:30:17.607Z", + "timestamp": "2026-03-02T14:45:48.013Z", "disclosures": [ { "identifier": "pn-zone-*", diff --git a/metadata/modules/feedadBidAdapter.json b/metadata/modules/feedadBidAdapter.json index b4bc9e17f6d..898c77b0f70 100644 --- a/metadata/modules/feedadBidAdapter.json +++ b/metadata/modules/feedadBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://api.feedad.com/tcf-device-disclosures.json": { - "timestamp": "2026-01-28T16:30:17.803Z", + "timestamp": "2026-03-02T14:45:48.194Z", "disclosures": [ { "identifier": "__fad_data", diff --git a/metadata/modules/floxisBidAdapter.json b/metadata/modules/floxisBidAdapter.json new file mode 100644 index 00000000000..c58d76bc57a --- /dev/null +++ b/metadata/modules/floxisBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "floxis", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/fwsspBidAdapter.json b/metadata/modules/fwsspBidAdapter.json index d0e9d6e3281..c950b152e00 100644 --- a/metadata/modules/fwsspBidAdapter.json +++ b/metadata/modules/fwsspBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://iab.fwmrm.net/g/devicedisclosure.json": { - "timestamp": "2026-01-28T16:30:17.913Z", + "timestamp": "2026-03-02T14:45:48.313Z", "disclosures": [] } }, diff --git a/metadata/modules/gamoshiBidAdapter.json b/metadata/modules/gamoshiBidAdapter.json index 4fdd963b192..05ce430c856 100644 --- a/metadata/modules/gamoshiBidAdapter.json +++ b/metadata/modules/gamoshiBidAdapter.json @@ -2,8 +2,8 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.gamoshi.com/disclosures-client-storage.json": { - "timestamp": "2026-01-28T16:30:18.136Z", - "disclosures": [] + "timestamp": "2026-03-02T14:45:48.653Z", + "disclosures": null } }, "components": [ diff --git a/metadata/modules/gemiusIdSystem.json b/metadata/modules/gemiusIdSystem.json index 6effe5fa731..9aa5b49be2d 100644 --- a/metadata/modules/gemiusIdSystem.json +++ b/metadata/modules/gemiusIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://gemius.com/media/documents/Gemius_SA_Vendor_Device_Storage.json": { - "timestamp": "2026-01-28T16:30:18.258Z", + "timestamp": "2026-03-02T14:45:49.814Z", "disclosures": [ { "identifier": "__gsyncs_gdpr", diff --git a/metadata/modules/glomexBidAdapter.json b/metadata/modules/glomexBidAdapter.json index 997f9f7bc1a..3ad4e7c7df2 100644 --- a/metadata/modules/glomexBidAdapter.json +++ b/metadata/modules/glomexBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://player.glomex.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:18.259Z", + "timestamp": "2026-03-02T14:45:49.816Z", "disclosures": [ { "identifier": "glomexUser", diff --git a/metadata/modules/goldbachBidAdapter.json b/metadata/modules/goldbachBidAdapter.json index 6b6d92bcf93..61b5f9f87a3 100644 --- a/metadata/modules/goldbachBidAdapter.json +++ b/metadata/modules/goldbachBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://gb-next.ch/TcfGoldbachDeviceStorage.json": { - "timestamp": "2026-01-28T16:30:18.282Z", + "timestamp": "2026-03-02T14:45:49.839Z", "disclosures": [ { "identifier": "dakt_2_session_id", diff --git a/metadata/modules/gridBidAdapter.json b/metadata/modules/gridBidAdapter.json index ae4df482acf..0420f51b838 100644 --- a/metadata/modules/gridBidAdapter.json +++ b/metadata/modules/gridBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.themediagrid.com/devicestorage.json": { - "timestamp": "2026-01-28T16:30:18.305Z", + "timestamp": "2026-03-02T14:45:49.864Z", "disclosures": [] } }, diff --git a/metadata/modules/gumgumBidAdapter.json b/metadata/modules/gumgumBidAdapter.json index f5735825ecb..9131fda39bc 100644 --- a/metadata/modules/gumgumBidAdapter.json +++ b/metadata/modules/gumgumBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://marketing.gumgum.com/devicestoragedisclosures.json": { - "timestamp": "2026-01-28T16:30:18.450Z", + "timestamp": "2026-03-02T14:45:49.990Z", "disclosures": [] } }, diff --git a/metadata/modules/hadronIdSystem.json b/metadata/modules/hadronIdSystem.json index c0fb0c8617e..a02dd109e19 100644 --- a/metadata/modules/hadronIdSystem.json +++ b/metadata/modules/hadronIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://p.ad.gt/static/iab_tcf.json": { - "timestamp": "2026-01-28T16:30:18.502Z", + "timestamp": "2026-03-02T14:45:50.048Z", "disclosures": [ { "identifier": "au/sid", diff --git a/metadata/modules/hadronRtdProvider.json b/metadata/modules/hadronRtdProvider.json index b24598c78f8..b5eb21067e8 100644 --- a/metadata/modules/hadronRtdProvider.json +++ b/metadata/modules/hadronRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://p.ad.gt/static/iab_tcf.json": { - "timestamp": "2026-01-28T16:30:18.608Z", + "timestamp": "2026-03-02T14:45:50.186Z", "disclosures": [ { "identifier": "au/sid", diff --git a/metadata/modules/harionBidAdapter.json b/metadata/modules/harionBidAdapter.json new file mode 100644 index 00000000000..dc71450537a --- /dev/null +++ b/metadata/modules/harionBidAdapter.json @@ -0,0 +1,18 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": { + "https://markappmedia.site/vendor.json": { + "timestamp": "2026-03-02T14:45:50.186Z", + "disclosures": [] + } + }, + "components": [ + { + "componentType": "bidder", + "componentName": "harion", + "aliasOf": null, + "gvlid": 1406, + "disclosureURL": "https://markappmedia.site/vendor.json" + } + ] +} \ No newline at end of file diff --git a/metadata/modules/holidBidAdapter.json b/metadata/modules/holidBidAdapter.json index 4342f2840f2..d5fc01d7c33 100644 --- a/metadata/modules/holidBidAdapter.json +++ b/metadata/modules/holidBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ads.holid.io/devicestorage.json": { - "timestamp": "2026-01-28T16:30:18.608Z", + "timestamp": "2026-03-02T14:45:50.576Z", "disclosures": [ { "identifier": "uids", diff --git a/metadata/modules/hybridBidAdapter.json b/metadata/modules/hybridBidAdapter.json index b812dcc3411..efc1583902c 100644 --- a/metadata/modules/hybridBidAdapter.json +++ b/metadata/modules/hybridBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://st.hybrid.ai/policy/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:18.857Z", + "timestamp": "2026-03-02T14:45:50.868Z", "disclosures": [] } }, diff --git a/metadata/modules/id5IdSystem.json b/metadata/modules/id5IdSystem.json index 822336b6feb..886832932a7 100644 --- a/metadata/modules/id5IdSystem.json +++ b/metadata/modules/id5IdSystem.json @@ -2,8 +2,250 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://id5-sync.com/tcf/disclosures.json": { - "timestamp": "2026-01-28T16:30:19.113Z", - "disclosures": [] + "timestamp": "2026-03-02T14:45:51.113Z", + "disclosures": [ + { + "identifier": "id5id", + "type": "web", + "maxAgeSeconds": 7776000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_exp", + "type": "web", + "maxAgeSeconds": 7776000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_last", + "type": "web", + "maxAgeSeconds": 7776000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_last_exp", + "type": "web", + "maxAgeSeconds": 7776000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_cached_consent_data", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_cached_consent_data_exp", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_cached_pd_{partnerId}", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_cached_pd_exp", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_cached_pd", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_privacy", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_privacy_exp", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_{partnerId}_nb", + "type": "web", + "maxAgeSeconds": 7776000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_{partnerId}_nb_exp", + "type": "web", + "maxAgeSeconds": 7776000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_v2_{cacheId}", + "type": "web", + "maxAgeSeconds": 1296000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_v2_signature", + "type": "web", + "maxAgeSeconds": 1296000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_extensions", + "type": "web", + "maxAgeSeconds": 28800, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_cached_segments_{partnerId}", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5id_cached_segments_{partnerId}_exp", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5_trueLink_privacy", + "type": "web", + "maxAgeSeconds": 2592000, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5-tl-ts", + "type": "cookie", + "maxAgeSeconds": 7776000, + "cookieRefresh": true, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5-tl-redirect-timestamp", + "type": "cookie", + "maxAgeSeconds": 7776000, + "cookieRefresh": true, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5-tl-redirect-fail", + "type": "cookie", + "maxAgeSeconds": 604800, + "cookieRefresh": true, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5-tl-optout", + "type": "cookie", + "maxAgeSeconds": 7776000, + "cookieRefresh": true, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5-true-link", + "type": "cookie", + "maxAgeSeconds": 7776000, + "cookieRefresh": true, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5-true-link-refresh", + "type": "cookie", + "maxAgeSeconds": 7776000, + "cookieRefresh": true, + "purposes": [ + 1, + 3 + ] + }, + { + "identifier": "id5-true-link-refresh-exp", + "type": "cookie", + "maxAgeSeconds": 7776000, + "cookieRefresh": true, + "purposes": [ + 1, + 3 + ] + } + ] } }, "components": [ diff --git a/metadata/modules/identityLinkIdSystem.json b/metadata/modules/identityLinkIdSystem.json index e0da9afd224..03bcaedd660 100644 --- a/metadata/modules/identityLinkIdSystem.json +++ b/metadata/modules/identityLinkIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.ats.rlcdn.com/device-storage-disclosure.json": { - "timestamp": "2026-01-28T16:30:19.391Z", + "timestamp": "2026-03-02T14:45:51.402Z", "disclosures": [ { "identifier": "_lr_retry_request", diff --git a/metadata/modules/illuminBidAdapter.json b/metadata/modules/illuminBidAdapter.json index 9872fe179fe..eadb5eea6ca 100644 --- a/metadata/modules/illuminBidAdapter.json +++ b/metadata/modules/illuminBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://admanmedia.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:19.410Z", + "timestamp": "2026-03-02T14:45:51.424Z", "disclosures": [] } }, diff --git a/metadata/modules/impactifyBidAdapter.json b/metadata/modules/impactifyBidAdapter.json index ff872bf7c59..a84d454fd03 100644 --- a/metadata/modules/impactifyBidAdapter.json +++ b/metadata/modules/impactifyBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ad.impactify.io/tcfvendors.json": { - "timestamp": "2026-01-28T16:30:19.701Z", + "timestamp": "2026-03-02T14:45:51.728Z", "disclosures": [ { "identifier": "_im*", diff --git a/metadata/modules/improvedigitalBidAdapter.json b/metadata/modules/improvedigitalBidAdapter.json index 93613334ee6..a6e1e421e03 100644 --- a/metadata/modules/improvedigitalBidAdapter.json +++ b/metadata/modules/improvedigitalBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://sellers.improvedigital.com/tcf-cookies.json": { - "timestamp": "2026-01-28T16:30:20.078Z", + "timestamp": "2026-03-02T14:45:52.052Z", "disclosures": [ { "identifier": "tuuid", diff --git a/metadata/modules/inmobiBidAdapter.json b/metadata/modules/inmobiBidAdapter.json index 12ac730cfab..818bc7ee34a 100644 --- a/metadata/modules/inmobiBidAdapter.json +++ b/metadata/modules/inmobiBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://publisher.inmobi.com/public/disclosure": { - "timestamp": "2026-01-28T16:30:20.079Z", + "timestamp": "2026-03-02T14:45:52.052Z", "disclosures": [] } }, diff --git a/metadata/modules/insticatorBidAdapter.json b/metadata/modules/insticatorBidAdapter.json index 08318bac5bf..93ac9d8e09a 100644 --- a/metadata/modules/insticatorBidAdapter.json +++ b/metadata/modules/insticatorBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.insticator.com/iab/device-storage-disclosure.json": { - "timestamp": "2026-01-28T16:30:20.115Z", + "timestamp": "2026-03-02T14:45:52.089Z", "disclosures": [ { "identifier": "visitorGeo", diff --git a/metadata/modules/insuradsBidAdapter.json b/metadata/modules/insuradsBidAdapter.json new file mode 100644 index 00000000000..05a0e236aad --- /dev/null +++ b/metadata/modules/insuradsBidAdapter.json @@ -0,0 +1,45 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": { + "https://www.insurads.com/tcf-vdsod.json": { + "timestamp": "2026-03-02T14:45:52.162Z", + "disclosures": [ + { + "identifier": "___iat_ses", + "type": "cookie", + "maxAgeSeconds": 1800, + "cookieRefresh": true, + "purposes": [ + 1, + 2, + 7, + 9, + 10 + ] + }, + { + "identifier": "___iat_vis", + "type": "cookie", + "maxAgeSeconds": 15552000, + "cookieRefresh": true, + "purposes": [ + 1, + 2, + 7, + 9, + 10 + ] + } + ] + } + }, + "components": [ + { + "componentType": "bidder", + "componentName": "insurads", + "aliasOf": null, + "gvlid": 596, + "disclosureURL": "https://www.insurads.com/tcf-vdsod.json" + } + ] +} \ No newline at end of file diff --git a/metadata/modules/intentIqIdSystem.json b/metadata/modules/intentIqIdSystem.json index 3e419eef19a..43fe76d2cad 100644 --- a/metadata/modules/intentIqIdSystem.json +++ b/metadata/modules/intentIqIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://agent.intentiq.com/GDPR/gdpr.json": { - "timestamp": "2026-01-28T16:30:20.139Z", + "timestamp": "2026-03-02T14:45:52.351Z", "disclosures": [] } }, diff --git a/metadata/modules/invibesBidAdapter.json b/metadata/modules/invibesBidAdapter.json index 63fcebf8060..9b23ea0bc54 100644 --- a/metadata/modules/invibesBidAdapter.json +++ b/metadata/modules/invibesBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.invibes.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:20.189Z", + "timestamp": "2026-03-02T14:45:52.421Z", "disclosures": [ { "identifier": "ivvcap", diff --git a/metadata/modules/ipromBidAdapter.json b/metadata/modules/ipromBidAdapter.json index 4f8ff0b1227..7f9b4c104c2 100644 --- a/metadata/modules/ipromBidAdapter.json +++ b/metadata/modules/ipromBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://core.iprom.net/info/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:20.533Z", + "timestamp": "2026-03-02T14:45:52.785Z", "disclosures": [] } }, diff --git a/metadata/modules/ixBidAdapter.json b/metadata/modules/ixBidAdapter.json index 35f6279a5b8..ba7581331d0 100644 --- a/metadata/modules/ixBidAdapter.json +++ b/metadata/modules/ixBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.indexexchange.com/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:21.016Z", + "timestamp": "2026-03-02T14:45:53.259Z", "disclosures": [ { "identifier": "ix_features", diff --git a/metadata/modules/justIdSystem.json b/metadata/modules/justIdSystem.json index b83e3eb1d63..f20fbceea31 100644 --- a/metadata/modules/justIdSystem.json +++ b/metadata/modules/justIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://audience-solutions.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:21.288Z", + "timestamp": "2026-03-02T14:45:53.334Z", "disclosures": [ { "identifier": "__jtuid", diff --git a/metadata/modules/justpremiumBidAdapter.json b/metadata/modules/justpremiumBidAdapter.json index 42a09fe26b8..7fb5438d310 100644 --- a/metadata/modules/justpremiumBidAdapter.json +++ b/metadata/modules/justpremiumBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.justpremium.com/devicestoragedisclosures.json": { - "timestamp": "2026-01-28T16:30:21.799Z", + "timestamp": "2026-03-02T14:45:53.863Z", "disclosures": [] } }, diff --git a/metadata/modules/jwplayerBidAdapter.json b/metadata/modules/jwplayerBidAdapter.json index c5f0094a6b1..6695015fb1e 100644 --- a/metadata/modules/jwplayerBidAdapter.json +++ b/metadata/modules/jwplayerBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.jwplayer.com/devicestorage.json": { - "timestamp": "2026-01-28T16:30:21.822Z", + "timestamp": "2026-03-02T14:45:53.882Z", "disclosures": [] } }, diff --git a/metadata/modules/kargoBidAdapter.json b/metadata/modules/kargoBidAdapter.json index 96032f6827e..18c7713446a 100644 --- a/metadata/modules/kargoBidAdapter.json +++ b/metadata/modules/kargoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://storage.cloud.kargo.com/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:21.990Z", + "timestamp": "2026-03-02T14:45:54.165Z", "disclosures": [ { "identifier": "krg_crb", diff --git a/metadata/modules/kueezRtbBidAdapter.json b/metadata/modules/kueezRtbBidAdapter.json index 65251cb83cd..38316d32257 100644 --- a/metadata/modules/kueezRtbBidAdapter.json +++ b/metadata/modules/kueezRtbBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://en.kueez.com/tcf.json": { - "timestamp": "2026-01-28T16:30:22.016Z", + "timestamp": "2026-03-02T14:45:54.193Z", "disclosures": [ { "identifier": "ck48wz12sqj7", diff --git a/metadata/modules/leagueMBidAdapter.json b/metadata/modules/leagueMBidAdapter.json new file mode 100644 index 00000000000..f4b02875bbb --- /dev/null +++ b/metadata/modules/leagueMBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "leagueM", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/limelightDigitalBidAdapter.json b/metadata/modules/limelightDigitalBidAdapter.json index 32782e737c0..14e44dfc1e0 100644 --- a/metadata/modules/limelightDigitalBidAdapter.json +++ b/metadata/modules/limelightDigitalBidAdapter.json @@ -2,11 +2,11 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://policy.iion.io/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:22.072Z", + "timestamp": "2026-03-02T14:45:54.240Z", "disclosures": [] }, "https://orangeclickmedia.com/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:22.103Z", + "timestamp": "2026-03-02T14:45:54.278Z", "disclosures": [] } }, @@ -32,13 +32,6 @@ "gvlid": 1358, "disclosureURL": "https://policy.iion.io/deviceStorage.json" }, - { - "componentType": "bidder", - "componentName": "apester", - "aliasOf": "limelightDigital", - "gvlid": null, - "disclosureURL": null - }, { "componentType": "bidder", "componentName": "adsyield", @@ -102,13 +95,6 @@ "gvlid": null, "disclosureURL": null }, - { - "componentType": "bidder", - "componentName": "adnimation", - "aliasOf": "limelightDigital", - "gvlid": null, - "disclosureURL": null - }, { "componentType": "bidder", "componentName": "rtbdemand", diff --git a/metadata/modules/liveIntentIdSystem.json b/metadata/modules/liveIntentIdSystem.json index 1cb74699212..73069893950 100644 --- a/metadata/modules/liveIntentIdSystem.json +++ b/metadata/modules/liveIntentIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://b-code.liadm.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:22.104Z", + "timestamp": "2026-03-02T14:45:54.278Z", "disclosures": [ { "identifier": "_lc2_fpi", diff --git a/metadata/modules/liveIntentRtdProvider.json b/metadata/modules/liveIntentRtdProvider.json index c7ec883ac04..8c36b5b69d2 100644 --- a/metadata/modules/liveIntentRtdProvider.json +++ b/metadata/modules/liveIntentRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://b-code.liadm.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:22.118Z", + "timestamp": "2026-03-02T14:45:54.418Z", "disclosures": [ { "identifier": "_lc2_fpi", diff --git a/metadata/modules/livewrappedBidAdapter.json b/metadata/modules/livewrappedBidAdapter.json index 03011a99e3a..a5fb8083be9 100644 --- a/metadata/modules/livewrappedBidAdapter.json +++ b/metadata/modules/livewrappedBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://content.lwadm.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:22.119Z", + "timestamp": "2026-03-02T14:45:54.419Z", "disclosures": [ { "identifier": "uid", diff --git a/metadata/modules/locIdSystem.json b/metadata/modules/locIdSystem.json new file mode 100644 index 00000000000..87e8fc03475 --- /dev/null +++ b/metadata/modules/locIdSystem.json @@ -0,0 +1,20 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "userId", + "componentName": "locId", + "gvlid": null, + "disclosureURL": null, + "aliasOf": null + }, + { + "componentType": "userId", + "componentName": "locid", + "gvlid": null, + "disclosureURL": null, + "aliasOf": "locId" + } + ] +} \ No newline at end of file diff --git a/metadata/modules/loopmeBidAdapter.json b/metadata/modules/loopmeBidAdapter.json index 57dbea94060..33632aaca12 100644 --- a/metadata/modules/loopmeBidAdapter.json +++ b/metadata/modules/loopmeBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://co.loopme.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:22.147Z", + "timestamp": "2026-03-02T14:45:54.453Z", "disclosures": [] } }, diff --git a/metadata/modules/lotamePanoramaIdSystem.json b/metadata/modules/lotamePanoramaIdSystem.json index 7bf465e8b28..1a84524e9d6 100644 --- a/metadata/modules/lotamePanoramaIdSystem.json +++ b/metadata/modules/lotamePanoramaIdSystem.json @@ -2,8 +2,65 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tags.crwdcntrl.net/privacy/tcf-purposes.json": { - "timestamp": "2026-01-28T16:30:22.264Z", + "timestamp": "2026-03-02T14:45:54.576Z", "disclosures": [ + { + "identifier": "_cc_id", + "type": "cookie", + "maxAgeSeconds": 23328000, + "cookieRefresh": true, + "purposes": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ] + }, + { + "identifier": "_cc_cc", + "type": "cookie", + "maxAgeSeconds": 23328000, + "cookieRefresh": true, + "purposes": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ] + }, + { + "identifier": "_cc_aud", + "type": "cookie", + "maxAgeSeconds": 23328000, + "cookieRefresh": true, + "purposes": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ] + }, { "identifier": "lotame_domain_check", "type": "cookie", @@ -23,6 +80,25 @@ 11 ] }, + { + "identifier": "_pubcid", + "type": "cookie", + "maxAgeSeconds": 23328000, + "cookieRefresh": true, + "purposes": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ] + }, { "identifier": "panoramaId", "type": "web", @@ -124,6 +200,23 @@ 10, 11 ] + }, + { + "identifier": "_pubcid", + "type": "web", + "purposes": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ] } ] } diff --git a/metadata/modules/luponmediaBidAdapter.json b/metadata/modules/luponmediaBidAdapter.json index 55ff987e50a..96e987d9dc2 100644 --- a/metadata/modules/luponmediaBidAdapter.json +++ b/metadata/modules/luponmediaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://luponmedia.com/vendor_device_storage.json": { - "timestamp": "2026-01-28T16:30:22.285Z", + "timestamp": "2026-03-02T14:45:54.640Z", "disclosures": [] } }, diff --git a/metadata/modules/madvertiseBidAdapter.json b/metadata/modules/madvertiseBidAdapter.json index 0e9003e97bb..096ca78f06b 100644 --- a/metadata/modules/madvertiseBidAdapter.json +++ b/metadata/modules/madvertiseBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adserver.bluestack.app/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:22.706Z", + "timestamp": "2026-03-02T14:45:55.051Z", "disclosures": [] } }, diff --git a/metadata/modules/marsmediaBidAdapter.json b/metadata/modules/marsmediaBidAdapter.json index dcaaeb86fce..e20881844c8 100644 --- a/metadata/modules/marsmediaBidAdapter.json +++ b/metadata/modules/marsmediaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://mars.media/apis/tcf-v2.json": { - "timestamp": "2026-01-28T16:30:23.103Z", + "timestamp": "2026-03-02T14:45:55.409Z", "disclosures": [] } }, diff --git a/metadata/modules/mediaConsortiumBidAdapter.json b/metadata/modules/mediaConsortiumBidAdapter.json index 066fa719827..19b9a989b2c 100644 --- a/metadata/modules/mediaConsortiumBidAdapter.json +++ b/metadata/modules/mediaConsortiumBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.hubvisor.io/assets/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:23.277Z", + "timestamp": "2026-03-02T14:45:55.518Z", "disclosures": [ { "identifier": "hbv:turbo-cmp", diff --git a/metadata/modules/mediaforceBidAdapter.json b/metadata/modules/mediaforceBidAdapter.json index 8d3aa85d0cb..43aee7b5d99 100644 --- a/metadata/modules/mediaforceBidAdapter.json +++ b/metadata/modules/mediaforceBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://comparisons.org/privacy.json": { - "timestamp": "2026-01-28T16:30:23.420Z", + "timestamp": "2026-03-02T14:45:55.650Z", "disclosures": [] } }, diff --git a/metadata/modules/mediafuseBidAdapter.json b/metadata/modules/mediafuseBidAdapter.json index 26645889fa4..64dfcf767a5 100644 --- a/metadata/modules/mediafuseBidAdapter.json +++ b/metadata/modules/mediafuseBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://acdn.adnxs.com/gvl/1d/xandrdevicestoragedisclosures.json": { - "timestamp": "2026-01-28T16:30:23.465Z", + "timestamp": "2026-03-02T14:45:55.669Z", "disclosures": [] } }, diff --git a/metadata/modules/mediagoBidAdapter.json b/metadata/modules/mediagoBidAdapter.json index a270ee3d116..ffa894357ad 100644 --- a/metadata/modules/mediagoBidAdapter.json +++ b/metadata/modules/mediagoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.mediago.io/js/tcf.json": { - "timestamp": "2026-01-28T16:30:23.466Z", + "timestamp": "2026-03-02T14:45:55.670Z", "disclosures": [] } }, diff --git a/metadata/modules/mediakeysBidAdapter.json b/metadata/modules/mediakeysBidAdapter.json index f240f43bcc6..c41604e62be 100644 --- a/metadata/modules/mediakeysBidAdapter.json +++ b/metadata/modules/mediakeysBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://s3.eu-west-3.amazonaws.com/adserving.resourcekeys.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:23.528Z", + "timestamp": "2026-03-02T14:45:55.769Z", "disclosures": [] } }, diff --git a/metadata/modules/medianetBidAdapter.json b/metadata/modules/medianetBidAdapter.json index 60aeed6e7e0..b62853ac937 100644 --- a/metadata/modules/medianetBidAdapter.json +++ b/metadata/modules/medianetBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.media.net/tcfv2/gvl/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:23.849Z", + "timestamp": "2026-03-02T14:45:56.051Z", "disclosures": [ { "identifier": "_mNExInsl", @@ -246,7 +246,7 @@ ] }, "https://trustedstack.com/tcf/gvl/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:23.898Z", + "timestamp": "2026-03-02T14:45:56.188Z", "disclosures": [ { "identifier": "usp_status", diff --git a/metadata/modules/mediasquareBidAdapter.json b/metadata/modules/mediasquareBidAdapter.json index d3f0d0f1e80..b3c225207bc 100644 --- a/metadata/modules/mediasquareBidAdapter.json +++ b/metadata/modules/mediasquareBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://mediasquare.fr/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:23.940Z", + "timestamp": "2026-03-02T14:45:56.239Z", "disclosures": [] } }, diff --git a/metadata/modules/mgidBidAdapter.json b/metadata/modules/mgidBidAdapter.json index 6de647d3359..d6da81c2fe5 100644 --- a/metadata/modules/mgidBidAdapter.json +++ b/metadata/modules/mgidBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.mgid.com/assets/devicestorage.json": { - "timestamp": "2026-01-28T16:30:24.472Z", + "timestamp": "2026-03-02T14:45:56.794Z", "disclosures": [] } }, diff --git a/metadata/modules/mgidRtdProvider.json b/metadata/modules/mgidRtdProvider.json index c59d0acffd1..d6dd6937212 100644 --- a/metadata/modules/mgidRtdProvider.json +++ b/metadata/modules/mgidRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.mgid.com/assets/devicestorage.json": { - "timestamp": "2026-01-28T16:30:24.565Z", + "timestamp": "2026-03-02T14:45:56.864Z", "disclosures": [] } }, diff --git a/metadata/modules/mgidXBidAdapter.json b/metadata/modules/mgidXBidAdapter.json index 3f4ae1d1507..d0da32736ce 100644 --- a/metadata/modules/mgidXBidAdapter.json +++ b/metadata/modules/mgidXBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.mgid.com/assets/devicestorage.json": { - "timestamp": "2026-01-28T16:30:24.565Z", + "timestamp": "2026-03-02T14:45:56.864Z", "disclosures": [] } }, diff --git a/metadata/modules/mileBidAdapter.json b/metadata/modules/mileBidAdapter.json new file mode 100644 index 00000000000..ba3e8b683fc --- /dev/null +++ b/metadata/modules/mileBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "mile", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/minutemediaBidAdapter.json b/metadata/modules/minutemediaBidAdapter.json index 33d4ba2e97e..9908f3b78ee 100644 --- a/metadata/modules/minutemediaBidAdapter.json +++ b/metadata/modules/minutemediaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://disclosures.mmctsvc.com/device-storage.json": { - "timestamp": "2026-01-28T16:30:24.567Z", + "timestamp": "2026-03-02T14:45:56.865Z", "disclosures": [] } }, diff --git a/metadata/modules/missenaBidAdapter.json b/metadata/modules/missenaBidAdapter.json index 721444fc081..14a16812ce4 100644 --- a/metadata/modules/missenaBidAdapter.json +++ b/metadata/modules/missenaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ad.missena.io/iab.json": { - "timestamp": "2026-01-28T16:30:24.587Z", + "timestamp": "2026-03-02T14:45:56.886Z", "disclosures": [] } }, diff --git a/metadata/modules/mobianRtdProvider.json b/metadata/modules/mobianRtdProvider.json index 4c8248d6f80..1ce8457b058 100644 --- a/metadata/modules/mobianRtdProvider.json +++ b/metadata/modules/mobianRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://js.outcomes.net/tcf.json": { - "timestamp": "2026-01-28T16:30:24.642Z", + "timestamp": "2026-03-02T14:45:56.944Z", "disclosures": [] } }, diff --git a/metadata/modules/mobkoiBidAdapter.json b/metadata/modules/mobkoiBidAdapter.json index 2aaa9ba6fbe..3982ab341b1 100644 --- a/metadata/modules/mobkoiBidAdapter.json +++ b/metadata/modules/mobkoiBidAdapter.json @@ -2,8 +2,8 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.maximus.mobkoi.com/tcf/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:24.662Z", - "disclosures": [] + "timestamp": "2026-03-02T14:45:57.043Z", + "disclosures": null } }, "components": [ diff --git a/metadata/modules/mobkoiIdSystem.json b/metadata/modules/mobkoiIdSystem.json index a64f5821912..8f9ed0091ef 100644 --- a/metadata/modules/mobkoiIdSystem.json +++ b/metadata/modules/mobkoiIdSystem.json @@ -2,8 +2,8 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.maximus.mobkoi.com/tcf/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:24.690Z", - "disclosures": [] + "timestamp": "2026-03-02T14:45:57.065Z", + "disclosures": null } }, "components": [ diff --git a/metadata/modules/msftBidAdapter.json b/metadata/modules/msftBidAdapter.json index 397bbfb971d..8ac1b408eae 100644 --- a/metadata/modules/msftBidAdapter.json +++ b/metadata/modules/msftBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://acdn.adnxs.com/gvl/1d/xandrdevicestoragedisclosures.json": { - "timestamp": "2026-01-28T16:30:24.691Z", + "timestamp": "2026-03-02T14:45:57.066Z", "disclosures": [] } }, diff --git a/metadata/modules/nativeryBidAdapter.json b/metadata/modules/nativeryBidAdapter.json index b2716873663..2a3f0c5c99a 100644 --- a/metadata/modules/nativeryBidAdapter.json +++ b/metadata/modules/nativeryBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdnimg.nativery.com/widget/js/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:24.694Z", + "timestamp": "2026-03-02T14:45:57.067Z", "disclosures": [] } }, diff --git a/metadata/modules/nativoBidAdapter.json b/metadata/modules/nativoBidAdapter.json index 2edcc2b20c9..59662e64c8a 100644 --- a/metadata/modules/nativoBidAdapter.json +++ b/metadata/modules/nativoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://iab.nativo.com/tcf-disclosures.json": { - "timestamp": "2026-01-28T16:30:25.034Z", + "timestamp": "2026-03-02T14:45:57.381Z", "disclosures": [] } }, diff --git a/metadata/modules/newspassidBidAdapter.json b/metadata/modules/newspassidBidAdapter.json index 2bdde04b806..57fd3223351 100644 --- a/metadata/modules/newspassidBidAdapter.json +++ b/metadata/modules/newspassidBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.aditude.com/storageaccess.json": { - "timestamp": "2026-01-28T16:30:25.072Z", + "timestamp": "2026-03-02T14:45:57.429Z", "disclosures": [] } }, diff --git a/metadata/modules/nextMillenniumBidAdapter.json b/metadata/modules/nextMillenniumBidAdapter.json index 4a1acbcebee..d57a7a52695 100644 --- a/metadata/modules/nextMillenniumBidAdapter.json +++ b/metadata/modules/nextMillenniumBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://nextmillennium.io/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:25.073Z", + "timestamp": "2026-03-02T14:45:57.430Z", "disclosures": [] } }, diff --git a/metadata/modules/nextrollBidAdapter.json b/metadata/modules/nextrollBidAdapter.json index 3235d22633f..f366a7a8120 100644 --- a/metadata/modules/nextrollBidAdapter.json +++ b/metadata/modules/nextrollBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://s.adroll.com/shares/device_storage.json": { - "timestamp": "2026-01-28T16:30:25.140Z", + "timestamp": "2026-03-02T14:45:57.516Z", "disclosures": [ { "identifier": "__adroll_fpc", diff --git a/metadata/modules/nexx360BidAdapter.json b/metadata/modules/nexx360BidAdapter.json index 8c05a5de13c..00c1ef09267 100644 --- a/metadata/modules/nexx360BidAdapter.json +++ b/metadata/modules/nexx360BidAdapter.json @@ -2,19 +2,19 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://fast.nexx360.io/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:26.058Z", + "timestamp": "2026-03-02T14:45:58.404Z", "disclosures": [] }, "https://static.first-id.fr/tcf/cookie.json": { - "timestamp": "2026-01-28T16:30:25.421Z", + "timestamp": "2026-03-02T14:45:57.804Z", "disclosures": [] }, "https://i.plug.it/banners/js/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:25.445Z", + "timestamp": "2026-03-02T14:45:57.826Z", "disclosures": [] }, "https://player.glomex.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:25.665Z", + "timestamp": "2026-03-02T14:45:57.949Z", "disclosures": [ { "identifier": "glomexUser", @@ -46,7 +46,7 @@ ] }, "https://gdpr.pubx.ai/devicestoragedisclosure.json": { - "timestamp": "2026-01-28T16:30:25.665Z", + "timestamp": "2026-03-02T14:45:57.949Z", "disclosures": [ { "identifier": "pubx:defaults", @@ -61,7 +61,7 @@ ] }, "https://yieldbird.com/devicestorage.json": { - "timestamp": "2026-01-28T16:30:25.684Z", + "timestamp": "2026-03-02T14:45:58.021Z", "disclosures": [] } }, diff --git a/metadata/modules/nobidBidAdapter.json b/metadata/modules/nobidBidAdapter.json index e87c1602d61..056f7d06193 100644 --- a/metadata/modules/nobidBidAdapter.json +++ b/metadata/modules/nobidBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://public.servenobid.com/gdpr_tcf/vendor_device_storage_operational_disclosures.json": { - "timestamp": "2026-01-28T16:30:26.059Z", + "timestamp": "2026-03-02T14:45:58.405Z", "disclosures": [] } }, diff --git a/metadata/modules/nodalsAiRtdProvider.json b/metadata/modules/nodalsAiRtdProvider.json index 63188ce57d5..3c0a03590fe 100644 --- a/metadata/modules/nodalsAiRtdProvider.json +++ b/metadata/modules/nodalsAiRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://static.nodals.ai/vendor.json": { - "timestamp": "2026-01-28T16:30:26.074Z", + "timestamp": "2026-03-02T14:45:58.420Z", "disclosures": [ { "identifier": "localStorage", diff --git a/metadata/modules/novatiqIdSystem.json b/metadata/modules/novatiqIdSystem.json index 1191fd80de1..704ad832740 100644 --- a/metadata/modules/novatiqIdSystem.json +++ b/metadata/modules/novatiqIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://novatiq.com/privacy/iab/novatiq.json": { - "timestamp": "2026-01-28T16:30:27.691Z", + "timestamp": "2026-03-02T14:46:00.252Z", "disclosures": [ { "identifier": "novatiq", diff --git a/metadata/modules/oguryBidAdapter.json b/metadata/modules/oguryBidAdapter.json index 3b044f32034..e0eda9c248a 100644 --- a/metadata/modules/oguryBidAdapter.json +++ b/metadata/modules/oguryBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://privacy.ogury.co/disclosure.json": { - "timestamp": "2026-01-28T16:30:28.031Z", + "timestamp": "2026-03-02T14:46:00.621Z", "disclosures": [] } }, diff --git a/metadata/modules/omnidexBidAdapter.json b/metadata/modules/omnidexBidAdapter.json index cb9afb31fb1..b9cfde98549 100644 --- a/metadata/modules/omnidexBidAdapter.json +++ b/metadata/modules/omnidexBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.omni-dex.io/devicestorage.json": { - "timestamp": "2026-01-28T16:30:28.095Z", + "timestamp": "2026-03-02T14:46:00.670Z", "disclosures": [ { "identifier": "ck48wz12sqj7", diff --git a/metadata/modules/omsBidAdapter.json b/metadata/modules/omsBidAdapter.json index e0f6d4fa074..bf3d6e763b9 100644 --- a/metadata/modules/omsBidAdapter.json +++ b/metadata/modules/omsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.marphezis.com/tcf-vendor-disclosures.json": { - "timestamp": "2026-01-28T16:30:28.157Z", + "timestamp": "2026-03-02T14:46:00.723Z", "disclosures": [] } }, diff --git a/metadata/modules/onetagBidAdapter.json b/metadata/modules/onetagBidAdapter.json index 3d0c4ca8dee..94e1cbede34 100644 --- a/metadata/modules/onetagBidAdapter.json +++ b/metadata/modules/onetagBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://onetag-cdn.com/privacy/tcf_storage.json": { - "timestamp": "2026-01-28T16:30:28.158Z", + "timestamp": "2026-03-02T14:46:00.724Z", "disclosures": [ { "identifier": "onetag_sid", diff --git a/metadata/modules/openwebBidAdapter.json b/metadata/modules/openwebBidAdapter.json index e84a0376dac..b4ee396237c 100644 --- a/metadata/modules/openwebBidAdapter.json +++ b/metadata/modules/openwebBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://spotim-prd-static-assets.s3.amazonaws.com/iab/device-storage.json": { - "timestamp": "2026-01-28T16:30:28.487Z", + "timestamp": "2026-03-02T14:46:01.014Z", "disclosures": [] } }, diff --git a/metadata/modules/openxBidAdapter.json b/metadata/modules/openxBidAdapter.json index 6d6fa2c2c23..dfb10023709 100644 --- a/metadata/modules/openxBidAdapter.json +++ b/metadata/modules/openxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.openx.com/device-storage.json": { - "timestamp": "2026-01-28T16:30:28.525Z", + "timestamp": "2026-03-02T14:46:01.052Z", "disclosures": [] } }, diff --git a/metadata/modules/operaadsBidAdapter.json b/metadata/modules/operaadsBidAdapter.json index 76226db2c54..be92865dfd2 100644 --- a/metadata/modules/operaadsBidAdapter.json +++ b/metadata/modules/operaadsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://res.adx.opera.com/dsd.json": { - "timestamp": "2026-01-28T16:30:28.576Z", + "timestamp": "2026-03-02T14:46:01.082Z", "disclosures": [] } }, diff --git a/metadata/modules/optidigitalBidAdapter.json b/metadata/modules/optidigitalBidAdapter.json index 1e17c7686fd..51ed301458d 100644 --- a/metadata/modules/optidigitalBidAdapter.json +++ b/metadata/modules/optidigitalBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://scripts.opti-digital.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:28.625Z", + "timestamp": "2026-03-02T14:46:01.269Z", "disclosures": [] } }, diff --git a/metadata/modules/optoutBidAdapter.json b/metadata/modules/optoutBidAdapter.json index a894055a0b3..fef02111513 100644 --- a/metadata/modules/optoutBidAdapter.json +++ b/metadata/modules/optoutBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://adserving.optoutadvertising.com/dsd": { - "timestamp": "2026-01-28T16:30:28.705Z", + "timestamp": "2026-03-02T14:46:01.427Z", "disclosures": [] } }, diff --git a/metadata/modules/orbidderBidAdapter.json b/metadata/modules/orbidderBidAdapter.json index 0ae9bf725ab..b9571894c8a 100644 --- a/metadata/modules/orbidderBidAdapter.json +++ b/metadata/modules/orbidderBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://orbidder.otto.de/disclosure/dsd.json": { - "timestamp": "2026-01-28T16:30:28.971Z", + "timestamp": "2026-03-02T14:46:01.687Z", "disclosures": [] } }, diff --git a/metadata/modules/outbrainBidAdapter.json b/metadata/modules/outbrainBidAdapter.json index 5e5a4cd4aeb..325c70c587c 100644 --- a/metadata/modules/outbrainBidAdapter.json +++ b/metadata/modules/outbrainBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.outbrain.com/privacy/wp-json/privacy/v2/devicestorage.json": { - "timestamp": "2026-01-28T16:30:29.295Z", + "timestamp": "2026-03-02T14:46:02.006Z", "disclosures": [ { "identifier": "dicbo_id", diff --git a/metadata/modules/ozoneBidAdapter.json b/metadata/modules/ozoneBidAdapter.json index ea70878496b..23d5ad8eb09 100644 --- a/metadata/modules/ozoneBidAdapter.json +++ b/metadata/modules/ozoneBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://prebid.the-ozone-project.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:29.606Z", + "timestamp": "2026-03-02T14:46:02.249Z", "disclosures": [] } }, diff --git a/metadata/modules/pairIdSystem.json b/metadata/modules/pairIdSystem.json index d83173c0933..d4c804fcc8c 100644 --- a/metadata/modules/pairIdSystem.json +++ b/metadata/modules/pairIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.gstatic.com/iabtcf/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:29.871Z", + "timestamp": "2026-03-02T14:46:02.471Z", "disclosures": [ { "identifier": "__gads", diff --git a/metadata/modules/panxoBidAdapter.json b/metadata/modules/panxoBidAdapter.json index 271639b6bcc..e06de639217 100644 --- a/metadata/modules/panxoBidAdapter.json +++ b/metadata/modules/panxoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.panxo.ai/tcf/device-storage.json": { - "timestamp": "2026-01-28T16:30:29.889Z", + "timestamp": "2026-03-02T14:46:02.495Z", "disclosures": [ { "identifier": "panxo_uid", diff --git a/metadata/modules/panxoRtdProvider.json b/metadata/modules/panxoRtdProvider.json new file mode 100644 index 00000000000..5ad682b104d --- /dev/null +++ b/metadata/modules/panxoRtdProvider.json @@ -0,0 +1,12 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "rtd", + "componentName": "panxo", + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/performaxBidAdapter.json b/metadata/modules/performaxBidAdapter.json index f845d868039..5bdea8a1dd9 100644 --- a/metadata/modules/performaxBidAdapter.json +++ b/metadata/modules/performaxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.performax.cz/device_storage.json": { - "timestamp": "2026-01-28T16:30:30.114Z", + "timestamp": "2026-03-02T14:46:02.691Z", "disclosures": [ { "identifier": "px2uid", diff --git a/metadata/modules/permutiveIdentityManagerIdSystem.json b/metadata/modules/permutiveIdentityManagerIdSystem.json index 8d762cfa8ac..647b813dccd 100644 --- a/metadata/modules/permutiveIdentityManagerIdSystem.json +++ b/metadata/modules/permutiveIdentityManagerIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://assets.permutive.app/tcf/tcf.json": { - "timestamp": "2026-01-28T16:30:30.530Z", + "timestamp": "2026-03-02T14:46:03.107Z", "disclosures": [ { "identifier": "_pdfps", diff --git a/metadata/modules/permutiveRtdProvider.json b/metadata/modules/permutiveRtdProvider.json index b3183046ced..0f41067e138 100644 --- a/metadata/modules/permutiveRtdProvider.json +++ b/metadata/modules/permutiveRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://assets.permutive.app/tcf/tcf.json": { - "timestamp": "2026-01-28T16:30:30.723Z", + "timestamp": "2026-03-02T14:46:03.283Z", "disclosures": [ { "identifier": "_pdfps", diff --git a/metadata/modules/pixfutureBidAdapter.json b/metadata/modules/pixfutureBidAdapter.json index 404e5160044..c04bfe3f164 100644 --- a/metadata/modules/pixfutureBidAdapter.json +++ b/metadata/modules/pixfutureBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.pixfuture.com/vendor-disclosures.json": { - "timestamp": "2026-01-28T16:30:30.724Z", + "timestamp": "2026-03-02T14:46:03.285Z", "disclosures": [] } }, diff --git a/metadata/modules/playdigoBidAdapter.json b/metadata/modules/playdigoBidAdapter.json index e1cfc53a2be..56ff6f0ebc5 100644 --- a/metadata/modules/playdigoBidAdapter.json +++ b/metadata/modules/playdigoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://playdigo.com/file.json": { - "timestamp": "2026-01-28T16:30:30.771Z", + "timestamp": "2026-03-02T14:46:03.345Z", "disclosures": [] } }, diff --git a/metadata/modules/prebid-core.json b/metadata/modules/prebid-core.json index 27215922c30..3ef5aaa70c7 100644 --- a/metadata/modules/prebid-core.json +++ b/metadata/modules/prebid-core.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/probes.json": { - "timestamp": "2026-01-28T16:29:58.199Z", + "timestamp": "2026-03-02T14:44:46.315Z", "disclosures": [ { "identifier": "_rdc*", @@ -23,7 +23,7 @@ ] }, "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/debugging.json": { - "timestamp": "2026-01-28T16:29:58.213Z", + "timestamp": "2026-03-02T14:44:46.315Z", "disclosures": [ { "identifier": "__*_debugging__", @@ -41,6 +41,11 @@ "componentName": "fpdEnrichment", "disclosureURL": "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/probes.json" }, + { + "componentType": "prebid", + "componentName": "storage", + "disclosureURL": "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/probes.json" + }, { "componentType": "prebid", "componentName": "debugging", diff --git a/metadata/modules/precisoBidAdapter.json b/metadata/modules/precisoBidAdapter.json index 051accb3c62..63ae55fec56 100644 --- a/metadata/modules/precisoBidAdapter.json +++ b/metadata/modules/precisoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://preciso.net/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:30.948Z", + "timestamp": "2026-03-02T14:46:03.527Z", "disclosures": [ { "identifier": "XXXXX_viewnew", diff --git a/metadata/modules/prismaBidAdapter.json b/metadata/modules/prismaBidAdapter.json index 0d1691488d9..b1668166cfa 100644 --- a/metadata/modules/prismaBidAdapter.json +++ b/metadata/modules/prismaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://fast.nexx360.io/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:30.998Z", + "timestamp": "2026-03-02T14:46:03.818Z", "disclosures": [] } }, diff --git a/metadata/modules/programmaticXBidAdapter.json b/metadata/modules/programmaticXBidAdapter.json index 1221f3c8eb4..e88a5f370ba 100644 --- a/metadata/modules/programmaticXBidAdapter.json +++ b/metadata/modules/programmaticXBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://progrtb.com/tcf-vendor-disclosures.json": { - "timestamp": "2026-01-28T16:30:30.998Z", + "timestamp": "2026-03-02T14:46:03.818Z", "disclosures": [] } }, diff --git a/metadata/modules/proxistoreBidAdapter.json b/metadata/modules/proxistoreBidAdapter.json index f6bd76fdd1b..905a96a3b49 100644 --- a/metadata/modules/proxistoreBidAdapter.json +++ b/metadata/modules/proxistoreBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://abs.proxistore.com/assets/json/proxistore_device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:31.059Z", + "timestamp": "2026-03-02T14:46:03.882Z", "disclosures": [] } }, diff --git a/metadata/modules/publinkIdSystem.json b/metadata/modules/publinkIdSystem.json index fb02ded585d..7ad66a8914c 100644 --- a/metadata/modules/publinkIdSystem.json +++ b/metadata/modules/publinkIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://s-usweb.dotomi.com/assets/js/taggy-js/2.18.9/device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:31.543Z", + "timestamp": "2026-03-02T14:46:04.343Z", "disclosures": [ { "identifier": "dtm_status", diff --git a/metadata/modules/pubmaticBidAdapter.json b/metadata/modules/pubmaticBidAdapter.json index 607009f53ac..7dc64f1d033 100644 --- a/metadata/modules/pubmaticBidAdapter.json +++ b/metadata/modules/pubmaticBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.pubmatic.com/devicestorage.json": { - "timestamp": "2026-01-28T16:30:31.544Z", + "timestamp": "2026-03-02T14:46:04.343Z", "disclosures": [] } }, diff --git a/metadata/modules/pubmaticIdSystem.json b/metadata/modules/pubmaticIdSystem.json index c57d7c0d7c4..2545e56212f 100644 --- a/metadata/modules/pubmaticIdSystem.json +++ b/metadata/modules/pubmaticIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.pubmatic.com/devicestorage.json": { - "timestamp": "2026-01-28T16:30:31.576Z", + "timestamp": "2026-03-02T14:46:04.569Z", "disclosures": [] } }, diff --git a/metadata/modules/pubstackBidAdapter.json b/metadata/modules/pubstackBidAdapter.json new file mode 100644 index 00000000000..5081e22a00d --- /dev/null +++ b/metadata/modules/pubstackBidAdapter.json @@ -0,0 +1,25 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": { + "https://cdn.pbstck.com/privacy_policies/device_storage_disclosures.json": { + "timestamp": "2026-03-02T14:46:04.602Z", + "disclosures": [] + } + }, + "components": [ + { + "componentType": "bidder", + "componentName": "pubstack", + "aliasOf": null, + "gvlid": 1408, + "disclosureURL": "https://cdn.pbstck.com/privacy_policies/device_storage_disclosures.json" + }, + { + "componentType": "bidder", + "componentName": "pubstack_server", + "aliasOf": "pubstack", + "gvlid": 1408, + "disclosureURL": "https://cdn.pbstck.com/privacy_policies/device_storage_disclosures.json" + } + ] +} \ No newline at end of file diff --git a/metadata/modules/pulsepointBidAdapter.json b/metadata/modules/pulsepointBidAdapter.json index 4bacd53227a..3134a58466f 100644 --- a/metadata/modules/pulsepointBidAdapter.json +++ b/metadata/modules/pulsepointBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bh.contextweb.com/tcf/vendorInfo.json": { - "timestamp": "2026-01-28T16:30:31.577Z", + "timestamp": "2026-03-02T14:46:04.603Z", "disclosures": [] } }, diff --git a/metadata/modules/quantcastBidAdapter.json b/metadata/modules/quantcastBidAdapter.json index 426abc3ac5c..0b8de794a6c 100644 --- a/metadata/modules/quantcastBidAdapter.json +++ b/metadata/modules/quantcastBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.quantcast.com/.well-known/devicestorage.json": { - "timestamp": "2026-01-28T16:30:31.593Z", + "timestamp": "2026-03-02T14:46:04.619Z", "disclosures": [ { "identifier": "__qca", diff --git a/metadata/modules/quantcastIdSystem.json b/metadata/modules/quantcastIdSystem.json index 6c28e90ab63..0ac7d60db4a 100644 --- a/metadata/modules/quantcastIdSystem.json +++ b/metadata/modules/quantcastIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.quantcast.com/.well-known/devicestorage.json": { - "timestamp": "2026-01-28T16:30:31.773Z", + "timestamp": "2026-03-02T14:46:04.798Z", "disclosures": [ { "identifier": "__qca", diff --git a/metadata/modules/r2b2BidAdapter.json b/metadata/modules/r2b2BidAdapter.json index 6b3f923e892..673069e3845 100644 --- a/metadata/modules/r2b2BidAdapter.json +++ b/metadata/modules/r2b2BidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://delivery.r2b2.io/cookie_disclosure": { - "timestamp": "2026-01-28T16:30:31.773Z", + "timestamp": "2026-03-02T14:46:04.800Z", "disclosures": [ { "identifier": "AdTrack-hide-*", diff --git a/metadata/modules/readpeakBidAdapter.json b/metadata/modules/readpeakBidAdapter.json index 6b75cbc9276..752bc1a2f76 100644 --- a/metadata/modules/readpeakBidAdapter.json +++ b/metadata/modules/readpeakBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://static.readpeak.com/tcf/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:32.145Z", + "timestamp": "2026-03-02T14:46:05.271Z", "disclosures": [ { "identifier": "rp_uidfp", diff --git a/metadata/modules/relayBidAdapter.json b/metadata/modules/relayBidAdapter.json index fe416c67d88..622cc457012 100644 --- a/metadata/modules/relayBidAdapter.json +++ b/metadata/modules/relayBidAdapter.json @@ -2,8 +2,8 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://relay42.com/hubfs/raw_assets/public/IAB.json": { - "timestamp": "2026-01-28T16:30:32.167Z", - "disclosures": [] + "timestamp": "2026-03-02T14:46:05.294Z", + "disclosures": null } }, "components": [ diff --git a/metadata/modules/relevantdigitalBidAdapter.json b/metadata/modules/relevantdigitalBidAdapter.json index 08eedf926e5..5addfe8ad58 100644 --- a/metadata/modules/relevantdigitalBidAdapter.json +++ b/metadata/modules/relevantdigitalBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.relevant-digital.com/resources/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:32.375Z", + "timestamp": "2026-03-02T14:46:06.100Z", "disclosures": [] } }, diff --git a/metadata/modules/resetdigitalBidAdapter.json b/metadata/modules/resetdigitalBidAdapter.json index 703482de16d..dd359941cee 100644 --- a/metadata/modules/resetdigitalBidAdapter.json +++ b/metadata/modules/resetdigitalBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://resetdigital.co/GDPR-TCF.json": { - "timestamp": "2026-01-28T16:30:32.683Z", + "timestamp": "2026-03-02T14:46:06.261Z", "disclosures": [] } }, diff --git a/metadata/modules/responsiveAdsBidAdapter.json b/metadata/modules/responsiveAdsBidAdapter.json index 89d23bb6652..37dc05e23e9 100644 --- a/metadata/modules/responsiveAdsBidAdapter.json +++ b/metadata/modules/responsiveAdsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://publish.responsiveads.com/tcf/tcf-v2.json": { - "timestamp": "2026-01-28T16:30:32.722Z", + "timestamp": "2026-03-02T14:46:06.304Z", "disclosures": [] } }, diff --git a/metadata/modules/revantageBidAdapter.json b/metadata/modules/revantageBidAdapter.json new file mode 100644 index 00000000000..90eda1e36ad --- /dev/null +++ b/metadata/modules/revantageBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "revantage", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/revcontentBidAdapter.json b/metadata/modules/revcontentBidAdapter.json index 3f45c788da2..4c36e64fbd2 100644 --- a/metadata/modules/revcontentBidAdapter.json +++ b/metadata/modules/revcontentBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://sothebys.revcontent.com/static/device_storage.json": { - "timestamp": "2026-01-28T16:30:32.781Z", + "timestamp": "2026-03-02T14:46:06.345Z", "disclosures": [ { "identifier": "__ID", diff --git a/metadata/modules/revnewBidAdapter.json b/metadata/modules/revnewBidAdapter.json index 29fdb8b0198..6a2c2b0b465 100644 --- a/metadata/modules/revnewBidAdapter.json +++ b/metadata/modules/revnewBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://mediafuse.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:32.795Z", + "timestamp": "2026-03-02T14:46:06.391Z", "disclosures": [] } }, diff --git a/metadata/modules/rhythmoneBidAdapter.json b/metadata/modules/rhythmoneBidAdapter.json index 31d017d8c39..b15321eabee 100644 --- a/metadata/modules/rhythmoneBidAdapter.json +++ b/metadata/modules/rhythmoneBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://video.unrulymedia.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:32.929Z", + "timestamp": "2026-03-02T14:46:06.448Z", "disclosures": [] } }, diff --git a/metadata/modules/richaudienceBidAdapter.json b/metadata/modules/richaudienceBidAdapter.json index db4f6fbb7d4..10611865e4a 100644 --- a/metadata/modules/richaudienceBidAdapter.json +++ b/metadata/modules/richaudienceBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdnj.richaudience.com/52a26ab9400b2a9f5aabfa20acf3196g.json": { - "timestamp": "2026-01-28T16:30:33.193Z", + "timestamp": "2026-03-02T14:46:06.805Z", "disclosures": [] } }, diff --git a/metadata/modules/riseBidAdapter.json b/metadata/modules/riseBidAdapter.json index 15a580963ff..3891bd224cb 100644 --- a/metadata/modules/riseBidAdapter.json +++ b/metadata/modules/riseBidAdapter.json @@ -2,11 +2,11 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://d2pm7iglz0b6eq.cloudfront.net/RiseDeviceStorage.json": { - "timestamp": "2026-01-28T16:30:33.264Z", + "timestamp": "2026-03-02T14:46:06.861Z", "disclosures": [] }, "https://spotim-prd-static-assets.s3.amazonaws.com/iab/device-storage.json": { - "timestamp": "2026-01-28T16:30:33.264Z", + "timestamp": "2026-03-02T14:46:06.861Z", "disclosures": [] } }, diff --git a/metadata/modules/rixengineBidAdapter.json b/metadata/modules/rixengineBidAdapter.json index 437a19779b1..980500fae02 100644 --- a/metadata/modules/rixengineBidAdapter.json +++ b/metadata/modules/rixengineBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.algorix.co/gdpr-disclosure.json": { - "timestamp": "2026-01-28T16:30:33.264Z", + "timestamp": "2026-03-02T14:46:06.862Z", "disclosures": [] } }, diff --git a/metadata/modules/rtbhouseBidAdapter.json b/metadata/modules/rtbhouseBidAdapter.json index 20c7edbd123..31714df7b2e 100644 --- a/metadata/modules/rtbhouseBidAdapter.json +++ b/metadata/modules/rtbhouseBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://rtbhouse.com/DeviceStorage.json": { - "timestamp": "2026-01-28T16:30:33.289Z", + "timestamp": "2026-03-02T14:46:06.887Z", "disclosures": [ { "identifier": "_rtbh.*", diff --git a/metadata/modules/rubiconBidAdapter.json b/metadata/modules/rubiconBidAdapter.json index e8f991d7e6d..68ecbd8f4a9 100644 --- a/metadata/modules/rubiconBidAdapter.json +++ b/metadata/modules/rubiconBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://gdpr.rubiconproject.com/dvplus/devicestoragedisclosure.json": { - "timestamp": "2026-01-28T16:30:33.692Z", + "timestamp": "2026-03-02T14:46:07.020Z", "disclosures": [] } }, diff --git a/metadata/modules/scaliburBidAdapter.json b/metadata/modules/scaliburBidAdapter.json index 95100416ca0..b39b894bd9f 100644 --- a/metadata/modules/scaliburBidAdapter.json +++ b/metadata/modules/scaliburBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://legal.overwolf.com/docs/overwolf/website/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:33.929Z", + "timestamp": "2026-03-02T14:46:07.254Z", "disclosures": [ { "identifier": "scluid", diff --git a/metadata/modules/screencoreBidAdapter.json b/metadata/modules/screencoreBidAdapter.json index 19bce2ec18b..1772818cedc 100644 --- a/metadata/modules/screencoreBidAdapter.json +++ b/metadata/modules/screencoreBidAdapter.json @@ -2,8 +2,8 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://screencore.io/tcf.json": { - "timestamp": "2026-01-28T16:30:33.953Z", - "disclosures": null + "timestamp": "2026-03-02T14:46:07.274Z", + "disclosures": [] } }, "components": [ diff --git a/metadata/modules/seedingAllianceBidAdapter.json b/metadata/modules/seedingAllianceBidAdapter.json index fe8d650a43f..801af2fb55e 100644 --- a/metadata/modules/seedingAllianceBidAdapter.json +++ b/metadata/modules/seedingAllianceBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://s.nativendo.de/cdn/asset/tcf/purpose-specific-storage-and-access-information.json": { - "timestamp": "2026-01-28T16:30:36.548Z", + "timestamp": "2026-03-02T14:46:07.339Z", "disclosures": [] } }, diff --git a/metadata/modules/seedtagBidAdapter.json b/metadata/modules/seedtagBidAdapter.json index dc5a53845ed..3ad01be8a3a 100644 --- a/metadata/modules/seedtagBidAdapter.json +++ b/metadata/modules/seedtagBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.seedtag.com/vendor.json": { - "timestamp": "2026-01-28T16:30:36.576Z", + "timestamp": "2026-03-02T14:46:07.366Z", "disclosures": [] } }, diff --git a/metadata/modules/semantiqRtdProvider.json b/metadata/modules/semantiqRtdProvider.json index e03a5cffba7..296ab8e1865 100644 --- a/metadata/modules/semantiqRtdProvider.json +++ b/metadata/modules/semantiqRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://audienzz.com/device_storage_disclosure_vendor_783.json": { - "timestamp": "2026-01-28T16:30:36.576Z", + "timestamp": "2026-03-02T14:46:07.366Z", "disclosures": [] } }, diff --git a/metadata/modules/setupadBidAdapter.json b/metadata/modules/setupadBidAdapter.json index 9114eb7ffec..20bb4dab06f 100644 --- a/metadata/modules/setupadBidAdapter.json +++ b/metadata/modules/setupadBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cookies.stpd.cloud/disclosures.json": { - "timestamp": "2026-01-28T16:30:36.628Z", + "timestamp": "2026-03-02T14:46:07.471Z", "disclosures": [] } }, diff --git a/metadata/modules/sevioBidAdapter.json b/metadata/modules/sevioBidAdapter.json index 9c5e2a82b2a..4326d208d40 100644 --- a/metadata/modules/sevioBidAdapter.json +++ b/metadata/modules/sevioBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://sevio.com/tcf.json": { - "timestamp": "2026-01-28T16:30:36.792Z", + "timestamp": "2026-03-02T14:46:07.609Z", "disclosures": [] } }, diff --git a/metadata/modules/sharedIdSystem.json b/metadata/modules/sharedIdSystem.json index deda98910f7..cda116dbd54 100644 --- a/metadata/modules/sharedIdSystem.json +++ b/metadata/modules/sharedIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/sharedId-optout.json": { - "timestamp": "2026-01-28T16:30:36.949Z", + "timestamp": "2026-03-02T14:46:07.758Z", "disclosures": [ { "identifier": "_pubcid_optout", diff --git a/metadata/modules/sharethroughBidAdapter.json b/metadata/modules/sharethroughBidAdapter.json index 5d835a1ba39..8c2ce012b1d 100644 --- a/metadata/modules/sharethroughBidAdapter.json +++ b/metadata/modules/sharethroughBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://assets.sharethrough.com/gvl.json": { - "timestamp": "2026-01-28T16:30:36.949Z", + "timestamp": "2026-03-02T14:46:07.758Z", "disclosures": [] } }, diff --git a/metadata/modules/showheroes-bsBidAdapter.json b/metadata/modules/showheroes-bsBidAdapter.json index 7dcee4cef53..9abcf1c23b4 100644 --- a/metadata/modules/showheroes-bsBidAdapter.json +++ b/metadata/modules/showheroes-bsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://static-origin.showheroes.com/gvl_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:36.971Z", + "timestamp": "2026-03-02T14:46:07.777Z", "disclosures": [] } }, diff --git a/metadata/modules/silvermobBidAdapter.json b/metadata/modules/silvermobBidAdapter.json index 21743e158e4..c5aa4e9afcb 100644 --- a/metadata/modules/silvermobBidAdapter.json +++ b/metadata/modules/silvermobBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://silvermob.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:37.398Z", + "timestamp": "2026-03-02T14:46:08.247Z", "disclosures": [] } }, diff --git a/metadata/modules/sirdataRtdProvider.json b/metadata/modules/sirdataRtdProvider.json index 292ad9c1408..348ce7f9fd4 100644 --- a/metadata/modules/sirdataRtdProvider.json +++ b/metadata/modules/sirdataRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.sirdata.eu/sirdata_device_storage_disclosure.json": { - "timestamp": "2026-01-28T16:30:37.414Z", + "timestamp": "2026-03-02T14:46:08.262Z", "disclosures": [] } }, diff --git a/metadata/modules/smaatoBidAdapter.json b/metadata/modules/smaatoBidAdapter.json index 35d7af131ec..413fb0f4ca3 100644 --- a/metadata/modules/smaatoBidAdapter.json +++ b/metadata/modules/smaatoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://resources.smaato.com/hubfs/Smaato/IAB/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:37.710Z", + "timestamp": "2026-03-02T14:46:08.585Z", "disclosures": [] } }, diff --git a/metadata/modules/smartadserverBidAdapter.json b/metadata/modules/smartadserverBidAdapter.json index a1ed3e1f8bd..a1c3212113e 100644 --- a/metadata/modules/smartadserverBidAdapter.json +++ b/metadata/modules/smartadserverBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://apps.smartadserver.com/device-storage-disclosures/equativDeviceStorageDisclosures.json": { - "timestamp": "2026-01-28T16:30:37.818Z", + "timestamp": "2026-03-02T14:46:08.665Z", "disclosures": [] } }, diff --git a/metadata/modules/smartxBidAdapter.json b/metadata/modules/smartxBidAdapter.json index 2bbf3c45927..06854dd00c3 100644 --- a/metadata/modules/smartxBidAdapter.json +++ b/metadata/modules/smartxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.smartclip.net/iab/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:37.819Z", + "timestamp": "2026-03-02T14:46:08.666Z", "disclosures": [] } }, diff --git a/metadata/modules/smartyadsBidAdapter.json b/metadata/modules/smartyadsBidAdapter.json index c56d5f330e6..202d8317bad 100644 --- a/metadata/modules/smartyadsBidAdapter.json +++ b/metadata/modules/smartyadsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://smartyads.com/tcf.json": { - "timestamp": "2026-01-28T16:30:37.844Z", + "timestamp": "2026-03-02T14:46:08.685Z", "disclosures": [] } }, diff --git a/metadata/modules/smilewantedBidAdapter.json b/metadata/modules/smilewantedBidAdapter.json index 5c3b44c8296..7b14099448a 100644 --- a/metadata/modules/smilewantedBidAdapter.json +++ b/metadata/modules/smilewantedBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://smilewanted.com/vendor-device-storage-disclosures.json": { - "timestamp": "2026-01-28T16:30:37.886Z", + "timestamp": "2026-03-02T14:46:08.723Z", "disclosures": [] } }, diff --git a/metadata/modules/snigelBidAdapter.json b/metadata/modules/snigelBidAdapter.json index 68d1c0c2815..ef1115d5554 100644 --- a/metadata/modules/snigelBidAdapter.json +++ b/metadata/modules/snigelBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.snigelweb.com/gvl/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:38.328Z", + "timestamp": "2026-03-02T14:46:09.181Z", "disclosures": [] } }, diff --git a/metadata/modules/sonaradsBidAdapter.json b/metadata/modules/sonaradsBidAdapter.json index b41bae638fe..b75cfe08e7c 100644 --- a/metadata/modules/sonaradsBidAdapter.json +++ b/metadata/modules/sonaradsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bridgeupp.com/device-storage-disclosure.json": { - "timestamp": "2026-01-28T16:30:38.465Z", + "timestamp": "2026-03-02T14:46:09.233Z", "disclosures": [] } }, diff --git a/metadata/modules/sonobiBidAdapter.json b/metadata/modules/sonobiBidAdapter.json index bef53619e19..6ebb47d3e03 100644 --- a/metadata/modules/sonobiBidAdapter.json +++ b/metadata/modules/sonobiBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://sonobi.com/tcf2-device-storage-disclosure.json": { - "timestamp": "2026-01-28T16:30:38.695Z", + "timestamp": "2026-03-02T14:46:09.460Z", "disclosures": [] } }, diff --git a/metadata/modules/sovrnBidAdapter.json b/metadata/modules/sovrnBidAdapter.json index ae4dc05951d..0abfb9c0b85 100644 --- a/metadata/modules/sovrnBidAdapter.json +++ b/metadata/modules/sovrnBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.sovrn.com/tcf-cookie-disclosure/disclosure.json": { - "timestamp": "2026-01-28T16:30:38.930Z", + "timestamp": "2026-03-02T14:46:09.690Z", "disclosures": [] } }, diff --git a/metadata/modules/sparteoBidAdapter.json b/metadata/modules/sparteoBidAdapter.json index 4f549fdb2e4..b89e3d7a3d7 100644 --- a/metadata/modules/sparteoBidAdapter.json +++ b/metadata/modules/sparteoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bid.bricks-co.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:38.952Z", + "timestamp": "2026-03-02T14:46:09.709Z", "disclosures": [ { "identifier": "fastCMP-addtlConsent", diff --git a/metadata/modules/ssmasBidAdapter.json b/metadata/modules/ssmasBidAdapter.json index ccc832d962a..be24aefdf2e 100644 --- a/metadata/modules/ssmasBidAdapter.json +++ b/metadata/modules/ssmasBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://semseoymas.com/iab.json": { - "timestamp": "2026-01-28T16:30:39.228Z", + "timestamp": "2026-03-02T14:46:09.989Z", "disclosures": null } }, diff --git a/metadata/modules/sspBCBidAdapter.json b/metadata/modules/sspBCBidAdapter.json index db6d59ff097..a99dc0c8ea5 100644 --- a/metadata/modules/sspBCBidAdapter.json +++ b/metadata/modules/sspBCBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ssp.wp.pl/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:39.834Z", + "timestamp": "2026-03-02T14:46:10.636Z", "disclosures": null } }, diff --git a/metadata/modules/stackadaptBidAdapter.json b/metadata/modules/stackadaptBidAdapter.json index f0049ce614b..6a53c79a6f2 100644 --- a/metadata/modules/stackadaptBidAdapter.json +++ b/metadata/modules/stackadaptBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://s3.amazonaws.com/stackadapt_public/disclosures.json": { - "timestamp": "2026-01-28T16:30:39.835Z", + "timestamp": "2026-03-02T14:46:10.637Z", "disclosures": [ { "identifier": "sa-camp-*", @@ -62,6 +62,17 @@ 4 ] }, + { + "identifier": "sa-user-id-v4", + "type": "cookie", + "maxAgeSeconds": 31536000, + "cookieRefresh": false, + "purposes": [ + 1, + 3, + 4 + ] + }, { "identifier": "sa-user-id", "type": "web", @@ -84,6 +95,17 @@ 4 ] }, + { + "identifier": "sa-user-id-v4", + "type": "web", + "maxAgeSeconds": null, + "cookieRefresh": false, + "purposes": [ + 1, + 3, + 4 + ] + }, { "identifier": "sa-camp-*", "type": "web", diff --git a/metadata/modules/startioBidAdapter.json b/metadata/modules/startioBidAdapter.json index b2288e475a3..62dadaf9188 100644 --- a/metadata/modules/startioBidAdapter.json +++ b/metadata/modules/startioBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://info.startappservice.com/tcf/start.io_domains.json": { - "timestamp": "2026-01-28T16:30:39.868Z", + "timestamp": "2026-03-02T14:46:10.672Z", "disclosures": [] } }, diff --git a/metadata/modules/stroeerCoreBidAdapter.json b/metadata/modules/stroeerCoreBidAdapter.json index d665b481f92..4830347118e 100644 --- a/metadata/modules/stroeerCoreBidAdapter.json +++ b/metadata/modules/stroeerCoreBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.stroeer.de/StroeerSSP_deviceStorage.json": { - "timestamp": "2026-01-28T16:30:39.889Z", + "timestamp": "2026-03-02T14:46:10.688Z", "disclosures": [] } }, diff --git a/metadata/modules/stvBidAdapter.json b/metadata/modules/stvBidAdapter.json index d1ce12c1ae4..0409bb9984d 100644 --- a/metadata/modules/stvBidAdapter.json +++ b/metadata/modules/stvBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.adtech.app/gen/deviceStorageDisclosure/stv.json": { - "timestamp": "2026-01-28T16:30:40.266Z", + "timestamp": "2026-03-02T14:46:11.041Z", "disclosures": [] } }, diff --git a/metadata/modules/sublimeBidAdapter.json b/metadata/modules/sublimeBidAdapter.json index 77f960e3892..babdae509ce 100644 --- a/metadata/modules/sublimeBidAdapter.json +++ b/metadata/modules/sublimeBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://gdpr.ayads.co/cookiepolicy.json": { - "timestamp": "2026-01-28T16:30:40.913Z", + "timestamp": "2026-03-02T14:46:11.678Z", "disclosures": [ { "identifier": "dnt", diff --git a/metadata/modules/taboolaBidAdapter.json b/metadata/modules/taboolaBidAdapter.json index 07e38d4e2f3..c829cd46687 100644 --- a/metadata/modules/taboolaBidAdapter.json +++ b/metadata/modules/taboolaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://accessrequest.taboola.com/iab-tcf-v2-disclosure.json": { - "timestamp": "2026-01-28T16:30:41.174Z", + "timestamp": "2026-03-02T14:46:11.938Z", "disclosures": [ { "identifier": "trc_cookie_storage", diff --git a/metadata/modules/taboolaIdSystem.json b/metadata/modules/taboolaIdSystem.json index 5192f0b5a53..6fcf6a5ec28 100644 --- a/metadata/modules/taboolaIdSystem.json +++ b/metadata/modules/taboolaIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://accessrequest.taboola.com/iab-tcf-v2-disclosure.json": { - "timestamp": "2026-01-28T16:30:41.833Z", + "timestamp": "2026-03-02T14:46:12.149Z", "disclosures": [ { "identifier": "trc_cookie_storage", diff --git a/metadata/modules/tadvertisingBidAdapter.json b/metadata/modules/tadvertisingBidAdapter.json index a3cb6303200..35fb4310c9c 100644 --- a/metadata/modules/tadvertisingBidAdapter.json +++ b/metadata/modules/tadvertisingBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tcf.emetriq.de/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:41.834Z", + "timestamp": "2026-03-02T14:46:12.150Z", "disclosures": [] } }, diff --git a/metadata/modules/tappxBidAdapter.json b/metadata/modules/tappxBidAdapter.json index a487b12cb7d..6a1843cea0d 100644 --- a/metadata/modules/tappxBidAdapter.json +++ b/metadata/modules/tappxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://tappx.com/devicestorage.json": { - "timestamp": "2026-01-28T16:30:41.858Z", + "timestamp": "2026-03-02T14:46:12.408Z", "disclosures": [] } }, diff --git a/metadata/modules/targetVideoBidAdapter.json b/metadata/modules/targetVideoBidAdapter.json index ec93fdb78b9..d785b33951a 100644 --- a/metadata/modules/targetVideoBidAdapter.json +++ b/metadata/modules/targetVideoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://target-video.com/vendors-device-storage-and-operational-disclosures.json": { - "timestamp": "2026-01-28T16:30:41.884Z", + "timestamp": "2026-03-02T14:46:12.436Z", "disclosures": [ { "identifier": "brid_location", diff --git a/metadata/modules/teadsBidAdapter.json b/metadata/modules/teadsBidAdapter.json index daed112213d..669db4c09d6 100644 --- a/metadata/modules/teadsBidAdapter.json +++ b/metadata/modules/teadsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://iab-cookie-disclosure.teads.tv/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:41.885Z", + "timestamp": "2026-03-02T14:46:12.437Z", "disclosures": [] } }, diff --git a/metadata/modules/teadsIdSystem.json b/metadata/modules/teadsIdSystem.json index 8e927eaff33..a84486229b0 100644 --- a/metadata/modules/teadsIdSystem.json +++ b/metadata/modules/teadsIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://iab-cookie-disclosure.teads.tv/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:41.907Z", + "timestamp": "2026-03-02T14:46:12.457Z", "disclosures": [] } }, diff --git a/metadata/modules/tealBidAdapter.json b/metadata/modules/tealBidAdapter.json index 704d75aadae..fb6a14599bd 100644 --- a/metadata/modules/tealBidAdapter.json +++ b/metadata/modules/tealBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://c.bids.ws/iab/disclosures.json": { - "timestamp": "2026-01-28T16:30:41.907Z", + "timestamp": "2026-03-02T14:46:12.457Z", "disclosures": [] } }, diff --git a/metadata/modules/teqBlazeSalesAgentBidAdapter.json b/metadata/modules/teqBlazeSalesAgentBidAdapter.json new file mode 100644 index 00000000000..2442df240d1 --- /dev/null +++ b/metadata/modules/teqBlazeSalesAgentBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "teqBlazeSalesAgent", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/tncIdSystem.json b/metadata/modules/tncIdSystem.json index 647cab4a5cc..dd50f1b4493 100644 --- a/metadata/modules/tncIdSystem.json +++ b/metadata/modules/tncIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://js.tncid.app/iab-tcf-device-storage-disclosure.json": { - "timestamp": "2026-01-28T16:30:41.941Z", + "timestamp": "2026-03-02T14:46:12.515Z", "disclosures": [] } }, diff --git a/metadata/modules/topicsFpdModule.json b/metadata/modules/topicsFpdModule.json index 95987763bde..e4683b84727 100644 --- a/metadata/modules/topicsFpdModule.json +++ b/metadata/modules/topicsFpdModule.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/topicsFpdModule.json": { - "timestamp": "2026-01-28T16:29:58.214Z", + "timestamp": "2026-03-02T14:44:46.316Z", "disclosures": [ { "identifier": "prebid:topics", diff --git a/metadata/modules/toponBidAdapter.json b/metadata/modules/toponBidAdapter.json index d008f59f2dd..719b4868651 100644 --- a/metadata/modules/toponBidAdapter.json +++ b/metadata/modules/toponBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://mores.toponad.net/tmp/tpn/toponads_tcf_disclosure.json": { - "timestamp": "2026-01-28T16:30:41.959Z", + "timestamp": "2026-03-02T14:46:12.534Z", "disclosures": [] } }, diff --git a/metadata/modules/tripleliftBidAdapter.json b/metadata/modules/tripleliftBidAdapter.json index 94cc97bcf8a..ae8f461ead0 100644 --- a/metadata/modules/tripleliftBidAdapter.json +++ b/metadata/modules/tripleliftBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://triplelift.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:41.986Z", + "timestamp": "2026-03-02T14:46:12.661Z", "disclosures": [] } }, diff --git a/metadata/modules/ttdBidAdapter.json b/metadata/modules/ttdBidAdapter.json index d6d3b8cebec..825c0388f7a 100644 --- a/metadata/modules/ttdBidAdapter.json +++ b/metadata/modules/ttdBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ttd-misc-public-assets.s3.us-west-2.amazonaws.com/deviceStorageDisclosureURL.json": { - "timestamp": "2026-01-28T16:30:42.047Z", + "timestamp": "2026-03-02T14:46:12.692Z", "disclosures": [] } }, diff --git a/metadata/modules/twistDigitalBidAdapter.json b/metadata/modules/twistDigitalBidAdapter.json index 9d23eddf753..fc4095423c5 100644 --- a/metadata/modules/twistDigitalBidAdapter.json +++ b/metadata/modules/twistDigitalBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://twistdigital.net/iab.json": { - "timestamp": "2026-01-28T16:30:42.047Z", + "timestamp": "2026-03-02T14:46:12.692Z", "disclosures": [ { "identifier": "vdzj1_{id}", diff --git a/metadata/modules/underdogmediaBidAdapter.json b/metadata/modules/underdogmediaBidAdapter.json index fefaee8ca12..dbd497898d7 100644 --- a/metadata/modules/underdogmediaBidAdapter.json +++ b/metadata/modules/underdogmediaBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bid.underdog.media/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:42.135Z", + "timestamp": "2026-03-02T14:46:12.772Z", "disclosures": [] } }, diff --git a/metadata/modules/undertoneBidAdapter.json b/metadata/modules/undertoneBidAdapter.json index eab5cb0d139..0b02483de93 100644 --- a/metadata/modules/undertoneBidAdapter.json +++ b/metadata/modules/undertoneBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.undertone.com/js/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:42.159Z", + "timestamp": "2026-03-02T14:46:12.790Z", "disclosures": [] } }, diff --git a/metadata/modules/unifiedIdSystem.json b/metadata/modules/unifiedIdSystem.json index c036a28e618..cc27f390b4d 100644 --- a/metadata/modules/unifiedIdSystem.json +++ b/metadata/modules/unifiedIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ttd-misc-public-assets.s3.us-west-2.amazonaws.com/deviceStorageDisclosureURL.json": { - "timestamp": "2026-01-28T16:30:42.227Z", + "timestamp": "2026-03-02T14:46:12.878Z", "disclosures": [] } }, diff --git a/metadata/modules/unrulyBidAdapter.json b/metadata/modules/unrulyBidAdapter.json index e2175b3f549..5d00220a7b8 100644 --- a/metadata/modules/unrulyBidAdapter.json +++ b/metadata/modules/unrulyBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://video.unrulymedia.com/deviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:42.227Z", + "timestamp": "2026-03-02T14:46:12.878Z", "disclosures": [] } }, diff --git a/metadata/modules/userId.json b/metadata/modules/userId.json index 4cb33e6f4a1..a7288064da5 100644 --- a/metadata/modules/userId.json +++ b/metadata/modules/userId.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/userId-optout.json": { - "timestamp": "2026-01-28T16:29:58.216Z", + "timestamp": "2026-03-02T14:44:46.317Z", "disclosures": [ { "identifier": "_pbjs_id_optout", diff --git a/metadata/modules/utiqIdSystem.json b/metadata/modules/utiqIdSystem.json index 071faa17400..6e7e629b0ac 100644 --- a/metadata/modules/utiqIdSystem.json +++ b/metadata/modules/utiqIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/modules/utiqDeviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:42.227Z", + "timestamp": "2026-03-02T14:46:12.878Z", "disclosures": [ { "identifier": "utiqPass", diff --git a/metadata/modules/utiqMtpIdSystem.json b/metadata/modules/utiqMtpIdSystem.json index 2df7ca96f1b..8e9e76248b8 100644 --- a/metadata/modules/utiqMtpIdSystem.json +++ b/metadata/modules/utiqMtpIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/modules/utiqDeviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:42.229Z", + "timestamp": "2026-03-02T14:46:12.879Z", "disclosures": [ { "identifier": "utiqPass", diff --git a/metadata/modules/validationFpdModule.json b/metadata/modules/validationFpdModule.json index ca2ae6a8883..05f5921394d 100644 --- a/metadata/modules/validationFpdModule.json +++ b/metadata/modules/validationFpdModule.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.jsdelivr.net/gh/prebid/Prebid.js/metadata/disclosures/prebid/sharedId-optout.json": { - "timestamp": "2026-01-28T16:29:58.215Z", + "timestamp": "2026-03-02T14:44:46.316Z", "disclosures": [ { "identifier": "_pubcid_optout", diff --git a/metadata/modules/valuadBidAdapter.json b/metadata/modules/valuadBidAdapter.json index 9141c9dceda..4bcb9939955 100644 --- a/metadata/modules/valuadBidAdapter.json +++ b/metadata/modules/valuadBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.valuad.cloud/tcfdevice.json": { - "timestamp": "2026-01-28T16:30:42.229Z", + "timestamp": "2026-03-02T14:46:12.879Z", "disclosures": [] } }, diff --git a/metadata/modules/verbenBidAdapter.json b/metadata/modules/verbenBidAdapter.json new file mode 100644 index 00000000000..47a939ac97a --- /dev/null +++ b/metadata/modules/verbenBidAdapter.json @@ -0,0 +1,13 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": {}, + "components": [ + { + "componentType": "bidder", + "componentName": "verben", + "aliasOf": null, + "gvlid": null, + "disclosureURL": null + } + ] +} \ No newline at end of file diff --git a/metadata/modules/vidazooBidAdapter.json b/metadata/modules/vidazooBidAdapter.json index 8cfb42a6329..d2fee191de4 100644 --- a/metadata/modules/vidazooBidAdapter.json +++ b/metadata/modules/vidazooBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://vidazoo.com/gdpr-tcf/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:42.541Z", + "timestamp": "2026-03-02T14:46:13.094Z", "disclosures": [ { "identifier": "ck48wz12sqj7", diff --git a/metadata/modules/vidoomyBidAdapter.json b/metadata/modules/vidoomyBidAdapter.json index a15352d7209..4902540cf3f 100644 --- a/metadata/modules/vidoomyBidAdapter.json +++ b/metadata/modules/vidoomyBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://vidoomy.com/storageurl/devicestoragediscurl.json": { - "timestamp": "2026-01-28T16:30:42.610Z", + "timestamp": "2026-03-02T14:46:13.172Z", "disclosures": [] } }, diff --git a/metadata/modules/viouslyBidAdapter.json b/metadata/modules/viouslyBidAdapter.json index a9ba5b1ea69..c26728473ea 100644 --- a/metadata/modules/viouslyBidAdapter.json +++ b/metadata/modules/viouslyBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://bid.bricks-co.com/.well-known/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:42.721Z", + "timestamp": "2026-03-02T14:46:18.010Z", "disclosures": [ { "identifier": "fastCMP-addtlConsent", diff --git a/metadata/modules/visxBidAdapter.json b/metadata/modules/visxBidAdapter.json index 82a358c4252..de208d4fa21 100644 --- a/metadata/modules/visxBidAdapter.json +++ b/metadata/modules/visxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.yoc.com/visx/sellers/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:42.722Z", + "timestamp": "2026-03-02T14:46:18.011Z", "disclosures": [ { "identifier": "__vads", diff --git a/metadata/modules/vlybyBidAdapter.json b/metadata/modules/vlybyBidAdapter.json index f981ce6b7d8..a8a8be09eb6 100644 --- a/metadata/modules/vlybyBidAdapter.json +++ b/metadata/modules/vlybyBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.vlyby.com/conf/iab/gvl.json": { - "timestamp": "2026-01-28T16:30:42.933Z", + "timestamp": "2026-03-02T14:46:18.324Z", "disclosures": [] } }, diff --git a/metadata/modules/voxBidAdapter.json b/metadata/modules/voxBidAdapter.json index 534d5aa909f..71d1adacb65 100644 --- a/metadata/modules/voxBidAdapter.json +++ b/metadata/modules/voxBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://st.hybrid.ai/policy/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:43.350Z", + "timestamp": "2026-03-02T14:46:18.645Z", "disclosures": [] } }, diff --git a/metadata/modules/vrtcalBidAdapter.json b/metadata/modules/vrtcalBidAdapter.json index 98b4bf97df2..492cc4a9c29 100644 --- a/metadata/modules/vrtcalBidAdapter.json +++ b/metadata/modules/vrtcalBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://vrtcal.com/docs/gdpr-tcf-disclosures.json": { - "timestamp": "2026-01-28T16:30:43.350Z", + "timestamp": "2026-03-02T14:46:18.645Z", "disclosures": [] } }, diff --git a/metadata/modules/vuukleBidAdapter.json b/metadata/modules/vuukleBidAdapter.json index 82f8197b440..9ff99596a87 100644 --- a/metadata/modules/vuukleBidAdapter.json +++ b/metadata/modules/vuukleBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn.vuukle.com/data-privacy/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:43.368Z", + "timestamp": "2026-03-02T14:46:18.662Z", "disclosures": [ { "identifier": "vuukle_token", diff --git a/metadata/modules/weboramaRtdProvider.json b/metadata/modules/weboramaRtdProvider.json index 942e28d93a9..3d5c7c23945 100644 --- a/metadata/modules/weboramaRtdProvider.json +++ b/metadata/modules/weboramaRtdProvider.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://weborama.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:43.680Z", + "timestamp": "2026-03-02T14:46:18.964Z", "disclosures": [] } }, diff --git a/metadata/modules/welectBidAdapter.json b/metadata/modules/welectBidAdapter.json index bf17a459ac4..f6fe924992e 100644 --- a/metadata/modules/welectBidAdapter.json +++ b/metadata/modules/welectBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://www.welect.de/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:43.935Z", + "timestamp": "2026-03-02T14:46:19.465Z", "disclosures": [] } }, diff --git a/metadata/modules/yahooAdsBidAdapter.json b/metadata/modules/yahooAdsBidAdapter.json index 70f246f825d..214ac134bbe 100644 --- a/metadata/modules/yahooAdsBidAdapter.json +++ b/metadata/modules/yahooAdsBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://meta.legal.yahoo.com/iab-tcf/v2/device-storage-disclosure.json": { - "timestamp": "2026-01-28T16:30:44.341Z", + "timestamp": "2026-03-02T14:46:19.840Z", "disclosures": [ { "identifier": "vmcid", diff --git a/metadata/modules/yaleoBidAdapter.json b/metadata/modules/yaleoBidAdapter.json new file mode 100644 index 00000000000..b78e431d9c6 --- /dev/null +++ b/metadata/modules/yaleoBidAdapter.json @@ -0,0 +1,18 @@ +{ + "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", + "disclosures": { + "https://audienzz.com/device_storage_disclosure_vendor_783.json": { + "timestamp": "2026-03-02T14:46:19.841Z", + "disclosures": [] + } + }, + "components": [ + { + "componentType": "bidder", + "componentName": "yaleo", + "aliasOf": null, + "gvlid": 783, + "disclosureURL": "https://audienzz.com/device_storage_disclosure_vendor_783.json" + } + ] +} \ No newline at end of file diff --git a/metadata/modules/yieldlabBidAdapter.json b/metadata/modules/yieldlabBidAdapter.json index 546bfd19305..52962311fa8 100644 --- a/metadata/modules/yieldlabBidAdapter.json +++ b/metadata/modules/yieldlabBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://ad.yieldlab.net/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:44.342Z", + "timestamp": "2026-03-02T14:46:19.841Z", "disclosures": [] } }, diff --git a/metadata/modules/yieldloveBidAdapter.json b/metadata/modules/yieldloveBidAdapter.json index d311669356b..e0f3e660ae6 100644 --- a/metadata/modules/yieldloveBidAdapter.json +++ b/metadata/modules/yieldloveBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://cdn-a.yieldlove.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:44.472Z", + "timestamp": "2026-03-02T14:46:20.279Z", "disclosures": [ { "identifier": "session_id", diff --git a/metadata/modules/yieldmoBidAdapter.json b/metadata/modules/yieldmoBidAdapter.json index 0f2052e3cf4..ce311353d52 100644 --- a/metadata/modules/yieldmoBidAdapter.json +++ b/metadata/modules/yieldmoBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://devicestoragedisclosureurl.yieldmo.com/deviceStorage.json": { - "timestamp": "2026-01-28T16:30:44.497Z", + "timestamp": "2026-03-02T14:46:20.302Z", "disclosures": [] } }, diff --git a/metadata/modules/zeotapIdPlusIdSystem.json b/metadata/modules/zeotapIdPlusIdSystem.json index 6105ace2968..3f9dfb1b424 100644 --- a/metadata/modules/zeotapIdPlusIdSystem.json +++ b/metadata/modules/zeotapIdPlusIdSystem.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://spl.zeotap.com/assets/iab-disclosure.json": { - "timestamp": "2026-01-28T16:30:44.585Z", + "timestamp": "2026-03-02T14:46:20.390Z", "disclosures": [] } }, diff --git a/metadata/modules/zeta_globalBidAdapter.json b/metadata/modules/zeta_globalBidAdapter.json index 274bd4c75f0..f215e1e0463 100644 --- a/metadata/modules/zeta_globalBidAdapter.json +++ b/metadata/modules/zeta_globalBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://zetaglobal.com/ZetaDeviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:44.708Z", + "timestamp": "2026-03-02T14:46:20.525Z", "disclosures": [] } }, diff --git a/metadata/modules/zeta_global_sspBidAdapter.json b/metadata/modules/zeta_global_sspBidAdapter.json index e51e6fd80ec..517fd76fb4c 100644 --- a/metadata/modules/zeta_global_sspBidAdapter.json +++ b/metadata/modules/zeta_global_sspBidAdapter.json @@ -2,7 +2,7 @@ "NOTICE": "do not edit - this file is autogenerated by `gulp update-metadata`", "disclosures": { "https://zetaglobal.com/ZetaDeviceStorageDisclosure.json": { - "timestamp": "2026-01-28T16:30:44.784Z", + "timestamp": "2026-03-02T14:46:20.656Z", "disclosures": [] } }, diff --git a/modules/.submodules.json b/modules/.submodules.json index c6274fdcbfd..c6b0db32e78 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -32,6 +32,7 @@ "kinessoIdSystem", "liveIntentIdSystem", "lmpIdSystem", + "locIdSystem", "lockrAIMIdSystem", "lotamePanoramaIdSystem", "merkleIdSystem", @@ -117,6 +118,7 @@ "optimeraRtdProvider", "overtoneRtdProvider", "oxxionRtdProvider", + "panxoRtdProvider", "permutiveRtdProvider", "pubmaticRtdProvider", "pubxaiRtdProvider", @@ -147,4 +149,4 @@ "topLevelPaapi" ] } -} \ No newline at end of file +} diff --git a/modules/51DegreesRtdProvider.js b/modules/51DegreesRtdProvider.js index f5c76357ffc..5c07511a280 100644 --- a/modules/51DegreesRtdProvider.js +++ b/modules/51DegreesRtdProvider.js @@ -8,6 +8,7 @@ import { mergeDeep, prefixLog, } from '../src/utils.js'; +import {getDevicePixelRatio} from '../libraries/devicePixelRatio/devicePixelRatio.js'; const MODULE_NAME = '51Degrees'; export const LOG_PREFIX = `[${MODULE_NAME} RTD Submodule]:`; @@ -126,7 +127,7 @@ export const get51DegreesJSURL = (pathData, win) => { ); deepSetNotEmptyValue(qs, '51D_ScreenPixelsHeight', _window?.screen?.height); deepSetNotEmptyValue(qs, '51D_ScreenPixelsWidth', _window?.screen?.width); - deepSetNotEmptyValue(qs, '51D_PixelRatio', _window?.devicePixelRatio); + deepSetNotEmptyValue(qs, '51D_PixelRatio', getDevicePixelRatio(_window)); const _qs = formatQS(qs); const _qsString = _qs ? `${queryPrefix}${_qs}` : ''; diff --git a/modules/aceexBidAdapter.js b/modules/aceexBidAdapter.js new file mode 100644 index 00000000000..71c3ac070dc --- /dev/null +++ b/modules/aceexBidAdapter.js @@ -0,0 +1,97 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { + buildRequestsBase, + buildPlacementProcessingFunction, +} from '../libraries/teqblazeUtils/bidderUtils.js'; + +import { deepAccess } from '../src/utils.js'; + +const BIDDER_CODE = 'aceex'; +const GVLID = 1387; +const AD_REQUEST_URL = 'https://bl-us.aceex.io/?secret_key=prebidjs'; + +const addCustomFieldsToPlacement = (bid, bidderRequest, placement) => { + placement.trafficType = placement.adFormat; + placement.publisherId = bid.params.publisherId; + placement.internalKey = bid.params.internalKey; +}; + +const placementProcessingFunction = buildPlacementProcessingFunction({ addCustomFieldsToPlacement }); + +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid: (bid) => { + return !!(bid.bidId && bid.params?.publisherId && bid.params?.trafficType); + }, + + buildRequests: (validBidRequests = [], bidderRequest) => { + const base = buildRequestsBase({ adUrl: AD_REQUEST_URL, validBidRequests, bidderRequest, placementProcessingFunction }); + + base.data.cat = deepAccess(bidderRequest, 'ortb2.cat'); + base.data.keywords = deepAccess(bidderRequest, 'ortb2.keywords'); + base.data.badv = deepAccess(bidderRequest, 'ortb2.badv'); + base.data.wseat = deepAccess(bidderRequest, 'ortb2.wseat'); + base.data.bseat = deepAccess(bidderRequest, 'ortb2.bseat'); + + return base; + }, + + interpretResponse: (serverResponse, bidRequest) => { + if (!serverResponse || !serverResponse.body || !Array.isArray(serverResponse.body.seatbid)) return []; + + const repackedBids = []; + + serverResponse.body.seatbid.forEach(seatbidItem => { + seatbidItem.bid.forEach((bid) => { + const originalPlacement = bidRequest.data.placements?.find(pl => pl.bidId === bid.id); + + const repackedBid = { + cpm: bid.price, + creativeId: bid.crid, + currency: 'USD', + dealId: bid.dealid, + height: bid.h, + width: bid.w, + mediaType: originalPlacement.adFormat, + netRevenue: true, + requestId: bid.id, + ttl: 1200, + meta: { + advertiserDomains: bid.adomain + }, + }; + + switch (originalPlacement.adFormat) { + case 'video': + repackedBid.vastXml = bid.adm; + break; + + case 'banner': + repackedBid.ad = bid.adm; + break; + + case 'native': + const nativeResponse = JSON.parse(bid.adm).native; + + const { assets, imptrackers, link } = nativeResponse; + repackedBid.native = { + ortb: { assets, imptrackers, link }, + }; + break; + + default: break; + }; + + repackedBids.push(repackedBid); + }) + }); + + return repackedBids; + }, +}; + +registerBidder(spec); diff --git a/modules/aceexBidAdapter.md b/modules/aceexBidAdapter.md new file mode 100644 index 00000000000..6efa00acd47 --- /dev/null +++ b/modules/aceexBidAdapter.md @@ -0,0 +1,67 @@ +# Overview + +``` +Module Name: Aceex Bidder Adapter +Module Type: Bidder Adapter +Maintainer: tech@aceex.io +``` + +# Description + +Module that connects Prebid.JS publishers to Aceex ad-exchange + +# Parameters + +| Name | Scope | Description | Example | +| :------------ | :------- | :------------------------ | :------------------- | +| `publisherId` | required | Publisher ID on platform | 219 | +| `trafficType` | required | Configures the mediaType that should be used. Values can be banner, native or video | "banner" | +| `internalKey` | required | Publisher hash on platform | "j1opp02hsma8119" | +| `bidfloor` | required | Bidfloor | 0.1 | + +# Test Parameters +``` + var adUnits = [ + // Will return static test banner + { + code: 'placementId_0', + mediaTypes: { + banner: { + sizes: [[300, 250]], + } + }, + bids: [ + { + bidder: 'aceex', + params: { + publisherId: 219, + internalKey: 'j1opp02hsma8119', + trafficType: 'banner', + bidfloor: 0.2 + } + } + ] + }, + // Will return test vast video + { + code: 'placementId_0', + mediaTypes: { + video: { + playerSize: [640, 480], + context: 'instream' + } + }, + bids: [ + { + bidder: 'aceex', + params: { + publisherId: 219, + internalKey: 'j1opp02hsma8119', + trafficType: 'video', + bidfloor: 1.1 + } + } + ] + } + ]; +``` diff --git a/modules/adclusterBidAdapter.js b/modules/adclusterBidAdapter.js new file mode 100644 index 00000000000..b8b1f248582 --- /dev/null +++ b/modules/adclusterBidAdapter.js @@ -0,0 +1,183 @@ +import { registerBidder } from "../src/adapters/bidderFactory.js"; +import { BANNER, VIDEO } from "../src/mediaTypes.js"; + +const BIDDER_CODE = "adcluster"; +const ENDPOINT = "https://core.adcluster.com.tr/bid"; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO], + + isBidRequestValid(bid) { + return !!bid?.params?.unitId; + }, + + buildRequests(validBidRequests, bidderRequest) { + const _auctionId = bidderRequest.auctionId || ""; + const payload = { + bidderCode: bidderRequest.bidderCode, + auctionId: _auctionId, + bidderRequestId: bidderRequest.bidderRequestId, + bids: validBidRequests.map((b) => buildImp(b)), + auctionStart: bidderRequest.auctionStart, + timeout: bidderRequest.timeout, + start: bidderRequest.start, + regs: { ext: {} }, + user: { ext: {} }, + source: { ext: {} }, + }; + + // privacy + if (bidderRequest?.gdprConsent) { + payload.regs = payload.regs || { ext: {} }; + payload.regs.ext = payload.regs.ext || {}; + payload.regs.ext.gdpr = bidderRequest.gdprConsent.gdprApplies ? 1 : 0; + payload.user.ext.consent = bidderRequest.gdprConsent.consentString || ""; + } + if (bidderRequest?.uspConsent) { + payload.regs = payload.regs || { ext: {} }; + payload.regs.ext.us_privacy = bidderRequest.uspConsent; + } + if (bidderRequest?.ortb2?.regs?.gpp) { + payload.regs = payload.regs || { ext: {} }; + payload.regs.ext.gpp = bidderRequest.ortb2.regs.gpp; + payload.regs.ext.gppSid = bidderRequest.ortb2.regs.gpp_sid; + } + if (validBidRequests[0]?.userIdAsEids) { + payload.user.ext.eids = validBidRequests[0].userIdAsEids; + } + if (validBidRequests[0]?.ortb2?.source?.ext?.schain) { + payload.source.ext.schain = validBidRequests[0].ortb2.source.ext.schain; + } + + return { + method: "POST", + url: ENDPOINT, + data: payload, + options: { contentType: "text/plain" }, + }; + }, + + interpretResponse(serverResponse) { + const body = serverResponse?.body; + if (!body || !Array.isArray(body)) return []; + const bids = []; + + body.forEach((b) => { + const mediaType = detectMediaType(b); + const bid = { + requestId: b.requestId, + cpm: b.cpm, + currency: b.currency, + width: b.width, + height: b.height, + creativeId: b.creativeId, + ttl: b.ttl, + netRevenue: b.netRevenue, + meta: { + advertiserDomains: b.meta?.advertiserDomains || [], + }, + mediaType, + }; + + if (mediaType === BANNER) { + bid.ad = b.ad; + } + if (mediaType === VIDEO) { + bid.vastUrl = b.ad; + } + bids.push(bid); + }); + + return bids; + }, +}; + +/* ---------- helpers ---------- */ + +function buildImp(bid) { + const _transactionId = bid.transactionId || ""; + const _adUnitId = bid.adUnitId || ""; + const _auctionId = bid.auctionId || ""; + const imp = { + params: { + unitId: bid.params.unitId, + }, + bidId: bid.bidId, + bidderRequestId: bid.bidderRequestId, + transactionId: _transactionId, + adUnitId: _adUnitId, + auctionId: _auctionId, + ext: { + floors: getFloorsAny(bid), + }, + }; + + if (bid.params && bid.params.previewMediaId) { + imp.params.previewMediaId = bid.params.previewMediaId; + } + + const mt = bid.mediaTypes || {}; + + // BANNER + if (mt.banner?.sizes?.length) { + imp.width = mt.banner.sizes[0] && mt.banner.sizes[0][0]; + imp.height = mt.banner.sizes[0] && mt.banner.sizes[0][1]; + } + if (mt.video) { + const v = mt.video; + const playerSize = toSizeArray(v.playerSize); + const [vw, vh] = playerSize?.[0] || []; + imp.width = vw; + imp.height = vh; + imp.video = { + minduration: v.minduration || 1, + maxduration: v.maxduration || 120, + ext: { + context: v.context || "instream", + floor: getFloors(bid, "video", playerSize?.[0]), + }, + }; + } + + return imp; +} + +function toSizeArray(s) { + if (!s) return null; + // playerSize can be [w,h] or [[w,h], [w2,h2]] + return Array.isArray(s[0]) ? s : [s]; +} + +function getFloors(bid, mediaType = "banner", size) { + try { + if (!bid.getFloor) return null; + // size can be [w,h] or '*' + const sz = Array.isArray(size) ? size : "*"; + const res = bid.getFloor({ mediaType, size: sz }); + return res && typeof res.floor === "number" ? res.floor : null; + } catch { + return null; + } +} + +function detectMediaType(bid) { + if (bid.mediaType === "video") return VIDEO; + else return BANNER; +} + +function getFloorsAny(bid) { + // Try to collect floors per type + const out = {}; + const mt = bid.mediaTypes || {}; + if (mt.banner) { + out.banner = getFloors(bid, "banner", "*"); + } + if (mt.video) { + const ps = toSizeArray(mt.video.playerSize); + out.video = getFloors(bid, "video", (ps && ps[0]) || "*"); + } + return out; +} + +registerBidder(spec); diff --git a/modules/adclusterBidAdapter.md b/modules/adclusterBidAdapter.md new file mode 100644 index 00000000000..59300e2c857 --- /dev/null +++ b/modules/adclusterBidAdapter.md @@ -0,0 +1,46 @@ +# Overview + +**Module Name**: Adcluster Bidder Adapter +**Module Type**: Bidder Adapter +**Maintainer**: dev@adcluster.com.tr + +# Description + +Prebid.js bidder adapter module for connecting to Adcluster. + +# Test Parameters + +``` +var adUnits = [ + { + code: 'adcluster-banner', + mediaTypes: { + banner: { + sizes: [[300, 250]], + } + }, + bids: [{ + bidder: 'adcluster', + params: { + unitId: '42d1f525-5792-47a6-846d-1825e53c97d6', + previewMediaId: "b4dbc48c-0b90-4628-bc55-f46322b89b63", + }, + }] + }, + { + code: 'adcluster-video', + mediaTypes: { + video: { + playerSize: [[640, 480]], + } + }, + bids: [{ + bidder: 'adcluster', + params: { + unitId: "37dd91b2-049d-4027-94b9-d63760fc10d3", + previewMediaId: "133b7dc9-bb6e-4ab2-8f95-b796cf19f27e", + }, + }] + } +]; +``` diff --git a/modules/admaticBidAdapter.js b/modules/admaticBidAdapter.js index 4e75f7e583f..d83e7ed5ae9 100644 --- a/modules/admaticBidAdapter.js +++ b/modules/admaticBidAdapter.js @@ -28,6 +28,7 @@ export const spec = { { code: 'monetixads', gvlid: 1281 }, { code: 'netaddiction', gvlid: 1281 }, { code: 'adt', gvlid: 779 }, + { code: 'adrubi', gvlid: 779 }, { code: 'yobee', gvlid: 1281 } ], supportedMediaTypes: [BANNER, VIDEO, NATIVE], diff --git a/modules/adnimationBidAdapter.js b/modules/adnimationBidAdapter.js new file mode 100644 index 00000000000..21ac9090f38 --- /dev/null +++ b/modules/adnimationBidAdapter.js @@ -0,0 +1,47 @@ +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER, VIDEO} from '../src/mediaTypes.js'; +import {getStorageManager} from '../src/storageManager.js'; +import { + isBidRequestValid, + onBidWon, + createUserSyncGetter, + createBuildRequestsFn, + createInterpretResponseFn +} from '../libraries/vidazooUtils/bidderUtils.js'; + +const DEFAULT_SUB_DOMAIN = 'exchange'; +const BIDDER_CODE = 'adnimation'; +const BIDDER_VERSION = '1.0.0'; +export const storage = getStorageManager({bidderCode: BIDDER_CODE}); + +export function createDomain(subDomain = DEFAULT_SUB_DOMAIN) { + return `https://${subDomain}.adnimation.com`; +} + +function createUniqueRequestData(hashUrl, bid) { + const {auctionId, transactionId} = bid; + return { + auctionId, + transactionId + }; +} + +const buildRequests = createBuildRequestsFn(createDomain, createUniqueRequestData, storage, BIDDER_CODE, BIDDER_VERSION, false); +const interpretResponse = createInterpretResponseFn(BIDDER_CODE, false); +const getUserSyncs = createUserSyncGetter({ + iframeSyncUrl: 'https://sync.adnimation.com/api/sync/iframe', + imageSyncUrl: 'https://sync.adnimation.com/api/sync/image' +}); + +export const spec = { + code: BIDDER_CODE, + version: BIDDER_VERSION, + supportedMediaTypes: [BANNER, VIDEO], + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, + onBidWon, +}; + +registerBidder(spec); diff --git a/modules/adnimationBidAdapter.md b/modules/adnimationBidAdapter.md new file mode 100644 index 00000000000..df62967bd8d --- /dev/null +++ b/modules/adnimationBidAdapter.md @@ -0,0 +1,36 @@ +# Overview + +**Module Name:** Adnimation Bidder Adapter + +**Module Type:** Bidder Adapter + +**Maintainer:** prebid@adnimation.com + +# Description + +Module that connects to Adnimation's demand sources. + +# Test Parameters + +```js +var adUnits = [ + { + code: 'test-ad', + sizes: [[300, 250]], + bids: [ + { + bidder: 'adnimation', + params: { + cId: '562524b21b1c1f08117667f9', + pId: '59ac17c192832d0016683fe3', + bidFloor: 0.0001, + ext: { + param1: 'loremipsum', + param2: 'dolorsitamet' + } + } + } + ] + } +]; +``` diff --git a/modules/adtrueBidAdapter.js b/modules/adtrueBidAdapter.js index 2c4278b9fb8..d8759bcbda6 100644 --- a/modules/adtrueBidAdapter.js +++ b/modules/adtrueBidAdapter.js @@ -400,7 +400,7 @@ function _createImpressionObject(bid, conf) { } } else { // mediaTypes is not present, so this is a banner only impression - // this part of code is required for older testcases with no 'mediaTypes' to run succesfully. + // this part of code is required for older testcases with no 'mediaTypes' to run successfully. bannerObj = { pos: 0, w: bid.params.width, diff --git a/modules/apesterBidAdapter.js b/modules/apesterBidAdapter.js new file mode 100644 index 00000000000..589b1b5210f --- /dev/null +++ b/modules/apesterBidAdapter.js @@ -0,0 +1,49 @@ +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER, VIDEO} from '../src/mediaTypes.js'; +import {getStorageManager} from '../src/storageManager.js'; +import { + isBidRequestValid, + onBidWon, + createUserSyncGetter, + createBuildRequestsFn, + createInterpretResponseFn +} from '../libraries/vidazooUtils/bidderUtils.js'; + +const DEFAULT_SUB_DOMAIN = 'bidder'; +const BIDDER_CODE = 'apester'; +const BIDDER_VERSION = '1.0.0'; +const GVLID = 354; +export const storage = getStorageManager({bidderCode: BIDDER_CODE}); + +export function createDomain(subDomain = DEFAULT_SUB_DOMAIN) { + return `https://${subDomain}.apester.com`; +} + +function createUniqueRequestData(hashUrl, bid) { + const {auctionId, transactionId} = bid; + return { + auctionId, + transactionId + }; +} + +const buildRequests = createBuildRequestsFn(createDomain, createUniqueRequestData, storage, BIDDER_CODE, BIDDER_VERSION, false); +const interpretResponse = createInterpretResponseFn(BIDDER_CODE, false); +const getUserSyncs = createUserSyncGetter({ + iframeSyncUrl: 'https://sync.apester.com/api/sync/iframe', + imageSyncUrl: 'https://sync.apester.com/api/sync/image' +}); + +export const spec = { + code: BIDDER_CODE, + version: BIDDER_VERSION, + supportedMediaTypes: [BANNER, VIDEO], + gvlid: GVLID, + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, + onBidWon, +}; + +registerBidder(spec); diff --git a/modules/apesterBidAdapter.md b/modules/apesterBidAdapter.md new file mode 100644 index 00000000000..c43707d8a28 --- /dev/null +++ b/modules/apesterBidAdapter.md @@ -0,0 +1,36 @@ +# Overview + +**Module Name:** Apester Bidder Adapter + +**Module Type:** Bidder Adapter + +**Maintainer:** roni.katz@apester.com + +# Description + +Module that connects to Apester's demand sources. + +# Test Parameters + +```js +var adUnits = [ + { + code: 'test-ad', + sizes: [[300, 250]], + bids: [ + { + bidder: 'apester', + params: { + cId: '562524b21b1c1f08117667f9', + pId: '59ac17c192832d0016683fe3', + bidFloor: 0.0001, + ext: { + param1: 'loremipsum', + param2: 'dolorsitamet' + } + } + } + ] + } +]; +``` diff --git a/modules/beopBidAdapter.js b/modules/beopBidAdapter.js index 16a67a42432..13089b5be72 100644 --- a/modules/beopBidAdapter.js +++ b/modules/beopBidAdapter.js @@ -192,6 +192,40 @@ function buildTrackingParams(data, info, value) { }; } +function normalizeAdUnitCode(adUnitCode) { + if (!adUnitCode || typeof adUnitCode !== 'string') return undefined; + + // Only normalize GPT auto-generated adUnitCodes (div-gpt-ad-*) + // For non-GPT codes, return original string unchanged to preserve case + if (!/^div-gpt-ad[-_]/i.test(adUnitCode)) { + return adUnitCode; + } + + // GPT handling: strip prefix and random suffix + let slot = adUnitCode; + slot = slot.replace(/^div-gpt-ad[-_]?/i, ''); + + /** + * Remove only long numeric suffixes (likely auto-generated IDs). + * Preserve short numeric suffixes as they may be meaningful slot indices. + * + * Examples removed: + * div-gpt-ad-article_top_123456 → article_top + * div-gpt-ad-sidebar-1678459238475 → sidebar + * + * Examples preserved: + * div-gpt-ad-topbanner-1 → topbanner-1 + * div-gpt-ad-topbanner-2 → topbanner-2 + */ + slot = slot.replace(/([_-])\d{6,}$/, ''); + + slot = slot.toLowerCase().trim(); + + if (slot.length < 3) return undefined; + + return slot; +} + function beOpRequestSlotsMaker(bid, bidderRequest) { const bannerSizes = deepAccess(bid, 'mediaTypes.banner.sizes'); const publisherCurrency = getCurrencyFromBidderRequest(bidderRequest) || getValue(bid.params, 'currency') || 'EUR'; @@ -211,7 +245,11 @@ function beOpRequestSlotsMaker(bid, bidderRequest) { nptnid: getValue(bid.params, 'networkPartnerId'), bid: getBidIdParameter('bidId', bid), brid: getBidIdParameter('bidderRequestId', bid), - name: getBidIdParameter('adUnitCode', bid), + name: deepAccess(bid, 'ortb2Imp.ext.gpid') || + deepAccess(bid, 'ortb2Imp.ext.data.adslot') || + deepAccess(bid, 'ortb2Imp.ext.data.adserver.adslot') || + bid.ortb2Imp?.tagid || + normalizeAdUnitCode(bid.adUnitCode), tid: bid.ortb2Imp?.ext?.tid || '', brc: getBidIdParameter('bidRequestsCount', bid), bdrc: getBidIdParameter('bidderRequestCount', bid), diff --git a/modules/bidResponseFilter/index.js b/modules/bidResponseFilter/index.js index 64026958bc6..dc131e0bca1 100644 --- a/modules/bidResponseFilter/index.js +++ b/modules/bidResponseFilter/index.js @@ -29,7 +29,7 @@ export function reset() { } export function addBidResponseHook(next, adUnitCode, bid, reject, index = auctionManager.index) { - const {bcat = [], badv = []} = index.getOrtb2(bid) || {}; + const {bcat = [], badv = [], cattax = 1} = index.getOrtb2(bid) || {}; const bidRequest = index.getBidRequest(bid); const battr = bidRequest?.ortb2Imp[bid.mediaType]?.battr || index.getAdUnit(bid)?.ortb2Imp[bid.mediaType]?.battr || []; @@ -43,11 +43,15 @@ export function addBidResponseHook(next, adUnitCode, bid, reject, index = auctio advertiserDomains = [], attr: metaAttr, mediaType: metaMediaType, + cattax: metaCattax = 1, } = bid.meta || {}; // checking if bid fulfills ortb2 fields rules - if ((catConfig.enforce && bcat.some(category => [primaryCatId, ...secondaryCatIds].includes(category))) || - (catConfig.blockUnknown && !primaryCatId)) { + const normalizedMetaCattax = Number(metaCattax); + const normalizedRequestCattax = Number(cattax); + const isCattaxMatch = normalizedMetaCattax === normalizedRequestCattax; + if ((catConfig.enforce && isCattaxMatch && bcat.some(category => [primaryCatId, ...secondaryCatIds].includes(category))) || + (catConfig.blockUnknown && (!isCattaxMatch || !primaryCatId))) { reject(BID_CATEGORY_REJECTION_REASON); } else if ((advConfig.enforce && badv.some(domain => advertiserDomains.includes(domain))) || (advConfig.blockUnknown && !advertiserDomains.length)) { diff --git a/modules/bidmaticBidAdapter.js b/modules/bidmaticBidAdapter.js index 4265b48428f..daa379421b6 100644 --- a/modules/bidmaticBidAdapter.js +++ b/modules/bidmaticBidAdapter.js @@ -4,7 +4,6 @@ import { cleanObj, deepAccess, flatten, - getWinDimensions, isArray, isNumber, logWarn, @@ -13,7 +12,7 @@ import { import { config } from '../src/config.js'; import { BANNER, VIDEO } from '../src/mediaTypes.js'; import { chunk } from '../libraries/chunk/chunk.js'; -import { getBoundingClientRect } from '../libraries/boundingClientRect/boundingClientRect.js'; +import {getPlacementPositionUtils} from "../libraries/placementPositionInfo/placementPositionInfo.js"; /** * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid @@ -21,10 +20,13 @@ import { getBoundingClientRect } from '../libraries/boundingClientRect/boundingC * @typedef {import('../src/adapters/bidderFactory.js').BidderSpec} BidderSpec */ +const ADAPTER_VERSION = 'v1.0.0'; const URL = 'https://adapter.bidmatic.io/bdm/auction'; const BIDDER_CODE = 'bidmatic'; const SYNCS_DONE = new Set(); +const { getPlacementEnv, getPlacementInfo } = getPlacementPositionUtils() + /** @type {BidderSpec} */ export const spec = { code: BIDDER_CODE, @@ -144,6 +146,7 @@ export function parseResponseBody(serverResponse, adapterRequest) { export function remapBidRequest(bidRequests, adapterRequest) { const bidRequestBody = { + AdapterVersion: ADAPTER_VERSION, Domain: deepAccess(adapterRequest, 'refererInfo.page'), ...getPlacementEnv() }; @@ -237,50 +240,4 @@ export function createBid(bidResponse) { }; } -function getPlacementInfo(bidReq) { - const placementElementNode = document.getElementById(bidReq.adUnitCode); - try { - return cleanObj({ - AuctionsCount: bidReq.auctionsCount, - DistanceToView: getViewableDistance(placementElementNode) - }); - } catch (e) { - logWarn('Error while getting placement info', e); - return {}; - } -} - -/** - * @param element - */ -function getViewableDistance(element) { - if (!element) return 0; - const elementRect = getBoundingClientRect(element); - - if (!elementRect) { - return 0; - } - - const elementMiddle = elementRect.top + (elementRect.height / 2); - const viewportHeight = getWinDimensions().innerHeight - if (elementMiddle > window.scrollY + viewportHeight) { - // element is below the viewport - return Math.round(elementMiddle - (window.scrollY + viewportHeight)); - } - // element is above the viewport -> negative value - return Math.round(elementMiddle); -} - -function getPageHeight() { - return document.documentElement.scrollHeight || document.body.scrollHeight; -} - -function getPlacementEnv() { - return cleanObj({ - TimeFromNavigation: Math.floor(performance.now()), - TabActive: document.visibilityState === 'visible', - PageHeight: getPageHeight() - }) -} - registerBidder(spec); diff --git a/modules/chromeAiRtdProvider.js b/modules/chromeAiRtdProvider.js index 9fd2d4c6639..75f17b6312a 100644 --- a/modules/chromeAiRtdProvider.js +++ b/modules/chromeAiRtdProvider.js @@ -15,6 +15,7 @@ export const CONSTANTS = Object.freeze({ STORAGE_KEY: 'chromeAi_detected_data', // Single key for both language and keywords MIN_TEXT_LENGTH: 20, ACTIVATION_EVENTS: ['click', 'keydown', 'mousedown', 'touchend', 'pointerdown', 'pointerup'], + MAX_TEXT_LENGTH: 1000, // Limit to prevent QuotaExceededError with Chrome AI APIs DEFAULT_CONFIG: { languageDetector: { enabled: true, @@ -94,6 +95,11 @@ export const getPageText = () => { logMessage(`${CONSTANTS.LOG_PRE_FIX} Not enough text content (length: ${text?.length || 0}) for processing.`); return null; } + // Limit text length to prevent QuotaExceededError with Chrome AI APIs + if (text.length > CONSTANTS.MAX_TEXT_LENGTH) { + logMessage(`${CONSTANTS.LOG_PRE_FIX} Truncating text from ${text.length} to ${CONSTANTS.MAX_TEXT_LENGTH} chars.`); + return text.substring(0, CONSTANTS.MAX_TEXT_LENGTH); + } return text; }; diff --git a/modules/conceptxBidAdapter.js b/modules/conceptxBidAdapter.js index 67ebd88e4e4..eef57e0aaa8 100644 --- a/modules/conceptxBidAdapter.js +++ b/modules/conceptxBidAdapter.js @@ -1,81 +1,258 @@ import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER } from '../src/mediaTypes.js'; -// import { logError, logInfo, logWarn, parseUrl } from '../src/utils.js'; const BIDDER_CODE = 'conceptx'; -const ENDPOINT_URL = 'https://conceptx.cncpt-central.com/openrtb'; -// const LOG_PREFIX = 'ConceptX: '; +const ENDPOINT_URL = 'https://cxba-s2s.cncpt.dk/openrtb2/auction'; const GVLID = 1340; export const spec = { code: BIDDER_CODE, supportedMediaTypes: [BANNER], gvlid: GVLID, + isBidRequestValid: function (bid) { - return !!(bid.bidId && bid.params.site && bid.params.adunit); + return !!(bid.bidId && bid.params && bid.params.adunit); }, buildRequests: function (validBidRequests, bidderRequest) { - // logWarn(LOG_PREFIX + 'all native assets containing URL should be sent as placeholders with sendId(icon, image, clickUrl, displayUrl, privacyLink, privacyIcon)'); const requests = []; - let requestUrl = `${ENDPOINT_URL}` - if (bidderRequest && bidderRequest.gdprConsent && bidderRequest.gdprConsent.gdprApplies) { - requestUrl += '?gdpr_applies=' + bidderRequest.gdprConsent.gdprApplies; - requestUrl += '&consentString=' + bidderRequest.gdprConsent.consentString; - } - for (var i = 0; i < validBidRequests.length; i++) { - const requestParent = { adUnits: [], meta: {} }; - const bid = validBidRequests[i] - const { adUnitCode, auctionId, bidId, bidder, bidderRequestId, ortb2 } = bid - requestParent.meta = { adUnitCode, auctionId, bidId, bidder, bidderRequestId, ortb2 } - - const { site, adunit } = bid.params - const adUnit = { site, adunit, targetId: bid.bidId } - if (bid.mediaTypes && bid.mediaTypes.banner && bid.mediaTypes.banner.sizes) adUnit.dimensions = bid.mediaTypes.banner.sizes - requestParent.adUnits.push(adUnit); + + for (let i = 0; i < validBidRequests.length; i++) { + const bid = validBidRequests[i]; + const { + adUnitCode, + auctionId, + bidId, + bidder, + bidderRequestId, + ortb2 = {}, + } = bid; + const params = bid.params || {}; + + // PBS URL + GDPR query params + let url = ENDPOINT_URL; + const query = []; + + // Only add GDPR params when gdprApplies is explicitly 0 or 1 + if (bidderRequest && bidderRequest.gdprConsent) { + let gdprApplies = bidderRequest.gdprConsent.gdprApplies; + if (typeof gdprApplies === 'boolean') { + gdprApplies = gdprApplies ? 1 : 0; + } + if (gdprApplies === 0 || gdprApplies === 1) { + query.push('gdpr_applies=' + gdprApplies); + if (bidderRequest.gdprConsent.consentString) { + query.push( + 'gdpr_consent=' + + encodeURIComponent(bidderRequest.gdprConsent.consentString) + ); + } + } + } + + if (query.length) { + url += '?' + query.join('&'); + } + + // site + const page = + params.site || (ortb2.site && ortb2.site.page) || ''; + const domain = + params.domain || (ortb2.site && ortb2.site.domain) || page; + + const site = { + id: domain || page || adUnitCode, + domain: domain || '', + page: page || '', + }; + + // banner sizes from mediaTypes.banner.sizes + const formats = []; + if ( + bid.mediaTypes && + bid.mediaTypes.banner && + bid.mediaTypes.banner.sizes + ) { + let sizes = bid.mediaTypes.banner.sizes; + if (sizes.length && typeof sizes[0] === 'number') { + sizes = [sizes]; + } + for (let j = 0; j < sizes.length; j++) { + const size = sizes[j]; + if (size && size.length === 2) { + formats.push({ w: size[0], h: size[1] }); + } + } + } + + const banner = formats.length ? { format: formats } : {}; + + // currency & timeout + let currency = 'DKK'; + if ( + bidderRequest && + bidderRequest.currency && + bidderRequest.currency.adServerCurrency + ) { + currency = bidderRequest.currency.adServerCurrency; + } + + const tmax = (bidderRequest && bidderRequest.timeout) || 500; + + // device + const ua = + typeof navigator !== 'undefined' && navigator.userAgent + ? navigator.userAgent + : 'Mozilla/5.0'; + const device = { ua }; + + // build OpenRTB request for PBS with stored requests + const ortbRequest = { + id: auctionId || bidId, + site, + device, + cur: [currency], + tmax, + imp: [ + { + id: bidId, + banner, + ext: { + prebid: { + storedrequest: { + id: params.adunit, + }, + }, + }, + }, + ], + ext: { + prebid: { + storedrequest: { + id: 'cx_global', + }, + custommeta: { + adUnitCode, + auctionId, + bidId, + bidder, + bidderRequestId, + }, + }, + }, + }; + + // GDPR in body + if (bidderRequest && bidderRequest.gdprConsent) { + let gdprAppliesBody = bidderRequest.gdprConsent.gdprApplies; + if (typeof gdprAppliesBody === 'boolean') { + gdprAppliesBody = gdprAppliesBody ? 1 : 0; + } + + if (!ortbRequest.user) ortbRequest.user = {}; + if (!ortbRequest.user.ext) ortbRequest.user.ext = {}; + + if (bidderRequest.gdprConsent.consentString) { + ortbRequest.user.ext.consent = + bidderRequest.gdprConsent.consentString; + } + + if (!ortbRequest.regs) ortbRequest.regs = {}; + if (!ortbRequest.regs.ext) ortbRequest.regs.ext = {}; + + if (gdprAppliesBody === 0 || gdprAppliesBody === 1) { + ortbRequest.regs.ext.gdpr = gdprAppliesBody; + } + } + + // user IDs -> user.ext.eids + if (bid.userIdAsEids && bid.userIdAsEids.length) { + if (!ortbRequest.user) ortbRequest.user = {}; + if (!ortbRequest.user.ext) ortbRequest.user.ext = {}; + ortbRequest.user.ext.eids = bid.userIdAsEids; + } + requests.push({ method: 'POST', - url: requestUrl, + url, options: { - withCredentials: false, + withCredentials: true, }, - data: JSON.stringify(requestParent), + data: JSON.stringify(ortbRequest), }); } return requests; }, - interpretResponse: function (serverResponse, bidRequest) { - const bidResponses = []; - const bidResponsesFromServer = serverResponse.body.bidResponses; - if (Array.isArray(bidResponsesFromServer) && bidResponsesFromServer.length === 0) { - return bidResponses - } - const firstBid = bidResponsesFromServer[0] - if (!firstBid) { - return bidResponses + interpretResponse: function (serverResponse, request) { + const body = + serverResponse && serverResponse.body ? serverResponse.body : {}; + + // PBS OpenRTB: seatbid[].bid[] + if ( + !body.seatbid || + !Array.isArray(body.seatbid) || + body.seatbid.length === 0 + ) { + return []; } - const firstSeat = firstBid.ads[0] - if (!firstSeat) { - return bidResponses + + const currency = body.cur || 'DKK'; + const bids = []; + + // recover referrer (site.page) from original request + let referrer = ''; + try { + if (request && request.data) { + const originalReq = + typeof request.data === 'string' + ? JSON.parse(request.data) + : request.data; + if (originalReq && originalReq.site && originalReq.site.page) { + referrer = originalReq.site.page; + } + } + } catch (_) {} + + for (let i = 0; i < body.seatbid.length; i++) { + const seatbid = body.seatbid[i]; + if (!seatbid.bid || !Array.isArray(seatbid.bid)) continue; + + for (let j = 0; j < seatbid.bid.length; j++) { + const b = seatbid.bid[j]; + + if (!b || typeof b.price !== 'number' || !b.adm) continue; + + bids.push({ + requestId: b.impid || b.id, + cpm: b.price, + width: b.w, + height: b.h, + creativeId: b.crid || b.id || '', + dealId: b.dealid || b.dealId || undefined, + currency, + netRevenue: true, + ttl: 300, + referrer, + ad: b.adm, + }); + } } - const bidResponse = { - requestId: firstSeat.requestId, - cpm: firstSeat.cpm, - width: firstSeat.width, - height: firstSeat.height, - creativeId: firstSeat.creativeId, - dealId: firstSeat.dealId, - currency: firstSeat.currency, - netRevenue: true, - ttl: firstSeat.ttl, - referrer: firstSeat.referrer, - ad: firstSeat.html - }; - bidResponses.push(bidResponse); - return bidResponses; + + return bids; + }, + + /** + * Cookie sync for conceptx is handled by the enrichment script's runPbsCookieSync, + * which calls https://cxba-s2s.cncpt.dk/cookie_sync with bidders. The PBS returns + * bidder_status with usersync URLs, and the script runs iframe/image syncs. + * The adapter does not return sync URLs here since those come from the cookie_sync + * endpoint, not the auction response. + */ + getUserSyncs: function () { + return []; }, +}; -} registerBidder(spec); diff --git a/modules/datablocksBidAdapter.js b/modules/datablocksBidAdapter.js index 7f5a4bedd62..25ded85bf05 100644 --- a/modules/datablocksBidAdapter.js +++ b/modules/datablocksBidAdapter.js @@ -7,7 +7,7 @@ import {getStorageManager} from '../src/storageManager.js'; import {ajax} from '../src/ajax.js'; import {convertOrtbRequestToProprietaryNative} from '../src/native.js'; import {getAdUnitSizes} from '../libraries/sizeUtils/sizeUtils.js'; -import {isWebdriverEnabled} from '../libraries/webdriver/webdriver.js'; +import {isWebdriverEnabled, isSeleniumDetected} from '../libraries/webdriver/webdriver.js'; import { buildNativeRequest, parseNativeResponse } from '../libraries/nativeAssetsUtils.js'; export const storage = getStorageManager({bidderCode: 'datablocks'}); @@ -432,37 +432,7 @@ export class BotClientTests { }, selenium: function () { - let response = false; - - if (window && document) { - const results = [ - 'webdriver' in window, - '_Selenium_IDE_Recorder' in window, - 'callSelenium' in window, - '_selenium' in window, - '__webdriver_script_fn' in document, - '__driver_evaluate' in document, - '__webdriver_evaluate' in document, - '__selenium_evaluate' in document, - '__fxdriver_evaluate' in document, - '__driver_unwrapped' in document, - '__webdriver_unwrapped' in document, - '__selenium_unwrapped' in document, - '__fxdriver_unwrapped' in document, - '__webdriver_script_func' in document, - document.documentElement.getAttribute('selenium') !== null, - document.documentElement.getAttribute('webdriver') !== null, - document.documentElement.getAttribute('driver') !== null - ]; - - results.forEach(result => { - if (result === true) { - response = true; - } - }) - } - - return response; + return isSeleniumDetected(window, document); }, } } diff --git a/modules/dpaiBidAdapter.js b/modules/dpaiBidAdapter.js new file mode 100644 index 00000000000..4cf359b196b --- /dev/null +++ b/modules/dpaiBidAdapter.js @@ -0,0 +1,19 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { isBidRequestValid, buildRequests, interpretResponse, getUserSyncs } from '../libraries/teqblazeUtils/bidderUtils.js'; + +const BIDDER_CODE = 'dpai'; +const AD_URL = 'https://ssp.drift-pixel.ai/pbjs'; +const SYNC_URL = 'https://sync.drift-pixel.ai'; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid: isBidRequestValid(), + buildRequests: buildRequests(AD_URL), + interpretResponse, + getUserSyncs: getUserSyncs(SYNC_URL) +}; + +registerBidder(spec); diff --git a/modules/dpaiBidAdapter.md b/modules/dpaiBidAdapter.md new file mode 100644 index 00000000000..4882abdacc4 --- /dev/null +++ b/modules/dpaiBidAdapter.md @@ -0,0 +1,79 @@ +# Overview + +``` +Module Name: DPAI Bidder Adapter +Module Type: DPAI Bidder Adapter +Maintainer: adops@driftpixel.ai +``` + +# Description + +Connects to DPAI exchange for bids. +DPAI bid adapter supports Banner, Video (instream and outstream) and Native. + +# Test Parameters +``` + var adUnits = [ + // Will return static test banner + { + code: 'adunit1', + mediaTypes: { + banner: { + sizes: [ [300, 250], [320, 50] ], + } + }, + bids: [ + { + bidder: 'dpai', + params: { + placementId: 'testBanner', + } + } + ] + }, + { + code: 'addunit2', + mediaTypes: { + video: { + playerSize: [ [640, 480] ], + context: 'instream', + minduration: 5, + maxduration: 60, + } + }, + bids: [ + { + bidder: 'dpai', + params: { + placementId: 'testVideo', + } + } + ] + }, + { + code: 'addunit3', + mediaTypes: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + }, + bids: [ + { + bidder: 'dpai', + params: { + placementId: 'testNative', + } + } + ] + } + ]; +``` diff --git a/modules/floxisBidAdapter.js b/modules/floxisBidAdapter.js new file mode 100644 index 00000000000..c677f054a46 --- /dev/null +++ b/modules/floxisBidAdapter.js @@ -0,0 +1,149 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { triggerPixel, mergeDeep } from '../src/utils.js'; + +const BIDDER_CODE = 'floxis'; +const DEFAULT_BID_TTL = 300; +const DEFAULT_CURRENCY = 'USD'; +const DEFAULT_NET_REVENUE = true; +const DEFAULT_REGION = 'us-e'; +const DEFAULT_PARTNER = BIDDER_CODE; +const PARTNER_REGION_WHITELIST = { + [DEFAULT_PARTNER]: [DEFAULT_REGION], +}; + +function isAllowedPartnerRegion(partner, region) { + return PARTNER_REGION_WHITELIST[partner]?.includes(region) || false; +} + +function getEndpointUrl(seat, region, partner) { + if (!isAllowedPartnerRegion(partner, region)) return null; + const host = partner === BIDDER_CODE + ? `${region}.floxis.tech` + : `${partner}-${region}.floxis.tech`; + return `https://${host}/pbjs?seat=${encodeURIComponent(seat)}`; +} + +function normalizeBidParams(params = {}) { + return { + seat: params.seat, + region: params.region ?? DEFAULT_REGION, + partner: params.partner ?? DEFAULT_PARTNER + }; +} + +const CONVERTER = ortbConverter({ + context: { + netRevenue: DEFAULT_NET_REVENUE, + ttl: DEFAULT_BID_TTL, + currency: DEFAULT_CURRENCY + }, + imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + imp.secure = bidRequest.ortb2Imp?.secure ?? 1; + + let floorInfo; + if (typeof bidRequest.getFloor === 'function') { + try { + floorInfo = bidRequest.getFloor({ + currency: DEFAULT_CURRENCY, + mediaType: '*', + size: '*' + }); + } catch (e) { } + } + const floor = floorInfo?.floor; + const floorCur = floorInfo?.currency || DEFAULT_CURRENCY; + if (typeof floor === 'number' && !isNaN(floor)) { + imp.bidfloor = floor; + imp.bidfloorcur = floorCur; + } + + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + const req = buildRequest(imps, bidderRequest, context); + mergeDeep(req, { + at: 1, + ext: { + prebid: { + adapter: BIDDER_CODE, + version: '$prebid.version$' + } + } + }); + return req; + } +}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid(bid) { + const params = bid?.params; + if (!params) return false; + const { seat, region, partner } = normalizeBidParams(params); + if (typeof seat !== 'string' || !seat.length) return false; + if (!isAllowedPartnerRegion(partner, region)) return false; + return true; + }, + + buildRequests(validBidRequests = [], bidderRequest = {}) { + if (!validBidRequests.length) return []; + const filteredBidRequests = validBidRequests.filter((bidRequest) => spec.isBidRequestValid(bidRequest)); + if (!filteredBidRequests.length) return []; + + const bidRequestsByParams = filteredBidRequests.reduce((groups, bidRequest) => { + const { seat, region, partner } = normalizeBidParams(bidRequest.params); + const key = `${seat}|${region}|${partner}`; + groups[key] = groups[key] || []; + groups[key].push({ + ...bidRequest, + params: { + ...bidRequest.params, + seat, + region, + partner + } + }); + return groups; + }, {}); + + return Object.values(bidRequestsByParams).map((groupedBidRequests) => { + const { seat, region, partner } = groupedBidRequests[0].params; + const url = getEndpointUrl(seat, region, partner); + if (!url) return null; + return { + method: 'POST', + url, + data: CONVERTER.toORTB({ bidRequests: groupedBidRequests, bidderRequest }), + options: { + withCredentials: true, + contentType: 'text/plain' + } + }; + }).filter(Boolean); + }, + + interpretResponse(response, request) { + if (!response?.body || !request?.data) return []; + return CONVERTER.fromORTB({ request: request.data, response: response.body })?.bids || []; + }, + + getUserSyncs() { + return []; + }, + + onBidWon(bid) { + if (bid.burl) { + triggerPixel(bid.burl); + } + if (bid.nurl) { + triggerPixel(bid.nurl); + } + } +}; + +registerBidder(spec); diff --git a/modules/floxisBidAdapter.md b/modules/floxisBidAdapter.md new file mode 100644 index 00000000000..f36db0c6577 --- /dev/null +++ b/modules/floxisBidAdapter.md @@ -0,0 +1,59 @@ +# Overview + +``` +Module Name: Floxis Bidder Adapter +Module Type: Bidder Adapter +Maintainer: admin@floxis.tech +``` + +# Description + +The Floxis Bid Adapter enables integration with the Floxis programmatic advertising platform via Prebid.js. It supports banner, video (instream and outstream), and native formats. + +**Key Features:** +- Banner, Video and Native ad support +- OpenRTB 2.x compliant +- Privacy regulation compliance (GDPR, USP, GPP, COPPA) +- Prebid.js Floors Module support + +## Supported Media Types +- Banner +- Video +- Native + +## Floors Module Support +The Floxis Bid Adapter supports the Prebid.js [Floors Module](https://docs.prebid.org/dev-docs/modules/floors.html). Floor values are automatically included in the OpenRTB request as `imp.bidfloor` and `imp.bidfloorcur`. + +## Privacy +Privacy fields (GDPR, USP, GPP, COPPA) are handled by Prebid.js core and automatically included in the OpenRTB request. + +## Example Usage +```javascript +pbjs.addAdUnits([ + { + code: 'adunit-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [{ + bidder: 'floxis', + params: { + seat: 'testSeat', + region: 'us-e', + partner: 'floxis' + } + }] + } +]); +``` + +# Configuration + +## Parameters + +| Name | Scope | Description | Example | Type | +| --- | --- | --- | --- | --- | +| `seat` | required | Seat identifier | `'testSeat'` | `string` | +| `region` | required | Region identifier for routing | `'us-e'` | `string` | +| `partner` | required | Partner identifier | `'floxis'` | `string` | + +## Testing +Unit tests are provided in `test/spec/modules/floxisBidAdapter_spec.js` and cover validation, request building, response interpretation, and bid-won notifications. diff --git a/modules/gamAdServerVideo.js b/modules/gamAdServerVideo.js index 9613af93e4e..b97f4ff598c 100644 --- a/modules/gamAdServerVideo.js +++ b/modules/gamAdServerVideo.js @@ -28,7 +28,7 @@ import { fetch } from '../src/ajax.js'; import XMLUtil from '../libraries/xmlUtils/xmlUtils.js'; import {getGlobalVarName} from '../src/buildOptions.js'; -import { uspDataHandler } from '../src/consentHandler.js'; +import { gppDataHandler, uspDataHandler } from '../src/consentHandler.js'; /** * @typedef {Object} DfpVideoParams * @@ -299,6 +299,7 @@ export async function getVastXml(options, localCacheMap = vastLocalCache) { const video = adUnit?.mediaTypes?.video; const sdkApis = (video?.api || []).join(','); const usPrivacy = uspDataHandler.getConsentData?.(); + const gpp = gppDataHandler.getConsentData?.(); // Adding parameters required by ima if (config.getConfig('cache.useLocal') && window.google?.ima) { vastUrl = new URL(vastUrl); @@ -310,6 +311,12 @@ export async function getVastXml(options, localCacheMap = vastLocalCache) { } if (usPrivacy) { vastUrl.searchParams.set('us_privacy', usPrivacy); + } else if (gpp) { + // Extract an usPrivacy string from the GPP string if possible + const uspFromGpp = retrieveUspInfoFromGpp(gpp); + if (uspFromGpp) { + vastUrl.searchParams.set('us_privacy', uspFromGpp) + } } vastUrl = vastUrl.toString(); @@ -329,6 +336,41 @@ export async function getVastXml(options, localCacheMap = vastLocalCache) { return gamVastWrapper; } +/** + * Extract a US Privacy string from the GPP data + */ +function retrieveUspInfoFromGpp(gpp) { + if (!gpp) { + return undefined; + } + const parsedSections = gpp.gppData?.parsedSections; + if (parsedSections) { + if (parsedSections.uspv1) { + const usp = parsedSections.uspv1; + return `${usp.Version}${usp.Notice}${usp.OptOutSale}${usp.LspaCovered}` + } else { + let saleOptOut; + let saleOptOutNotice; + Object.values(parsedSections).forEach(parsedSection => { + (Array.isArray(parsedSection) ? parsedSection : [parsedSection]).forEach(ps => { + const sectionSaleOptOut = ps.SaleOptOut; + const sectionSaleOptOutNotice = ps.SaleOptOutNotice; + if (saleOptOut === undefined && saleOptOutNotice === undefined && sectionSaleOptOut != null && sectionSaleOptOutNotice != null) { + saleOptOut = sectionSaleOptOut; + saleOptOutNotice = sectionSaleOptOutNotice; + } + }); + }); + if (saleOptOut !== undefined && saleOptOutNotice !== undefined) { + const uspOptOutSale = saleOptOut === 0 ? '-' : saleOptOut === 1 ? 'Y' : 'N'; + const uspOptOutNotice = saleOptOutNotice === 0 ? '-' : saleOptOutNotice === 1 ? 'Y' : 'N'; + const uspLspa = uspOptOutSale === '-' && uspOptOutNotice === '-' ? '-' : 'Y'; + return `1${uspOptOutNotice}${uspOptOutSale}${uspLspa}`; + } + } + } + return undefined +} export async function getBase64BlobContent(blobUrl) { const response = await fetch(blobUrl); diff --git a/modules/gumgumBidAdapter.js b/modules/gumgumBidAdapter.js index 3f7339a4f08..1b8cc95e2dc 100644 --- a/modules/gumgumBidAdapter.js +++ b/modules/gumgumBidAdapter.js @@ -1,12 +1,11 @@ import {BANNER, VIDEO} from '../src/mediaTypes.js'; import {_each, deepAccess, getWinDimensions, logError, logWarn, parseSizesInput} from '../src/utils.js'; -import {getDevicePixelRatio} from '../libraries/devicePixelRatio/devicePixelRatio.js'; import {config} from '../src/config.js'; +import { getConnectionInfo } from '../libraries/connectionInfo/connectionUtils.js'; +import {getDevicePixelRatio} from '../libraries/devicePixelRatio/devicePixelRatio.js'; import {getStorageManager} from '../src/storageManager.js'; - import {registerBidder} from '../src/adapters/bidderFactory.js'; -import { getConnectionInfo } from '../libraries/connectionInfo/connectionUtils.js'; /** * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest @@ -324,28 +323,53 @@ function getGreatestDimensions(sizes) { return [maxw, maxh]; } -function getEids(userId) { - const idProperties = [ - 'uid', - 'eid', - 'lipbid', - 'envelope', - 'id' - ]; - - return Object.keys(userId).reduce(function (eids, provider) { - const eid = userId[provider]; - switch (typeof eid) { - case 'string': - eids[provider] = eid; - break; - - case 'object': - const idProp = idProperties.filter(prop => eid.hasOwnProperty(prop)); - idProp.length && (eids[provider] = eid[idProp[0]]); - break; +function getFirstUid(eid) { + if (!eid || !Array.isArray(eid.uids)) return null; + return eid.uids.find(uid => uid && uid.id); +} + +function getUserEids(bidRequest, bidderRequest) { + const bidderRequestEids = deepAccess(bidderRequest, 'ortb2.user.ext.eids'); + if (Array.isArray(bidderRequestEids) && bidderRequestEids.length) { + return bidderRequestEids; + } + const bidEids = deepAccess(bidRequest, 'userIdAsEids'); + if (Array.isArray(bidEids) && bidEids.length) { + return bidEids; + } + const bidUserEids = deepAccess(bidRequest, 'user.ext.eids'); + if (Array.isArray(bidUserEids) && bidUserEids.length) { + return bidUserEids; + } + return []; +} + +function isPubProvidedIdEid(eid) { + const source = (eid && eid.source) ? eid.source.toLowerCase() : ''; + if (!source || !pubProvidedIdSources.includes(source) || !Array.isArray(eid.uids)) return false; + return eid.uids.some(uid => uid && uid.ext && uid.ext.stype); +} + +function getEidsFromEidsArray(eids) { + return (Array.isArray(eids) ? eids : []).reduce((ids, eid) => { + const source = (eid.source || '').toLowerCase(); + if (source === 'uidapi.com') { + const uid = getFirstUid(eid); + if (uid) { + ids.uid2 = uid.id; + } + } else if (source === 'liveramp.com') { + const uid = getFirstUid(eid); + if (uid) { + ids.idl_env = uid.id; + } + } else if (source === 'adserver.org' && Array.isArray(eid.uids)) { + const tdidUid = eid.uids.find(uid => uid && uid.id && uid.ext && uid.ext.rtiPartner === 'TDID'); + if (tdidUid) { + ids.tdid = tdidUid.id; + } } - return eids; + return ids; }, {}); } @@ -369,12 +393,12 @@ function buildRequests(validBidRequests, bidderRequest) { bidId, mediaTypes = {}, params = {}, - userId = {}, ortb2Imp, adUnitCode = '' } = bidRequest; const { currency, floor } = _getFloor(mediaTypes, params.bidfloor, bidRequest); - const eids = getEids(userId); + const userEids = getUserEids(bidRequest, bidderRequest); + const eids = getEidsFromEidsArray(userEids); const gpid = deepAccess(ortb2Imp, 'ext.gpid'); const paapiEligible = deepAccess(ortb2Imp, 'ext.ae') === 1 let sizes = [1, 1]; @@ -399,16 +423,20 @@ function buildRequests(validBidRequests, bidderRequest) { } } // Send filtered pubProvidedId's - if (userId && userId.pubProvidedId) { - const filteredData = userId.pubProvidedId.filter(item => pubProvidedIdSources.includes(item.source)); + if (userEids.length) { + const filteredData = userEids.filter(isPubProvidedIdEid); const maxLength = 1800; // replace this with your desired maximum length const truncatedJsonString = jsoStringifynWithMaxLength(filteredData, maxLength); - data.pubProvidedId = truncatedJsonString + if (filteredData.length) { + data.pubProvidedId = truncatedJsonString + } } // ADJS-1286 Read id5 id linktype field - if (userId && userId.id5id && userId.id5id.uid && userId.id5id.ext) { - data.id5Id = userId.id5id.uid || null - data.id5IdLinkType = userId.id5id.ext.linkType || null + const id5Eid = userEids.find(eid => (eid.source || '').toLowerCase() === 'id5-sync.com'); + const id5Uid = getFirstUid(id5Eid); + if (id5Uid && id5Uid.ext) { + data.id5Id = id5Uid.id || null + data.id5IdLinkType = id5Uid.ext.linkType || null } // ADTS-169 add adUnitCode to requests if (adUnitCode) data.aun = adUnitCode; diff --git a/modules/harionBidAdapter.js b/modules/harionBidAdapter.js new file mode 100644 index 00000000000..40b1b8282b3 --- /dev/null +++ b/modules/harionBidAdapter.js @@ -0,0 +1,25 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { isBidRequestValid, buildRequests, interpretResponse } from '../libraries/teqblazeUtils/bidderUtils.js'; + +const BIDDER_CODE = 'harion'; +const GVLID = 1406; +const AD_URL = 'https://east-api.harion-ma.com/pbjs'; + +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid: isBidRequestValid(), + buildRequests: buildRequests(AD_URL), + interpretResponse: (serverResponse) => { + if (!serverResponse || !Array.isArray(serverResponse.body)) { + return []; + } + + return interpretResponse(serverResponse); + } +}; + +registerBidder(spec); diff --git a/modules/harionBidAdapter.md b/modules/harionBidAdapter.md new file mode 100644 index 00000000000..a2ec196eeab --- /dev/null +++ b/modules/harionBidAdapter.md @@ -0,0 +1,79 @@ +# Overview + +``` +Module Name: Harion Bidder Adapter +Module Type: Harion Bidder Adapter +Maintainer: adtech@markappmedia.site +``` + +# Description + +Connects to Harion exchange for bids. +Harion bid adapter supports Banner, Video (instream and outstream) and Native. + +# Test Parameters +``` + var adUnits = [ + // Will return static test banner + { + code: 'adunit1', + mediaTypes: { + banner: { + sizes: [ [300, 250], [320, 50] ], + } + }, + bids: [ + { + bidder: 'harion', + params: { + placementId: 'testBanner', + } + } + ] + }, + { + code: 'addunit2', + mediaTypes: { + video: { + playerSize: [ [640, 480] ], + context: 'instream', + minduration: 5, + maxduration: 60, + } + }, + bids: [ + { + bidder: 'harion', + params: { + placementId: 'testVideo', + } + } + ] + }, + { + code: 'addunit3', + mediaTypes: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + }, + bids: [ + { + bidder: 'harion', + params: { + placementId: 'testNative', + } + } + ] + } + ]; +``` diff --git a/modules/humansecurityRtdProvider.js b/modules/humansecurityRtdProvider.js deleted file mode 100644 index aeb872beb8d..00000000000 --- a/modules/humansecurityRtdProvider.js +++ /dev/null @@ -1,180 +0,0 @@ -/** - * This module adds humansecurity provider to the real time data module - * - * The {@link module:modules/realTimeData} module is required - * The module will inject the HUMAN Security script into the context where Prebid.js is initialized, enriching bid requests with specific data to provide advanced protection against ad fraud and spoofing. - * @module modules/humansecurityRtdProvider - * @requires module:modules/realTimeData - */ - -import { submodule } from '../src/hook.js'; -import { - prefixLog, - mergeDeep, - generateUUID, - getWindowSelf, -} from '../src/utils.js'; -import { getRefererInfo } from '../src/refererDetection.js'; -import { getGlobal } from '../src/prebidGlobal.js'; -import { loadExternalScript } from '../src/adloader.js'; -import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; - -/** - * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule - * @typedef {import('../modules/rtdModule/index.js').SubmoduleConfig} SubmoduleConfig - * @typedef {import('../modules/rtdModule/index.js').UserConsentData} UserConsentData - */ - -const SUBMODULE_NAME = 'humansecurity'; -const SCRIPT_URL = 'https://sonar.script.ac/prebid/rtd.js'; - -const { logInfo, logWarn, logError } = prefixLog(`[${SUBMODULE_NAME}]:`); - -/** @type {string} */ -let clientId = ''; - -/** @type {boolean} */ -let verbose = false; - -/** @type {string} */ -let sessionId = ''; - -/** @type {Object} */ -let hmnsData = { }; - -/** - * Submodule registration - */ -function main() { - submodule('realTimeData', /** @type {RtdSubmodule} */ ({ - name: SUBMODULE_NAME, - - // - init: (config, userConsent) => { - try { - load(config); - return true; - } catch (err) { - logError('init', err.message); - return false; - } - }, - - getBidRequestData: onGetBidRequestData - })); -} - -/** - * Injects HUMAN Security script on the page to facilitate pre-bid signal collection. - * @param {SubmoduleConfig} config - */ -function load(config) { - // By default, this submodule loads the generic implementation script - // only identified by the referrer information. In the future, if publishers - // want to have analytics where their websites are grouped, they can request - // Client ID from HUMAN, pass it here, and it will enable advanced reporting - clientId = config?.params?.clientId || ''; - if (clientId && (typeof clientId !== 'string' || !/^\w{3,16}$/.test(clientId))) { - throw new Error(`The 'clientId' parameter must be a short alphanumeric string`); - } - - // Load/reset the state - verbose = !!config?.params?.verbose; - sessionId = generateUUID(); - hmnsData = {}; - - // We rely on prebid implementation to get the best domain possible here - // In some cases, it still might be null, though - const refDomain = getRefererInfo().domain || ''; - - // Once loaded, the implementation script will publish an API using - // the session ID value it was given in data attributes - const scriptAttrs = { 'data-sid': sessionId }; - const scriptUrl = `${SCRIPT_URL}?r=${refDomain}${clientId ? `&c=${clientId}` : ''}`; - - loadExternalScript(scriptUrl, MODULE_TYPE_RTD, SUBMODULE_NAME, onImplLoaded, null, scriptAttrs); -} - -/** - * The callback to loadExternalScript - * Establishes the bridge between this RTD submodule and the loaded implementation - */ -function onImplLoaded() { - // We then get a hold on this script using the knowledge of this session ID - const wnd = getWindowSelf(); - const impl = wnd[`sonar_${sessionId}`]; - if (typeof impl !== 'object' || typeof impl.connect !== 'function') { - verbose && logWarn('onload', 'Unable to access the implementation script'); - return; - } - - // And set up a bridge between the RTD submodule and the implementation. - // The first argument is used to identify the caller. - // The callback might be called multiple times to update the signals - // once more precise information is available. - impl.connect(getGlobal(), onImplMessage); -} - -/** - * The bridge function will be called by the implementation script - * to update the token information or report errors - * @param {Object} msg - */ -function onImplMessage(msg) { - if (typeof msg !== 'object') { - return; - } - - switch (msg.type) { - case 'hmns': { - hmnsData = mergeDeep({}, msg.data || {}); - break; - } - case 'error': { - logError('impl', msg.data || ''); - break; - } - case 'warn': { - verbose && logWarn('impl', msg.data || ''); - break; - } - case 'info': { - verbose && logInfo('impl', msg.data || ''); - break; - } - } -} - -/** - * onGetBidRequestData is called once per auction. - * Update the `ortb2Fragments` object with the data from the injected script. - * - * @param {Object} reqBidsConfigObj - * @param {function} callback - * @param {SubmoduleConfig} config - * @param {UserConsentData} userConsent - */ -function onGetBidRequestData(reqBidsConfigObj, callback, config, userConsent) { - // Add device.ext.hmns to the global ORTB data for all vendors to use - // At the time of writing this submodule, "hmns" is an object defined - // internally by humansecurity, and it currently contains "v1" field - // with a token that contains collected signals about this session. - mergeDeep(reqBidsConfigObj.ortb2Fragments.global, { device: { ext: { hmns: hmnsData } } }); - callback(); -} - -/** - * Exporting local (and otherwise encapsulated to this module) functions - * for testing purposes - */ -export const __TEST__ = { - SUBMODULE_NAME, - SCRIPT_URL, - main, - load, - onImplLoaded, - onImplMessage, - onGetBidRequestData -}; - -main(); diff --git a/modules/humansecurityRtdProvider.md b/modules/humansecurityRtdProvider.md index 6722319cbb5..fceb012e27c 100644 --- a/modules/humansecurityRtdProvider.md +++ b/modules/humansecurityRtdProvider.md @@ -23,7 +23,7 @@ sent within bid requests, and used for bot detection on the backend. * No incremental signals collected beyond existing HUMAN post-bid solution * Offsets negative impact from loss of granularity in IP and User Agent at bid time * Does not expose collected IVT signal to any party who doesn’t otherwise already have access to the same signal collected post-bid -* Does not introduce meaningful latency, as demonstrated in the Latency section +* Does not introduce meaningful latency * Comes at no additional cost to collect IVT signal and make it available at bid time * Leveraged to differentiate the invalid bid requests at device level, and cannot be used to identify a user or a device, thus preserving privacy. @@ -56,8 +56,8 @@ pbjs.setConfig({ }); ``` -It can be optionally parameterized, for example, to include client ID obtained from HUMAN, -should any advanced reporting be needed, or to have verbose output for troubleshooting: +Other parameters can also be provided. For example, a client ID obtained from HUMAN can +optionally be provided, or verbose output can be enabled for troubleshooting purposes: ```javascript pbjs.setConfig({ @@ -79,6 +79,7 @@ pbjs.setConfig({ | :--------------- | :------------ | :------------------------------------------------------------------ |:---------| | `clientId` | String | Should you need advanced reporting, contact [prebid@humansecurity.com](prebid@humansecurity.com) to receive client ID. | No | | `verbose` | Boolean | Only set to `true` if troubleshooting issues. | No | +| `perBidderOptOut` | string[] | Pass any bidder alias to opt-out from per-bidder signal generation. | No | ## Logging, latency and troubleshooting @@ -89,9 +90,6 @@ of type `ERROR`. With `verbose` parameter set to `true`, it may additionally: * Call `logWarning`, resulting in `auctionDebug` events of type `WARNING`, * Call `logInfo` with latency information. - * To observe these messages in console, Prebid.js must be run in - [debug mode](https://docs.prebid.org/dev-docs/publisher-api-reference/setConfig.html#debugging) - - either by adding `?pbjs_debug=true` to your page's URL, or by configuring with `pbjs.setConfig({ debug: true });` Example output of the latency information: @@ -139,6 +137,7 @@ There are a few points that are worth being mentioned separately, to avoid confu the ever-evolving nature of the threat landscape without the publishers having to rebuild their Prebid.js frequently. * The signal collection script is also obfuscated, as a defense-in-depth measure in order to complicate tampering by bad actors, as are all similar scripts in the industry, which is something that cannot be accommodated by Prebid.js itself. +* The collected signals are encrypted before they are passed to bid adapters and can only be interpreted by HUMAN backend systems. ## Why is this approach an innovation? @@ -199,10 +198,14 @@ ensuring the value of the inventory. ## FAQ +### Is partnership with HUMAN required to use the submodule? + +No. Using this submodule does not require any prior communication with HUMAN or being a client of HUMAN. +It is free and usage of the submodule doesn’t automatically make a Publisher HUMAN client. + ### Is latency an issue? The HUMAN Security RTD submodule is designed to minimize any latency in the auction within normal SLAs. - ### Do publishers get any insight into how the measurement is judged? Having the The HUMAN Security RTD submodule be part of the prebid process will allow the publisher to have insight @@ -211,13 +214,10 @@ inventory to the buyer. ### How are privacy concerns addressed? -The HUMAN Security RTD submodule seeks to reduce the impacts from signal deprecation that are inevitable without -compromising privacy by avoiding re-identification. Each bid request is enriched with just enough signal -to identify if the traffic is invalid or not. - -By having the The HUMAN Security RTD submodule operate at the Prebid level, data can be controlled -and not as freely passed through the bidstream where it may be accessible to various unknown parties. +The HUMAN Security RTD submodule seeks to reduce the impacts of signal deprecation without compromising privacy. +Each bid request is enriched with just enough signal to identify if the traffic is invalid or not, and these +signals are encrypted before being included in bid requests to prevent misuse. Note: anti-fraud use cases typically have carve outs in laws and regulations to permit data collection essential for effective fraud mitigation, but this does not constitute legal advice and you should -consult your attorney when making data access decisions. +consult your attorney when making data access decisions. \ No newline at end of file diff --git a/modules/humansecurityRtdProvider.ts b/modules/humansecurityRtdProvider.ts new file mode 100644 index 00000000000..96b99eadfb7 --- /dev/null +++ b/modules/humansecurityRtdProvider.ts @@ -0,0 +1,204 @@ +/** + * This module adds humansecurity provider to the real time data module + * + * The {@link module:modules/realTimeData} module is required + * The module will inject the HUMAN Security script into the context where Prebid.js is initialized, enriching bid requests with specific + * data to provide advanced protection against ad fraud and spoofing. + * @module modules/humansecurityRtdProvider + * @requires module:modules/realTimeData + */ +import { submodule } from '../src/hook.js'; +import { prefixLog, generateUUID, getWindowSelf } from '../src/utils.js'; +import { getRefererInfo } from '../src/refererDetection.js'; +import { getGlobal, PrebidJS } from '../src/prebidGlobal.js'; +import { loadExternalScript } from '../src/adloader.js'; +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; +import { AllConsentData } from '../src/consentHandler.ts'; +import type { RTDProvider, RTDProviderConfig, RtdProviderSpec } from './rtdModule/spec.ts'; +import type { StartAuctionOptions } from '../src/prebid.ts'; +import type { AuctionProperties } from '../src/auction.ts'; + +declare module './rtdModule/spec.ts' { + interface ProviderConfig { + humansecurity: { + params?: { + clientId?: string; + verbose?: boolean; + perBidderOptOut?: string[]; + }; + }; + } +} + +interface HumanSecurityImpl { + connect( + pbjs: PrebidJS, + callback: (m: string) => void | null, + config: RTDProviderConfig<'humansecurity'> + ): void; + + getBidRequestData( + reqBidsConfigObj: StartAuctionOptions, + callback: () => void, + config: RTDProviderConfig<'humansecurity'>, + userConsent: AllConsentData + ): void; + + onAuctionInitEvent( + pbjs: PrebidJS, + auctionDetails: AuctionProperties, + config: RTDProviderConfig<'humansecurity'>, + userConsent: AllConsentData + ): void; +} + +const SUBMODULE_NAME = 'humansecurity' as const; +const SCRIPT_URL = 'https://sonar.script.ac/prebid/rtd.js'; +const MODULE_VERSION = 1; + +const { logWarn, logError } = prefixLog(`[${SUBMODULE_NAME}]:`); + +let implRef: HumanSecurityImpl | null = null; +let clientId: string = ''; +let verbose: boolean = false; +let sessionId: string = ''; + +/** + * Injects HUMAN Security script on the page to facilitate pre-bid signal collection. + */ + +const load = (config: RTDProviderConfig<'humansecurity'>) => { + // Load implementation script and pass configuration parameters via data attributes + clientId = config?.params?.clientId || ''; + if (clientId && (typeof clientId !== 'string' || !/^\w{3,16}$/.test(clientId))) { + throw new Error(`The 'clientId' parameter must be a short alphanumeric string`); + } + + // Load/reset the state + verbose = !!config?.params?.verbose; + implRef = null; + sessionId = generateUUID(); + + // Get the best domain possible here, it still might be null + const refDomain = getRefererInfo().domain || ''; + + // Once loaded, the implementation script will publish an API using + // the session ID value it was given in data attributes + const scriptAttrs = { 'data-sid': sessionId }; + const scriptUrl = `${SCRIPT_URL}?r=${refDomain}${clientId ? `&c=${clientId}` : ''}&mv=${MODULE_VERSION}`; + + loadExternalScript(scriptUrl, MODULE_TYPE_RTD, SUBMODULE_NAME, () => onImplLoaded(config), null, scriptAttrs); +} + +/** + * Retrieves the implementation object created by the loaded script + * using the session ID as a key + */ + +const getImpl = () => { + // Use cached reference if already resolved + if (implRef && typeof implRef === 'object' && typeof implRef.connect === 'function') return implRef; + + // Attempt to resolve from window by session ID + const wnd = getWindowSelf(); + const impl: HumanSecurityImpl = wnd[`sonar_${sessionId}`]; + + if (typeof impl !== 'object' || typeof impl.connect !== 'function') { + verbose && logWarn('onload', 'Unable to access the implementation script'); + return; + } + implRef = impl; + return impl; +} + +/** + * The callback to loadExternalScript + * Establishes the bridge between this RTD submodule and the loaded implementation + */ + +const onImplLoaded = (config: RTDProviderConfig<'humansecurity'>) => { + const impl = getImpl(); + if (!impl) return; + + // And set up a bridge between the RTD submodule and the implementation. + impl.connect(getGlobal(), null, config); +} + +/** + * The bridge function will be called by the implementation script + * to update the token information or report errors + */ + +/** + * https://docs.prebid.org/dev-docs/add-rtd-submodule.html#getbidrequestdata + */ + +const getBidRequestData = ( + reqBidsConfigObj: StartAuctionOptions, + callback: () => void, + config: RtdProviderSpec<'humansecurity'>, + userConsent: AllConsentData +) => { + const impl = getImpl(); + if (!impl || typeof impl.getBidRequestData !== 'function') { + // Implementation not available; continue auction by invoking the callback. + callback(); + return; + } + + impl.getBidRequestData(reqBidsConfigObj, callback, config, userConsent); +} + +/** + * Event hooks + * https://docs.prebid.org/dev-docs/add-rtd-submodule.html#using-event-listeners + */ + +const onAuctionInitEvent = (auctionDetails: AuctionProperties, config: RTDProviderConfig<'humansecurity'>, userConsent: AllConsentData) => { + const impl = getImpl(); + if (!impl || typeof impl.onAuctionInitEvent !== 'function') return; + impl.onAuctionInitEvent(getGlobal(), auctionDetails, config, userConsent); +} + +/** + * Submodule registration + */ + +type RtdProviderSpecWithHooks

= RtdProviderSpec

& { + onAuctionInitEvent?: (auctionDetails: AuctionProperties, config: RTDProviderConfig

, userConsent: AllConsentData) => void; +}; + +const subModule: RtdProviderSpecWithHooks<'humansecurity'> = ({ + name: SUBMODULE_NAME, + init: (config, _userConsent) => { + try { + load(config); + return true; + } catch (err) { + const message = (err && typeof err === 'object' && 'message' in err) + ? (err as any).message + : String(err); + logError('init', message); + return false; + } + }, + getBidRequestData, + onAuctionInitEvent, +}); + +const registerSubModule = () => { submodule('realTimeData', subModule); } +registerSubModule(); + +/** + * Exporting local (and otherwise encapsulated to this module) functions + * for testing purposes + */ + +export const __TEST__ = { + SUBMODULE_NAME, + SCRIPT_URL, + main: registerSubModule, + load, + onImplLoaded, + getBidRequestData +}; diff --git a/modules/id5IdSystem.js b/modules/id5IdSystem.js index d8f553c6ae0..c3e2e6c31ed 100644 --- a/modules/id5IdSystem.js +++ b/modules/id5IdSystem.js @@ -8,6 +8,7 @@ import { deepAccess, deepClone, + deepEqual, deepSetValue, isEmpty, isEmptyStr, @@ -118,6 +119,7 @@ export const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleNam * @property {Array} [segments] - A list of segments to push to partners. Supported only in multiplexing. * @property {boolean} [disableUaHints] - When true, look up of high entropy values through user agent hints is disabled. * @property {string} [gamTargetingPrefix] - When set, the GAM targeting tags will be set and use the specified prefix, for example 'id5'. + * @property {boolean} [exposeTargeting] - When set, the ID5 targeting consumer mechanism will be enabled. */ /** @@ -569,17 +571,30 @@ function incrementNb(cachedObj) { } function updateTargeting(fetchResponse, config) { - if (config.params.gamTargetingPrefix) { - const tags = fetchResponse.tags; - if (tags) { + const tags = fetchResponse.tags; + if (tags) { + if (config.params.gamTargetingPrefix) { window.googletag = window.googletag || {cmd: []}; window.googletag.cmd = window.googletag.cmd || []; window.googletag.cmd.push(() => { for (const tag in tags) { - window.googletag.pubads().setTargeting(config.params.gamTargetingPrefix + '_' + tag, tags[tag]); + window.googletag.setConfig({targeting: {[config.params.gamTargetingPrefix + '_' + tag]: tags[tag]}}); } }); } + + if (config.params.exposeTargeting && !deepEqual(window.id5tags?.tags, tags)) { + window.id5tags = window.id5tags || {cmd: []}; + window.id5tags.cmd = window.id5tags.cmd || []; + window.id5tags.cmd.forEach(tagsCallback => { + setTimeout(() => tagsCallback(tags), 0); + }); + window.id5tags.cmd.push = function (tagsCallback) { + tagsCallback(tags) + Array.prototype.push.call(window.id5tags.cmd, tagsCallback); + }; + window.id5tags.tags = tags + } } } diff --git a/modules/id5IdSystem.md b/modules/id5IdSystem.md index 363dd02e831..aaf34fa4054 100644 --- a/modules/id5IdSystem.md +++ b/modules/id5IdSystem.md @@ -33,7 +33,8 @@ pbjs.setConfig({ }, disableExtensions: false,// optional canCookieSync: true, // optional, has effect only when externalModuleUrl is used - gamTargetingPrefix: "id5" // optional, when set the ID5 module will set gam targeting paramaters with this prefix + gamTargetingPrefix: "id5", // optional, when set the ID5 module will set gam targeting paramaters with this prefix + exposeTargeting: false // optional, when set the ID5 module will execute `window.id5tags.cmd` callbacks for custom targeting tags }, storage: { type: 'html5', // "html5" is the required storage type @@ -47,25 +48,26 @@ pbjs.setConfig({ }); ``` -| Param under userSync.userIds[] | Scope | Type | Description | Example | -| --- | --- | --- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| --- | -| name | Required | String | The name of this module: `"id5Id"` | `"id5Id"` | -| params | Required | Object | Details for the ID5 ID. | | -| params.partner | Required | Number | This is the ID5 Partner Number obtained from registering with ID5. | `173` | -| params.externalModuleUrl | Optional | String | The URL for the id5-prebid external module. It is recommended to use the latest version at the URL in the example. Source code available [here](https://github.com/id5io/id5-api.js/blob/master/src/id5PrebidModule.js). | https://cdn.id5-sync.com/api/1.0/id5PrebidModule.js -| params.pd | Optional | String | Partner-supplied data used for linking ID5 IDs across domains. See [our documentation](https://wiki.id5.io/en/identitycloud/retrieve-id5-ids/passing-partner-data-to-id5) for details on generating the string. Omit the parameter or leave as an empty string if no data to supply | `"MT1iNTBjY..."` | -| params.provider | Optional | String | An identifier provided by ID5 to technology partners who manage Prebid setups on behalf of publishers. Reach out to [ID5](mailto:prebid@id5.io) if you have questions about this parameter | `pubmatic-identity-hub` | -| params.abTesting | Optional | Object | Allows publishers to easily run an A/B Test. If enabled and the user is in the Control Group, the ID5 ID will NOT be exposed to bid adapters for that request | Disabled by default | -| params.abTesting.enabled | Optional | Boolean | Set this to `true` to turn on this feature | `true` or `false` | -| params.abTesting.controlGroupPct | Optional | Number | Must be a number between `0.0` and `1.0` (inclusive) and is used to determine the percentage of requests that fall into the control group (and thus not exposing the ID5 ID). For example, a value of `0.20` will result in 20% of requests without an ID5 ID and 80% with an ID. | `0.1` | -| params.disableExtensions | Optional | Boolean | Set this to `true` to force turn off extensions call. Default `false` | `true` or `false` | -| params.canCookieSync | Optional | Boolean | Set this to `true` to enable cookie syncing with other ID5 partners. See [our documentation](https://wiki.id5.io/docs/initiate-cookie-sync-to-id5) for details. Default `false` | `true` or `false` | +| Param under userSync.userIds[] | Scope | Type | Description | Example | +| --- | --- | --- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------| +| name | Required | String | The name of this module: `"id5Id"` | `"id5Id"` | +| params | Required | Object | Details for the ID5 ID. | | +| params.partner | Required | Number | This is the ID5 Partner Number obtained from registering with ID5. | `173` | +| params.externalModuleUrl | Optional | String | The URL for the id5-prebid external module. It is recommended to use the latest version at the URL in the example. Source code available [here](https://github.com/id5io/id5-api.js/blob/master/src/id5PrebidModule.js). | https://cdn.id5-sync.com/api/1.0/id5PrebidModule.js +| params.pd | Optional | String | Partner-supplied data used for linking ID5 IDs across domains. See [our documentation](https://wiki.id5.io/en/identitycloud/retrieve-id5-ids/passing-partner-data-to-id5) for details on generating the string. Omit the parameter or leave as an empty string if no data to supply | `"MT1iNTBjY..."` | +| params.provider | Optional | String | An identifier provided by ID5 to technology partners who manage Prebid setups on behalf of publishers. Reach out to [ID5](mailto:prebid@id5.io) if you have questions about this parameter | `pubmatic-identity-hub` | +| params.abTesting | Optional | Object | Allows publishers to easily run an A/B Test. If enabled and the user is in the Control Group, the ID5 ID will NOT be exposed to bid adapters for that request | Disabled by default | +| params.abTesting.enabled | Optional | Boolean | Set this to `true` to turn on this feature | `true` or `false` | +| params.abTesting.controlGroupPct | Optional | Number | Must be a number between `0.0` and `1.0` (inclusive) and is used to determine the percentage of requests that fall into the control group (and thus not exposing the ID5 ID). For example, a value of `0.20` will result in 20% of requests without an ID5 ID and 80% with an ID. | `0.1` | +| params.disableExtensions | Optional | Boolean | Set this to `true` to force turn off extensions call. Default `false` | `true` or `false` | +| params.canCookieSync | Optional | Boolean | Set this to `true` to enable cookie syncing with other ID5 partners. See [our documentation](https://wiki.id5.io/docs/initiate-cookie-sync-to-id5) for details. Default `false` | `true` or `false` | | params.gamTargetingPrefix | Optional | String | When this parameter is set the ID5 module will set appropriate GAM pubads targeting tags | `id5` | -| storage | Required | Object | Storage settings for how the User ID module will cache the ID5 ID locally | | -| storage.type | Required | String | This is where the results of the user ID will be stored. ID5 **requires** `"html5"`. | `"html5"` | -| storage.name | Required | String | The name of the local storage where the user ID will be stored. ID5 **requires** `"id5id"`. | `"id5id"` | -| storage.expires | Optional | Integer | How long (in days) the user ID information will be stored. ID5 recommends `90`. | `90` | -| storage.refreshInSeconds | Optional | Integer | How many seconds until the ID5 ID will be refreshed. ID5 strongly recommends 2 hours between refreshes | `7200` | +| params.exposeTargeting | Optional | Boolean | When this parameter is set the ID5 module will execute `window.id5tags.cmd` callbacks for custom targeting tags | `false` | +| storage | Required | Object | Storage settings for how the User ID module will cache the ID5 ID locally | | +| storage.type | Required | String | This is where the results of the user ID will be stored. ID5 **requires** `"html5"`. | `"html5"` | +| storage.name | Required | String | The name of the local storage where the user ID will be stored. ID5 **requires** `"id5id"`. | `"id5id"` | +| storage.expires | Optional | Integer | How long (in days) the user ID information will be stored. ID5 recommends `90`. | `90` | +| storage.refreshInSeconds | Optional | Integer | How many seconds until the ID5 ID will be refreshed. ID5 strongly recommends 2 hours between refreshes | `7200` | **ATTENTION:** As of Prebid.js v4.14.0, ID5 requires `storage.type` to be `"html5"` and `storage.name` to be `"id5id"`. Using other values will display a warning today, but in an upcoming release, it will prevent the ID5 module from loading. This change is to ensure the ID5 module in Prebid.js interoperates properly with the [ID5 API](https://github.com/id5io/id5-api.js) and to reduce the size of publishers' first-party cookies that are sent to their web servers. If you have any questions, please reach out to us at [prebid@id5.io](mailto:prebid@id5.io). diff --git a/modules/insticatorBidAdapter.js b/modules/insticatorBidAdapter.js index 447947e43a4..b00c1e7bef8 100644 --- a/modules/insticatorBidAdapter.js +++ b/modules/insticatorBidAdapter.js @@ -5,7 +5,7 @@ import {deepAccess, generateUUID, logError, isArray, isInteger, isArrayOfNums, d import {getStorageManager} from '../src/storageManager.js'; const BIDDER_CODE = 'insticator'; -const ENDPOINT = 'https://ex.ingage.tech/v1/openrtb'; // production endpoint +const ENDPOINT = 'https://ex.ingage.tech/v1/openrtb'; const USER_ID_KEY = 'hb_insticator_uid'; const USER_ID_COOKIE_EXP = 2592000000; // 30 days const BID_TTL = 300; // 5 minutes @@ -29,7 +29,16 @@ export const OPTIONAL_VIDEO_PARAMS = { 'playbackend': (value) => isInteger(value) && [1, 2, 3].includes(value), 'delivery': (value) => isArrayOfNums(value), 'pos': (value) => isInteger(value) && [0, 1, 2, 3, 4, 5, 6, 7].includes(value), - 'api': (value) => isArrayOfNums(value)}; + 'api': (value) => isArrayOfNums(value), + // ORTB 2.6 video parameters + 'podid': (value) => typeof value === 'string' && value.length > 0, + 'podseq': (value) => isInteger(value) && value >= 0, + 'poddur': (value) => isInteger(value) && value > 0, + 'slotinpod': (value) => isInteger(value) && [-1, 0, 1, 2].includes(value), + 'mincpmpersec': (value) => typeof value === 'number' && value > 0, + 'maxseq': (value) => isInteger(value) && value > 0, + 'rqddurs': (value) => isArrayOfNums(value) && value.every(v => v > 0), +}; const ORTB_SITE_FIRST_PARTY_DATA = { 'cat': v => Array.isArray(v) && v.every(c => typeof c === 'string'), @@ -114,11 +123,11 @@ function buildVideo(bidRequest) { const optionalParams = {}; for (const param in OPTIONAL_VIDEO_PARAMS) { - if (bidRequestVideo[param] && OPTIONAL_VIDEO_PARAMS[param](bidRequestVideo[param])) { + if (bidRequestVideo[param] != null && OPTIONAL_VIDEO_PARAMS[param](bidRequestVideo[param])) { optionalParams[param] = bidRequestVideo[param]; } // remove invalid optional params from bidder specific overrides - if (videoBidderParams[param] && !OPTIONAL_VIDEO_PARAMS[param](videoBidderParams[param])) { + if (videoBidderParams[param] != null && !OPTIONAL_VIDEO_PARAMS[param](videoBidderParams[param])) { delete videoBidderParams[param]; } } @@ -442,8 +451,9 @@ function buildRequest(validBidRequests, bidderRequest) { return req; } -function buildBid(bid, bidderRequest) { +function buildBid(bid, bidderRequest, seatbid) { const originalBid = ((bidderRequest.bids) || []).find((b) => b.bidId === bid.impid); + let meta = {} if (bid.ext && bid.ext.meta) { @@ -454,27 +464,79 @@ function buildBid(bid, bidderRequest) { meta.advertiserDomains = bid.adomain } + // ORTB 2.6: Add category support + if (bid.cat && Array.isArray(bid.cat) && bid.cat.length > 0) { + meta.primaryCatId = bid.cat[0]; + if (bid.cat.length > 1) { + meta.secondaryCatIds = bid.cat.slice(1); + } + } + + // ORTB 2.6: Add seat from seatbid + if (seatbid && seatbid.seat) { + meta.seat = seatbid.seat; + } + + // ORTB 2.6: Add creative attributes + if (bid.attr && Array.isArray(bid.attr)) { + meta.attr = bid.attr; + } + + // Determine media type using multiple signals let mediaType = 'banner'; - if (bid.adm && bid.adm.includes(' 0 ? Math.min(bid.exp, configTTL) : configTTL; + const bidResponse = { requestId: bid.impid, creativeId: bid.crid, cpm: bid.price, currency: 'USD', netRevenue: true, - ttl: bid.exp || config.getConfig('insticator.bidTTL') || BID_TTL, + ttl: ttl, width: bid.w, height: bid.h, mediaType: mediaType, ad: bid.adm, - adUnitCode: originalBid.adUnitCode, + adUnitCode: originalBid?.adUnitCode, ...(Object.keys(meta).length > 0 ? {meta} : {}) }; + // ORTB 2.6: Add deal ID + if (bid.dealid) { + bidResponse.dealId = bid.dealid; + } + + // ORTB 2.6: Add billing URL for billing notification + if (bid.burl) { + bidResponse.burl = bid.burl; + } + + // ORTB 2.6: Add notice URL for win notification + if (bid.nurl) { + bidResponse.nurl = bid.nurl; + } + if (mediaType === 'video') { bidResponse.vastXml = bid.adm; + + // ORTB 2.6: Add video duration for adpod support + if (bid.dur && isInteger(bid.dur) && bid.dur > 0) { + bidResponse.video = bidResponse.video || {}; + bidResponse.video.durationSeconds = bid.dur; + } } // Inticator bid adaptor only returns `vastXml` for video bids. No VastUrl or videoCache. @@ -493,7 +555,7 @@ function buildBid(bid, bidderRequest) { } function buildBidSet(seatbid, bidderRequest) { - return seatbid.bid.map((bid) => buildBid(bid, bidderRequest)); + return seatbid.bid.map((bid) => buildBid(bid, bidderRequest, seatbid)); } function validateSize(size) { @@ -652,9 +714,19 @@ export const spec = { if (deepAccess(validBidRequests[0], 'params.bid_endpoint_request_url')) { endpointUrl = deepAccess(validBidRequests[0], 'params.bid_endpoint_request_url').replace(/^http:/, 'https:'); } + + // Add publisherId as query parameter if present and non-empty + const publisherId = deepAccess(validBidRequests[0], 'params.publisherId'); + if (publisherId && publisherId.trim() !== '') { + const urlObj = new URL(endpointUrl); + urlObj.searchParams.set('publisherId', publisherId); + endpointUrl = urlObj.toString(); + } } if (validBidRequests.length > 0) { + const ortbRequest = buildRequest(validBidRequests, bidderRequest); + requests.push({ method: 'POST', url: endpointUrl, @@ -662,7 +734,7 @@ export const spec = { contentType: 'application/json', withCredentials: true, }, - data: JSON.stringify(buildRequest(validBidRequests, bidderRequest)), + data: JSON.stringify(ortbRequest), bidderRequest, }); } @@ -673,11 +745,22 @@ export const spec = { interpretResponse: function (serverResponse, request) { const bidderRequest = request.bidderRequest; const body = serverResponse.body; - if (!body || body.id !== bidderRequest.bidderRequestId) { - logError('insticator: response id does not match bidderRequestId'); + + // Handle 204 No Content or empty response body (valid "no bid" scenario) + if (!body || !body.id) { return []; } + // Validate response ID matches request ID + if (body.id !== bidderRequest.bidderRequestId) { + logError('insticator: response id does not match bidderRequestId', { + responseId: body.id, + bidderRequestId: bidderRequest.bidderRequestId + }); + return []; + } + + // No seatbid means no bids (valid scenario) if (!body.seatbid) { return []; } @@ -686,7 +769,9 @@ export const spec = { buildBidSet(seatbid, bidderRequest) ); - return bidsets.reduce((a, b) => a.concat(b), []); + const finalBids = bidsets.reduce((a, b) => a.concat(b), []); + + return finalBids; }, getUserSyncs: function (options, responses) { diff --git a/modules/insuradsBidAdapter.md b/modules/insuradsBidAdapter.md new file mode 100644 index 00000000000..11c0bc248e7 --- /dev/null +++ b/modules/insuradsBidAdapter.md @@ -0,0 +1,55 @@ +# Overview + +``` +Module Name: InsurAds Bid Adapter +Module Type: Bidder Adapter +Maintainer: jclimaco@insurads.com +``` + +# Description + +Connects to InsurAds network for bids. + +# Test Parameters + +## Web + +### Display +``` +var adUnits = [ + // Banner adUnit + { + code: 'banner-div', + mediaTypes: { + banner: { + sizes: [[300, 250], [300,600]] + } + }, + bids: [{ + bidder: 'insurads', + params: { + tagId: 'ToBeSupplied' + } + }] + }, +]; +``` + +### Video Instream +``` + var videoAdUnit = { + code: 'video1', + mediaTypes: { + video: { + playerSize: [640, 480], + context: 'instream' + } + }, + bids: [{ + bidder: 'insurads', + params: { + tagId: 'ToBeSupplied' + } + }] + }; +``` diff --git a/modules/insuradsBidAdapter.ts b/modules/insuradsBidAdapter.ts new file mode 100644 index 00000000000..30f506118b7 --- /dev/null +++ b/modules/insuradsBidAdapter.ts @@ -0,0 +1,149 @@ +import { deepSetValue, generateUUID, logError } from '../src/utils.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { AdapterRequest, BidderSpec, registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js' + +import { interpretResponse, enrichImp, enrichRequest, getAmxId, getLocalStorageFunctionGenerator, getUserSyncs } from '../libraries/nexx360Utils/index.js'; +import { getBoundingClientRect } from '../libraries/boundingClientRect/boundingClientRect.js'; +import { BidRequest, ClientBidderRequest } from '../src/adapterManager.js'; +import { ORTBImp, ORTBRequest } from '../src/prebid.public.js'; +import { config } from '../src/config.js'; + +const BIDDER_CODE = 'insurads'; +const REQUEST_URL = 'https://fast.nexx360.io/booster'; +const PAGE_VIEW_ID = generateUUID(); +const BIDDER_VERSION = '7.1'; +const GVLID = 596; +const ALT_KEY = 'nexx360_storage'; + +const DEFAULT_GZIP_ENABLED = false; + +type RequireAtLeastOne = + Omit & { + [K in Keys]-?: Required> & + Partial>> + }[Keys]; + +type InsurAdsBidParams = RequireAtLeastOne<{ + tagId?: string; + placement?: string; + videoTagId?: string; + nativeTagId?: string; + adUnitPath?: string; + adUnitName?: string; + divId?: string; + allBids?: boolean; + customId?: string; + bidders?: Record; +}, "tagId" | "placement">; + +declare module '../src/adUnits' { + interface BidderParams { + ['nexx360']: InsurAdsBidParams; + } +} + +export const STORAGE = getStorageManager({ + bidderCode: BIDDER_CODE, +}); + +export const getInsurAdsLocalStorage = getLocalStorageFunctionGenerator<{ nexx360Id: string }>( + STORAGE, + BIDDER_CODE, + ALT_KEY, + 'nexx360Id' +); + +export const getGzipSetting = (): boolean => { + const bidderConfig = config.getBidderConfig(); + const gzipEnabled = bidderConfig.insurads?.gzipEnabled; + + if (gzipEnabled === true || gzipEnabled === 'true') { + return true; + } + return DEFAULT_GZIP_ENABLED; +} + +const converter = ortbConverter({ + context: { + netRevenue: true, // or false if your adapter should set bidResponse.netRevenue = false + ttl: 90, // default bidResponse.ttl (when not specified in ORTB response.seatbid[].bid[].exp) + }, + imp(buildImp, bidRequest: BidRequest, context) { + let imp: ORTBImp = buildImp(bidRequest, context); + imp = enrichImp(imp, bidRequest); + const divId = bidRequest.params.divId || bidRequest.adUnitCode; + const slotEl: HTMLElement | null = typeof divId === 'string' ? document.getElementById(divId) : null; + if (slotEl) { + const { width, height } = getBoundingClientRect(slotEl); + deepSetValue(imp, 'ext.dimensions.slotW', width); + deepSetValue(imp, 'ext.dimensions.slotH', height); + deepSetValue(imp, 'ext.dimensions.cssMaxW', slotEl.style?.maxWidth); + deepSetValue(imp, 'ext.dimensions.cssMaxH', slotEl.style?.maxHeight); + } + deepSetValue(imp, 'ext.nexx360', bidRequest.params); + deepSetValue(imp, 'ext.nexx360.divId', divId); + if (bidRequest.params.adUnitPath) deepSetValue(imp, 'ext.adUnitPath', bidRequest.params.adUnitPath); + if (bidRequest.params.adUnitName) deepSetValue(imp, 'ext.adUnitName', bidRequest.params.adUnitName); + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + let request: ORTBRequest = buildRequest(imps, bidderRequest, context); + const amxId = getAmxId(STORAGE, BIDDER_CODE); + request = enrichRequest(request, amxId, PAGE_VIEW_ID, BIDDER_VERSION); + return request; + }, +}); + +const isBidRequestValid = (bid: BidRequest): boolean => { + if (bid.params.adUnitName && (typeof bid.params.adUnitName !== 'string' || bid.params.adUnitName === '')) { + logError('bid.params.adUnitName needs to be a string'); + return false; + } + if (bid.params.adUnitPath && (typeof bid.params.adUnitPath !== 'string' || bid.params.adUnitPath === '')) { + logError('bid.params.adUnitPath needs to be a string'); + return false; + } + if (bid.params.divId && (typeof bid.params.divId !== 'string' || bid.params.divId === '')) { + logError('bid.params.divId needs to be a string'); + return false; + } + if (bid.params.allBids && typeof bid.params.allBids !== 'boolean') { + logError('bid.params.allBids needs to be a boolean'); + return false; + } + if (!bid.params.tagId && !bid.params.videoTagId && !bid.params.nativeTagId && !bid.params.placement) { + logError('bid.params.tagId or bid.params.videoTagId or bid.params.nativeTagId or bid.params.placement must be defined'); + return false; + } + return true; +}; + +const buildRequests = ( + bidRequests: BidRequest[], + bidderRequest: ClientBidderRequest, +): AdapterRequest => { + const data: ORTBRequest = converter.toORTB({ bidRequests, bidderRequest }) + const adapterRequest: AdapterRequest = { + method: 'POST', + url: REQUEST_URL, + data, + options: { + endpointCompression: getGzipSetting() + }, + } + return adapterRequest; +} + +export const spec: BidderSpec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, +}; + +registerBidder(spec); diff --git a/modules/koblerBidAdapter.js b/modules/koblerBidAdapter.js index 2b0c55b2fb9..0eb6a74abfe 100644 --- a/modules/koblerBidAdapter.js +++ b/modules/koblerBidAdapter.js @@ -204,7 +204,12 @@ function buildOpenRtbImpObject(validBidRequest) { }, bidfloor: floorInfo.floor, bidfloorcur: floorInfo.currency, - pmp: buildPmpObject(validBidRequest) + pmp: buildPmpObject(validBidRequest), + ext: { + prebid: { + adunitcode: validBidRequest.adUnitCode + } + } }; } diff --git a/modules/leagueMBidAdapter.js b/modules/leagueMBidAdapter.js new file mode 100644 index 00000000000..3a0f1cdbebb --- /dev/null +++ b/modules/leagueMBidAdapter.js @@ -0,0 +1,17 @@ +import {BANNER, VIDEO} from '../src/mediaTypes.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {buildRequests, getUserSyncs, interpretResponse, isBidRequestValid} from '../libraries/xeUtils/bidderUtils.js'; + +const BIDDER_CODE = 'leagueM'; +const ENDPOINT = 'https://pbjs.league-m.media'; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO], + isBidRequestValid: bid => isBidRequestValid(bid, ['pid']), + buildRequests: (validBidRequests, bidderRequest) => buildRequests(validBidRequests, bidderRequest, ENDPOINT), + interpretResponse, + getUserSyncs +} + +registerBidder(spec); diff --git a/modules/leagueMBidAdapter.md b/modules/leagueMBidAdapter.md new file mode 100644 index 00000000000..5ef96b0d66b --- /dev/null +++ b/modules/leagueMBidAdapter.md @@ -0,0 +1,54 @@ +# Overview + +``` +Module Name: LeagueM Bidder Adapter +Module Type: LeagueM Bidder Adapter +Maintainer: prebid@league-m.com +``` + +# Description + +Module that connects to league-m.media demand sources + +# Test Parameters +``` +var adUnits = [ + { + code: 'test-banner', + mediaTypes: { + banner: { + sizes: [[300, 250]], + } + }, + bids: [ + { + bidder: 'leagueM', + params: { + env: 'leagueM', + pid: 'aa8217e20131c095fe9dba67981040b0', + ext: {} + } + } + ] + }, + { + code: 'test-video', + sizes: [ [ 640, 480 ] ], + mediaTypes: { + video: { + playerSize: [640, 480], + context: 'instream', + skippable: true + } + }, + bids: [{ + bidder: 'leagueM', + params: { + env: 'leagueM', + pid: 'aa8217e20131c095fe9dba67981040b0', + ext: {} + } + }] + } +]; +``` diff --git a/modules/limelightDigitalBidAdapter.js b/modules/limelightDigitalBidAdapter.js index 283a89c5a3f..34c5704155f 100644 --- a/modules/limelightDigitalBidAdapter.js +++ b/modules/limelightDigitalBidAdapter.js @@ -1,7 +1,8 @@ -import { logMessage, groupBy, flatten, uniques } from '../src/utils.js'; +import { uniques, flatten, deepSetValue } from '../src/utils.js'; import { registerBidder } from '../src/adapters/bidderFactory.js'; import { BANNER, VIDEO } from '../src/mediaTypes.js'; import { ajax } from '../src/ajax.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; /** * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest @@ -10,6 +11,9 @@ import { ajax } from '../src/ajax.js'; */ const BIDDER_CODE = 'limelightDigital'; +const DEFAULT_NET_REVENUE = true; +const DEFAULT_TTL = 300; +const MTYPE_MAP = { 1: BANNER, 2: VIDEO }; /** * Determines whether or not the given bid response is valid. @@ -21,7 +25,7 @@ function isBidResponseValid(bid) { if (!bid.requestId || !bid.cpm || !bid.creativeId || !bid.ttl || !bid.currency || !bid.meta.advertiserDomains) { return false; } - switch (bid.meta.mediaType) { + switch (bid.mediaType) { case BANNER: return Boolean(bid.width && bid.height && bid.ad); case VIDEO: @@ -30,12 +34,46 @@ function isBidResponseValid(bid) { return false; } +const converter = ortbConverter({ + context: { + netRevenue: DEFAULT_NET_REVENUE, + ttl: DEFAULT_TTL + }, + imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + for (let i = 1; i <= 5; i++) { + const customValue = bidRequest.params[`custom${i}`]; + if (customValue !== undefined) { + deepSetValue(imp, `ext.c${i}`, customValue); + } + } + deepSetValue(imp, `ext.adUnitId`, bidRequest.params.adUnitId); + return imp; + }, + bidResponse(buildBidResponse, bid, context) { + let mediaType; + if (bid.mtype) { + mediaType = MTYPE_MAP[bid.mtype]; + } + if (!mediaType && bid.ext?.mediaType) { + mediaType = bid.ext.mediaType; + } + if (!mediaType && context.imp) { + if (context.imp.banner) mediaType = BANNER; + else if (context.imp.video) mediaType = VIDEO; + } + if (mediaType) { + context.mediaType = mediaType; + } + return buildBidResponse(bid, context); + } +}); + export const spec = { code: BIDDER_CODE, aliases: [ { code: 'pll' }, { code: 'iionads', gvlid: 1358 }, - { code: 'apester' }, { code: 'adsyield' }, { code: 'tgm' }, { code: 'adtg_org' }, @@ -45,7 +83,6 @@ export const spec = { { code: 'stellorMediaRtb' }, { code: 'smootai' }, { code: 'anzuExchange' }, - { code: 'adnimation' }, { code: 'rtbdemand' }, { code: 'altstar' }, { code: 'vaayaMedia' }, @@ -66,22 +103,62 @@ export const spec = { }, /** - * Make a server request from the list of BidRequests. + * Make a server request from the list of BidRequests using OpenRTB format. * - * @return ServerRequest Info describing the request to the server. + * @param {BidRequest[]} validBidRequests - Array of valid bid requests + * @param {Object} bidderRequest - The bidder request object + * @return {Object[]} Array of server requests */ buildRequests: (validBidRequests, bidderRequest) => { - let winTop; - try { - winTop = window.top; - winTop.location.toString(); - } catch (e) { - logMessage(e); - winTop = window; - } - const placements = groupBy(validBidRequests.map(bidRequest => buildPlacement(bidRequest)), 'host') - return Object.keys(placements) - .map(host => buildRequest(winTop, host, placements[host].map(placement => placement.adUnit), bidderRequest)); + const normalizedBids = validBidRequests.map(bid => { + const adUnitType = bid.params.adUnitType || BANNER + if (!bid.mediaTypes && bid.sizes) { + if (adUnitType === BANNER) { + return { ...bid, mediaTypes: { banner: { sizes: bid.sizes } } }; + } else { + return { ...bid, mediaTypes: { video: { playerSize: bid.sizes } } }; + } + } + if (bid.mediaTypes && bid.sizes) { + const mediaTypes = { ...bid.mediaTypes }; + if (adUnitType === BANNER && mediaTypes.banner) { + mediaTypes.banner = { + ...mediaTypes.banner, + sizes: (mediaTypes.banner.sizes || []).concat(bid.sizes) + }; + } + if (adUnitType === VIDEO && mediaTypes.video) { + mediaTypes.video = { + ...mediaTypes.video, + playerSize: (mediaTypes.video.playerSize || []).concat(bid.sizes) + }; + } + return { ...bid, mediaTypes }; + } + return bid; + }); + const bidRequestsByHost = normalizedBids.reduce((groups, bid) => { + const host = bid.params.host; + groups[host] = groups[host] || []; + groups[host].push(bid); + return groups; + }, {}); + const enrichedBidderRequest = { + ...bidderRequest, + ortb2: { + ...bidderRequest.ortb2, + site: { + ...bidderRequest.ortb2?.site, + page: bidderRequest.ortb2?.site?.page || bidderRequest.refererInfo?.page + } + } + }; + + return Object.entries(bidRequestsByHost).map(([host, bids]) => ({ + method: 'POST', + url: `https://${host}/ortbhb`, + data: converter.toORTB({ bidRequests: bids, bidderRequest: enrichedBidderRequest }) + })); }, /** @@ -100,22 +177,20 @@ export const spec = { }, /** - * Unpack the response from the server into a list of bids. + * Unpack the OpenRTB response from the server into a list of bids. * - * @param {ServerResponse} serverResponse A successful response from the server. - * @return {Bid[]} An array of bids which were nested inside the server. + * @param {ServerResponse} response - A successful response from the server + * @param {Object} request - The request object that was sent + * @return {Bid[]} An array of bids */ - interpretResponse: (serverResponse, bidRequest) => { - const bidResponses = []; - const serverBody = serverResponse.body; - const len = serverBody.length; - for (let i = 0; i < len; i++) { - const bidResponse = serverBody[i]; - if (isBidResponseValid(bidResponse)) { - bidResponses.push(bidResponse); - } + interpretResponse: (response, request) => { + if (!response.body) { + return []; } - return bidResponses; + return converter.fromORTB({ + response: response.body, + request: request.data + }).bids.filter(bid => isBidResponseValid(bid)); }, getUserSyncs: (syncOptions, serverResponses, gdprConsent, uspConsent) => { @@ -137,74 +212,3 @@ export const spec = { }; registerBidder(spec); - -function buildRequest(winTop, host, adUnits, bidderRequest) { - return { - method: 'POST', - url: `https://${host}/hb`, - data: { - secure: (location.protocol === 'https:'), - deviceWidth: winTop.screen.width, - deviceHeight: winTop.screen.height, - adUnits: adUnits, - ortb2: bidderRequest?.ortb2, - refererInfo: bidderRequest?.refererInfo, - sua: bidderRequest?.ortb2?.device?.sua, - page: bidderRequest?.ortb2?.site?.page || bidderRequest?.refererInfo?.page - } - } -} - -function buildPlacement(bidRequest) { - let sizes; - if (bidRequest.mediaTypes) { - switch (bidRequest.params.adUnitType) { - case BANNER: - if (bidRequest.mediaTypes.banner && bidRequest.mediaTypes.banner.sizes) { - sizes = bidRequest.mediaTypes.banner.sizes; - } - break; - case VIDEO: - if (bidRequest.mediaTypes.video && bidRequest.mediaTypes.video.playerSize) { - sizes = [bidRequest.mediaTypes.video.playerSize]; - } - break; - } - } - sizes = (sizes || []).concat(bidRequest.sizes || []); - return { - host: bidRequest.params.host, - adUnit: { - id: bidRequest.params.adUnitId, - bidId: bidRequest.bidId, - transactionId: bidRequest.ortb2Imp?.ext?.tid, - sizes: sizes.map(size => { - let floorInfo = null; - if (typeof bidRequest.getFloor === 'function') { - try { - floorInfo = bidRequest.getFloor({ - currency: 'USD', - mediaType: bidRequest.params.adUnitType, - size: [size[0], size[1]] - }); - } catch (e) {} - } - return { - width: size[0], - height: size[1], - floorInfo: floorInfo - }; - }), - type: bidRequest.params.adUnitType.toUpperCase(), - ortb2Imp: bidRequest.ortb2Imp, - publisherId: bidRequest.params.publisherId, - userIdAsEids: bidRequest.userIdAsEids, - supplyChain: bidRequest?.ortb2?.source?.ext?.schain, - custom1: bidRequest.params.custom1, - custom2: bidRequest.params.custom2, - custom3: bidRequest.params.custom3, - custom4: bidRequest.params.custom4, - custom5: bidRequest.params.custom5 - } - } -} diff --git a/modules/locIdSystem.js b/modules/locIdSystem.js new file mode 100644 index 00000000000..5e06e29b302 --- /dev/null +++ b/modules/locIdSystem.js @@ -0,0 +1,676 @@ +/** + * This file is licensed under the Apache 2.0 license. + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * This module adds LocID to the User ID module + * The {@link module:modules/userId} module is required. + * @module modules/locIdSystem + * @requires module:modules/userId + */ + +import { logWarn, logError } from '../src/utils.js'; +import { submodule } from '../src/hook.js'; +import { gppDataHandler, uspDataHandler } from '../src/adapterManager.js'; +import { ajaxBuilder } from '../src/ajax.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { VENDORLESS_GVLID } from '../src/consentHandler.js'; +import { MODULE_TYPE_UID } from '../src/activities/modules.js'; + +const MODULE_NAME = 'locId'; +const LOG_PREFIX = 'LocID:'; +const DEFAULT_TIMEOUT_MS = 800; +const DEFAULT_EID_SOURCE = 'locid.com'; +// EID atype: 1 = AdCOM AgentTypeWeb (agent type for web environments) +const DEFAULT_EID_ATYPE = 1; +const MAX_ID_LENGTH = 512; +const MAX_CONNECTION_IP_LENGTH = 64; +const DEFAULT_IP_CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours +const IP_CACHE_SUFFIX = '_ip'; + +export const storage = getStorageManager({ moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME }); + +/** + * Normalizes privacy mode config to a boolean flag. + * Supports both requirePrivacySignals (boolean) and privacyMode (string enum). + * + * Precedence: requirePrivacySignals (if true) takes priority over privacyMode. + * - privacyMode is the preferred high-level setting for new integrations. + * - requirePrivacySignals exists for backwards compatibility with integrators + * who prefer a simple boolean. + * + * @param {Object} params - config.params + * @returns {boolean} true if privacy signals are required, false otherwise + */ +function shouldRequirePrivacySignals(params) { + // requirePrivacySignals=true takes precedence (backwards compatibility) + if (params?.requirePrivacySignals === true) { + return true; + } + if (params?.privacyMode === 'requireSignals') { + return true; + } + // Default: allowWithoutSignals + return false; +} + +function getUspConsent(consentData) { + if (consentData && consentData.usp != null) { + return consentData.usp; + } + return consentData?.uspConsent; +} + +function getGppConsent(consentData) { + if (consentData && consentData.gpp != null) { + return consentData.gpp; + } + return consentData?.gppConsent; +} + +/** + * Checks if any privacy signals are present in consentData or data handlers. + * + * IMPORTANT: gdprApplies alone does NOT count as a privacy signal. + * A publisher may set gdprApplies=true without having a CMP installed. + * We only consider GDPR signals "present" when actual consent framework + * artifacts exist (consentString, vendorData). This supports LI-based + * operation where no TCF consent string is required. + * + * "Signals present" means ANY of the following are available: + * - consentString or gdpr.consentString (indicates CMP provided framework data) + * - vendorData or gdpr.vendorData (indicates CMP provided vendor data) + * - usp or uspConsent (US Privacy string) + * - gpp or gppConsent (GPP consent data) + * - Data from gppDataHandler or uspDataHandler + * + * @param {Object} consentData - The consent data object passed to getId + * @returns {boolean} true if any privacy signals are present + */ +function hasPrivacySignals(consentData) { + // Check GDPR-related signals (flat and nested) + // NOTE: gdprApplies alone is NOT a signal - it just indicates jurisdiction. + // A signal requires actual CMP artifacts (consentString or vendorData). + if (consentData?.consentString || consentData?.gdpr?.consentString) { + return true; + } + if (consentData?.vendorData || consentData?.gdpr?.vendorData) { + return true; + } + + // Check USP consent + const uspConsent = getUspConsent(consentData); + if (uspConsent) { + return true; + } + + // Check GPP consent + const gppConsent = getGppConsent(consentData); + if (gppConsent) { + return true; + } + + // Check data handlers + const uspFromHandler = uspDataHandler.getConsentData(); + if (uspFromHandler) { + return true; + } + + const gppFromHandler = gppDataHandler.getConsentData(); + if (gppFromHandler) { + return true; + } + + return false; +} + +function isValidId(id) { + return typeof id === 'string' && id.trim().length > 0 && id.length <= MAX_ID_LENGTH; +} + +function isValidConnectionIp(ip) { + return typeof ip === 'string' && ip.length > 0 && ip.length <= MAX_CONNECTION_IP_LENGTH; +} + +function normalizeStoredId(storedId) { + if (!storedId) { + return null; + } + if (typeof storedId === 'string') { + return null; + } + if (typeof storedId === 'object') { + // Preserve explicit null for id (means "empty tx_cloc, valid cached response"). + // 'id' in storedId is needed because ?? treats null as nullish and would + // incorrectly fall through to tx_cloc. + const hasExplicitId = 'id' in storedId; + const id = hasExplicitId ? storedId.id : (storedId.tx_cloc ?? null); + const connectionIp = storedId.connectionIp ?? storedId.connection_ip; + return { ...storedId, id, connectionIp }; + } + return null; +} + +/** + * Checks privacy framework signals. Returns true if ID operations are allowed. + * + * LocID operates under Legitimate Interest and does not require a TCF consent + * string when no privacy framework is present. When privacy signals exist, + * framework processing restrictions are enforced. + * + * @param {Object} consentData - The consent data object from Prebid + * @param {Object} params - config.params for privacy mode settings + * @returns {boolean} true if ID operations are allowed + */ +function hasValidConsent(consentData, params) { + const requireSignals = shouldRequirePrivacySignals(params); + const signalsPresent = hasPrivacySignals(consentData); + + // B) If privacy signals are NOT present + if (!signalsPresent) { + if (requireSignals) { + logWarn(LOG_PREFIX, 'Privacy signals required but none present'); + return false; + } + // Default: allow operation without privacy signals (LI-based operation) + return true; + } + + // A) Privacy signals ARE present - enforce applicable restrictions + // + // Note: privacy signals can come from GDPR, USP, or GPP. GDPR checks only + // apply when GDPR is flagged AND CMP artifacts (consentString/vendorData) + // are present. gdprApplies alone does not trigger GDPR enforcement. + + // Check GDPR - support both flat and nested shapes + const gdprApplies = consentData?.gdprApplies === true || consentData?.gdpr?.gdprApplies === true; + const consentString = consentData?.consentString || consentData?.gdpr?.consentString; + const vendorData = consentData?.vendorData || consentData?.gdpr?.vendorData; + const gdprCmpArtifactsPresent = !!(consentString || vendorData); + + if (gdprApplies && gdprCmpArtifactsPresent) { + // When GDPR applies AND we have CMP signals, require consentString + if (!consentString || consentString.length === 0) { + logWarn(LOG_PREFIX, 'GDPR framework data missing consent string'); + return false; + } + } + + // Check USP for processing restriction + const uspData = getUspConsent(consentData) ?? uspDataHandler.getConsentData(); + if (uspData && uspData.length >= 3 && uspData.charAt(2) === 'Y') { + logWarn(LOG_PREFIX, 'US Privacy framework processing restriction detected'); + return false; + } + + // Check GPP for processing restriction + const gppData = getGppConsent(consentData) ?? gppDataHandler.getConsentData(); + if (gppData?.applicableSections?.includes(7) && + gppData?.parsedSections?.usnat?.KnownChildSensitiveDataConsents?.includes(1)) { + logWarn(LOG_PREFIX, 'GPP usnat KnownChildSensitiveDataConsents processing restriction detected'); + return false; + } + + return true; +} + +function parseEndpointResponse(response) { + if (!response) { + return null; + } + try { + return typeof response === 'string' ? JSON.parse(response) : response; + } catch (e) { + logError(LOG_PREFIX, 'Error parsing endpoint response:', e.message); + return null; + } +} + +/** + * Extracts LocID from endpoint response. + * Only tx_cloc is accepted. + */ +function extractLocIdFromResponse(parsed) { + if (!parsed) return null; + + if (isValidId(parsed.tx_cloc)) { + return parsed.tx_cloc; + } + + logWarn(LOG_PREFIX, 'Could not extract valid tx_cloc from response'); + return null; +} + +function extractConnectionIp(parsed) { + if (!parsed) { + return null; + } + const connectionIp = parsed.connection_ip ?? parsed.connectionIp; + return isValidConnectionIp(connectionIp) ? connectionIp : null; +} + +function getIpCacheKey(config) { + const baseName = config?.storage?.name || '_locid'; + return config?.params?.ipCacheName || (baseName + IP_CACHE_SUFFIX); +} + +function getIpCacheTtlMs(config) { + const ttl = config?.params?.ipCacheTtlMs; + return (typeof ttl === 'number' && ttl > 0) ? ttl : DEFAULT_IP_CACHE_TTL_MS; +} + +function readIpCache(config) { + try { + const key = getIpCacheKey(config); + const raw = storage.getDataFromLocalStorage(key); + if (!raw) return null; + const entry = JSON.parse(raw); + if (!entry || typeof entry !== 'object') return null; + if (!isValidConnectionIp(entry.ip)) return null; + if (typeof entry.expiresAt === 'number' && Date.now() > entry.expiresAt) return null; + return entry; + } catch (e) { + logWarn(LOG_PREFIX, 'Error reading IP cache:', e.message); + return null; + } +} + +function writeIpCache(config, ip) { + if (!isValidConnectionIp(ip)) return; + try { + const key = getIpCacheKey(config); + const nowMs = Date.now(); + const ttlMs = getIpCacheTtlMs(config); + const entry = { + ip: ip, + fetchedAt: nowMs, + expiresAt: nowMs + ttlMs + }; + storage.setDataInLocalStorage(key, JSON.stringify(entry)); + } catch (e) { + logWarn(LOG_PREFIX, 'Error writing IP cache:', e.message); + } +} + +/** + * Parses an IP response from an IP-only endpoint. + * Supports JSON ({ip: "..."}, {connection_ip: "..."}) and plain text IP. + */ +function parseIpResponse(response) { + if (!response) return null; + + if (typeof response === 'string') { + const trimmed = response.trim(); + if (trimmed.charAt(0) === '{') { + try { + const parsed = JSON.parse(trimmed); + const ip = parsed.ip || parsed.connection_ip || parsed.connectionIp; + return isValidConnectionIp(ip) ? ip : null; + } catch (e) { + // Not valid JSON, try as plain text + } + } + return isValidConnectionIp(trimmed) ? trimmed : null; + } + + if (typeof response === 'object') { + const ip = response.ip || response.connection_ip || response.connectionIp; + return isValidConnectionIp(ip) ? ip : null; + } + + return null; +} + +/** + * Checks if a stored tx_cloc entry is valid for reuse. + * Accepts both valid id strings AND null (empty tx_cloc is a valid cached result). + */ +function isStoredEntryReusable(normalizedStored, currentIp) { + if (!normalizedStored || !isValidConnectionIp(normalizedStored.connectionIp)) { + return false; + } + if (isExpired(normalizedStored)) { + return false; + } + if (currentIp && normalizedStored.connectionIp !== currentIp) { + return false; + } + // id must be either a valid string or explicitly null (empty tx_cloc) + return normalizedStored.id === null || isValidId(normalizedStored.id); +} + +function getExpiresAt(config, nowMs) { + const expiresDays = config?.storage?.expires; + if (typeof expiresDays !== 'number' || expiresDays <= 0) { + return undefined; + } + return nowMs + (expiresDays * 24 * 60 * 60 * 1000); +} + +function buildStoredId(id, connectionIp, config) { + const nowMs = Date.now(); + return { + id, + connectionIp, + createdAt: nowMs, + updatedAt: nowMs, + expiresAt: getExpiresAt(config, nowMs) + }; +} + +function isExpired(storedEntry) { + return typeof storedEntry?.expiresAt === 'number' && Date.now() > storedEntry.expiresAt; +} + +/** + * Builds the request URL, appending altId if configured. + * Preserves URL fragments by appending query params before the hash. + */ +function buildRequestUrl(endpoint, altId) { + if (!altId) { + return endpoint; + } + + // Split on hash to preserve fragment + const hashIndex = endpoint.indexOf('#'); + let base = endpoint; + let fragment = ''; + + if (hashIndex !== -1) { + base = endpoint.substring(0, hashIndex); + fragment = endpoint.substring(hashIndex); + } + + const separator = base.includes('?') ? '&' : '?'; + return `${base}${separator}alt_id=${encodeURIComponent(altId)}${fragment}`; +} + +/** + * Fetches LocID from the configured endpoint (GET only). + */ +function fetchLocIdFromEndpoint(config, callback) { + const params = config?.params || {}; + const endpoint = params.endpoint; + const timeoutMs = params.timeoutMs || DEFAULT_TIMEOUT_MS; + + if (!endpoint) { + logError(LOG_PREFIX, 'No endpoint configured'); + callback(undefined); + return; + } + + const requestUrl = buildRequestUrl(endpoint, params.altId); + + const requestOptions = { + method: 'GET', + contentType: 'application/json', + withCredentials: params.withCredentials === true + }; + + // Add x-api-key header if apiKey is configured + if (params.apiKey) { + requestOptions.customHeaders = { + 'x-api-key': params.apiKey + }; + } + + let callbackFired = false; + const safeCallback = (result) => { + if (!callbackFired) { + callbackFired = true; + callback(result); + } + }; + + const onSuccess = (response) => { + const parsed = parseEndpointResponse(response); + if (!parsed) { + safeCallback(undefined); + return; + } + const connectionIp = extractConnectionIp(parsed); + if (!connectionIp) { + logWarn(LOG_PREFIX, 'Missing or invalid connection_ip in response'); + safeCallback(undefined); + return; + } + // tx_cloc may be null (empty/missing for this IP) -- this is a valid cacheable result. + // connection_ip is always required. + const locId = extractLocIdFromResponse(parsed); + writeIpCache(config, connectionIp); + safeCallback(buildStoredId(locId, connectionIp, config)); + }; + + const onError = (error) => { + logWarn(LOG_PREFIX, 'Request failed:', error); + safeCallback(undefined); + }; + + try { + const ajax = ajaxBuilder(timeoutMs); + ajax(requestUrl, { success: onSuccess, error: onError }, null, requestOptions); + } catch (e) { + logError(LOG_PREFIX, 'Error initiating request:', e.message); + safeCallback(undefined); + } +} + +/** + * Fetches the connection IP from a separate lightweight endpoint (GET only). + * Callback receives the IP string on success or null on failure. + */ +function fetchIpFromEndpoint(config, callback) { + const params = config?.params || {}; + const ipEndpoint = params.ipEndpoint; + const timeoutMs = params.timeoutMs || DEFAULT_TIMEOUT_MS; + + if (!ipEndpoint) { + callback(null); + return; + } + + let callbackFired = false; + const safeCallback = (result) => { + if (!callbackFired) { + callbackFired = true; + callback(result); + } + }; + + const onSuccess = (response) => { + const ip = parseIpResponse(response); + safeCallback(ip); + }; + + const onError = (error) => { + logWarn(LOG_PREFIX, 'IP endpoint request failed:', error); + safeCallback(null); + }; + + try { + const ajax = ajaxBuilder(timeoutMs); + const requestOptions = { + method: 'GET', + withCredentials: params.withCredentials === true + }; + if (params.apiKey) { + requestOptions.customHeaders = { + 'x-api-key': params.apiKey + }; + } + ajax(ipEndpoint, { success: onSuccess, error: onError }, null, requestOptions); + } catch (e) { + logError(LOG_PREFIX, 'Error initiating IP request:', e.message); + safeCallback(null); + } +} + +export const locIdSubmodule = { + name: MODULE_NAME, + aliasName: 'locid', + gvlid: VENDORLESS_GVLID, + + /** + * Decode stored value into userId object. + */ + decode(value) { + if (!value || typeof value !== 'object') { + return undefined; + } + const id = value?.id ?? value?.tx_cloc; + const connectionIp = value?.connectionIp ?? value?.connection_ip; + if (isValidId(id) && isValidConnectionIp(connectionIp)) { + return { locId: id }; + } + return undefined; + }, + + /** + * Get the LocID from endpoint. + * Returns {id} for sync or {callback} for async per Prebid patterns. + * + * Two-tier cache: IP cache (4h default) and tx_cloc cache (7d default). + * IP is refreshed more frequently to detect network changes while keeping + * tx_cloc stable for its full cache period. + */ + getId(config, consentData, storedId) { + const params = config?.params || {}; + + // Check privacy restrictions first + if (!hasValidConsent(consentData, params)) { + return undefined; + } + + const normalizedStored = normalizeStoredId(storedId); + const cachedIp = readIpCache(config); + + // Step 1: IP cache is valid -- check if tx_cloc matches + if (cachedIp) { + if (isStoredEntryReusable(normalizedStored, cachedIp.ip)) { + return { id: normalizedStored }; + } + // IP cached but tx_cloc missing, expired, or IP mismatch -- full fetch + return { + callback: (callback) => { + fetchLocIdFromEndpoint(config, callback); + } + }; + } + + // Step 2: IP cache expired or missing + if (params.ipEndpoint) { + // Two-call optimization: lightweight IP check first + return { + callback: (callback) => { + fetchIpFromEndpoint(config, (freshIp) => { + if (!freshIp) { + // IP fetch failed; fall back to main endpoint + fetchLocIdFromEndpoint(config, callback); + return; + } + writeIpCache(config, freshIp); + // Check if stored tx_cloc matches the fresh IP + if (isStoredEntryReusable(normalizedStored, freshIp)) { + callback(normalizedStored); + return; + } + // IP changed or no valid tx_cloc -- full fetch + fetchLocIdFromEndpoint(config, callback); + }); + } + }; + } + + // Step 3: No ipEndpoint configured -- call main endpoint to refresh IP. + // Only update tx_cloc if IP changed or tx_cloc cache expired. + return { + callback: (callback) => { + fetchLocIdFromEndpoint(config, (freshEntry) => { + if (!freshEntry) { + callback(undefined); + return; + } + // Honor empty tx_cloc: if the server returned null, use the fresh + // entry so stale identifiers are cleared (cached as id: null). + if (freshEntry.id === null) { + callback(freshEntry); + return; + } + // IP is already cached by fetchLocIdFromEndpoint's onSuccess. + // Check if we should preserve the existing tx_cloc (avoid churning it). + if (normalizedStored?.id !== null && isStoredEntryReusable(normalizedStored, freshEntry.connectionIp)) { + callback(normalizedStored); + return; + } + // IP changed or tx_cloc expired/missing -- use fresh entry + callback(freshEntry); + }); + } + }; + }, + + /** + * Extend existing LocID. + * Accepts id: null (empty tx_cloc) as a valid cached result. + * If IP cache is missing/expired/mismatched, return a callback to refresh. + */ + extendId(config, consentData, storedId) { + const normalizedStored = normalizeStoredId(storedId); + if (!normalizedStored || !isValidConnectionIp(normalizedStored.connectionIp)) { + return undefined; + } + // Accept both valid id strings AND null (empty tx_cloc is a valid cached result) + if (normalizedStored.id !== null && !isValidId(normalizedStored.id)) { + return undefined; + } + if (isExpired(normalizedStored)) { + return undefined; + } + if (!hasValidConsent(consentData, config?.params)) { + return undefined; + } + const refreshInSeconds = config?.storage?.refreshInSeconds; + if (typeof refreshInSeconds === 'number' && refreshInSeconds > 0) { + const createdAt = normalizedStored.createdAt; + if (typeof createdAt !== 'number') { + return undefined; + } + const refreshAfterMs = refreshInSeconds * 1000; + if (Date.now() - createdAt >= refreshAfterMs) { + return undefined; + } + } + // Check IP cache -- if expired/missing or IP changed, trigger re-fetch + const cachedIp = readIpCache(config); + if (!cachedIp || cachedIp.ip !== normalizedStored.connectionIp) { + return { + callback: (callback) => { + fetchLocIdFromEndpoint(config, callback); + } + }; + } + return { id: normalizedStored }; + }, + + /** + * EID configuration following standard Prebid shape. + */ + eids: { + locId: { + source: DEFAULT_EID_SOURCE, + atype: DEFAULT_EID_ATYPE, + getValue: function (data) { + if (typeof data === 'string') { + return data; + } + if (!data || typeof data !== 'object') { + return undefined; + } + return data?.id ?? data?.tx_cloc ?? data?.locId ?? data?.locid; + } + } + } +}; + +submodule('userId', locIdSubmodule); diff --git a/modules/locIdSystem.md b/modules/locIdSystem.md new file mode 100644 index 00000000000..6ffe02fe841 --- /dev/null +++ b/modules/locIdSystem.md @@ -0,0 +1,250 @@ +# LocID User ID Submodule + +## Overview + +The LocID User ID submodule retrieves a LocID from a configured first-party endpoint, honors applicable privacy framework processing restrictions when present, persists the identifier using Prebid's storage framework, and exposes the ID to bidders via the standard EIDs interface. + +LocID is a geospatial identifier provided by Digital Envoy. The endpoint is a publisher-controlled, first-party or on-premises service operated by the publisher, GrowthCode, or Digital Envoy. The endpoint derives location information server-side. The browser module does not transmit IP addresses. + +## Configuration + +```javascript +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'locId', + params: { + endpoint: 'https://id.example.com/locid', + ipEndpoint: 'https://id.example.com/ip' // optional: lightweight IP-only check + }, + storage: { + type: 'html5', + name: '_locid', + expires: 7 + } + }] + } +}); +``` + +## Parameters + +| Parameter | Type | Required | Default | Description | +| ----------------------- | ------- | -------- | ----------------------- | ----------------------------------------------------------------------------- | +| `endpoint` | String | Yes | – | First-party LocID endpoint (see Endpoint Requirements below) | +| `ipEndpoint` | String | No | – | Separate endpoint returning the connection IP (see IP Change Detection below) | +| `ipCacheTtlMs` | Number | No | `14400000` (4h) | TTL for the IP cache entry in milliseconds | +| `ipCacheName` | String | No | `{storage.name}_ip` | localStorage key for the IP cache (auto-derived if not set) | +| `altId` | String | No | – | Alternative identifier appended as `?alt_id=` query param | +| `timeoutMs` | Number | No | `800` | Request timeout in milliseconds | +| `withCredentials` | Boolean | No | `false` | Whether to include credentials on the request | +| `apiKey` | String | No | – | API key sent as `x-api-key` header on `endpoint` and `ipEndpoint` requests | +| `requirePrivacySignals` | Boolean | No | `false` | If `true`, requires privacy signals to be present | +| `privacyMode` | String | No | `'allowWithoutSignals'` | `'allowWithoutSignals'` or `'requireSignals'` | + +**Note on privacy configuration:** `privacyMode` is the preferred high-level setting for new integrations. `requirePrivacySignals` exists for backwards compatibility with integrators who prefer a simple boolean. If `requirePrivacySignals: true` is set, it takes precedence. + +### Endpoint Requirements + +The `endpoint` parameter must point to a **first-party proxy** or **on-premises service**—not the raw LocID Encrypt API directly. + +The LocID Encrypt API (`GET /encrypt?ip=&alt_id=`) requires the client IP address as a parameter. Since browsers cannot reliably determine their own public IP, a server-side proxy is required to: + +1. Receive the request from the browser +2. Extract the client IP from the incoming connection +3. Forward the request to the LocID Encrypt API with the IP injected +4. Return the response with `tx_cloc` and `connection_ip` to the browser (any `stable_cloc` is ignored client-side) + +This architecture ensures the browser never transmits IP addresses and the LocID service receives accurate location data. + +If you configure `altId`, the module appends it as `?alt_id=` to the endpoint URL. Your proxy can then forward this to the LocID API. + +### CORS Configuration + +If your endpoint is on a different origin or you set `withCredentials: true`, ensure your server returns appropriate CORS headers: + +```http +Access-Control-Allow-Origin: +Access-Control-Allow-Credentials: true +``` + +When using `withCredentials`, the server cannot use `Access-Control-Allow-Origin: *`; it must specify the exact origin. + +### Storage Configuration + +| Parameter | Required | Description | +| --------- | -------- | ---------------- | +| `type` | Yes | `'html5'` | +| `name` | Yes | Storage key name | +| `expires` | No | TTL in days | + +### Stored Value Format + +The module stores a structured object (rather than a raw string) so it can track IP-aware metadata: + +```json +{ + "id": "", + "connectionIp": "203.0.113.42", + "createdAt": 1738147200000, + "updatedAt": 1738147200000, + "expiresAt": 1738752000000 +} +``` + +When the endpoint returns a valid `connection_ip` but no usable `tx_cloc` (`null`, missing, empty, or whitespace-only), `id` is stored as `null`. This caches the "no location for this IP" result for the full cache period without re-fetching. The `decode()` function returns `undefined` for `null` IDs, so no EID is emitted in bid requests. + +**Important:** String-only stored values are treated as invalid and are not emitted. + +### IP Cache Format + +The module maintains a separate IP cache entry in localStorage (default key: `{storage.name}_ip`) with a shorter TTL (default 4 hours): + +```json +{ + "ip": "203.0.113.42", + "fetchedAt": 1738147200000, + "expiresAt": 1738161600000 +} +``` + +This entry is managed by the module directly via Prebid's `storageManager` and is independent of the framework-managed tx_cloc cache. + +## Operation Flow + +The module uses a two-tier cache: an IP cache (default 4-hour TTL) and a tx_cloc cache (TTL defined by `storage.expires`). The IP is refreshed more frequently to detect network changes while keeping tx_cloc stable for its full cache period. + +1. The module checks the IP cache for a current connection IP. +2. If the IP cache is valid, the module compares it against the stored tx_cloc entry's `connectionIp`. +3. If the IPs match and the tx_cloc entry is not expired, the cached tx_cloc is reused (even if `null`). +4. If the IP cache is expired or missing and `ipEndpoint` is configured, the module calls `ipEndpoint` to get the current IP, then compares with the stored tx_cloc. If the IPs match, the tx_cloc is reused without calling the main endpoint. +5. If the IPs differ, or the tx_cloc is expired/missing, or `ipEndpoint` is not configured, the module calls the main endpoint to get a fresh tx_cloc and connection IP. +6. The endpoint response may include a `null`, empty, whitespace-only, or missing `tx_cloc` (indicating no location for this IP). This is cached as `id: null` for the full cache period, and overrides any previously stored non-null ID for that same IP. +7. Both the IP cache and tx_cloc cache are updated after each endpoint call. +8. The ID is included in bid requests via the EIDs array. Entries with `null` tx_cloc are omitted from bid requests. + +## Endpoint Response Requirements + +The proxy must return: + +```json +{ + "tx_cloc": "", + "connection_ip": "203.0.113.42" +} +``` + +Notes: + +- `connection_ip` is always required. If missing, the entire response is treated as a failure. +- `tx_cloc` may be `null`, missing, empty, or whitespace-only when no location is available for the IP. This is a valid response and will be cached as `id: null` for the configured cache period. +- `tx_cloc` is the only value the browser module will store/transmit. +- `stable_cloc` may exist in proxy responses for server-side caching, but the client ignores it. + +## IP Change Detection + +The module uses a two-tier cache to detect IP changes without churning the tx_cloc identifier: + +- **IP cache** (default 4-hour TTL): Tracks the current connection IP. Stored in a separate localStorage key (`{storage.name}_ip`). +- **tx_cloc cache** (`storage.expires`): Stores the LocID. Managed by Prebid's userId framework. + +When the IP cache expires, the module refreshes the IP. If the IP is unchanged and the tx_cloc cache is still valid, the existing tx_cloc is reused without calling the main endpoint. + +### ipEndpoint (optional) + +When `ipEndpoint` is configured, the module calls it for lightweight IP-only checks. This avoids a full tx_cloc API call when only the IP needs refreshing. The endpoint should return the connection IP in one of these formats: + +- JSON: `{"ip": "203.0.113.42"}` or `{"connection_ip": "203.0.113.42"}` +- Plain text: `203.0.113.42` + +If `apiKey` is configured, the `x-api-key` header is included on `ipEndpoint` requests using the same `customHeaders` mechanism as the main endpoint. + +When `ipEndpoint` is not configured, the module falls back to calling the main endpoint to refresh the IP, but only updates the stored tx_cloc when the IP has changed or the tx_cloc cache has expired. In this mode, IP changes are only detected when the IP cache is refreshed (for example when it expires and `extendId()` returns a refresh callback); there is no separate lightweight proactive IP probe. + +### Prebid Refresh Triggers + +When `storage.refreshInSeconds` is set, the module will reuse the cached ID until `createdAt + refreshInSeconds`; once due (or if `createdAt` is missing), `extendId()` returns `undefined` to indicate the cached ID should not be reused. The `extendId()` method also checks the IP cache: if the IP cache is expired or missing, or if the cached IP differs from the stored tx_cloc's IP, it returns a callback that refreshes via the main endpoint. This enforces the IP cache TTL (`ipCacheTtlMs`) even when the tx_cloc cache has not expired. + +## Consent Handling + +LocID operates under Legitimate Interest (LI). By default, the module proceeds when no privacy framework signals are present. When privacy signals exist, they are enforced. Privacy frameworks can only stop LocID via global processing restrictions; they do not enable it. +For TCF integration, the module declares Prebid's vendorless GVL marker so purpose-level enforcement applies without vendor-ID checks. + +### Legal Basis and IP-Based Identifiers + +LocID is derived from IP-based geolocation. Because IP addresses are transient and shared, there is no meaningful IP-level choice to express. Privacy frameworks are only consulted to honor rare, publisher- or regulator-level instructions to stop all processing. When such a global processing restriction is signaled, LocID respects it by returning `undefined`. + +### Default Behavior (allowWithoutSignals) + +- **No privacy signals present**: Module proceeds and fetches the ID +- **Privacy signals present**: Enforcement rules apply (see below) + +### Strict Mode (requireSignals) + +Set `requirePrivacySignals: true` or `privacyMode: 'requireSignals'` to require privacy signals: + +```javascript +params: { + endpoint: 'https://id.example.com/locid', + requirePrivacySignals: true +} +``` + +In strict mode, the module returns `undefined` if no privacy signals are present. + +### Privacy Signal Enforcement + +When privacy signals **are** present, the module does not fetch or return an ID if any of the following apply: + +- GDPR applies and vendorData is present, but consentString is missing or empty +- The US Privacy string indicates a global processing restriction (third character is 'Y') +- GPP signals indicate an applicable processing restriction + +When GDPR applies and `consentString` is present, the module proceeds unless a framework processing restriction is signaled. + +### Privacy Signals Detection + +The module considers privacy signals "present" if any of the following exist: + +- `consentString` (TCF consent string from CMP) +- `vendorData` (TCF vendor data from CMP) +- `usp` or `uspConsent` (US Privacy string) +- `gpp` or `gppConsent` (GPP consent data) +- Data from `uspDataHandler` or `gppDataHandler` + +**Important:** `gdprApplies` alone does NOT constitute a privacy signal. A publisher may indicate GDPR jurisdiction without having a CMP installed. TCF framework data is only required when actual CMP artifacts (`consentString` or `vendorData`) are present. This supports Legitimate Interest-based operation in deployments without a full TCF implementation. + +## EID Output + +When available, the LocID is exposed as: + +```javascript +{ + source: "locid.com", + uids: [{ + id: "", + atype: 1 // AdCOM AgentTypeWeb + }] +} +``` + +## Identifier Type + +- **`atype: 1`** — The AdCOM agent type for web (`AgentTypeWeb`). This is used in EID emission. + +`atype` is an OpenRTB agent type (environment), not an IAB GVL vendor ID. + +## Debugging + +```javascript +pbjs.getUserIds().locId +pbjs.refreshUserIds() +localStorage.getItem('_locid') +localStorage.getItem('_locid_ip') // IP cache entry +``` + +## Validation Checklist + +- [ ] EID is present in bid requests when no processing restriction is signaled +- [ ] No network request occurs when a global processing restriction is signaled +- [ ] Stored IDs are reused across page loads diff --git a/modules/mediasquareBidAdapter.js b/modules/mediasquareBidAdapter.js index cb358ec4687..1d0b8f61da0 100644 --- a/modules/mediasquareBidAdapter.js +++ b/modules/mediasquareBidAdapter.js @@ -139,6 +139,9 @@ export const spec = { bidResponse['mediasquare'][param] = value[param]; } }); + if ('burls' in value) { + bidResponse['mediasquare']['burls'] = value['burls']; + } if ('native' in value) { bidResponse['native'] = value['native']; bidResponse['mediaType'] = 'native'; @@ -182,9 +185,22 @@ export const spec = { } const params = { pbjs: '$prebid.version$', referer: encodeURIComponent(getRefererInfo().page || getRefererInfo().topmostLocation) }; const endpoint = document.location.search.match(/msq_test=true/) ? BIDDER_URL_TEST : BIDDER_URL_PROD; - let paramsToSearchFor = ['bidder', 'code', 'match', 'hasConsent', 'context', 'increment', 'ova']; + if (bid.hasOwnProperty('mediasquare')) { - paramsToSearchFor.forEach(param => { + // if burls then fire tracking pixels and exit + if (bid.mediasquare.hasOwnProperty('burls') && Array.isArray(bid.mediasquare.burls) && bid.mediasquare.burls.length > 0) { + bid.mediasquare.burls.forEach(burl => { + const url = burl && burl.url; + if (!url) return; + const method = (burl.method ?? "GET").toUpperCase(); + const data = (method === "POST" && burl.data ? burl.data : null); + ajax(url, null, data ? JSON.stringify(data) : null, {method: method, withCredentials: true}); + }); + return true; + } + // no burl so checking for other mediasquare params + let msqParamsToSearchFor = ['bidder', 'code', 'match', 'hasConsent', 'context', 'increment', 'ova']; + msqParamsToSearchFor.forEach(param => { if (bid['mediasquare'].hasOwnProperty(param)) { params[param] = bid['mediasquare'][param]; if (typeof params[param] === 'number') { @@ -193,7 +209,8 @@ export const spec = { } }); }; - paramsToSearchFor = ['cpm', 'size', 'mediaType', 'currency', 'creativeId', 'adUnitCode', 'timeToRespond', 'requestId', 'auctionId', 'originalCpm', 'originalCurrency']; + + let paramsToSearchFor = ['cpm', 'size', 'mediaType', 'currency', 'creativeId', 'adUnitCode', 'timeToRespond', 'requestId', 'auctionId', 'originalCpm', 'originalCurrency']; paramsToSearchFor.forEach(param => { if (bid.hasOwnProperty(param)) { params[param] = bid[param]; diff --git a/modules/mileBidAdapter.md b/modules/mileBidAdapter.md new file mode 100644 index 00000000000..0124c5f4327 --- /dev/null +++ b/modules/mileBidAdapter.md @@ -0,0 +1,72 @@ +# Overview + +``` +Module Name: Mile Bid Adapter +Module Type: Bidder Adapter +Maintainer: tech@mile.tech +``` + +# Description + +This bidder adapter connects to Mile demand sources. + +# Bid Params + +| Name | Scope | Description | Example | Type | +|---------------|----------|-----------------------------------|------------------|--------| +| `placementId` | required | The placement ID for the ad unit | `'12345'` | string | +| `siteId` | required | The site ID for the publisher | `'site123'` | string | +| `publisherId` | required | The publisher ID | `'pub456'` | string | + +# Example Configuration + +```javascript +var adUnits = [{ + code: 'test-banner', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bids: [{ + bidder: 'mile', + params: { + placementId: 'test-placement', + siteId: 'test-site', + publisherId: 'test-pub' + } + }] +}]; +``` + +# User Sync Configuration + +To enable user syncing, configure Prebid.js with: + +```javascript +pbjs.setConfig({ + userSync: { + iframeEnabled: true, // Enable iframe syncs + filterSettings: { + iframe: { + bidders: ['mile'], + filter: 'include' + } + } + } +}); +``` + +# Test Parameters + +```javascript +{ + bidder: 'mile', + params: { + placementId: 'test-placement', + siteId: 'test-site', + publisherId: 'test-publisher-id' + } +} +``` + diff --git a/modules/mileBidAdapter.ts b/modules/mileBidAdapter.ts new file mode 100644 index 00000000000..f24feb8b74b --- /dev/null +++ b/modules/mileBidAdapter.ts @@ -0,0 +1,428 @@ +import { type BidderSpec, registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER } from '../src/mediaTypes.js'; +import { deepAccess, deepSetValue, generateUUID, logInfo, logError } from '../src/utils.js'; +import { getDNT } from '../libraries/dnt/index.js'; +import { ajax } from '../src/ajax.js'; + +/** + * Mile Bid Adapter + * + * This adapter handles: + * 1. User syncs by sending requests to the prebid server cookie sync endpoint + * 2. Bid requests by parsing necessary parameters from the prebid auction + */ + +const BIDDER_CODE = 'mile'; + +const MILE_BIDDER_HOST = 'https://pbs.atmtd.com'; +const ENDPOINT_URL = `${MILE_BIDDER_HOST}/mile/v1/request`; +const USER_SYNC_ENDPOINT = `https://scripts.atmtd.com/user-sync/load-cookie.html`; + +const MILE_ANALYTICS_ENDPOINT = `https://e01.atmtd.com/bidanalytics-event/json`; + +type MileBidParams = { + placementId: string; + siteId: string; + publisherId: string; +}; + +declare module '../src/adUnits' { + interface BidderParams { + [BIDDER_CODE]: MileBidParams; + } +} + +export let siteIdTracker : string | undefined; +export let publisherIdTracker : string | undefined; + +export function getLowestFloorPrice(bid) { + let floorPrice: number; + + if (typeof bid.getFloor === 'function') { + let sizes = [] + // Get floor prices for each banner size in the bid request + if (deepAccess(bid, 'mediaTypes.banner.sizes')) { + sizes = deepAccess(bid, 'mediaTypes.banner.sizes') + } else if (bid.sizes) { + sizes = bid.sizes + } + + sizes.forEach((size: string | number[]) => { + const [w, h] = typeof size === 'string' ? size.split('x') : size as number[]; + const floor = bid.getFloor({ currency: 'USD', mediaType: '*', size: [Number(w), Number(h)] }); + if (floor && floor.floor) { + if (floorPrice === undefined) { + floorPrice = floor.floor; + } else { + floorPrice = Math.min(floorPrice, floor.floor); + } + } + }); + } else { + floorPrice = 0 + } + + return floorPrice +} + +export const spec: BidderSpec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + isBidRequestValid: function (bid) { + const params = bid.params; + + if (!params?.placementId) { + logError(`${BIDDER_CODE}: Missing required param: placementId`); + return false; + } + + if (!params?.siteId) { + logError(`${BIDDER_CODE}: Missing required param: siteId`); + return false; + } + + if (!params?.publisherId) { + logError(`${BIDDER_CODE}: Missing required param: publisherId`); + return false; + } + + if (siteIdTracker === undefined) { + siteIdTracker = params.siteId; + } else if (siteIdTracker !== params.siteId) { + logError(`${BIDDER_CODE}: Site ID mismatch: ${siteIdTracker} !== ${params.siteId}`); + return false; + } + + if (publisherIdTracker === undefined) { + publisherIdTracker = params.publisherId; + } else if (publisherIdTracker !== params.publisherId) { + logError(`${BIDDER_CODE}: Publisher ID mismatch: ${publisherIdTracker} !== ${params.publisherId}`); + return false; + } + + return true; + }, + + /** + * Make a server request from the list of BidRequests. + * Builds an OpenRTB 2.5 compliant bid request. + * + * @param validBidRequests - An array of valid bids + * @param bidderRequest - The master bidder request object + * @returns ServerRequest info describing the request to the server + */ + buildRequests: function (validBidRequests, bidderRequest) { + logInfo(`${BIDDER_CODE}: Building batched request for ${validBidRequests.length} bids`); + + // Build imp[] array - one impression object per bid request + const imps = validBidRequests.map((bid) => { + const sizes = deepAccess(bid, 'mediaTypes.banner.sizes') || []; + const floorPrice = getLowestFloorPrice(bid); + + const imp: any = { + id: bid.bidId, + tagid: bid.params.placementId, + secure: 1, + banner: { + format: sizes.map((size: number[]) => ({ + w: size[0], + h: size[1], + })) + }, + ext: { + adUnitCode: bid.adUnitCode, + placementId: bid.params.placementId, + gpid: deepAccess(bid, 'ortb2Imp.ext.gpid') || deepAccess(bid, 'ortb2Imp.ext.data.pbadslot'), + }, + }; + + // Add bidfloor if available + if (floorPrice > 0) { + imp.bidfloor = floorPrice; + imp.bidfloorcur = 'USD'; + } + + return imp; + }); + + // Build the OpenRTB 2.5 BidRequest object + const openRtbRequest: any = { + id: bidderRequest.bidderRequestId || generateUUID(), + imp: imps, + tmax: bidderRequest.timeout, + cur: ['USD'], + site: { + id: siteIdTracker, + page: deepAccess(bidderRequest, 'refererInfo.page') || '', + domain: deepAccess(bidderRequest, 'refererInfo.domain') || '', + ref: deepAccess(bidderRequest, 'refererInfo.ref') || '', + publisher: { + id: publisherIdTracker, + }, + }, + user: {}, + // Device object + device: { + ua: navigator.userAgent, + language: navigator.language?.split('-')[0] || 'en', + dnt: getDNT() ? 1 : 0, + w: window.screen?.width, + h: window.screen?.height, + }, + + // Source object with supply chain + source: { + tid: deepAccess(bidderRequest, 'ortb2.source.tid') + }, + }; + + // Add schain if available + const schain = deepAccess(validBidRequests, '0.ortb2.source.ext.schain'); + if (schain) { + deepSetValue(openRtbRequest, 'source.ext.schain', schain); + } + + // User object + const eids = deepAccess(validBidRequests, '0.userIdAsEids'); + if (eids && eids.length) { + deepSetValue(openRtbRequest, 'user.ext.eids', eids); + } + + // Regs object for privacy/consent + const regs: any = { ext: {} }; + + // GDPR + if (bidderRequest.gdprConsent) { + regs.ext.gdpr = bidderRequest.gdprConsent.gdprApplies ? 1 : 0; + deepSetValue(openRtbRequest, 'user.ext.consent', bidderRequest.gdprConsent.consentString || ''); + } + + // US Privacy (CCPA) + if (bidderRequest.uspConsent) { + regs.ext.us_privacy = bidderRequest.uspConsent; + } + + // GPP + if (bidderRequest.gppConsent) { + regs.gpp = bidderRequest.gppConsent.gppString || ''; + regs.gpp_sid = bidderRequest.gppConsent.applicableSections || []; + } + + if (Object.keys(regs.ext).length > 0 || regs.gpp) { + openRtbRequest.regs = regs; + } + + // Merge any first-party data from ortb2 + if (bidderRequest.ortb2) { + if (bidderRequest.ortb2.site) { + openRtbRequest.site = { ...openRtbRequest.site, ...bidderRequest.ortb2.site }; + // Preserve publisher ID + openRtbRequest.site.publisher = { id: publisherIdTracker, ...bidderRequest.ortb2.site.publisher }; + } + if (bidderRequest.ortb2.user) { + openRtbRequest.user = { ...openRtbRequest.user, ...bidderRequest.ortb2.user }; + } + if (bidderRequest.ortb2.device) { + openRtbRequest.device = { ...openRtbRequest.device, ...bidderRequest.ortb2.device }; + } + } + + // Add prebid adapter version info + deepSetValue(openRtbRequest, 'ext.prebid.channel', { + name: 'pbjs', + version: '$prebid.version$', + }); + + return { + method: 'POST', + url: ENDPOINT_URL, + data: openRtbRequest + }; + }, + + /** + * Unpack the OpenRTB 2.5 response from the server into a list of bids. + * + * @param serverResponse - A successful response from the server. + * @param request - The request that was sent to the server. + * @returns An array of bids which were nested inside the server. + */ + interpretResponse: function (serverResponse, request) { + const bidResponses: any[] = []; + + if (!serverResponse?.body) { + logInfo(`${BIDDER_CODE}: Empty server response`); + return bidResponses; + } + + const response = serverResponse.body; + const currency = response.cur || 'USD'; + + // OpenRTB 2.5 response format: seatbid[] contains bid[] + const seatbids = response.bids || []; + + seatbids.forEach((bid: any) => { + if (!bid || !bid.cpm) { + return; + } + + const bidResponse = { + requestId: bid.requestId, + cpm: parseFloat(bid.cpm), + width: parseInt(bid.width || bid.w, 10), + height: parseInt(bid.height || bid.h, 10), + creativeId: bid.creativeId || bid.crid || bid.id, + currency: currency, + netRevenue: true, + bidder: BIDDER_CODE, + ttl: bid.ttl || 300, + ad: bid.ad, + mediaType: BANNER, + meta: { + advertiserDomains: bid.adomain || [], + upstreamBidder: bid.upstreamBidder || '', + siteUID: deepAccess(response, 'site.id') || '', + publisherID: deepAccess(response, 'site.publisher.id') || '', + page: deepAccess(response, 'site.page') || '', + domain: deepAccess(response, 'site.domain') || '', + } + }; + + // Handle nurl (win notice URL) if present + if (bid.nurl) { + (bidResponse as any).nurl = bid.nurl; + } + + // Handle burl (billing URL) if present + if (bid.burl) { + (bidResponse as any).burl = bid.burl; + } + + bidResponses.push(bidResponse); + }); + + return bidResponses; + }, + + /** + * Register user sync pixels or iframes to be dropped after the auction. + * + * @param syncOptions - Which sync types are allowed (iframe, image/pixel) + * @param serverResponses - Array of server responses from the auction + * @param gdprConsent - GDPR consent data + * @param uspConsent - US Privacy consent string + * @param gppConsent - GPP consent data + * @returns Array of user sync objects to be executed + */ + getUserSyncs: function ( + syncOptions, + serverResponses, + gdprConsent, + uspConsent, + gppConsent + ) { + logInfo(`${BIDDER_CODE}: getUserSyncs called`, { + iframeEnabled: syncOptions.iframeEnabled + }); + + const syncs = []; + + // Build query parameters for consent + const queryParams: string[] = []; + + // GDPR consent + if (gdprConsent) { + queryParams.push(`gdpr=${gdprConsent.gdprApplies ? 1 : 0}`); + queryParams.push(`gdpr_consent=${encodeURIComponent(gdprConsent.consentString || '')}`); + } + + // US Privacy / CCPA + if (uspConsent) { + queryParams.push(`us_privacy=${encodeURIComponent(uspConsent)}`); + } + + // GPP consent + if (gppConsent?.gppString) { + queryParams.push(`gpp=${encodeURIComponent(gppConsent.gppString)}`); + if (gppConsent.applicableSections?.length) { + queryParams.push(`gpp_sid=${encodeURIComponent(gppConsent.applicableSections.join(','))}`); + } + } + + const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; + + if (syncOptions.iframeEnabled) { + syncs.push({ + type: 'iframe' as const, + url: `${USER_SYNC_ENDPOINT}${queryString}`, + }); + } + + return syncs; + }, + + /** + * Called when a bid from this adapter wins the auction. + * Sends an XHR POST request to the bid's nurl (win notification URL). + * + * @param bid - The winning bid object + */ + onBidWon: function (bid) { + logInfo(`${BIDDER_CODE}: Bid won`, bid); + + const winNotificationData = { + adUnitCode: bid.adUnitCode, + metaData: { + impressionID: [bid.requestId], + }, + ua: navigator.userAgent, + timestamp: Date.now(), + winningSize: `${bid.width}x${bid.height}`, + cpm: bid.cpm, + eventType: 'mile-bidder-win-notify', + winningBidder: deepAccess(bid, 'meta.upstreamBidder') || '', + siteUID: deepAccess(bid, 'meta.siteUID') || '', + yetiPublisherID: deepAccess(bid, 'meta.publisherID') || '', + page: deepAccess(bid, 'meta.page') || '', + site: deepAccess(bid, 'meta.domain') || '', + } + + ajax(MILE_ANALYTICS_ENDPOINT, null, JSON.stringify([winNotificationData]), { method: 'POST'}); + + // @ts-expect-error - bid.nurl is not defined + if (bid.nurl) ajax(bid.nurl, null, null, { method: 'GET' }); + }, + + /** + * Called when bid requests timeout. + * Sends analytics notification for timed out bids. + * + * @param timeoutData - Array of bid requests that timed out + */ + onTimeout: function (timeoutData) { + logInfo(`${BIDDER_CODE}: Timeout for ${timeoutData.length} bid(s)`, timeoutData); + + if (timeoutData.length === 0) return; + + const timedOutBids = []; + + timeoutData.forEach((bid) => { + const timeoutNotificationData = { + adUnitCode: bid.adUnitCode, + metaData: { + impressionID: [bid.bidId], + configuredTimeout: [bid.timeout.toString()], + }, + ua: navigator.userAgent, + timestamp: Date.now(), + eventType: 'mile-bidder-timeout' + }; + + timedOutBids.push(timeoutNotificationData); + }); + + ajax(MILE_ANALYTICS_ENDPOINT, null, JSON.stringify(timedOutBids), { method: 'POST'}); + }, +}; + +registerBidder(spec); diff --git a/modules/missenaBidAdapter.js b/modules/missenaBidAdapter.js index 573f20fb302..dba7a723693 100644 --- a/modules/missenaBidAdapter.js +++ b/modules/missenaBidAdapter.js @@ -157,6 +157,7 @@ export const spec = { serverResponses, gdprConsent = {}, uspConsent, + gppConsent, ) { if (!syncOptions.iframeEnabled || !this.msnaApiKey) { return []; @@ -172,6 +173,13 @@ export const spec = { if (uspConsent) { url.searchParams.append('us_privacy', uspConsent); } + if (gppConsent?.gppString) { + url.searchParams.append('gpp', gppConsent.gppString); + url.searchParams.append( + 'gpp_sid', + (gppConsent.applicableSections || []).join(','), + ); + } return [{ type: 'iframe', url: url.href }]; }, diff --git a/modules/mobianRtdProvider.md b/modules/mobianRtdProvider.md index ccfc43e3aab..dee9cc12773 100644 --- a/modules/mobianRtdProvider.md +++ b/modules/mobianRtdProvider.md @@ -160,6 +160,36 @@ p1 = Advertisers (via Campaign IDs) should target these personas *AP Values is in the early stages of testing and is subject to change. +------------------ + +Additional Results Fields (API response) + +The fields below are present in the Mobian Contextual API `results` schema and are useful for downstream interpretation of content maturity and taxonomy. + +mobianMpaaRating: + +Type: integer | null + +Description: MPAA-style maturity rating score represented as an integer value in the API response. + +Behavior when unavailable: omitted when null. + +mobianEsrbRating: + +Type: integer | null + +Description: ESRB-style maturity rating score represented as an integer value in the API response. + +Behavior when unavailable: omitted when null. + +mobianContentTaxonomy: + +Type: string[] + +Description: IAB content taxonomy categories (broad topic buckets such as "News" or "Health"). + +Behavior when unavailable: may be returned as an empty array. + ## GAM Targeting: On each page load, the Mobian RTD module finds each ad slot on the page and performs the following function: diff --git a/modules/neuwoRtdProvider.js b/modules/neuwoRtdProvider.js index 3255547aa47..8371c20fb88 100644 --- a/modules/neuwoRtdProvider.js +++ b/modules/neuwoRtdProvider.js @@ -1,5 +1,6 @@ /** * @module neuwoRtdProvider + * @version 2.2.6 * @author Grzegorz Malisz * @see {project-root-directory}/integrationExamples/gpt/neuwoRtdProvider_example.html for an example/testing page. * @see {project-root-directory}/test/spec/modules/neuwoRtdProvider_spec.js for unit tests. @@ -7,8 +8,10 @@ * This module is a Prebid.js Real-Time Data (RTD) provider that integrates with the Neuwo API. * * It fetches contextual marketing categories (IAB content and audience) for the current page from the Neuwo API. - * The retrieved data is then injected into the bid request as OpenRTB (ORTB2)`site.content.data` + * The retrieved data is then injected into the bid request as OpenRTB (ORTB2) `site.content.data` * and `user.data` fragments, making it available for bidders to use in their decisioning process. + * Additionally, when enabled, the module populates OpenRTB 2.5 category fields (`ortb2.site.cat`, + * `ortb2.site.sectioncat`, `ortb2.site.pagecat`, `ortb2.site.content.cat`) with IAB Content Taxonomy 1.0 segments. * * @see {@link https://docs.prebid.org/dev-docs/add-rtd-submodule.html} for more information on development of Prebid.js RTD modules. * @see {@link https://docs.prebid.org/features/firstPartyData.html} for more information on Prebid.js First Party Data. @@ -18,24 +21,40 @@ import { ajax } from "../src/ajax.js"; import { submodule } from "../src/hook.js"; import { getRefererInfo } from "../src/refererDetection.js"; -import { deepSetValue, logError, logInfo, mergeDeep } from "../src/utils.js"; +import { + deepSetValue, + logError, + logInfo, + logWarn, + mergeDeep, +} from "../src/utils.js"; const MODULE_NAME = "NeuwoRTDModule"; +const MODULE_VERSION = "2.2.6"; export const DATA_PROVIDER = "www.neuwo.ai"; -// Cached API response to avoid redundant requests. -let globalCachedResponse; +// Default IAB Content Taxonomy version +const DEFAULT_IAB_CONTENT_TAXONOMY_VERSION = "2.2"; + +// Maximum number of cached API responses to keep. Oldest entries are evicted when exceeded. +const MAX_CACHE_ENTRIES = 10; +// Cached API responses keyed by full API URL to avoid redundant requests. +let cachedResponses = {}; +// In-flight request promises keyed by full API URL to prevent duplicate API calls during the same request cycle. +let pendingRequests = {}; /** - * Clears the cached API response. Primarily used for testing. + * Clears the cached API responses and pending requests. Primarily used for testing. * @private */ export function clearCache() { - globalCachedResponse = undefined; + cachedResponses = {}; + pendingRequests = {}; } // Maps the IAB Content Taxonomy version string to the corresponding segtax ID. // Based on https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/main/AdCOM%20v1.0%20FINAL.md#list--category-taxonomies- +// prettier-ignore const IAB_CONTENT_TAXONOMY_MAP = { "1.0": 1, "2.0": 2, @@ -53,14 +72,14 @@ const IAB_CONTENT_TAXONOMY_MAP = { * @returns {boolean} `true` if the module is configured correctly, otherwise `false`. */ function init(config, userConsent) { - logInfo(MODULE_NAME, "init:", config, userConsent); + logInfo(MODULE_NAME, "init():", "Version " + MODULE_VERSION, config, userConsent); const params = config?.params || {}; if (!params.neuwoApiUrl) { - logError(MODULE_NAME, "init:", "Missing Neuwo Edge API Endpoint URL"); + logError(MODULE_NAME, "init():", "Missing Neuwo Edge API Endpoint URL"); return false; } if (!params.neuwoApiToken) { - logError(MODULE_NAME, "init:", "Missing Neuwo API Token missing"); + logError(MODULE_NAME, "init():", "Missing Neuwo API Token"); return false; } return true; @@ -69,6 +88,9 @@ function init(config, userConsent) { /** * Fetches contextual data from the Neuwo API and enriches the bid request object with IAB categories. * Uses cached response if available to avoid redundant API calls. + * Automatically detects API capabilities from the endpoint URL format: + * - URLs containing "/v1/iab" use GET requests with server-side filtering + * - Other URLs use GET requests with client-side filtering (legacy support) * * @param {Object} reqBidsConfigObj The bid request configuration object. * @param {function} callback The callback function to continue the auction. @@ -77,70 +99,272 @@ function init(config, userConsent) { * @param {string} config.params.neuwoApiUrl The Neuwo API endpoint URL. * @param {string} config.params.neuwoApiToken The Neuwo API authentication token. * @param {string} [config.params.websiteToAnalyseUrl] Optional URL to analyze instead of current page. - * @param {string} [config.params.iabContentTaxonomyVersion] IAB content taxonomy version (default: "3.0"). - * @param {boolean} [config.params.enableCache=true] If true, caches API responses to avoid redundant requests (default: true). + * @param {string} [config.params.iabContentTaxonomyVersion="2.2"] IAB Content Taxonomy version. + * @param {boolean} [config.params.enableCache=true] If true, caches API responses to avoid redundant requests. + * @param {boolean} [config.params.enableOrtb25Fields=true] If true, populates OpenRTB 2.5 category fields (site.cat, site.sectioncat, site.pagecat, site.content.cat) with IAB Content Taxonomy 1.0 segments. * @param {boolean} [config.params.stripAllQueryParams] If true, strips all query parameters from the URL. * @param {string[]} [config.params.stripQueryParamsForDomains] List of domains for which to strip all query params. * @param {string[]} [config.params.stripQueryParams] List of specific query parameter names to strip. * @param {boolean} [config.params.stripFragments] If true, strips URL fragments (hash). + * @param {Object} [config.params.iabTaxonomyFilters] Per-tier filtering configuration for IAB Taxonomies. * @param {Object} userConsent The user consent object. */ -export function getBidRequestData(reqBidsConfigObj, callback, config, userConsent) { - logInfo(MODULE_NAME, "getBidRequestData:", "starting getBidRequestData", config); +export function getBidRequestData( + reqBidsConfigObj, + callback, + config, + userConsent +) { + logInfo( + MODULE_NAME, + "getBidRequestData():", + "starting getBidRequestData", + config + ); const { websiteToAnalyseUrl, neuwoApiUrl, neuwoApiToken, - iabContentTaxonomyVersion, + iabContentTaxonomyVersion = DEFAULT_IAB_CONTENT_TAXONOMY_VERSION, enableCache = true, + enableOrtb25Fields = true, stripAllQueryParams, stripQueryParamsForDomains, stripQueryParams, stripFragments, + iabTaxonomyFilters, } = config.params; const rawUrl = websiteToAnalyseUrl || getRefererInfo().page; + if (!rawUrl) { + logError(MODULE_NAME, "getBidRequestData():", "No URL available to analyse"); + callback(); + return; + } const processedUrl = cleanUrl(rawUrl, { stripAllQueryParams, stripQueryParamsForDomains, stripQueryParams, - stripFragments + stripFragments, }); const pageUrl = encodeURIComponent(processedUrl); - // Adjusted for pages api.url?prefix=test (to add params with '&') as well as api.url (to add params with '?') - const joiner = neuwoApiUrl.indexOf("?") < 0 ? "?" : "&"; - const neuwoApiUrlFull = - neuwoApiUrl + joiner + ["token=" + neuwoApiToken, "url=" + pageUrl].join("&"); + const contentSegtax = + IAB_CONTENT_TAXONOMY_MAP[iabContentTaxonomyVersion] || + IAB_CONTENT_TAXONOMY_MAP[DEFAULT_IAB_CONTENT_TAXONOMY_VERSION]; - const success = (response) => { - logInfo(MODULE_NAME, "getBidRequestData:", "Neuwo API raw response:", response); - try { - const responseParsed = JSON.parse(response); + // Detect whether the endpoint supports multi-taxonomy responses and server-side filtering. + // Use URL pathname to avoid false positives when "/v1/iab" appears in query params. + let isIabEndpoint = false; + try { + isIabEndpoint = new URL(neuwoApiUrl).pathname.includes("/v1/iab"); + } catch (e) { + isIabEndpoint = neuwoApiUrl.split("?")[0].includes("/v1/iab"); + } - if (enableCache) { - globalCachedResponse = responseParsed; - } + // Warn if OpenRTB 2.5 feature enabled with legacy endpoint + if (enableOrtb25Fields && !isIabEndpoint) { + logWarn( + MODULE_NAME, + "getBidRequestData():", + "OpenRTB 2.5 category fields require the /v1/iab endpoint" + ); + } - injectIabCategories(responseParsed, reqBidsConfigObj, iabContentTaxonomyVersion); - } catch (ex) { - logError(MODULE_NAME, "getBidRequestData:", "Error while processing Neuwo API response", ex); + const joiner = neuwoApiUrl.indexOf("?") < 0 ? "?" : "&"; + const urlParams = [ + "token=" + neuwoApiToken, + "url=" + pageUrl, + "_neuwo_prod=PrebidModule", + ]; + + // Request both IAB Content Taxonomy (based on config) and IAB Audience Taxonomy (segtax 4) + if (isIabEndpoint) { + urlParams.push("iabVersions=" + contentSegtax); + urlParams.push("iabVersions=4"); // IAB Audience 1.1 + + // Request IAB 1.0 for OpenRTB 2.5 fields if feature enabled. + // Skip when contentSegtax is already 1 -- already requested above. + if (enableOrtb25Fields && contentSegtax !== 1) { + urlParams.push("iabVersions=1"); // IAB Content 1.0 } - callback(); - }; - const error = (err) => { - logError(MODULE_NAME, "getBidRequestData:", "AJAX error:", err); - callback(); - }; + // Add flattened filter parameters to URL for GET request + const filterParams = buildFilterQueryParams( + iabTaxonomyFilters, + contentSegtax, + enableOrtb25Fields + ); + if (filterParams.length > 0) { + urlParams.push(...filterParams); + } + } - if (enableCache && globalCachedResponse) { - logInfo(MODULE_NAME, "getBidRequestData:", "Using cached response:", globalCachedResponse); - injectIabCategories(globalCachedResponse, reqBidsConfigObj, iabContentTaxonomyVersion); + const neuwoApiUrlFull = neuwoApiUrl + joiner + urlParams.join("&"); + + // For /v1/iab endpoints the full URL already encodes all config (iabVersions, filters). + // For legacy endpoints the URL only carries token + page URL, so append config-dependent + // values to the cache key to prevent different configs sharing a response that was + // transformed/filtered for a different taxonomy version or filter set. + let cacheKey = neuwoApiUrlFull; + if (!isIabEndpoint) { + cacheKey += "&_segtax=" + contentSegtax; + if (iabTaxonomyFilters && Object.keys(iabTaxonomyFilters).length > 0) { + cacheKey += "&_filters=" + JSON.stringify(iabTaxonomyFilters); + } + } + + // Cache flow: cached response -> pending request -> new request + // Each caller gets their own callback invoked when data is ready. + // Keyed by cacheKey to ensure different parameters never share cached data. + if (enableCache && cachedResponses[cacheKey]) { + // Previous request succeeded - use cached response immediately + logInfo( + MODULE_NAME, + "getBidRequestData():", + "Cache System:", + "Using cached response for:", + cacheKey + ); + injectIabCategories( + cachedResponses[cacheKey], + reqBidsConfigObj, + iabContentTaxonomyVersion, + enableOrtb25Fields + ); callback(); + } else if (enableCache && pendingRequests[cacheKey]) { + // Another caller started a request with the same params - wait for it + logInfo( + MODULE_NAME, + "getBidRequestData():", + "Cache System:", + "Waiting for pending request for:", + cacheKey + ); + pendingRequests[cacheKey] + .then((responseParsed) => { + if (responseParsed) { + injectIabCategories( + responseParsed, + reqBidsConfigObj, + iabContentTaxonomyVersion, + enableOrtb25Fields + ); + } + }) + .finally(() => callback()); } else { - logInfo(MODULE_NAME, "getBidRequestData:", "Calling Neuwo API Endpoint: ", neuwoApiUrlFull); - ajax(neuwoApiUrlFull, { success, error }, null); + // First request or cache disabled - make the API call + logInfo( + MODULE_NAME, + "getBidRequestData():", + "Cache System:", + "Calling Neuwo API Endpoint:", + neuwoApiUrlFull + ); + + const requestPromise = new Promise((resolve) => { + ajax( + neuwoApiUrlFull, + { + success: (response) => { + logInfo( + MODULE_NAME, + "getBidRequestData():", + "success():", + "Neuwo API raw response:", + response + ); + + let responseParsed; + try { + responseParsed = JSON.parse(response); + } catch (ex) { + logError( + MODULE_NAME, + "getBidRequestData():", + "success():", + "Error parsing Neuwo API response JSON:", + ex + ); + resolve(null); + return; + } + + try { + if (!isIabEndpoint) { + // Apply per-tier filtering to V1 format + const filteredMarketingCategories = filterIabTaxonomies( + responseParsed.marketing_categories, + iabTaxonomyFilters + ); + + // Transform filtered V1 response to unified internal format + responseParsed = transformV1ResponseToV2( + { marketing_categories: filteredMarketingCategories }, + contentSegtax + ); + } + + // Cache response, evicting oldest entry if at capacity. + // Only cache valid responses so failed requests can be retried. + if ( + enableCache && + responseParsed && + typeof responseParsed === "object" + ) { + // Object.keys() preserves string insertion order in modern JS engines. + const keys = Object.keys(cachedResponses); + if (keys.length >= MAX_CACHE_ENTRIES) { + delete cachedResponses[keys[0]]; + } + cachedResponses[cacheKey] = responseParsed; + } + + injectIabCategories( + responseParsed, + reqBidsConfigObj, + iabContentTaxonomyVersion, + enableOrtb25Fields + ); + resolve(responseParsed); + } catch (ex) { + logError( + MODULE_NAME, + "getBidRequestData():", + "success():", + "Error processing Neuwo API response:", + ex + ); + resolve(null); + } + }, + error: (err) => { + logError( + MODULE_NAME, + "getBidRequestData():", + "error():", + "AJAX error:", + err + ); + resolve(null); + }, + } + ); + }); + + if (enableCache) { + // Store promise so concurrent callers with same params can wait on it + pendingRequests[cacheKey] = requestPromise; + // Clear after settling so failed requests can be retried + requestPromise.finally(() => { + delete pendingRequests[cacheKey]; + }); + } + + // Signal this caller's auction to proceed once request completes + requestPromise.finally(() => callback()); } } @@ -160,14 +384,23 @@ export function getBidRequestData(reqBidsConfigObj, callback, config, userConsen * @returns {string} The cleaned URL. */ export function cleanUrl(url, options = {}) { - const { stripAllQueryParams, stripQueryParamsForDomains, stripQueryParams, stripFragments } = options; + const { + stripAllQueryParams, + stripQueryParamsForDomains, + stripQueryParams, + stripFragments, + } = options; if (!url) { - logInfo(MODULE_NAME, "cleanUrl:", "Empty or null URL provided, returning as-is"); + logInfo( + MODULE_NAME, + "cleanUrl():", + "Empty or null URL provided, returning as-is" + ); return url; } - logInfo(MODULE_NAME, "cleanUrl:", "Input URL:", url, "Options:", options); + logInfo(MODULE_NAME, "cleanUrl():", "Input URL:", url, "Options:", options); try { const urlObj = new URL(url); @@ -175,21 +408,24 @@ export function cleanUrl(url, options = {}) { // Strip fragments if requested if (stripFragments === true) { urlObj.hash = ""; - logInfo(MODULE_NAME, "cleanUrl:", "Stripped fragment from URL"); + logInfo(MODULE_NAME, "cleanUrl():", "Stripped fragment from URL"); } // Option 1: Strip all query params unconditionally if (stripAllQueryParams === true) { urlObj.search = ""; const cleanedUrl = urlObj.toString(); - logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", cleanedUrl); + logInfo(MODULE_NAME, "cleanUrl():", "Output URL:", cleanedUrl); return cleanedUrl; } // Option 2: Strip all query params for specific domains - if (Array.isArray(stripQueryParamsForDomains) && stripQueryParamsForDomains.length > 0) { + if ( + Array.isArray(stripQueryParamsForDomains) && + stripQueryParamsForDomains.length > 0 + ) { const hostname = urlObj.hostname; - const shouldStripForDomain = stripQueryParamsForDomains.some(domain => { + const shouldStripForDomain = stripQueryParamsForDomains.some((domain) => { // Support exact match or subdomain match return hostname === domain || hostname.endsWith("." + domain); }); @@ -197,7 +433,7 @@ export function cleanUrl(url, options = {}) { if (shouldStripForDomain) { urlObj.search = ""; const cleanedUrl = urlObj.toString(); - logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", cleanedUrl); + logInfo(MODULE_NAME, "cleanUrl():", "Output URL:", cleanedUrl); return cleanedUrl; } } @@ -208,21 +444,25 @@ export function cleanUrl(url, options = {}) { // - "??" is treated as query parameter with key "?" and value "" if (Array.isArray(stripQueryParams) && stripQueryParams.length > 0) { const queryParams = urlObj.searchParams; - logInfo(MODULE_NAME, "cleanUrl:", `Query parameters to strip: ${stripQueryParams}`); - stripQueryParams.forEach(param => { + logInfo( + MODULE_NAME, + "cleanUrl():", + `Query parameters to strip: ${stripQueryParams}` + ); + stripQueryParams.forEach((param) => { queryParams.delete(param); }); urlObj.search = queryParams.toString(); const cleanedUrl = urlObj.toString(); - logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", cleanedUrl); + logInfo(MODULE_NAME, "cleanUrl():", "Output URL:", cleanedUrl); return cleanedUrl; } const finalUrl = urlObj.toString(); - logInfo(MODULE_NAME, "cleanUrl:", "Output URL:", finalUrl); + logInfo(MODULE_NAME, "cleanUrl():", "Output URL:", finalUrl); return finalUrl; } catch (e) { - logError(MODULE_NAME, "cleanUrl:", "Error cleaning URL:", e); + logError(MODULE_NAME, "cleanUrl():", "Error cleaning URL:", e); return url; } } @@ -241,72 +481,435 @@ export function injectOrtbData(reqBidsConfigObj, path, data) { } /** - * Builds an IAB category data object for use in OpenRTB. + * Extracts all segment IDs from tier data into a flat array. + * Used for populating OpenRTB 2.5 category fields and building IAB data objects. + * + * @param {Object} tierData The tier data keyed by tier numbers (e.g., {"1": [{id: "IAB12"}], "2": [...]}). + * @returns {Array} Flat array of segment IDs (e.g., ["IAB12", "IAB12-3", "IAB12-5"]). + */ +export function extractCategoryIds(tierData) { + const ids = []; + + // Handle null, undefined, non-object, or array tierData + if (!tierData || typeof tierData !== "object" || Array.isArray(tierData)) { + return ids; + } + + // Process ALL tier keys present in tierData + Object.keys(tierData).forEach((tierKey) => { + const segments = tierData[tierKey]; + if (Array.isArray(segments)) { + segments.forEach((item) => { + if (item?.id) { + ids.push(item.id); + } + }); + } + }); + + return ids; +} + +/** + * Builds an IAB category data object for OpenRTB injection. + * Dynamically processes all tiers present in the response data. * - * @param {Object} marketingCategories Marketing Categories returned by Neuwo API. - * @param {string[]} tiers The tier keys to extract from marketingCategories. - * @param {number} segtax The IAB taxonomy version Id. - * @returns {Object} The constructed data object. + * @param {Object} tierData The tier data keyed by tier numbers (e.g., {"1": [...], "2": [...], "3": [...]}). + * @param {number} segtax The IAB Taxonomy segtax ID. + * @returns {Object} The OpenRTB data object with name, segment array, and ext.segtax. */ -export function buildIabData(marketingCategories, tiers, segtax) { - const data = { +export function buildIabData(tierData, segtax) { + const ids = extractCategoryIds(tierData); + return { name: DATA_PROVIDER, - segment: [], + segment: ids.map((id) => ({ id })), ext: { segtax }, }; +} + +/** + * v1 API specific + * Filters and limits a single tier's taxonomies based on relevance score and count. + * Used for client-side filtering with legacy endpoints. + * + * @param {Array} iabTaxonomies Array of IAB Taxonomy Segments objects with ID, label, and relevance. + * @param {Object} filter Filter configuration with optional threshold and limit properties. + * @returns {Array} Filtered and limited array of taxonomies, sorted by relevance (highest first). + */ +export function filterIabTaxonomyTier(iabTaxonomies, filter = {}) { + if (!Array.isArray(iabTaxonomies)) { + return []; + } + if (iabTaxonomies.length === 0) { + return iabTaxonomies; + } - tiers.forEach((tier) => { - const tierData = marketingCategories?.[tier]; + const { threshold, limit } = filter; + const hasThreshold = typeof threshold === "number" && threshold > 0; + const hasLimit = typeof limit === "number" && limit >= 0; + + // No effective filter configured -- return original order unchanged + if (!hasThreshold && !hasLimit) { + return iabTaxonomies; + } + + let filtered = [...iabTaxonomies]; // Create copy to avoid mutating original + + // Filter by minimum relevance score + if (hasThreshold) { + filtered = filtered.filter((item) => { + const relevance = parseFloat(item?.relevance); + return !isNaN(relevance) && relevance >= threshold; + }); + } + + // Sort by relevance (highest first) so limit keeps the most relevant items + if (hasLimit) { + filtered = filtered.sort((a, b) => { + const relA = parseFloat(a?.relevance) || 0; + const relB = parseFloat(b?.relevance) || 0; + return relB - relA; // Descending order + }); + filtered = filtered.slice(0, limit); + } + + return filtered; +} + +/** + * Maps tier configuration keys to API response keys. + */ +const TIER_KEY_MAP = { + ContentTier1: "iab_tier_1", + ContentTier2: "iab_tier_2", + ContentTier3: "iab_tier_3", + AudienceTier3: "iab_audience_tier_3", + AudienceTier4: "iab_audience_tier_4", + AudienceTier5: "iab_audience_tier_5", +}; + +/** + * v1 API specific + * Applies per-tier filtering to IAB taxonomies (client-side filtering for legacy endpoints). + * Filters taxonomies by relevance score and limits the count per tier. + * + * @param {Object} marketingCategories Marketing categories from legacy API response. + * @param {Object} [tierFilters] Per-tier filter configuration with human-readable tier names (e.g., {ContentTier1: {limit: 3, threshold: 0.75}}). + * @returns {Object} Filtered marketing categories with the same structure as input. + */ +export function filterIabTaxonomies(marketingCategories, tierFilters = {}) { + if (!marketingCategories || typeof marketingCategories !== "object") { + return marketingCategories; + } + + // If no filters provided, return original data + if (!tierFilters || Object.keys(tierFilters).length === 0) { + logInfo( + MODULE_NAME, + "filterIabTaxonomies():", + "No filters provided, returning original data" + ); + return marketingCategories; + } + + const filtered = {}; + + // Iterate through all tiers in the API response + Object.keys(marketingCategories).forEach((apiTierKey) => { + const tierData = marketingCategories[apiTierKey]; + + // Find the corresponding config key for this API tier + const configTierKey = Object.keys(TIER_KEY_MAP).find( + (key) => TIER_KEY_MAP[key] === apiTierKey + ); + + // Get filter for this tier (if configured) + const filter = configTierKey ? tierFilters[configTierKey] : {}; + + // Apply filter if this tier has data if (Array.isArray(tierData)) { - tierData.forEach((item) => { - const ID = item?.ID; - const label = item?.label; + filtered[apiTierKey] = filterIabTaxonomyTier(tierData, filter); + } else { + // Preserve non-array data as-is + filtered[apiTierKey] = tierData; + } + }); + + logInfo( + MODULE_NAME, + "filterIabTaxonomies():", + "Filtering results:", + "Original:", + marketingCategories, + "Filtered:", + filtered + ); + + return filtered; +} + +/** + * v1 API specific + * Transforms legacy API response format to unified internal format. + * Converts marketing_categories structure to segtax-based structure for consistent processing. + * + * Legacy format: { marketing_categories: { iab_tier_1: [...], iab_audience_tier_3: [...] } } + * Unified format: { "6": { "1": [...], "2": [...] }, "4": { "3": [...], "4": [...] } } + * + * @param {Object} v1Response The legacy API response with marketing_categories structure. + * @param {number} contentSegtax The segtax ID for content taxonomies (determined by iabContentTaxonomyVersion). + * @returns {Object} Unified format response keyed by segtax and tier numbers. + */ +export function transformV1ResponseToV2(v1Response, contentSegtax) { + const marketingCategories = v1Response?.marketing_categories || {}; + const contentSegtaxStr = String(contentSegtax); + const result = {}; + + // Content tiers: keyed by segtax from config + result[contentSegtaxStr] = {}; + if (marketingCategories.iab_tier_1) { + result[contentSegtaxStr]["1"] = transformSegmentsV1ToV2( + marketingCategories.iab_tier_1 + ); + } + if (marketingCategories.iab_tier_2) { + result[contentSegtaxStr]["2"] = transformSegmentsV1ToV2( + marketingCategories.iab_tier_2 + ); + } + if (marketingCategories.iab_tier_3) { + result[contentSegtaxStr]["3"] = transformSegmentsV1ToV2( + marketingCategories.iab_tier_3 + ); + } - if (ID && label) { - data.segment.push({ id: ID, name: label }); + // Audience tiers: segtax 4 + result["4"] = {}; + if (marketingCategories.iab_audience_tier_3) { + result["4"]["3"] = transformSegmentsV1ToV2( + marketingCategories.iab_audience_tier_3 + ); + } + if (marketingCategories.iab_audience_tier_4) { + result["4"]["4"] = transformSegmentsV1ToV2( + marketingCategories.iab_audience_tier_4 + ); + } + if (marketingCategories.iab_audience_tier_5) { + result["4"]["5"] = transformSegmentsV1ToV2( + marketingCategories.iab_audience_tier_5 + ); + } + + return result; +} + +/** + * v1 API specific + * Transforms segment objects from legacy format to unified format. + * Maps field names from legacy API response to unified internal representation. + * + * Legacy format: { ID: "123", label: "Category Name", relevance: "0.95" } + * Unified format: { id: "123", name: "Category Name", relevance: "0.95" } + * + * @param {Array} segments Array of legacy segment objects with ID, label, relevance. + * @returns {Array} Array of unified format segment objects with id, name, relevance. + */ +export function transformSegmentsV1ToV2(segments) { + if (!Array.isArray(segments)) return []; + return segments.map((seg) => ({ + id: seg.ID, + name: seg.label, + relevance: seg.relevance, + })); +} + +/** + * Builds flattened query parameters from IAB taxonomy filters. + * Converts human-readable tier names directly to query parameter format for GET requests. + * + * @param {Object} iabTaxonomyFilters Publisher's tier filter configuration using human-readable tier names. + * @param {number} contentSegtax The segtax ID for content taxonomies (determined by iabContentTaxonomyVersion). + * @param {boolean} [enableOrtb25Fields=true] If true, also applies filters to IAB Content Taxonomy 1.0 (segtax 1) for OpenRTB 2.5 category fields. + * @returns {Array} Array of query parameter strings (e.g., ["filter_6_1_limit=3", "filter_6_1_threshold=0.5"]). + * + * @example + * Input: { ContentTier1: { limit: 3, threshold: 0.5 }, AudienceTier3: { limit: 2 } }, contentSegtax=6 + * Output: ["filter_6_1_limit=3", "filter_6_1_threshold=0.5", "filter_4_3_limit=2"] + */ +export function buildFilterQueryParams( + iabTaxonomyFilters, + contentSegtax, + enableOrtb25Fields = true +) { + const params = []; + + if (!iabTaxonomyFilters || typeof iabTaxonomyFilters !== "object") { + return params; + } + + const TIER_TO_SEGTAX = { + ContentTier1: { segtax: contentSegtax, tier: "1" }, + ContentTier2: { segtax: contentSegtax, tier: "2" }, + ContentTier3: { segtax: contentSegtax, tier: "3" }, + AudienceTier3: { segtax: 4, tier: "3" }, + AudienceTier4: { segtax: 4, tier: "4" }, + AudienceTier5: { segtax: 4, tier: "5" }, + }; + + // Build query params from tier mappings + Object.entries(iabTaxonomyFilters).forEach(([tierName, filter]) => { + const mapping = TIER_TO_SEGTAX[tierName]; + if (mapping && filter && typeof filter === "object") { + const segtax = mapping.segtax; + const tier = mapping.tier; + + // Add each filter property (limit, threshold) as a query parameter + Object.keys(filter).forEach((prop) => { + const value = filter[prop]; + if (value !== undefined && value !== null) { + params.push(`filter_${segtax}_${tier}_${prop}=${value}`); } }); } }); - return data; + // Apply same filters to IAB 1.0 (segtax 1) for OpenRTB 2.5 fields. + // Skip when contentSegtax is already 1 -- the first loop already emitted filter_1_* params. + // Note: IAB 1.0 only has tiers 1 and 2 (tier 3 will be ignored if configured) + if (enableOrtb25Fields && contentSegtax !== 1) { + if (iabTaxonomyFilters.ContentTier1) { + Object.keys(iabTaxonomyFilters.ContentTier1).forEach((prop) => { + const value = iabTaxonomyFilters.ContentTier1[prop]; + if (value !== undefined && value !== null) { + params.push(`filter_1_1_${prop}=${value}`); + } + }); + } + + if (iabTaxonomyFilters.ContentTier2) { + Object.keys(iabTaxonomyFilters.ContentTier2).forEach((prop) => { + const value = iabTaxonomyFilters.ContentTier2[prop]; + if (value !== undefined && value !== null) { + params.push(`filter_1_2_${prop}=${value}`); + } + }); + } + } + + return params; } /** - * Processes the Neuwo API response to build and inject IAB content and audience categories - * into the bid request object. + * Processes the Neuwo API response and injects IAB Content and Audience Segments into the bid request. + * Extracts Segments from the response and injects them into ORTB2 structure. + * + * Response format: { "6": { "1": [{id, name}], "2": [...] }, "4": { "3": [...], "4": [...] } } + * - Content taxonomies are injected into ortb2.site.content.data + * - Audience taxonomies are injected into ortb2.user.data + * - If enableOrtb25Fields is true, IAB 1.0 segments are injected into OpenRTB 2.5 category fields * - * @param {Object} responseParsed The parsed JSON response from the Neuwo API. - * @param {Object} reqBidsConfigObj The bid request configuration object to be modified. - * @param {string} iabContentTaxonomyVersion The version of the IAB content taxonomy to use for segtax mapping. + * Only injects data if segments exist to avoid adding empty data structures. + * + * @param {Object} responseParsed The parsed API response. + * @param {Object} reqBidsConfigObj The bid request configuration object to be enriched. + * @param {string} iabContentTaxonomyVersion The IAB Content Taxonomy version for segtax mapping. + * @param {boolean} [enableOrtb25Fields=true] If true, populates OpenRTB 2.5 category fields with IAB Content Taxonomy 1.0 segments. */ -function injectIabCategories(responseParsed, reqBidsConfigObj, iabContentTaxonomyVersion) { - const marketingCategories = responseParsed.marketing_categories; - - if (!marketingCategories) { - logError(MODULE_NAME, "injectIabCategories:", "No Marketing Categories in Neuwo API response."); - return +export function injectIabCategories( + responseParsed, + reqBidsConfigObj, + iabContentTaxonomyVersion, + enableOrtb25Fields = true +) { + if (!responseParsed || typeof responseParsed !== "object") { + logError(MODULE_NAME, "injectIabCategories():", "Invalid response format"); + return; } - // Process content categories - const contentTiers = ["iab_tier_1", "iab_tier_2", "iab_tier_3"]; - const contentData = buildIabData( - marketingCategories, - contentTiers, - IAB_CONTENT_TAXONOMY_MAP[iabContentTaxonomyVersion] || IAB_CONTENT_TAXONOMY_MAP["3.0"] - ); + const contentSegtax = + IAB_CONTENT_TAXONOMY_MAP[iabContentTaxonomyVersion] || + IAB_CONTENT_TAXONOMY_MAP[DEFAULT_IAB_CONTENT_TAXONOMY_VERSION]; + const contentSegtaxStr = String(contentSegtax); + + // Extract IAB Content Taxonomy data for the configured version + const contentTiers = responseParsed[contentSegtaxStr] || {}; + const contentData = buildIabData(contentTiers, contentSegtax); + + // Extract IAB Audience Taxonomy data + const audienceTiers = responseParsed["4"] || {}; + const audienceData = buildIabData(audienceTiers, 4); - // Process audience categories - const audienceTiers = ["iab_audience_tier_3", "iab_audience_tier_4", "iab_audience_tier_5"]; - const audienceData = buildIabData(marketingCategories, audienceTiers, 4); + logInfo( + MODULE_NAME, + "injectIabCategories():", + "contentData structure:", + contentData + ); + logInfo( + MODULE_NAME, + "injectIabCategories():", + "audienceData structure:", + audienceData + ); - logInfo(MODULE_NAME, "injectIabCategories:", "contentData structure:", contentData); - logInfo(MODULE_NAME, "injectIabCategories:", "audienceData structure:", audienceData); + // Inject content and audience data independently to avoid sending empty structures + if (contentData.segment.length > 0) { + injectOrtbData(reqBidsConfigObj, "site.content.data", [contentData]); + logInfo( + MODULE_NAME, + "injectIabCategories():", + "Injected content data into site.content.data" + ); + } else { + logInfo( + MODULE_NAME, + "injectIabCategories():", + "No content segments to inject, skipping site.content.data" + ); + } - injectOrtbData(reqBidsConfigObj, "site.content.data", [contentData]); - injectOrtbData(reqBidsConfigObj, "user.data", [audienceData]); + if (audienceData.segment.length > 0) { + injectOrtbData(reqBidsConfigObj, "user.data", [audienceData]); + logInfo( + MODULE_NAME, + "injectIabCategories():", + "Injected audience data into user.data" + ); + } else { + logInfo( + MODULE_NAME, + "injectIabCategories():", + "No audience segments to inject, skipping user.data" + ); + } - logInfo(MODULE_NAME, "injectIabCategories:", "post-injection bidsConfig", reqBidsConfigObj); + // Inject OpenRTB 2.5 category fields if feature enabled + if (enableOrtb25Fields) { + const iab10Tiers = responseParsed["1"] || {}; // Segtax 1 = IAB Content 1.0 + const categoryIds = extractCategoryIds(iab10Tiers); // ["IAB12", "IAB12-3", ...] + + if (categoryIds.length > 0) { + // Inject same array into all four OpenRTB 2.5 category fields + injectOrtbData(reqBidsConfigObj, "site.cat", categoryIds); + injectOrtbData(reqBidsConfigObj, "site.sectioncat", categoryIds); + injectOrtbData(reqBidsConfigObj, "site.pagecat", categoryIds); + injectOrtbData(reqBidsConfigObj, "site.content.cat", categoryIds); + + logInfo( + MODULE_NAME, + "injectIabCategories():", + "Injected OpenRTB 2.5 category fields:", + categoryIds + ); + } else { + logInfo( + MODULE_NAME, + "injectIabCategories():", + "No IAB 1.0 segments available for OpenRTB 2.5 fields" + ); + } + } } export const neuwoRtdModule = { diff --git a/modules/neuwoRtdProvider.md b/modules/neuwoRtdProvider.md index 804130be1e6..e6c2798a1ad 100644 --- a/modules/neuwoRtdProvider.md +++ b/modules/neuwoRtdProvider.md @@ -10,55 +10,47 @@ The Neuwo RTD provider fetches real-time contextual data from the Neuwo API. Whe This data is then added to the bid request by populating the OpenRTB 2.x objects `ortb2.site.content.data` (for IAB Content Taxonomy) and `ortb2.user.data` (for IAB Audience Taxonomy). This enrichment allows bidders to leverage Neuwo's contextual analysis for more precise targeting and decision-making. +Additionally, when enabled, the module populates OpenRTB 2.5 category fields (`ortb2.site.cat`, `ortb2.site.sectioncat`, `ortb2.site.pagecat`, `ortb2.site.content.cat`) with IAB Content Taxonomy 1.0 segments. + Here is an example scheme of the data injected into the `ortb2` object by our module: ```javascript ortb2: { site: { + // OpenRTB 2.5 category fields (IAB Content Taxonomy 1.0) + cat: ["IAB12", "IAB12-3", "IAB12-5"], + sectioncat: ["IAB12", "IAB12-3", "IAB12-5"], + pagecat: ["IAB12", "IAB12-3", "IAB12-5"], content: { + // OpenRTB 2.5 category field (IAB Content Taxonomy 1.0) + cat: ["IAB12", "IAB12-3", "IAB12-5"], // IAB Content Taxonomy data is injected here data: [{ name: "www.neuwo.ai", - segment: [{ - id: "274", - name: "Home & Garden", - }, - { - id: "42", - name: "Books and Literature", - }, - { - id: "210", - name: "Food & Drink", - }, + segment: [ + { id: "274" }, + { id: "42" }, + { id: "210" }, ], ext: { - segtax: 7, + segtax: 6, }, - }, ], + }], }, }, user: { // IAB Audience Taxonomy data is injected here data: [{ name: "www.neuwo.ai", - segment: [{ - id: "49", - name: "Demographic | Gender | Female |", - }, - { - id: "161", - name: "Demographic | Marital Status | Married |", - }, - { - id: "6", - name: "Demographic | Age Range | 30-34 |", - }, + segment: [ + { id: "49" }, + { id: "161" }, + { id: "6" }, ], ext: { segtax: 4, }, - }, ], + }], }, } ``` @@ -74,16 +66,17 @@ This module is configured as part of the `realTimeData.dataProviders` object. ```javascript pbjs.setConfig({ realTimeData: { - auctionDelay: 500, // Value can be adjusted based on the needs + auctionDelay: 500, // Value can be adjusted based on the needs. Recommended to start with value `500` dataProviders: [ { name: "NeuwoRTDModule", - waitForIt: true, + waitForIt: true, // Recommended to be set to `true` params: { neuwoApiUrl: "", neuwoApiToken: "", - iabContentTaxonomyVersion: "3.0", + iabContentTaxonomyVersion: "2.2", enableCache: true, // Default: true. Caches API responses to avoid redundant requests + enableOrtb25Fields: true, // Default: true. }, }, ], @@ -93,23 +86,61 @@ pbjs.setConfig({ **Parameters** -| Name | Type | Required | Default | Description | -| :---------------------------------- | :------- | :------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | String | Yes | | The name of the module, which is `NeuwoRTDModule`. | -| `params` | Object | Yes | | Container for module-specific parameters. | -| `params.neuwoApiUrl` | String | Yes | | The endpoint URL for the Neuwo Edge API. | -| `params.neuwoApiToken` | String | Yes | | Your unique API token provided by Neuwo. | -| `params.iabContentTaxonomyVersion` | String | No | `'3.0'` | Specifies the version of the IAB Content Taxonomy to be used. Supported values: `'2.2'`, `'3.0'`. | -| `params.enableCache` | Boolean | No | `true` | If `true`, caches API responses to avoid redundant requests for the same page during the session. Set to `false` to disable caching and make a fresh API call on every bid request. | -| `params.stripAllQueryParams` | Boolean | No | `false` | If `true`, strips all query parameters from the URL before analysis. Takes precedence over other stripping options. | -| `params.stripQueryParamsForDomains` | String[] | No | `[]` | List of domains for which to strip **all** query parameters. When a domain matches, all query params are removed for that domain and all its subdomains (e.g., `'example.com'` strips params for both `'example.com'` and `'sub.example.com'`). This option takes precedence over `stripQueryParams` for matching domains. | -| `params.stripQueryParams` | String[] | No | `[]` | List of specific query parameter names to strip from the URL (e.g., `['utm_source', 'fbclid']`). Other parameters are preserved. Only applies when the domain does not match `stripQueryParamsForDomains`. | -| `params.stripFragments` | Boolean | No | `false` | If `true`, strips URL fragments (hash, e.g., `#section`) from the URL before analysis. | +| Name | Type | Required | Default | Description | +| :---------------------------------- | :------- | :------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | String | Yes | | The name of the module, which is `NeuwoRTDModule`. | +| `params` | Object | Yes | | Container for module-specific parameters. | +| `params.neuwoApiUrl` | String | Yes | | The endpoint URL for the Neuwo Edge API. | +| `params.neuwoApiToken` | String | Yes | | Your unique API token provided by Neuwo. | +| `params.iabContentTaxonomyVersion` | String | No | `'2.2'` | Specifies the version of the IAB Content Taxonomy to be used. Supported values: `'1.0'`, `'2.2'`, `'3.0'`. | +| `params.enableCache` | Boolean | No | `true` | If `true`, caches API responses to avoid redundant requests for the same page during the session. Set to `false` to disable caching and make a fresh API call on every bid request. | +| `params.enableOrtb25Fields` | Boolean | No | `true` | If `true`, populates OpenRTB 2.5 category fields (`ortb2.site.cat`, `ortb2.site.sectioncat`, `ortb2.site.pagecat`, `ortb2.site.content.cat`) with IAB Content Taxonomy 1.0 segments. See [OpenRTB 2.5 Category Fields](#openrtb-25-category-fields) section below for details. | +| `params.stripAllQueryParams` | Boolean | No | `false` | If `true`, strips all query parameters from the URL before analysis. Takes precedence over other stripping options. | +| `params.stripQueryParamsForDomains` | String[] | No | `[]` | List of domains for which to strip **all** query parameters. When a domain matches, all query params are removed for that domain and all its subdomains (e.g., `'example.com'` strips params for both `'example.com'` and `'sub.example.com'`). This option takes precedence over `stripQueryParams` for matching domains. | +| `params.stripQueryParams` | String[] | No | `[]` | List of specific query parameter names to strip from the URL (e.g., `['utm_source', 'fbclid']`). Other parameters are preserved. Only applies when the domain does not match `stripQueryParamsForDomains`. | +| `params.stripFragments` | Boolean | No | `false` | If `true`, strips URL fragments (hash, e.g., `#section`) from the URL before analysis. | +| `params.iabTaxonomyFilters` | Object | No | | Per-tier filtering configuration for IAB taxonomies. Allows filtering by relevance threshold and limiting the count of categories per tier. Filters configured for `ContentTier1` and `ContentTier2` are automatically applied to IAB Content Taxonomy 1.0 when `enableOrtb25Fields` is `true`. See [IAB Taxonomy Filtering](#iab-taxonomy-filtering) section for details. | ### API Response Caching By default, the module caches API responses during the page session to optimise performance and reduce redundant API calls. This behaviour can be disabled by setting `enableCache: false` if needed for dynamic content scenarios. +### OpenRTB 2.5 Category Fields + +The module supports populating OpenRTB 2.5 category fields with IAB Content Taxonomy 1.0 segments. This feature is enabled by default and provides additional contextual signals to bidders through standard OpenRTB fields. + +**Category Fields Populated:** + +- `ortb2.site.cat` - Array of IAB Content Taxonomy 1.0 category IDs +- `ortb2.site.sectioncat` - Array of IAB Content Taxonomy 1.0 category IDs +- `ortb2.site.pagecat` - Array of IAB Content Taxonomy 1.0 category IDs +- `ortb2.site.content.cat` - Array of IAB Content Taxonomy 1.0 category IDs + +**Result Example:** + +With `enableOrtb25Fields: true`, the module injects: + +```javascript +ortb2: { + site: { + // OpenRTB 2.5 category fields + cat: ["IAB12", "IAB12-3", "IAB12-5"], + sectioncat: ["IAB12", "IAB12-3", "IAB12-5"], + pagecat: ["IAB12", "IAB12-3", "IAB12-5"], + content: { + // OpenRTB 2.5 category field + cat: ["IAB12", "IAB12-3", "IAB12-5"], + // Standard content data + data: [{ + name: "www.neuwo.ai", + segment: [{ id: "274" }, { id: "42" }], + ext: { segtax: 6 } + }] + } + } +} +``` + ### URL Cleaning Options The module provides optional URL cleaning capabilities to strip query parameters and/or fragments from the analysed URL before sending it to the Neuwo API. This can be useful for privacy, caching, or analytics purposes. @@ -119,15 +150,15 @@ The module provides optional URL cleaning capabilities to strip query parameters ```javascript pbjs.setConfig({ realTimeData: { - auctionDelay: 500, // Value can be adjusted based on the needs + auctionDelay: 500, // Value can be adjusted based on the needs. Recommended to start with value `500` dataProviders: [ { name: "NeuwoRTDModule", - waitForIt: true, + waitForIt: true, // Recommended to be set to `true` params: { neuwoApiUrl: "", neuwoApiToken: "", - iabContentTaxonomyVersion: "3.0", + iabContentTaxonomyVersion: "2.2", // Option 1: Strip all query parameters from the URL stripAllQueryParams: true, @@ -147,6 +178,133 @@ pbjs.setConfig({ }); ``` +### IAB Taxonomy Filtering + +The module provides optional per-tier filtering for IAB taxonomies to control the quantity and quality of categories injected into bid requests. This allows you to limit categories based on their relevance score and restrict the maximum number of categories per tier. Filtering is performed server-side, which means only the filtered categories are returned in the response. This reduces bandwidth and improves performance. + +**Filter Configuration:** + +Each tier can have two optional parameters: + +- `threshold` (Number): Minimum relevance score (0.0 to 1.0). Categories below this threshold are excluded. +- `limit` (Number): Maximum number of categories to include for this tier (after filtering and sorting by relevance). + +**Available Tiers:** + +| Tier Name | Description | IAB Taxonomy | +| :-------------- | :------------------ | :----------------------------------- | +| `ContentTier1` | IAB Content Tier 1 | Based on configured taxonomy version | +| `ContentTier2` | IAB Content Tier 2 | Based on configured taxonomy version | +| `ContentTier3` | IAB Content Tier 3 | Based on configured taxonomy version | +| `AudienceTier3` | IAB Audience Tier 3 | IAB Audience Taxonomy 1.1 (segtax 4) | +| `AudienceTier4` | IAB Audience Tier 4 | IAB Audience Taxonomy 1.1 (segtax 4) | +| `AudienceTier5` | IAB Audience Tier 5 | IAB Audience Taxonomy 1.1 (segtax 4) | + +**Example with IAB taxonomy filtering:** + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 500, // Value can be adjusted based on the needs. Recommended to start with value `500` + dataProviders: [ + { + name: "NeuwoRTDModule", + waitForIt: true, // Recommended to be set to `true` + params: { + neuwoApiUrl: "", + neuwoApiToken: "", + iabContentTaxonomyVersion: "2.2", + + // Filter IAB taxonomies by tier + iabTaxonomyFilters: { + // Content Tier 1: Keep only the top category with at least 10% relevance + ContentTier1: { limit: 1, threshold: 0.1 }, + + // Content Tier 2: Keep top 2 categories with at least 10% relevance + ContentTier2: { limit: 2, threshold: 0.1 }, + + // Content Tier 3: Keep top 3 categories with at least 15% relevance + ContentTier3: { limit: 3, threshold: 0.15 }, + + // Audience Tier 3: Keep top 3 categories with at least 20% relevance + AudienceTier3: { limit: 3, threshold: 0.2 }, + + // Audience Tier 4: Keep top 5 categories with at least 20% relevance + AudienceTier4: { limit: 5, threshold: 0.2 }, + + // Audience Tier 5: Keep top 7 categories with at least 30% relevance + AudienceTier5: { limit: 7, threshold: 0.3 }, + }, + }, + }, + ], + }, +}); +``` + +**OpenRTB 2.5 Category Fields Filtering** + +When `iabTaxonomyFilters` are configured, the same filters applied to `ContentTier1` and `ContentTier2` are automatically applied to IAB Content Taxonomy 1.0 data used for these fields. Note that IAB Content Taxonomy 1.0 only has tiers 1 and 2, so `ContentTier3` filters are ignored for these fields. + +## Accessing Neuwo Data Outside Prebid.js + +The Neuwo RTD module enriches bid requests with contextual data that can be accessed in application code for analytics, targeting, integration with Google Ad Manager as [Publisher Provided Signals (PPS)](https://support.google.com/admanager/answer/15287325) or other purposes. The enriched data is available through Prebid.js events. + +### Example of Using the `bidRequested` Event + +Listen to the `bidRequested` event to access the enriched ORTB2 data. This event fires early in the auction lifecycle and provides direct access to the Neuwo data: + +```javascript +pbjs.que.push(function () { + pbjs.onEvent("bidRequested", function (bidRequest) { + // The ortb2 data is available directly on the bidRequest + const ortb2 = bidRequest.ortb2; + + // Extract Neuwo-specific data (from www.neuwo.ai provider) + const neuwoSiteData = ortb2?.site?.content?.data?.find( + (d) => d.name === "www.neuwo.ai" + ); + const neuwoUserData = ortb2?.user?.data?.find( + (d) => d.name === "www.neuwo.ai" + ); + + // Extract OpenRTB 2.5 category fields (if enableOrtb25Fields is true) + const categoryFields = { + siteCat: ortb2?.site?.cat, + siteSectioncat: ortb2?.site?.sectioncat, + sitePagecat: ortb2?.site?.pagecat, + contentCat: ortb2?.site?.content?.cat, + }; + + // Use the data in the application + console.log("Neuwo Site Content:", neuwoSiteData); + console.log("Neuwo User Data:", neuwoUserData); + console.log("OpenRTB 2.5 Category Fields:", categoryFields); + + // Example: Store in a global variable for later use + window.neuwoData = { + siteContent: neuwoSiteData, + user: neuwoUserData, + categoryFields: categoryFields, + }; + }); +}); +``` + +### Other Prebid.js Events + +The Neuwo data is also available in other Prebid.js events: + +| Order | Event | Fires Once Per | Data Location | +| :---- | :----------------- | :------------- | :----------------------------------- | +| 1 | `auctionInit` | Auction | `auctionData.bidderRequests[].ortb2` | +| 2 | `bidRequested` | Bidder | `bidRequest.ortb2` | +| 3 | `beforeBidderHttp` | Bidder | `bidRequests[].ortb2` | +| 4 | `bidResponse` | Bidder | `bidResponse.ortb2` | +| 5 | `auctionEnd` | Auction | `auctionData.bidderRequests[].ortb2` | + +For more information on Prebid.js events, see the [Prebid.js Event API documentation](https://docs.prebid.org/dev-docs/publisher-api-reference/getEvents.html). + ## Local Development Install the exact versions of packages specified in the lockfile: @@ -194,7 +352,7 @@ npx eslint 'modules/neuwoRtdProvider.js' --cache --cache-strategy content To run the module-specific tests: ```bash -npx gulp test-only --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter --file=test/spec/modules/euwoRtdProvider_spec.js +npx gulp test-only --modules=rtdModule,neuwoRtdProvider,appnexusBidAdapter --file=test/spec/modules/neuwoRtdProvider_spec.js ``` Skip building, if the project has already been built: @@ -202,3 +360,32 @@ Skip building, if the project has already been built: ```bash npx gulp test-only-nobuild --file=test/spec/modules/neuwoRtdProvider_spec.js ``` + +To generate test coverage report for the Neuwo RTD Module: + +```bash +npx gulp test-coverage --file=test/spec/modules/neuwoRtdProvider_spec.js +``` + +After running the coverage command, you can view the HTML report: + +```bash +# Open the coverage report in your browser +firefox build/coverage/lcov-report/index.html +# or +google-chrome build/coverage/lcov-report/index.html +``` + +Navigate to `modules/neuwoRtdProvider.js` in the report to see detailed line-by-line coverage with highlighted covered/uncovered lines. + +## Building for Production + +To generate minified code for production use: + +```bash +npx gulp build --modules=rtdModule,neuwoRtdProvider +``` + +This command creates optimised, minified code typically used on websites. + +> Version **2.2.6** diff --git a/modules/omsBidAdapter.js b/modules/omsBidAdapter.js index fd7be06409e..9eab71254d3 100644 --- a/modules/omsBidAdapter.js +++ b/modules/omsBidAdapter.js @@ -1,21 +1,18 @@ import { - isArray, - getWindowTop, deepSetValue, logError, logWarn, - createTrackPixelHtml, getBidIdParameter, getUniqueIdentifierStr, formatQS, + deepAccess, } from '../src/utils.js'; import {registerBidder} from '../src/adapters/bidderFactory.js'; import { BANNER, VIDEO } from '../src/mediaTypes.js'; import {ajax} from '../src/ajax.js'; -import {percentInView} from '../libraries/percentInView/percentInView.js'; import {getUserSyncParams} from '../libraries/userSyncUtils/userSyncUtils.js'; -import {getMinSize} from '../libraries/sizeUtils/sizeUtils.js'; -import {getBidFloor, isIframe} from '../libraries/omsUtils/index.js'; +import {getAdMarkup, getBidFloor, getDeviceType, getProcessedSizes} from '../libraries/omsUtils/index.js'; +import {getRoundedViewability} from '../libraries/omsUtils/viewability.js'; const BIDDER_CODE = 'oms'; const URL = 'https://rt.marphezis.com/hb'; @@ -38,19 +35,14 @@ export const spec = { function buildRequests(bidReqs, bidderRequest) { try { const impressions = bidReqs.map(bid => { - let bidSizes = bid?.mediaTypes?.banner?.sizes || bid.sizes || []; - bidSizes = ((isArray(bidSizes) && isArray(bidSizes[0])) ? bidSizes : [bidSizes]); - bidSizes = bidSizes.filter(size => isArray(size)); - const processedSizes = bidSizes.map(size => ({w: parseInt(size[0], 10), h: parseInt(size[1], 10)})); - - const element = document.getElementById(bid.adUnitCode); - const minSize = getMinSize(processedSizes); - const viewabilityAmount = _isViewabilityMeasurable(element) ? _getViewability(element, getWindowTop(), minSize) : 'na'; - const viewabilityAmountRounded = isNaN(viewabilityAmount) ? viewabilityAmount : Math.round(viewabilityAmount); + const bidSizes = bid?.mediaTypes?.banner?.sizes || bid.sizes || []; + const processedSizes = getProcessedSizes(bidSizes); + const viewabilityAmountRounded = getRoundedViewability(bid.adUnitCode, processedSizes); const gpidData = _extractGpidData(bid); const imp = { id: bid.bidId, + displaymanagerver: '$prebid.version$', ext: { ...gpidData }, @@ -72,6 +64,10 @@ function buildRequests(bidReqs, bidderRequest) { } } + if (deepAccess(bid, 'ortb2Imp.instl') === 1) { + imp.instl = 1; + } + const bidFloor = getBidFloor(bid); if (bidFloor) { @@ -95,7 +91,7 @@ function buildRequests(bidReqs, bidderRequest) { } }, device: { - devicetype: _getDeviceType(navigator.userAgent, bidderRequest?.ortb2?.device?.sua), + devicetype: getDeviceType(navigator.userAgent, bidderRequest?.ortb2?.device?.sua), w: screen.width, h: screen.height }, @@ -186,7 +182,7 @@ function interpretResponse(serverResponse) { bidResponse.vastXml = bid.adm; } else { bidResponse.mediaType = BANNER; - bidResponse.ad = _getAdMarkup(bid); + bidResponse.ad = getAdMarkup(bid); } return bidResponse; @@ -238,18 +234,6 @@ function _trackEvent(endpoint, data) { }); } -function _getDeviceType(ua, sua) { - if (sua?.mobile || (/(ios|ipod|ipad|iphone|android)/i).test(ua)) { - return 1 - } - - if ((/(smart[-]?tv|hbbtv|appletv|googletv|hdmi|netcast\.tv|viera|nettv|roku|\bdtv\b|sonydtv|inettvbrowser|\btv\b)/i).test(ua)) { - return 3 - } - - return 2 -} - function _getGpp(bidderRequest) { if (bidderRequest?.gppConsent != null) { return bidderRequest.gppConsent; @@ -260,22 +244,6 @@ function _getGpp(bidderRequest) { ); } -function _getAdMarkup(bid) { - let adm = bid.adm; - if ('nurl' in bid) { - adm += createTrackPixelHtml(bid.nurl); - } - return adm; -} - -function _isViewabilityMeasurable(element) { - return !isIframe() && element !== null; -} - -function _getViewability(element, topWin, {w, h} = {}) { - return getWindowTop().document.visibilityState === 'visible' ? percentInView(element, {w, h}) : 0; -} - function _extractGpidData(bid) { return { gpid: bid?.ortb2Imp?.ext?.gpid, diff --git a/modules/onomagicBidAdapter.js b/modules/onomagicBidAdapter.js index c3176d7abcc..f15ef2e63b1 100644 --- a/modules/onomagicBidAdapter.js +++ b/modules/onomagicBidAdapter.js @@ -1,17 +1,14 @@ import { _each, - createTrackPixelHtml, getBidIdParameter, + getBidIdParameter, getUniqueIdentifierStr, - getWindowTop, - isArray, logError, logWarn } from '../src/utils.js'; import {registerBidder} from '../src/adapters/bidderFactory.js'; import {BANNER} from '../src/mediaTypes.js'; -import { percentInView } from '../libraries/percentInView/percentInView.js'; -import {getMinSize} from '../libraries/sizeUtils/sizeUtils.js'; -import {getBidFloor, isIframe} from '../libraries/omsUtils/index.js'; +import {getAdMarkup, getBidFloor, getDeviceType, getProcessedSizes} from '../libraries/omsUtils/index.js'; +import {getRoundedViewability} from '../libraries/omsUtils/viewability.js'; const BIDDER_CODE = 'onomagic'; const URL = 'https://bidder.onomagic.com/hb'; @@ -34,17 +31,9 @@ function buildRequests(bidReqs, bidderRequest) { const onomagicImps = []; const publisherId = getBidIdParameter('publisherId', bidReqs[0].params); _each(bidReqs, function (bid) { - let bidSizes = (bid.mediaTypes && bid.mediaTypes.banner && bid.mediaTypes.banner.sizes) || bid.sizes; - bidSizes = ((isArray(bidSizes) && isArray(bidSizes[0])) ? bidSizes : [bidSizes]); - bidSizes = bidSizes.filter(size => isArray(size)); - const processedSizes = bidSizes.map(size => ({w: parseInt(size[0], 10), h: parseInt(size[1], 10)})); - - const element = document.getElementById(bid.adUnitCode); - const minSize = getMinSize(processedSizes); - const viewabilityAmount = _isViewabilityMeasurable(element) - ? _getViewability(element, getWindowTop(), minSize) - : 'na'; - const viewabilityAmountRounded = isNaN(viewabilityAmount) ? viewabilityAmount : Math.round(viewabilityAmount); + const bidSizes = (bid.mediaTypes && bid.mediaTypes.banner && bid.mediaTypes.banner.sizes) || bid.sizes; + const processedSizes = getProcessedSizes(bidSizes); + const viewabilityAmountRounded = getRoundedViewability(bid.adUnitCode, processedSizes); const imp = { id: bid.bidId, @@ -74,7 +63,7 @@ function buildRequests(bidReqs, bidderRequest) { } }, device: { - devicetype: _getDeviceType(), + devicetype: getDeviceType(), w: screen.width, h: screen.height }, @@ -127,7 +116,7 @@ function interpretResponse(serverResponse) { currency: 'USD', netRevenue: true, mediaType: BANNER, - ad: _getAdMarkup(onomagicBid), + ad: getAdMarkup(onomagicBid), ttl: 60, meta: { advertiserDomains: onomagicBid && onomagicBid.adomain ? onomagicBid.adomain : [] @@ -146,34 +135,4 @@ function getUserSyncs(syncOptions, responses, gdprConsent) { return []; } -function _isMobile() { - return (/(ios|ipod|ipad|iphone|android)/i).test(navigator.userAgent); -} - -function _isConnectedTV() { - return (/(smart[-]?tv|hbbtv|appletv|googletv|hdmi|netcast\.tv|viera|nettv|roku|\bdtv\b|sonydtv|inettvbrowser|\btv\b)/i).test(navigator.userAgent); -} - -function _getDeviceType() { - return _isMobile() ? 1 : _isConnectedTV() ? 3 : 2; -} - -function _getAdMarkup(bid) { - let adm = bid.adm; - if ('nurl' in bid) { - adm += createTrackPixelHtml(bid.nurl); - } - return adm; -} - -function _isViewabilityMeasurable(element) { - return !isIframe() && element !== null; -} - -function _getViewability(element, topWin, { w, h } = {}) { - return getWindowTop().document.visibilityState === 'visible' - ? percentInView(element, { w, h }) - : 0; -} - registerBidder(spec); diff --git a/modules/optidigitalBidAdapter.js b/modules/optidigitalBidAdapter.js index 1330bf2054f..6529f02b5bc 100755 --- a/modules/optidigitalBidAdapter.js +++ b/modules/optidigitalBidAdapter.js @@ -63,7 +63,8 @@ export const spec = { imp: validBidRequests.map(bidRequest => buildImp(bidRequest, ortb2)), badv: ortb2.badv || deepAccess(validBidRequests[0], 'params.badv') || [], bcat: ortb2.bcat || deepAccess(validBidRequests[0], 'params.bcat') || [], - bapp: deepAccess(validBidRequests[0], 'params.bapp') || [] + bapp: deepAccess(validBidRequests[0], 'params.bapp') || [], + device: ortb2.device || {} } if (validBidRequests[0].auctionId) { @@ -82,10 +83,14 @@ export const spec = { const gdpr = deepAccess(bidderRequest, 'gdprConsent'); if (bidderRequest && gdpr) { const isConsentString = typeof gdpr.consentString === 'string'; + const isGdprApplies = typeof gdpr.gdprApplies === 'boolean'; payload.gdpr = { consent: isConsentString ? gdpr.consentString : '', - required: true + required: isGdprApplies ? gdpr.gdprApplies : false }; + if (gdpr?.addtlConsent) { + payload.gdpr.addtlConsent = gdpr.addtlConsent; + } } if (bidderRequest && !gdpr) { payload.gdpr = { @@ -120,10 +125,16 @@ export const spec = { } } + const ortb2SiteKeywords = (bidderRequest?.ortb2?.site?.keywords || '')?.split(',').map(k => k.trim()).filter(k => k !== '').join(','); + if (ortb2SiteKeywords) { + payload.site = payload.site || {}; + payload.site.keywords = ortb2SiteKeywords; + } + const payloadObject = JSON.stringify(payload); return { method: 'POST', - url: ENDPOINT_URL, + url: `${ENDPOINT_URL}/${payload.publisherId}`, data: payloadObject }; }, @@ -230,6 +241,11 @@ function buildImp(bidRequest, ortb2) { imp.battr = battr; } + const gpid = deepAccess(bidRequest, 'ortb2Imp.ext.gpid'); + if (gpid) { + imp.gpid = gpid; + } + return imp; } diff --git a/modules/panxoRtdProvider.js b/modules/panxoRtdProvider.js new file mode 100644 index 00000000000..5865106e564 --- /dev/null +++ b/modules/panxoRtdProvider.js @@ -0,0 +1,227 @@ +/** + * This module adds Panxo AI traffic classification to the real time data module. + * + * The {@link module:modules/realTimeData} module is required. + * The module injects the Panxo signal collection script, enriching bid requests + * with AI traffic classification data and contextual signals for improved targeting. + * @module modules/panxoRtdProvider + * @requires module:modules/realTimeData + */ + +import { submodule } from '../src/hook.js'; +import { + prefixLog, + mergeDeep, + generateUUID, + getWindowSelf, +} from '../src/utils.js'; +import { getRefererInfo } from '../src/refererDetection.js'; +import { getGlobal } from '../src/prebidGlobal.js'; +import { loadExternalScript } from '../src/adloader.js'; +import { MODULE_TYPE_RTD } from '../src/activities/modules.js'; + +/** + * @typedef {import('../modules/rtdModule/index.js').RtdSubmodule} RtdSubmodule + * @typedef {import('../modules/rtdModule/index.js').SubmoduleConfig} SubmoduleConfig + * @typedef {import('../modules/rtdModule/index.js').UserConsentData} UserConsentData + */ + +const SUBMODULE_NAME = 'panxo'; +const SCRIPT_URL = 'https://api.idsequoia.ai/rtd.js'; + +const { logWarn, logError } = prefixLog(`[${SUBMODULE_NAME}]:`); + +/** @type {string} */ +let siteId = ''; + +/** @type {boolean} */ +let verbose = false; + +/** @type {string} */ +let sessionId = ''; + +/** @type {Object} */ +let panxoData = {}; + +/** @type {boolean} */ +let implReady = false; + +/** @type {Array} */ +let pendingCallbacks = []; + +/** + * Submodule registration + */ +function main() { + submodule('realTimeData', /** @type {RtdSubmodule} */ ({ + name: SUBMODULE_NAME, + + init: (config, userConsent) => { + try { + load(config); + return true; + } catch (err) { + logError('init', err.message); + return false; + } + }, + + getBidRequestData: onGetBidRequestData + })); +} + +/** + * Validates configuration and loads the Panxo signal collection script. + * @param {SubmoduleConfig} config + */ +function load(config) { + siteId = config?.params?.siteId || ''; + if (!siteId || typeof siteId !== 'string') { + throw new Error(`The 'siteId' parameter is required and must be a string`); + } + + // siteId is a 16-character hex hash identifying the publisher property + if (!/^[a-f0-9]{16}$/.test(siteId)) { + throw new Error(`The 'siteId' parameter must be a valid 16-character hex identifier`); + } + + // Load/reset the state + verbose = !!config?.params?.verbose; + sessionId = generateUUID(); + panxoData = {}; + implReady = false; + pendingCallbacks = []; + + const refDomain = getRefererInfo().domain || ''; + + // The implementation script uses the session parameter to register + // a bridge API on window['panxo_' + sessionId] + const scriptUrl = `${SCRIPT_URL}?siteId=${siteId}&session=${sessionId}&r=${refDomain}`; + + loadExternalScript(scriptUrl, MODULE_TYPE_RTD, SUBMODULE_NAME, onImplLoaded); +} + +/** + * Callback invoked when the external script finishes loading. + * Establishes the bridge between this RTD submodule and the implementation. + */ +function onImplLoaded() { + const wnd = getWindowSelf(); + const impl = wnd[`panxo_${sessionId}`]; + if (typeof impl !== 'object' || typeof impl.connect !== 'function') { + if (verbose) logWarn('onload', 'Unable to access the implementation script'); + if (!implReady) { + implReady = true; + flushPendingCallbacks(); + } + return; + } + + // Set up the bridge. The callback may be called multiple times as + // more precise signal data becomes available. + impl.connect(getGlobal(), onImplMessage); +} + +/** + * Bridge callback invoked by the implementation script to update signal data. + * When the first signal arrives, flushes any pending auction callbacks so + * the auction can proceed with enriched data. + * @param {Object} msg + */ +function onImplMessage(msg) { + if (!msg || typeof msg !== 'object') { + return; + } + + switch (msg.type) { + case 'signal': { + panxoData = mergeDeep({}, msg.data || {}); + if (!implReady) { + implReady = true; + flushPendingCallbacks(); + } + break; + } + case 'error': { + logError('impl', msg.data || ''); + if (!implReady) { + implReady = true; + flushPendingCallbacks(); + } + break; + } + } +} + +/** + * Flush all pending getBidRequestData callbacks. + * Called when the implementation script sends its first signal. + */ +function flushPendingCallbacks() { + const cbs = pendingCallbacks.splice(0); + cbs.forEach(cb => cb()); +} + +/** + * Called once per auction to enrich bid request ORTB data. + * + * If the implementation script has already sent signal data, enrichment + * happens synchronously and the callback fires immediately. Otherwise the + * callback is deferred until the first signal arrives. The Prebid RTD + * framework enforces `auctionDelay` as the upper bound on this wait, so + * the auction is never blocked indefinitely. + * + * Adds the following fields: + * - device.ext.panxo: session signal token for traffic verification + * - site.ext.data.panxo: contextual AI traffic classification data + * + * @param {Object} reqBidsConfigObj + * @param {function} callback + * @param {SubmoduleConfig} config + * @param {UserConsentData} userConsent + */ +function onGetBidRequestData(reqBidsConfigObj, callback, config, userConsent) { + function enrichAndDone() { + const ortb2 = {}; + + // Add device-level signal (opaque session token) + if (panxoData.device) { + mergeDeep(ortb2, { device: { ext: { panxo: panxoData.device } } }); + } + + // Add site-level contextual data (AI classification) + if (panxoData.site && Object.keys(panxoData.site).length > 0) { + mergeDeep(ortb2, { site: { ext: { data: { panxo: panxoData.site } } } }); + } + + mergeDeep(reqBidsConfigObj.ortb2Fragments.global, ortb2); + callback(); + } + + // If data already arrived, proceed immediately + if (implReady) { + enrichAndDone(); + return; + } + + // Otherwise, wait for the implementation script to send its first signal. + // The auctionDelay configured by the publisher (e.g. 1500ms) acts as the + // maximum wait time -- Prebid will call our callback when it expires. + pendingCallbacks.push(enrichAndDone); +} + +/** + * Exporting local functions for testing purposes. + */ +export const __TEST__ = { + SUBMODULE_NAME, + SCRIPT_URL, + main, + load, + onImplLoaded, + onImplMessage, + onGetBidRequestData, + flushPendingCallbacks +}; + +main(); diff --git a/modules/panxoRtdProvider.md b/modules/panxoRtdProvider.md new file mode 100644 index 00000000000..bc9be90d152 --- /dev/null +++ b/modules/panxoRtdProvider.md @@ -0,0 +1,45 @@ +# Overview + +``` +Module Name: Panxo RTD Provider +Module Type: RTD Provider +Maintainer: prebid@panxo.ai +``` + +# Description + +The Panxo RTD module enriches OpenRTB bid requests with real-time AI traffic classification signals. It detects visits originating from AI assistants and provides contextual data through `device.ext.panxo` and `site.ext.data.panxo`, enabling the Panxo Bid Adapter and other demand partners to apply differentiated bidding on AI-referred inventory. + +To use this module, contact [publishers@panxo.ai](mailto:publishers@panxo.ai) or sign up at [app.panxo.com](https://app.panxo.com) to receive your property identifier. + +# Build + +```bash +gulp build --modules=rtdModule,panxoRtdProvider,... +``` + +> `rtdModule` is required to use the Panxo RTD module. + +# Configuration + +```javascript +pbjs.setConfig({ + realTimeData: { + auctionDelay: 300, + dataProviders: [{ + name: 'panxo', + waitForIt: true, + params: { + siteId: 'a1b2c3d4e5f67890' + } + }] + } +}); +``` + +## Parameters + +| Name | Type | Description | Required | +| :-------- | :------ | :----------------------------------------------------- | :------- | +| `siteId` | String | 16-character hex property identifier provided by Panxo | Yes | +| `verbose` | Boolean | Enable verbose logging for troubleshooting | No | diff --git a/modules/priceFloors.ts b/modules/priceFloors.ts index 45460c66425..1ea98a337dd 100644 --- a/modules/priceFloors.ts +++ b/modules/priceFloors.ts @@ -843,6 +843,11 @@ export type FloorsConfig = Pick * If set to false, the Price Floors Module will still provide floors for bid adapters, there will be no floor enforcement. */ enforceJS?: boolean; + /** + * Array of bidders to enforce JS floors on when enforceJS is true. + * Defaults to ['*'] (all bidders). + */ + enforceBidders?: (BidderCode | '*')[]; /** * If set to true (the default), the Price Floors Module will signal to Prebid Server to pass floors to it’s bid * adapters and enforce floors. @@ -901,6 +906,7 @@ export function handleSetFloorsConfig(config) { 'userIds', validateUserIdsConfig, 'enforcement', enforcement => pick(enforcement || {}, [ 'enforceJS', enforceJS => enforceJS !== false, // defaults to true + 'enforceBidders', enforceBidders => Array.isArray(enforceBidders) && enforceBidders.length > 0 ? enforceBidders : ['*'], 'enforcePBS', enforcePBS => enforcePBS === true, // defaults to false 'floorDeals', floorDeals => floorDeals === true, // defaults to false 'bidAdjustment', bidAdjustment => bidAdjustment !== false, // defaults to true, @@ -983,9 +989,12 @@ function addFloorDataToBid(floorData, floorInfo, bid: Partial, adjustedCpm) */ function shouldFloorBid(floorData, floorInfo, bid) { const enforceJS = deepAccess(floorData, 'enforcement.enforceJS') !== false; + const enforceBidders = deepAccess(floorData, 'enforcement.enforceBidders') || ['*']; + const bidderCode = bid?.adapterCode || bid?.bidderCode || bid?.bidder; + const shouldEnforceBidder = enforceBidders.includes('*') || (bidderCode != null && enforceBidders.includes(bidderCode)); const shouldFloorDeal = deepAccess(floorData, 'enforcement.floorDeals') === true || !bid.dealId; const bidBelowFloor = bid.floorData.cpmAfterAdjustments < floorInfo.matchingFloor; - return enforceJS && (bidBelowFloor && shouldFloorDeal); + return enforceJS && shouldEnforceBidder && (bidBelowFloor && shouldFloorDeal); } /** diff --git a/modules/proxistoreBidAdapter.js b/modules/proxistoreBidAdapter.js index 5c66be10804..cea18be596f 100644 --- a/modules/proxistoreBidAdapter.js +++ b/modules/proxistoreBidAdapter.js @@ -1,185 +1,160 @@ -import { isFn, isPlainObject } from '../src/utils.js'; -import { registerBidder } from '../src/adapters/bidderFactory.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {ortbConverter} from '../libraries/ortbConverter/converter.js'; +import {BANNER} from '../src/mediaTypes.js'; +import {deepSetValue} from '../src/utils.js'; const BIDDER_CODE = 'proxistore'; const PROXISTORE_VENDOR_ID = 418; -const COOKIE_BASE_URL = 'https://abs.proxistore.com/v3/rtb/prebid/multi'; -const COOKIE_LESS_URL = - 'https://abs.cookieless-proxistore.com/v3/rtb/prebid/multi'; - -function _createServerRequest(bidRequests, bidderRequest) { - var sizeIds = []; - bidRequests.forEach(function (bid) { - var sizeId = { - id: bid.bidId, - sizes: bid.sizes.map(function (size) { - return { - width: size[0], - height: size[1], - }; - }), - floor: _assignFloor(bid), - segments: _assignSegments(bid), - }; - sizeIds.push(sizeId); - }); - var payload = { - // TODO: fix auctionId leak: https://github.com/prebid/Prebid.js/issues/9781 - auctionId: bidRequests[0].auctionId, - transactionId: bidRequests[0].ortb2Imp?.ext?.tid, - bids: sizeIds, - website: bidRequests[0].params.website, - language: bidRequests[0].params.language, - gdpr: { - applies: false, - consentGiven: false, - }, - }; +const COOKIE_BASE_URL = 'https://abs.proxistore.com/v3/rtb/openrtb'; +const COOKIE_LESS_URL = 'https://abs.cookieless-proxistore.com/v3/rtb/openrtb'; +const SYNC_BASE_URL = 'https://abs.proxistore.com/v3/rtb/sync'; + +const converter = ortbConverter({ + context: { + mediaType: BANNER, + netRevenue: true, + ttl: 30, + currency: 'EUR', + }, + request(buildRequest, imps, bidderRequest, context) { + const request = buildRequest(imps, bidderRequest, context); + const bidRequests = context.bidRequests; + if (bidRequests && bidRequests.length > 0) { + const params = bidRequests[0].params; + if (params.website) { + deepSetValue(request, 'ext.proxistore.website', params.website); + } + if (params.language) { + deepSetValue(request, 'ext.proxistore.language', params.language); + } + } + return request; + } +}); - if (bidderRequest && bidderRequest.gdprConsent) { - var gdprConsent = bidderRequest.gdprConsent; +/** + * Determines whether or not the given bid request is valid. + * + * @param bid The bid params to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ +function isBidRequestValid(bid) { + return !!(bid.params.website && bid.params.language); +} - if ( - typeof gdprConsent.gdprApplies === 'boolean' && - gdprConsent.gdprApplies - ) { - payload.gdpr.applies = true; - } +/** + * Make a server request from the list of BidRequests. + * + * @param bidRequests - an array of bids + * @param bidderRequest + * @return ServerRequest Info describing the request to the server. + */ +function buildRequests(bidRequests, bidderRequest) { + let gdprApplies = false; + let consentGiven = false; + + if (bidderRequest && bidderRequest.gdprConsent) { + const gdprConsent = bidderRequest.gdprConsent; - if ( - typeof gdprConsent.consentString === 'string' && - gdprConsent.consentString - ) { - payload.gdpr.consentString = bidderRequest.gdprConsent.consentString; + if (typeof gdprConsent.gdprApplies === 'boolean' && gdprConsent.gdprApplies) { + gdprApplies = true; } if (gdprConsent.vendorData) { - var vendorData = gdprConsent.vendorData; - + const vendorData = gdprConsent.vendorData; if ( vendorData.vendor && vendorData.vendor.consents && - typeof vendorData.vendor.consents[PROXISTORE_VENDOR_ID.toString(10)] !== - 'undefined' + vendorData.vendor.consents[PROXISTORE_VENDOR_ID.toString(10)] !== 'undefined' ) { - payload.gdpr.consentGiven = - !!vendorData.vendor.consents[PROXISTORE_VENDOR_ID.toString(10)]; + consentGiven = !!vendorData.vendor.consents[PROXISTORE_VENDOR_ID.toString(10)]; } } } - var options = { + const options = { contentType: 'application/json', - withCredentials: payload.gdpr.consentGiven, + withCredentials: consentGiven, customHeaders: { - version: '1.0.4', + version: '2.0.0', }, }; - var endPointUri = - payload.gdpr.consentGiven || !payload.gdpr.applies - ? COOKIE_BASE_URL - : COOKIE_LESS_URL; + + const endPointUri = consentGiven || !gdprApplies ? COOKIE_BASE_URL : COOKIE_LESS_URL; return { method: 'POST', url: endPointUri, - data: JSON.stringify(payload), + data: converter.toORTB({ bidRequests, bidderRequest }), options: options, }; } -function _assignSegments(bid) { - var segs = (bid.ortb2 && bid.ortb2.user && bid.ortb2.user.ext && bid.ortb2.user.ext.data && bid.ortb2.user.ext.data.sd_rtd && bid.ortb2.user.ext.data.sd_rtd.segments ? bid.ortb2.user.ext.data.sd_rtd.segments : []); - var cats = {}; - if (bid.ortb2 && bid.ortb2.site && bid.ortb2.site.ext && bid.ortb2.site.ext.data && bid.ortb2.site.ext.data.sd_rtd) { - if (bid.ortb2.site.ext.data.sd_rtd.categories) { - segs = segs.concat(bid.ortb2.site.ext.data.sd_rtd.categories); - } - if (bid.ortb2.site.ext.data.sd_rtd.categories_score) { - cats = bid.ortb2.site.ext.data.sd_rtd.categories_score; - } - } - - return { - segments: segs, - contextual_categories: cats - }; -} - -function _createBidResponse(response) { - return { - requestId: response.requestId, - cpm: response.cpm, - width: response.width, - height: response.height, - ad: response.ad, - ttl: response.ttl, - creativeId: response.creativeId, - currency: response.currency, - netRevenue: response.netRevenue, - vastUrl: response.vastUrl, - vastXml: response.vastXml, - dealId: response.dealId, - meta: response.meta, - }; -} /** - * Determines whether or not the given bid request is valid. + * Unpack the response from the server into a list of bids. * - * @param bid The bid params to validate. - * @return boolean True if this is a valid bid, and false otherwise. + * @param response + * @param request + * @return An array of bids which were nested inside the server. */ - -function isBidRequestValid(bid) { - return !!(bid.params.website && bid.params.language); +function interpretResponse(response, request) { + if (response.body) { + return converter.fromORTB({response: response.body, request: request.data}).bids; + } + return []; } -/** - * Make a server request from the list of BidRequests. - * - * @param bidRequests - an array of bids - * @param bidderRequest - * @return ServerRequest Info describing the request to the server. - */ - -function buildRequests(bidRequests, bidderRequest) { - var request = _createServerRequest(bidRequests, bidderRequest); - return request; -} /** - * Unpack the response from the server into a list of bids. + * Register user sync pixels and iframes. * - * @param serverResponse A successful response from the server. - * @param bidRequest Request original server request - * @return An array of bids which were nested inside the server. + * @param syncOptions - which sync types are enabled + * @param responses - server responses + * @param gdprConsent - GDPR consent data + * @return Array of sync objects */ +function getUserSyncs(syncOptions, responses, gdprConsent) { + const syncs = []; -function interpretResponse(serverResponse, bidRequest) { - return serverResponse.body.map(_createBidResponse); -} + // Only sync if consent given or GDPR doesn't apply + const consentGiven = gdprConsent?.vendorData?.vendor?.consents?.[PROXISTORE_VENDOR_ID]; + if (gdprConsent?.gdprApplies && !consentGiven) { + return syncs; + } -function _assignFloor(bid) { - if (!isFn(bid.getFloor)) { - return bid.params.bidFloor ? bid.params.bidFloor : null; + const params = new URLSearchParams(); + if (gdprConsent) { + params.set('gdpr', gdprConsent.gdprApplies ? '1' : '0'); + if (gdprConsent.consentString) { + params.set('gdpr_consent', gdprConsent.consentString); + } + } + + if (syncOptions.pixelEnabled) { + syncs.push({ + type: 'image', + url: `${SYNC_BASE_URL}/image?${params}` + }); } - const floor = bid.getFloor({ - currency: 'EUR', - mediaType: 'banner', - size: '*', - }); - if (isPlainObject(floor) && !isNaN(floor.floor) && floor.currency === 'EUR') { - return floor.floor; + if (syncOptions.iframeEnabled) { + syncs.push({ + type: 'iframe', + url: `${SYNC_BASE_URL}/iframe?${params}` + }); } - return null; + + return syncs; } export const spec = { code: BIDDER_CODE, + gvlid: PROXISTORE_VENDOR_ID, isBidRequestValid: isBidRequestValid, buildRequests: buildRequests, interpretResponse: interpretResponse, - gvlid: PROXISTORE_VENDOR_ID, + getUserSyncs: getUserSyncs, + supportedMediaTypes: [BANNER], + browsingTopics: true, }; registerBidder(spec); diff --git a/modules/pubmaticBidAdapter.js b/modules/pubmaticBidAdapter.js index f23665670e9..5051eb8e6ee 100644 --- a/modules/pubmaticBidAdapter.js +++ b/modules/pubmaticBidAdapter.js @@ -756,6 +756,7 @@ export const spec = { code: BIDDER_CODE, gvlid: 76, supportedMediaTypes: [BANNER, VIDEO, NATIVE], + alwaysHasCapacity: true, /** * Determines whether or not the given bid request is valid. Valid bid request must have placementId and hbid * diff --git a/modules/pubstackBidAdapter.md b/modules/pubstackBidAdapter.md new file mode 100644 index 00000000000..dc3df3b8ee5 --- /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 00000000000..62126136e72 --- /dev/null +++ b/modules/pubstackBidAdapter.ts @@ -0,0 +1,156 @@ +import { canAccessWindowTop, deepSetValue, getWindowSelf, getWindowTop, 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 { getPlacementPositionUtils } from '../libraries/placementPositionInfo/placementPositionInfo.js'; +import { getGptSlotInfoForAdUnitCode } from '../libraries/gptUtils/gptUtils.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 cntTimeouts = 0; +const { getPlacementEnv, getPlacementInfo } = getPlacementPositionUtils(); + +const getElementForAdUnitCode = (adUnitCode: string): HTMLElement | undefined => { + if (!adUnitCode) return; + const win = canAccessWindowTop() ? getWindowTop() : getWindowSelf(); + const doc = win?.document; + let element = doc?.getElementById(adUnitCode) as HTMLElement | null; + if (element) return element; + const divId = getGptSlotInfoForAdUnitCode(adUnitCode)?.divId; + element = divId ? doc?.getElementById(divId) as HTMLElement | null : null; + if (element) return element; +}; + +const converter = ortbConverter({ + imp(buildImp, bidRequest: BidRequest, context) { + const element = getElementForAdUnitCode(bidRequest.adUnitCode); + const placementInfo = getPlacementInfo(bidRequest); + const imp = buildImp(bidRequest, context); + deepSetValue(imp, `ext.prebid.bidder.${BIDDER_CODE}.adUnitName`, bidRequest.params.adUnitName); + deepSetValue(imp, `ext.prebid.placement.code`, bidRequest.adUnitCode); + deepSetValue(imp, `ext.prebid.placement.domId`, element?.id); + deepSetValue(imp, `ext.prebid.placement.viewability`, placementInfo.PlacementPercentView); + deepSetValue(imp, `ext.prebid.placement.viewportDistance`, placementInfo.DistanceToView); + deepSetValue(imp, `ext.prebid.placement.height`, placementInfo.ElementHeight); + deepSetValue(imp, `ext.prebid.placement.auctionsCount`, placementInfo.AuctionsCount); + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + cntRequest++; + const placementEnv = getPlacementEnv(); + 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.request.count`, cntRequest); + deepSetValue(request, `ext.prebid.request.timeoutCount`, cntTimeouts); + deepSetValue(request, `ext.prebid.page.tabActive`, placementEnv.TabActive); + deepSetValue(request, `ext.prebid.page.height`, placementEnv.PageHeight); + deepSetValue(request, `ext.prebid.page.viewportHeight`, placementEnv.ViewportHeight); + deepSetValue(request, `ext.prebid.page.timeFromNavigation`, placementEnv.TimeFromNavigation); + 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, + onTimeout: () => cntTimeouts++, +}; + +registerBidder(spec); diff --git a/modules/revantageBidAdapter.js b/modules/revantageBidAdapter.js new file mode 100644 index 00000000000..0a0186d5b59 --- /dev/null +++ b/modules/revantageBidAdapter.js @@ -0,0 +1,407 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { deepClone, deepAccess, logWarn, logError, triggerPixel } from '../src/utils.js'; +import { BANNER, VIDEO } from '../src/mediaTypes.js'; + +const BIDDER_CODE = 'revantage'; +const ENDPOINT_URL = 'https://bid.revantage.io/bid'; +const SYNC_URL = 'https://sync.revantage.io/sync'; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO], + + isBidRequestValid: function(bid) { + return !!(bid && bid.params && bid.params.feedId); + }, + + buildRequests: function(validBidRequests, bidderRequest) { + // Handle null/empty bid requests + if (!validBidRequests || validBidRequests.length === 0) { + return []; + } + + // All bid requests in a batch must have the same feedId + // If not, we log a warning and return an empty array + const feedId = validBidRequests[0]?.params?.feedId; + const allSameFeedId = validBidRequests.every(bid => bid.params.feedId === feedId); + if (!allSameFeedId) { + logWarn('Revantage: All bid requests in a batch must have the same feedId'); + return []; + } + + try { + const openRtbBidRequest = makeOpenRtbRequest(validBidRequests, bidderRequest); + return { + method: 'POST', + url: ENDPOINT_URL + '?feed=' + encodeURIComponent(feedId), + data: JSON.stringify(openRtbBidRequest), + options: { + contentType: 'text/plain', + withCredentials: false + }, + bidRequests: validBidRequests + }; + } catch (e) { + logError('Revantage: buildRequests failed', e); + return []; + } + }, + + interpretResponse: function(serverResponse, request) { + const bids = []; + const resp = serverResponse.body; + const originalBids = request.bidRequests || []; + const bidIdMap = {}; + originalBids.forEach(b => { bidIdMap[b.bidId] = b; }); + + if (!resp || !Array.isArray(resp.seatbid)) return bids; + + resp.seatbid.forEach(seatbid => { + if (Array.isArray(seatbid.bid)) { + seatbid.bid.forEach(rtbBid => { + const originalBid = bidIdMap[rtbBid.impid]; + if (!originalBid || !rtbBid.price || rtbBid.price <= 0) return; + + // Check for ad markup + const hasAdMarkup = !!(rtbBid.adm || rtbBid.vastXml || rtbBid.vastUrl); + if (!hasAdMarkup) { + logWarn('Revantage: No ad markup in bid'); + return; + } + + const bidResponse = { + requestId: originalBid.bidId, + cpm: rtbBid.price, + width: rtbBid.w || getFirstSize(originalBid, 0, 300), + height: rtbBid.h || getFirstSize(originalBid, 1, 250), + creativeId: rtbBid.crid || rtbBid.id || rtbBid.adid || 'revantage-' + Date.now(), + dealId: rtbBid.dealid, + currency: resp.cur || 'USD', + netRevenue: true, + ttl: 300, + meta: { + advertiserDomains: rtbBid.adomain || [], + dsp: seatbid.seat || 'unknown', + networkName: 'Revantage' + } + }; + + // Add burl for server-side win notification + if (rtbBid.burl) { + bidResponse.burl = rtbBid.burl; + } + + // Determine if this is a video bid + // FIX: Check for VAST content in adm even for multi-format ad units + const isVideo = (rtbBid.ext && rtbBid.ext.mediaType === 'video') || + rtbBid.vastXml || rtbBid.vastUrl || + isVastAdm(rtbBid.adm) || + (originalBid.mediaTypes && originalBid.mediaTypes.video && + !originalBid.mediaTypes.banner); + + if (isVideo) { + bidResponse.mediaType = VIDEO; + bidResponse.vastXml = rtbBid.vastXml || rtbBid.adm; + bidResponse.vastUrl = rtbBid.vastUrl; + + if (!bidResponse.vastUrl && !bidResponse.vastXml) { + logWarn('Revantage: Video bid missing VAST content'); + return; + } + } else { + bidResponse.mediaType = BANNER; + bidResponse.ad = rtbBid.adm; + + if (!bidResponse.ad) { + logWarn('Revantage: Banner bid missing ad markup'); + return; + } + } + + // Add DSP price if available + if (rtbBid.ext && rtbBid.ext.dspPrice) { + bidResponse.meta.dspPrice = rtbBid.ext.dspPrice; + } + + bids.push(bidResponse); + }); + } + }); + + return bids; + }, + + getUserSyncs: function(syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) { + const syncs = []; + let params = '?cb=' + new Date().getTime(); + + if (gdprConsent) { + if (typeof gdprConsent.gdprApplies === 'boolean') { + params += '&gdpr=' + (gdprConsent.gdprApplies ? 1 : 0); + } + if (typeof gdprConsent.consentString === 'string') { + params += '&gdpr_consent=' + encodeURIComponent(gdprConsent.consentString); + } + } + + if (uspConsent && typeof uspConsent === 'string') { + params += '&us_privacy=' + encodeURIComponent(uspConsent); + } + + if (gppConsent) { + if (gppConsent.gppString) { + params += '&gpp=' + encodeURIComponent(gppConsent.gppString); + } + if (gppConsent.applicableSections) { + params += '&gpp_sid=' + encodeURIComponent(gppConsent.applicableSections.join(',')); + } + } + + if (syncOptions.iframeEnabled) { + syncs.push({ type: 'iframe', url: SYNC_URL + params }); + } + if (syncOptions.pixelEnabled) { + syncs.push({ type: 'image', url: SYNC_URL + params + '&tag=img' }); + } + + return syncs; + }, + + onBidWon: function(bid) { + if (bid.burl) { + triggerPixel(bid.burl); + } + } +}; + +// === MAIN RTB BUILDER === +function makeOpenRtbRequest(validBidRequests, bidderRequest) { + const imp = validBidRequests.map(bid => { + const sizes = getSizes(bid); + const floor = getBidFloorEnhanced(bid); + + const impression = { + id: bid.bidId, + tagid: bid.adUnitCode, + bidfloor: floor, + ext: { + feedId: deepAccess(bid, 'params.feedId'), + bidder: { + placementId: deepAccess(bid, 'params.placementId'), + publisherId: deepAccess(bid, 'params.publisherId') + } + } + }; + + // Add banner specs + if (bid.mediaTypes && bid.mediaTypes.banner) { + impression.banner = { + w: sizes[0][0], + h: sizes[0][1], + format: sizes.map(size => ({ w: size[0], h: size[1] })) + }; + } + + // Add video specs + if (bid.mediaTypes && bid.mediaTypes.video) { + const video = bid.mediaTypes.video; + impression.video = { + mimes: video.mimes || ['video/mp4', 'video/webm'], + minduration: video.minduration || 0, + maxduration: video.maxduration || 60, + protocols: video.protocols || [2, 3, 5, 6], + w: getVideoSize(video.playerSize, 0, 640), + h: getVideoSize(video.playerSize, 1, 360), + placement: video.placement || 1, + playbackmethod: video.playbackmethod || [1, 2], + api: video.api || [1, 2], + skip: video.skip || 0, + skipmin: video.skipmin || 0, + skipafter: video.skipafter || 0, + pos: video.pos || 0, + startdelay: video.startdelay || 0, + linearity: video.linearity || 1 + }; + } + + return impression; + }); + + let user = {}; + if (validBidRequests[0] && validBidRequests[0].userIdAsEids) { + user.eids = deepClone(validBidRequests[0].userIdAsEids); + } + + const ortb2 = bidderRequest.ortb2 || {}; + const site = { + domain: typeof window !== 'undefined' ? window.location.hostname : '', + page: typeof window !== 'undefined' ? window.location.href : '', + ref: typeof document !== 'undefined' ? document.referrer : '' + }; + + // Merge ortb2 site data + if (ortb2.site) { + Object.assign(site, deepClone(ortb2.site)); + } + + const device = deepClone(ortb2.device) || {}; + // Add basic device info if not present + if (!device.ua) { + device.ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + } + if (!device.language) { + device.language = typeof navigator !== 'undefined' ? navigator.language : ''; + } + if (!device.w) { + device.w = typeof screen !== 'undefined' ? screen.width : 0; + } + if (!device.h) { + device.h = typeof screen !== 'undefined' ? screen.height : 0; + } + if (!device.devicetype) { + device.devicetype = getDeviceType(); + } + + const regs = { ext: {} }; + if (bidderRequest.gdprConsent) { + regs.ext.gdpr = bidderRequest.gdprConsent.gdprApplies ? 1 : 0; + user.ext = { consent: bidderRequest.gdprConsent.consentString }; + } + if (bidderRequest.uspConsent) { + regs.ext.us_privacy = bidderRequest.uspConsent; + } + + // Add GPP consent + if (bidderRequest.gppConsent) { + if (bidderRequest.gppConsent.gppString) { + regs.ext.gpp = bidderRequest.gppConsent.gppString; + } + if (bidderRequest.gppConsent.applicableSections) { + // Send as array, not comma-separated string + regs.ext.gpp_sid = bidderRequest.gppConsent.applicableSections; + } + } + + // Get supply chain + const schain = bidderRequest.schain || (validBidRequests[0] && validBidRequests[0].schain); + + return { + id: bidderRequest.auctionId, + imp: imp, + site: site, + device: device, + user: user, + regs: regs, + schain: schain, + tmax: bidderRequest.timeout || 1000, + cur: ['USD'], + ext: { + prebid: { + version: '$prebid.version$' + } + } + }; +} + +// === UTILS === +function getSizes(bid) { + if (bid.mediaTypes && bid.mediaTypes.banner && Array.isArray(bid.mediaTypes.banner.sizes) && bid.mediaTypes.banner.sizes.length > 0) { + return bid.mediaTypes.banner.sizes; + } + if (bid.mediaTypes && bid.mediaTypes.video && bid.mediaTypes.video.playerSize && bid.mediaTypes.video.playerSize.length > 0) { + return bid.mediaTypes.video.playerSize; + } + if (bid.sizes && bid.sizes.length > 0) { + return bid.sizes; + } + return [[300, 250]]; +} + +function getFirstSize(bid, index, defaultVal) { + const sizes = getSizes(bid); + return (sizes && sizes[0] && sizes[0][index]) || defaultVal; +} + +/** + * Safely extract video dimensions from playerSize. + * Handles both nested [[640, 480]] and flat [640, 480] formats. + * @param {Array} playerSize - video.playerSize from mediaTypes config + * @param {number} index - 0 for width, 1 for height + * @param {number} defaultVal - fallback value + * @returns {number} + */ +function getVideoSize(playerSize, index, defaultVal) { + if (!playerSize || !Array.isArray(playerSize) || playerSize.length === 0) { + return defaultVal; + } + // Nested: [[640, 480]] or [[640, 480], [320, 240]] + if (Array.isArray(playerSize[0])) { + return playerSize[0][index] || defaultVal; + } + // Flat: [640, 480] + if (typeof playerSize[0] === 'number') { + return playerSize[index] || defaultVal; + } + return defaultVal; +} + +/** + * Detect if adm content is VAST XML (for multi-format video detection). + * @param {string} adm - ad markup string + * @returns {boolean} + */ +function isVastAdm(adm) { + if (typeof adm !== 'string') return false; + const trimmed = adm.trim(); + return trimmed.startsWith(' floor && floorInfo.currency === 'USD' && !isNaN(floorInfo.floor)) { + floor = floorInfo.floor; + } + } catch (e) { + // Continue to next size + } + } + + // Fallback to general floor + if (floor === 0) { + try { + const floorInfo = bid.getFloor({ currency: 'USD', mediaType: mediaType, size: '*' }); + if (typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(floorInfo.floor)) { + floor = floorInfo.floor; + } + } catch (e) { + logWarn('Revantage: getFloor threw error', e); + } + } + } + return floor; +} + +function getDeviceType() { + if (typeof screen === 'undefined') return 1; + const width = screen.width; + const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + + if (/iPhone|iPod/i.test(ua) || (width < 768 && /Mobile/i.test(ua))) return 2; // Mobile + if (/iPad/i.test(ua) || (width >= 768 && width < 1024)) return 5; // Tablet + return 1; // Desktop/PC +} + +// === REGISTER === +registerBidder(spec); diff --git a/modules/revantageBidAdapter.md b/modules/revantageBidAdapter.md new file mode 100644 index 00000000000..42a4ef4198d --- /dev/null +++ b/modules/revantageBidAdapter.md @@ -0,0 +1,34 @@ +# Overview + +``` +Module Name: ReVantage Bidder Adapter +Module Type: ReVantage Bidder Adapter +Maintainer: bern@revantage.io +``` + +# Description + +Connects to ReVantage exchange for bids. +ReVantage bid adapter supports Banner and Video. + +# Test Parameters +``` + var adUnits = [ + // Will return static test banner + { + code: 'adunit', + mediaTypes: { + banner: { + sizes: [ [300, 250], [320, 50] ], + } + }, + bids: [ + { + bidder: 'revantage', + params: { + feedId: 'testfeed', + } + } + ] + } +``` diff --git a/modules/rubiconBidAdapter.js b/modules/rubiconBidAdapter.js index a3c05dabe5d..477da80d420 100644 --- a/modules/rubiconBidAdapter.js +++ b/modules/rubiconBidAdapter.js @@ -30,7 +30,7 @@ import {getUserSyncParams} from '../libraries/userSyncUtils/userSyncUtils.js'; const DEFAULT_INTEGRATION = 'pbjs_lite'; const DEFAULT_PBS_INTEGRATION = 'pbjs'; -const DEFAULT_RENDERER_URL = 'https://video-outstream.rubiconproject.com/apex-2.2.1.js'; +const DEFAULT_RENDERER_URL = 'https://video-outstream.rubiconproject.com/apex-2.3.7.js'; // renderer code at https://github.com/rubicon-project/apex2 let rubiConf = config.getConfig('rubicon') || {}; @@ -116,6 +116,7 @@ var sizeMap = { 210: '1080x1920', 213: '1030x590', 214: '980x360', + 219: '1920x1080', 221: '1x1', 229: '320x180', 230: '2000x1400', @@ -128,7 +129,6 @@ var sizeMap = { 259: '998x200', 261: '480x480', 264: '970x1000', - 265: '1920x1080', 274: '1800x200', 278: '320x500', 282: '320x400', diff --git a/modules/rules/index.ts b/modules/rules/index.ts new file mode 100644 index 00000000000..1bbea07c5d2 --- /dev/null +++ b/modules/rules/index.ts @@ -0,0 +1,506 @@ +import { setLabels } from "../../libraries/analyticsAdapter/AnalyticsAdapter.ts"; +import { timeoutQueue } from "../../libraries/timeoutQueue/timeoutQueue.ts"; +import { ACTIVITY_ADD_BID_RESPONSE, ACTIVITY_FETCH_BIDS } from "../../src/activities/activities.js"; +import { MODULE_TYPE_BIDDER } from "../../src/activities/modules.ts"; +import { ACTIVITY_PARAM_COMPONENT_NAME, ACTIVITY_PARAM_COMPONENT_TYPE } from "../../src/activities/params.js"; +import { registerActivityControl } from "../../src/activities/rules.js"; +import { ajax } from "../../src/ajax.ts"; +import { AuctionIndex } from "../../src/auctionIndex.js"; +import { auctionManager } from "../../src/auctionManager.js"; +import { config } from "../../src/config.ts"; +import { getHook } from "../../src/hook.ts"; +import { generateUUID, logInfo, logWarn } from "../../src/utils.ts"; +import { timedAuctionHook } from "../../src/utils/perfMetrics.ts"; + +/** + * Configuration interface for the shaping rules module. + */ +interface ShapingRulesConfig { + /** + * Endpoint configuration for fetching rules from a remote server. + * If not provided, rules must be provided statically via the `rules` property. + */ + endpoint?: { + /** URL endpoint to fetch rules configuration from */ + url: string; + /** HTTP method to use for fetching rules (currently only 'GET' is supported) */ + method: string; + }; + /** + * Static rules configuration object. + * If provided, rules will be used directly without fetching from endpoint. + * Takes precedence over endpoint configuration. + */ + rules?: RulesConfig; + /** + * Delay in milliseconds to wait for rules to be fetched before starting the auction. + * If rules are not loaded within this delay, the auction will proceed anyway. + * Default: 0 (no delay) + */ + auctionDelay?: number; + /** + * Custom schema evaluator functions to extend the default set of evaluators. + * Keys are function names, values are evaluator functions that take args and context, + * and return a function that evaluates to a value when called. + */ + extraSchemaEvaluators?: { + [key: string]: (args: any[], context: any) => () => any; + }; +} + +/** + * Schema function definition used to compute values. + */ +interface ModelGroupSchema { + /** Function name inside the schema */ + function: string; + /** Arguments for the schema function */ + args?: any[]; +} + +/** + * Model group configuration for A/B testing with different rule configurations. + * Only one object within the group is chosen based on weight. + */ +interface ModelGroup { + /** Determines selection probability; only one object within the group is chosen */ + weight: number; + /** Indicates whether this model group is selected (set automatically based on weight) */ + selected?: boolean; + /** Optional key used to produce aTags, identifying experiments or optimization targets */ + analyticsKey: string; + /** Version identifier for analytics */ + version: string; + /** + * Optional array of functions used to compute values. + * Without it, only the default rule is applied. + */ + schema: ModelGroupSchema[]; + /** + * Optional rule array; if absent, only the default rule is used. + * Each rule has conditions that must be met and results that are triggered. + */ + rules: [{ + /** Conditions that must be met for the rule to apply */ + conditions: string[]; + /** Resulting actions triggered when conditions are met */ + results: [ + { + /** Function defining the result action */ + function: string; + /** Arguments for the result function */ + args: any[]; + } + ]; + }]; + /** + * Default results object used if errors occur or when no schema or rules are defined. + * Exists outside the rules array for structural clarity. + */ + default?: Array<{ + /** Function defining the default result action */ + function: string; + /** Arguments for the default result function */ + args: any; + }>; +} + +/** + * Independent set of rules that can be applied to a specific stage of the auction. + */ +interface RuleSet { + /** Human-readable name of the ruleset */ + name: string; + /** + * Indicates which module stage the ruleset applies to. + * Can be either `processed-auction-request` or `processed-auction` + */ + stage: string; + /** Version identifier for the ruleset */ + version: string; + /** + * Optional timestamp of the last update (ISO 8601 format: `YYYY-MM-DDThh:mm:ss[.sss][Z or ±hh:mm]`) + */ + timestamp?: string; + /** + * One or more model groups for A/B testing with different rule configurations. + * Allows A/B testing with different rule configurations. + */ + modelGroups: ModelGroup[]; +} + +/** + * Main configuration object for the shaping rules module. + */ +interface RulesConfig { + /** Version identifier for the rules configuration */ + version: string; + /** One or more independent sets of rules */ + ruleSets: RuleSet[]; + /** Optional timestamp of the last update (ISO 8601 format: `YYYY-MM-DDThh:mm:ss[.sss][Z or ±hh:mm]`) */ + timestamp?: string; + /** Enables or disables the module. Default: `true` */ + enabled: boolean; +} + +declare module '../../src/config' { + interface Config { + shapingRules?: ShapingRulesConfig; + } +} + +const MODULE_NAME = 'shapingRules'; + +const globalRandomStore = new WeakMap<{ auctionId: string }, number>(); + +let auctionConfigStore = new Map(); + +export const dep = { + getGlobalRandom: getGlobalRandom +}; + +function getGlobalRandom(auctionId: string, auctionIndex: AuctionIndex = auctionManager.index) { + if (!auctionId) { + return Math.random(); + } + const auction = auctionIndex.getAuction({auctionId}); + if (!globalRandomStore.has(auction)) { + globalRandomStore.set(auction, Math.random()); + } + return globalRandomStore.get(auction); +} + +const unregisterFunctions: Array<() => void> = [] + +let moduleConfig: ShapingRulesConfig = { + endpoint: { + method: 'GET', + url: '' + }, + auctionDelay: 0, + extraSchemaEvaluators: {} +}; + +let fetching = false; + +let rulesLoaded = false; + +const delayedAuctions = timeoutQueue(); + +let rulesConfig: RulesConfig = null; + +export function evaluateConfig(config: RulesConfig, auctionId: string) { + if (!config || !config.ruleSets) { + logWarn(`${MODULE_NAME}: Invalid structure for rules engine`); + return; + } + + if (!config.enabled) { + logInfo(`${MODULE_NAME}: Rules engine is disabled in the configuration.`); + return; + } + + const stageRules = config.ruleSets; + + const modelGroupsWithStage = getAssignedModelGroups(stageRules || []); + + for (const { modelGroups, stage } of modelGroupsWithStage) { + const modelGroup = modelGroups.find(group => group.selected); + if (!modelGroup) continue; + evaluateRules(modelGroup.rules || [], modelGroup.schema || [], stage, modelGroup.analyticsKey, auctionId, modelGroup.default); + } +} + +export function getAssignedModelGroups(rulesets: RuleSet[]): Array<{ modelGroups: ModelGroup[], stage: string }> { + return rulesets.flatMap(ruleset => { + const { modelGroups, stage } = ruleset; + if (!modelGroups?.length) { + return []; + } + + // Calculate cumulative weights for proper weighted random selection + let cumulativeWeight = 0; + const groupsWithCumulativeWeights = modelGroups.map(group => { + const groupWeight = group.weight ?? 100; + cumulativeWeight += groupWeight; + return { + group, + cumulativeWeight + }; + }); + + const weightSum = cumulativeWeight; + // Generate random value in range [0, weightSum) + // This ensures each group gets probability proportional to its weight + const randomValue = Math.random() * weightSum; + + // Find first group where cumulative weight >= randomValue + let selectedIndex = groupsWithCumulativeWeights.findIndex(({ cumulativeWeight }) => randomValue < cumulativeWeight); + + // Fallback: if no group was selected (shouldn't happen, but safety check) + if (selectedIndex === -1) { + selectedIndex = modelGroups.length - 1; + } + + // Create new model groups array with selected flag + const newModelGroups = modelGroups.map((group, index) => ({ + ...group, + selected: index === selectedIndex + })); + + return { + modelGroups: newModelGroups, + stage + }; + }); +} + +function evaluateRules(rules, schema, stage, analyticsKey, auctionId: string, defaultResults?) { + const modelGroupConfig = auctionConfigStore.get(auctionId) || []; + modelGroupConfig.push({ + rules, + schema, + stage, + analyticsKey, + defaultResults, + }); + auctionConfigStore.set(auctionId, modelGroupConfig); +} + +const schemaEvaluators = { + percent: (args, context) => () => { + const auctionId = context.auctiondId || context.bid?.auctionId; + return dep.getGlobalRandom(auctionId) * 100 < args[0] + }, + adUnitCode: (args, context) => () => context.adUnit.code, + adUnitCodeIn: (args, context) => () => args[0].includes(context.adUnit.code), + deviceCountry: (args, context) => () => context.ortb2?.device?.geo?.country, + deviceCountryIn: (args, context) => () => args[0].includes(context.ortb2?.device?.geo?.country), + channel: (args, context) => () => 'web', + eidAvailable: (args, context) => () => { + const eids = context.ortb2?.user?.eids || []; + return eids.length > 0; + }, + userFpdAvailable: (args, context) => () => { + const fpd = context.ortb2?.user?.data || {}; + const extFpd = context.ortb2?.user?.ext?.data || {}; + const mergedFpd = { ...fpd, ...extFpd }; + return Object.keys(mergedFpd).length > 0; + }, + fpdAvailable: (args, context) => () => { + const extData = context.ortb2?.user?.ext?.data || {}; + const usrData = context.ortb2?.user?.data || {}; + const siteExtData = context.ortb2?.site?.ext?.data || {}; + const siteContentData = context.ortb2?.site?.content?.data || {}; + const appExtData = context.ortb2?.app?.ext?.data || {}; + const appContentData = context.ortb2?.app?.content?.data || {}; + const mergedFpd = { ...extData, ...usrData, ...siteExtData, ...siteContentData, ...appExtData, ...appContentData }; + return Object.keys(mergedFpd).length > 0; + }, + gppSidIn: (args, context) => () => { + const gppSids = context.ortb2?.regs?.gpp_sid || []; + return args[0].some((sid) => gppSids.includes(sid)); + }, + tcfInScope: (args, context) => () => context.ortb2?.regs?.ext?.gdpr === 1, + domain: (args, context) => () => { + const domain = context.ortb2?.site?.domain || context.ortb2?.app?.domain || ''; + return domain; + }, + domainIn: (args, context) => () => { + const domain = context.ortb2?.site?.domain || context.ortb2?.app?.domain || ''; + return args[0].includes(domain); + }, + bundle: (args, context) => () => { + const bundle = context.ortb2?.app?.bundle || ''; + return bundle; + }, + bundleIn: (args, context) => () => { + const bundle = context.ortb2?.app?.bundle || ''; + return args[0].includes(bundle); + }, + mediaTypeIn: (args, context) => () => { + const mediaTypes = Object.keys(context.adUnit?.mediaTypes) || []; + return args[0].some((type) => mediaTypes.includes(type)); + }, + deviceTypeIn: (args, context) => () => { + const deviceType = context.ortb2?.device?.devicetype; + return args[0].includes(deviceType); + }, + bidPrice: (args, context) => () => { + const [operator, currency, value] = args || []; + const {cpm: bidPrice, currency: bidCurrency} = context.bid || {}; + if (bidCurrency !== currency) { + return false; + } + if (operator === 'gt') { + return bidPrice > value; + } else if (operator === 'gte') { + return bidPrice >= value; + } else if (operator === 'lt') { + return bidPrice < value; + } else if (operator === 'lte') { + return bidPrice <= value; + } + return false; + } +}; + +export function evaluateSchema(func, args, context) { + const extraEvaluators = moduleConfig.extraSchemaEvaluators || {}; + const evaluators = { ...schemaEvaluators, ...extraEvaluators }; + const evaluator = evaluators[func]; + if (evaluator) { + return evaluator(args, context); + } + return () => null; +} + +function evaluateCondition(condition, func) { + switch (condition) { + case '*': + return true + case 'true': + return func() === true; + case 'false': + return func() === false; + default: + return func() === condition; + } +} + +export function fetchRules(endpoint = moduleConfig.endpoint) { + if (fetching) { + logWarn(`${MODULE_NAME}: A fetch is already occurring. Skipping.`); + return; + } + + if (!endpoint?.url || endpoint?.method !== 'GET') return; + + fetching = true; + ajax(endpoint.url, { + success: (response: any) => { + fetching = false; + rulesLoaded = true; + rulesConfig = JSON.parse(response); + delayedAuctions.resume(); + logInfo(`${MODULE_NAME}: Rules configuration fetched successfully.`); + }, + error: () => { + fetching = false; + } + }, null, { method: 'GET' }); +} + +export function registerActivities() { + const stages = { + [ACTIVITY_FETCH_BIDS]: 'processed-auction-request', + [ACTIVITY_ADD_BID_RESPONSE]: 'processed-auction', + }; + + [ACTIVITY_FETCH_BIDS, ACTIVITY_ADD_BID_RESPONSE].forEach(activity => { + unregisterFunctions.push( + registerActivityControl(activity, MODULE_NAME, (params) => { + const auctionId = params.auctionId || params.bid?.auctionId; + if (params[ACTIVITY_PARAM_COMPONENT_TYPE] !== MODULE_TYPE_BIDDER) return; + if (!auctionId) return; + + const checkConditions = ({schema, conditions, stage}) => { + for (const [index, schemaEntry] of schema.entries()) { + const schemaFunction = evaluateSchema(schemaEntry.function, schemaEntry.args || [], params); + if (evaluateCondition(conditions[index], schemaFunction)) { + return true; + } + } + return false; + } + + const results = []; + let modelGroups = auctionConfigStore.get(auctionId) || []; + modelGroups = modelGroups.filter(modelGroup => modelGroup.stage === stages[activity]); + + // evaluate applicable results for each model group + for (const modelGroup of modelGroups) { + // find first rule that matches conditions + const selectedRule = modelGroup.rules.find(rule => checkConditions({...rule, schema: modelGroup.schema})); + if (selectedRule) { + results.push(...selectedRule.results); + } else if (Array.isArray(modelGroup.defaultResults)) { + const defaults = modelGroup.defaultResults.map(result => ({...result, analyticsKey: modelGroup.analyticsKey})); + results.push(...defaults); + } + } + + // set analytics labels for logAtag results + results + .filter(result => result.function === 'logAtag') + .forEach((result) => { + setLabels({ [auctionId + '-' + result.analyticsKey]: result.args.analyticsValue }); + }); + + // verify current bidder against applicable rules + const allow = results + .filter(result => ['excludeBidders', 'includeBidders'].includes(result.function)) + .every((result) => { + return result.args.every(({bidders}) => { + const bidderIncluded = bidders.includes(params[ACTIVITY_PARAM_COMPONENT_NAME]); + return result.function === 'excludeBidders' ? !bidderIncluded : bidderIncluded; + }); + }); + + if (!allow) { + return { allow, reason: `Bidder ${params.bid?.bidder} excluded by rules module` }; + } + }) + ); + }); +} + +export const startAuctionHook = timedAuctionHook('rules', function startAuctionHook(fn, req) { + req.auctionId = req.auctionId || generateUUID(); + evaluateConfig(rulesConfig, req.auctionId); + fn.call(this, req); +}); + +export const requestBidsHook = timedAuctionHook('rules', function requestBidsHook(fn, reqBidsConfigObj) { + const { auctionDelay = 0 } = moduleConfig; + const continueAuction = ((that) => () => fn.call(that, reqBidsConfigObj))(this); + + if (!rulesLoaded && auctionDelay > 0) { + delayedAuctions.submit(auctionDelay, continueAuction, () => { + logWarn(`${MODULE_NAME}: Fetch attempt did not return in time for auction ${reqBidsConfigObj.auctionId}`) + continueAuction(); + }); + } else { + continueAuction(); + } +}); + +function init(config: ShapingRulesConfig) { + moduleConfig = config; + registerActivities(); + auctionManager.onExpiry(auction => { + auctionConfigStore.delete(auction.getAuctionId()); + }); + // use static config if provided + if (config.rules) { + rulesConfig = config.rules; + } else { + fetchRules(); + } + getHook('requestBids').before(requestBidsHook, 50); + getHook('startAuction').before(startAuctionHook, 50); +} + +export function reset() { + try { + getHook('requestBids').getHooks({hook: requestBidsHook}).remove(); + getHook('startAuction').getHooks({hook: startAuctionHook}).remove(); + unregisterFunctions.forEach(unregister => unregister()); + unregisterFunctions.length = 0; + auctionConfigStore.clear(); + } catch (e) { + } + setLabels({}); +} + +config.getConfig(MODULE_NAME, config => init(config[MODULE_NAME])); diff --git a/modules/sevioBidAdapter.js b/modules/sevioBidAdapter.js index 66bfca681d9..b5db12b1cf5 100644 --- a/modules/sevioBidAdapter.js +++ b/modules/sevioBidAdapter.js @@ -43,6 +43,39 @@ const normalizeKeywords = (input) => { return []; }; +function resolveDataType(asset) { + if (typeof asset?.data?.type === 'number') { + return asset.data.type; + } + + if (typeof asset?.id === 'number') { + return asset.id; + } + + return null; +} + +// Helper: resolve the "image type" for an asset +// Returns 1 (icon), 3 (image) or null if unknown +function resolveImageType(asset) { + if (!asset) return null; + + // 1) explicit image type in the img block (preferred) + if (typeof asset.img?.type === 'number') return asset.img.type; + + // 2) fallback to data.type (some bidders put the type here) + if (typeof asset.data?.type === 'number') return asset.data.type; + + // 3) last resort: map legacy asset.id values to image types + // (13 -> icon, 14 -> image) — keep this mapping isolated here + if (typeof asset.id === 'number') { + if (asset.id === 13) return 1; // icon + if (asset.id === 14) return 3; // image + } + + return null; +} + const parseNativeAd = function (bid) { try { const nativeAd = JSON.parse(bid.ad); @@ -54,7 +87,8 @@ const parseNativeAd = function (bid) { } if (asset.data) { const value = asset.data.value; - switch (asset.data.type) { + const type = resolveDataType(asset); + switch (type) { case 1: if (value) native.sponsored = value; break; case 2: if (value) native.desc = value; break; case 3: if (value) native.rating = value; break; @@ -71,13 +105,14 @@ const parseNativeAd = function (bid) { } } if (asset.img) { - const { url, w = 0, h = 0, type } = asset.img; + const { url, w = 0, h = 0 } = asset.img; + const imgType = resolveImageType(asset); - if (type === 1 && url) { + if (imgType === 1 && url) { native.icon = url; native.icon_width = w; native.icon_height = h; - } else if (type === 3 && url) { + } else if (imgType === 3 && url) { native.image = url; native.image_width = w; native.image_height = h; diff --git a/modules/sovrnBidAdapter.js b/modules/sovrnBidAdapter.js index 2e38a88c2f4..1b337fd30cf 100644 --- a/modules/sovrnBidAdapter.js +++ b/modules/sovrnBidAdapter.js @@ -39,6 +39,7 @@ export const spec = { code: 'sovrn', supportedMediaTypes: [BANNER, VIDEO], gvlid: 13, + alwaysHasCapacity: true, /** * Check if the bid is a valid zone ID in either number or string form diff --git a/modules/taboolaBidAdapter.js b/modules/taboolaBidAdapter.js index 421ddbd256b..2cc43a8e925 100644 --- a/modules/taboolaBidAdapter.js +++ b/modules/taboolaBidAdapter.js @@ -1,7 +1,7 @@ 'use strict'; import {registerBidder} from '../src/adapters/bidderFactory.js'; -import {BANNER} from '../src/mediaTypes.js'; +import {BANNER, NATIVE} from '../src/mediaTypes.js'; import {config} from '../src/config.js'; import {deepSetValue, getWindowSelf, replaceAuctionPrice, isArray, safeJSONParse, isPlainObject, getWinDimensions} from '../src/utils.js'; import {getStorageManager} from '../src/storageManager.js'; @@ -15,7 +15,8 @@ import {getBoundingClientRect} from '../libraries/boundingClientRect/boundingCli const BIDDER_CODE = 'taboola'; const GVLID = 42; const CURRENCY = 'USD'; -export const END_POINT_URL = 'https://display.bidder.taboola.com/OpenRTB/TaboolaHB/auction'; +export const BANNER_ENDPOINT_URL = 'https://display.bidder.taboola.com/OpenRTB/TaboolaHB/auction'; +export const NATIVE_ENDPOINT_URL = 'https://native.bidder.taboola.com/OpenRTB/TaboolaHB/auction'; export const USER_SYNC_IMG_URL = 'https://trc.taboola.com/sg/prebidJS/1/cm'; export const USER_SYNC_IFRAME_URL = 'https://cdn.taboola.com/scripts/prebid_iframe_sync.html'; const USER_ID = 'user-id'; @@ -169,12 +170,11 @@ export function getElementSignals(adUnitCode) { const converter = ortbConverter({ context: { netRevenue: true, - mediaType: BANNER, ttl: 300 }, imp(buildImp, bidRequest, context) { const imp = buildImp(bidRequest, context); - fillTaboolaImpData(bidRequest, imp); + fillTaboolaImpData(bidRequest, imp, context); return imp; }, request(buildRequest, imps, bidderRequest, context) { @@ -183,12 +183,27 @@ const converter = ortbConverter({ return reqData; }, bidResponse(buildBidResponse, bid, context) { + if (bid.mtype === 4) { + context.mediaType = NATIVE; + } else if (bid.mtype === 1) { + context.mediaType = BANNER; + } + + if (context.mediaType === NATIVE) { + const admObj = safeJSONParse(bid.adm); + if (admObj?.native) { + bid.adm = JSON.stringify(admObj.native); + } + } + const bidResponse = buildBidResponse(bid, context); bidResponse.nurl = bid.nurl; if (bid.burl) { bidResponse.burl = bid.burl; } - bidResponse.ad = replaceAuctionPrice(bid.adm, bid.price); + if (bidResponse.mediaType !== NATIVE) { + bidResponse.ad = replaceAuctionPrice(bid.adm, bid.price); + } if (bid.ext && bid.ext.dchain) { deepSetValue(bidResponse, 'meta.dchain', bid.ext.dchain); } @@ -197,35 +212,37 @@ const converter = ortbConverter({ }); export const spec = { - supportedMediaTypes: [BANNER], + supportedMediaTypes: [BANNER, NATIVE], gvlid: GVLID, code: BIDDER_CODE, isBidRequestValid: (bidRequest) => { - return !!(bidRequest.sizes && - bidRequest.params && + const hasPublisherAndTag = !!(bidRequest.params && bidRequest.params.publisherId && bidRequest.params.tagId); + if (!hasPublisherAndTag) { + return false; + } + const { hasBanner, hasNative } = getMediaType(bidRequest); + return hasBanner || hasNative; }, buildRequests: (validBidRequests, bidderRequest) => { - const [bidRequest] = validBidRequests; - const auctionId = bidderRequest.auctionId || validBidRequests[0]?.auctionId; - const data = converter.toORTB({ - bidderRequest: bidderRequest, - bidRequests: validBidRequests, - context: { auctionId } + const bannerBids = []; + const nativeBids = []; + + validBidRequests.forEach(bid => { + const { hasBanner, hasNative } = getMediaType(bid); + if (hasBanner) bannerBids.push(bid); + if (hasNative) nativeBids.push(bid); }); - const {publisherId} = bidRequest.params; - const url = END_POINT_URL + '?publisher=' + publisherId; - return { - url, - method: 'POST', - data: data, - bids: validBidRequests, - options: { - withCredentials: false - }, - }; + const requests = []; + if (bannerBids.length) { + requests.push(createTaboolaRequest(bannerBids, bidderRequest, BANNER_ENDPOINT_URL, BANNER)); + } + if (nativeBids.length) { + requests.push(createTaboolaRequest(nativeBids, bidderRequest, NATIVE_ENDPOINT_URL, NATIVE)); + } + return requests; }, interpretResponse: (serverResponse, request) => { if (!request || !request.bids || !request.data) { @@ -346,6 +363,28 @@ export const spec = { }, }; +function createTaboolaRequest(bidRequests, bidderRequest, endpointUrl, mediaType) { + const [bidRequest] = bidRequests; + const auctionId = bidderRequest.auctionId || bidRequests[0]?.auctionId; + const data = converter.toORTB({ + bidderRequest: bidderRequest, + bidRequests: bidRequests, + context: { auctionId, mediaType } + }); + const {publisherId} = bidRequest.params; + const url = endpointUrl + '?publisher=' + publisherId; + + return { + url, + method: 'POST', + data: data, + bids: bidRequests, + options: { + withCredentials: false + }, + }; +} + function getSiteProperties({publisherId}, refererInfo, ortb2) { const {getPageUrl, getReferrer} = internal; return { @@ -431,14 +470,17 @@ function fillTaboolaReqData(bidderRequest, bidRequest, data, context) { } } -function fillTaboolaImpData(bid, imp) { +function fillTaboolaImpData(bid, imp, context) { const {tagId, position} = bid.params; - imp.banner = getBanners(bid, position); - imp.tagid = tagId; + if (imp.banner && position) { + imp.banner.pos = position; + } + imp.tagid = tagId; if (typeof bid.getFloor === 'function') { const floorInfo = bid.getFloor({ currency: CURRENCY, + mediaType: context.mediaType, size: '*' }); if (isPlainObject(floorInfo) && floorInfo.currency === CURRENCY && !isNaN(parseFloat(floorInfo.floor))) { @@ -476,22 +518,14 @@ function fillTaboolaImpData(bid, imp) { } } -function getBanners(bid, pos) { +function getMediaType(bidRequest) { + const hasBanner = !!bidRequest?.mediaTypes?.banner?.sizes; + const hasNative = !!bidRequest?.mediaTypes?.native; return { - ...getSizes(bid.sizes), - pos: pos - } -} - -function getSizes(sizes) { - return { - format: sizes.map(size => { - return { - w: size[0], - h: size[1] - } - }) - } + hasBanner, + hasNative, + mediaType: hasNative && !hasBanner ? NATIVE : BANNER + }; } registerBidder(spec); diff --git a/modules/targetVideoBidAdapter.js b/modules/targetVideoBidAdapter.js index 84730231543..723c9d77dbd 100644 --- a/modules/targetVideoBidAdapter.js +++ b/modules/targetVideoBidAdapter.js @@ -1,4 +1,4 @@ -import {_each, deepAccess, getDefinedParams, parseGPTSingleSizeArrayToRtbSize} from '../src/utils.js'; +import {_each, deepAccess, getDefinedParams, isFn, isPlainObject, parseGPTSingleSizeArrayToRtbSize} from '../src/utils.js'; import {BANNER, VIDEO} from '../src/mediaTypes.js'; import {registerBidder} from '../src/adapters/bidderFactory.js'; import {formatRequest, getRtbBid, getSiteObj, getSyncResponse, videoBid, bannerBid, createVideoTag} from '../libraries/targetVideoUtils/bidderUtils.js'; @@ -9,6 +9,22 @@ import {SOURCE, GVLID, BIDDER_CODE, VIDEO_PARAMS, BANNER_ENDPOINT_URL, VIDEO_END * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid */ +function getBidFloor(bid) { + if (!isFn(bid.getFloor)) { + return (bid.params.floor) ? bid.params.floor : null; + } + + const floor = bid.getFloor({ + currency: 'EUR', + mediaType: '*', + size: '*' + }); + if (isPlainObject(floor) && !isNaN(floor.floor) && floor.currency === 'EUR') { + return floor.floor; + } + return null; +} + export const spec = { code: BIDDER_CODE, @@ -38,13 +54,15 @@ export const spec = { version: '$prebid.version$' }; - for (let {params, bidId, sizes, mediaTypes, ...bid} of bidRequests) { + for (let {bidId, sizes, mediaTypes, ...bid} of bidRequests) { for (const mediaType in mediaTypes) { switch (mediaType) { case VIDEO: { + const params = bid.params; const video = mediaTypes[VIDEO]; const placementId = params.placementId; const site = getSiteObj(); + const floor = getBidFloor(bid); if (sizes && !Array.isArray(sizes[0])) sizes = [sizes]; @@ -71,6 +89,12 @@ export const spec = { video: getDefinedParams(video, VIDEO_PARAMS) } + const bidFloor = typeof floor === 'string' ? Number(floor.trim()) + : typeof floor === 'number' ? floor + : NaN; + + if (Number.isFinite(bidFloor) && bidFloor > 0) imp.bidfloor = bidFloor; + if (video.playerSize) { imp.video = Object.assign( imp.video, parseGPTSingleSizeArrayToRtbSize(video.playerSize[0]) || {} diff --git a/modules/targetVideoBidAdapter.md b/modules/targetVideoBidAdapter.md index a34ad0aff27..9a204a86991 100644 --- a/modules/targetVideoBidAdapter.md +++ b/modules/targetVideoBidAdapter.md @@ -43,6 +43,7 @@ var adUnits = [ bidder: 'targetVideo', params: { placementId: 12345, + floor: 2, reserve: 0, } }] diff --git a/modules/tealBidAdapter.js b/modules/tealBidAdapter.js index 4374646b102..f9175ffb564 100644 --- a/modules/tealBidAdapter.js +++ b/modules/tealBidAdapter.js @@ -1,7 +1,7 @@ import {deepSetValue, deepAccess, triggerPixel, deepClone, isEmpty, logError, shuffle} from '../src/utils.js'; import {registerBidder} from '../src/adapters/bidderFactory.js'; import {ortbConverter} from '../libraries/ortbConverter/converter.js' -import {BANNER} from '../src/mediaTypes.js'; +import {BANNER, NATIVE, VIDEO} from '../src/mediaTypes.js'; import {pbsExtensions} from '../libraries/pbsExtensions/pbsExtensions.js' const BIDDER_CODE = 'teal'; const GVLID = 1378; @@ -43,7 +43,7 @@ const converter = ortbConverter({ export const spec = { code: BIDDER_CODE, gvlid: GVLID, - supportedMediaTypes: [BANNER], + supportedMediaTypes: [BANNER, NATIVE, VIDEO], aliases: [], isBidRequestValid: function(bid) { diff --git a/modules/teqBlazeSalesAgentBidAdapter.js b/modules/teqBlazeSalesAgentBidAdapter.js new file mode 100644 index 00000000000..f2cbf2d57db --- /dev/null +++ b/modules/teqBlazeSalesAgentBidAdapter.js @@ -0,0 +1,41 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { + buildPlacementProcessingFunction, + buildRequestsBase, + interpretResponse, + isBidRequestValid +} from '../libraries/teqblazeUtils/bidderUtils.js'; + +const BIDDER_CODE = 'teqBlazeSalesAgent'; +const AD_URL = 'https://be-agent.teqblaze.io/pbjs'; + +const addCustomFieldsToPlacement = (bid, bidderRequest, placement) => { + const aeeSignals = bidderRequest.ortb2?.site?.ext?.data?.scope3_aee; + + if (aeeSignals) { + placement.axei = aeeSignals.include; + placement.axex = aeeSignals.exclude; + + if (aeeSignals.macro) { + placement.axem = aeeSignals.macro; + } + } +}; + +const placementProcessingFunction = buildPlacementProcessingFunction({ addCustomFieldsToPlacement }); + +const buildRequests = (validBidRequests = [], bidderRequest = {}) => { + return buildRequestsBase({ adUrl: AD_URL, validBidRequests, bidderRequest, placementProcessingFunction }); +}; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid: isBidRequestValid(['placementId']), + buildRequests, + interpretResponse +}; + +registerBidder(spec); diff --git a/modules/teqBlazeSalesAgentBidAdapter.md b/modules/teqBlazeSalesAgentBidAdapter.md new file mode 100644 index 00000000000..d0c1475643d --- /dev/null +++ b/modules/teqBlazeSalesAgentBidAdapter.md @@ -0,0 +1,79 @@ +# Overview + +``` +Module Name: TeqBlaze Sales Agent Bidder Adapter +Module Type: TeqBlaze Sales Agent Bidder Adapter +Maintainer: support@teqblaze.com +``` + +# Description + +Connects to TeqBlaze Sales Agent for bids. +TeqBlaze Sales Agent bid adapter supports Banner, Video (instream and outstream) and Native. + +# Test Parameters +``` + var adUnits = [ + // Will return static test banner + { + code: 'adunit1', + mediaTypes: { + banner: { + sizes: [ [300, 250], [320, 50] ], + } + }, + bids: [ + { + bidder: 'teqBlazeSalesAgent', + params: { + placementId: 'testBanner', + } + } + ] + }, + { + code: 'addunit2', + mediaTypes: { + video: { + playerSize: [ [640, 480] ], + context: 'instream', + minduration: 5, + maxduration: 60, + } + }, + bids: [ + { + bidder: 'teqBlazeSalesAgent', + params: { + placementId: 'testVideo', + } + } + ] + }, + { + code: 'addunit3', + mediaTypes: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + }, + bids: [ + { + bidder: 'teqBlazeSalesAgent', + params: { + placementId: 'testNative', + } + } + ] + } + ]; +``` diff --git a/modules/ttdBidAdapter.js b/modules/ttdBidAdapter.js index 4598c3b8a7a..d985870b074 100644 --- a/modules/ttdBidAdapter.js +++ b/modules/ttdBidAdapter.js @@ -21,6 +21,7 @@ const BIDDER_CODE_LONG = 'thetradedesk'; const BIDDER_ENDPOINT = 'https://direct.adsrvr.org/bid/bidder/'; const BIDDER_ENDPOINT_HTTP2 = 'https://d2.adsrvr.org/bid/bidder/'; const USER_SYNC_ENDPOINT = 'https://match.adsrvr.org'; +const TTL = 360; const MEDIA_TYPE = { BANNER: 1, @@ -143,6 +144,8 @@ function getImpression(bidRequest) { }; const gpid = utils.deepAccess(bidRequest, 'ortb2Imp.ext.gpid'); + const exp = TTL; + impression.exp = exp; const tagid = gpid || bidRequest.params.placementId; if (tagid) { impression.tagid = tagid; @@ -477,7 +480,7 @@ export const spec = { dealId: bid.dealid || null, currency: currency || 'USD', netRevenue: true, - ttl: bid.ttl || 360, + ttl: bid.ttl || TTL, meta: {}, }; diff --git a/modules/unrulyBidAdapter.js b/modules/unrulyBidAdapter.js index 3f9c4dd1253..bf6b70e26db 100644 --- a/modules/unrulyBidAdapter.js +++ b/modules/unrulyBidAdapter.js @@ -56,12 +56,6 @@ const RemoveDuplicateSizes = (validBid) => { } }; -const ConfigureProtectedAudience = (validBid, protectedAudienceEnabled) => { - if (!protectedAudienceEnabled && validBid.ortb2Imp && validBid.ortb2Imp.ext) { - delete validBid.ortb2Imp.ext.ae; - } -} - const getRequests = (conf, validBidRequests, bidderRequest) => { const {bids, bidderRequestId, bidderCode, ...bidderRequestData} = bidderRequest; const invalidBidsCount = bidderRequest.bids.length - validBidRequests.length; @@ -71,7 +65,6 @@ const getRequests = (conf, validBidRequests, bidderRequest) => { const currSiteId = validBid.params.siteId; addBidFloorInfo(validBid); RemoveDuplicateSizes(validBid); - ConfigureProtectedAudience(validBid, conf.protectedAudienceEnabled); requestBySiteId[currSiteId] = requestBySiteId[currSiteId] || []; requestBySiteId[currSiteId].push(validBid); }); @@ -226,43 +219,18 @@ export const adapter = { 'options': { 'contentType': 'application/json' }, - 'protectedAudienceEnabled': bidderRequest.paapi?.enabled }, validBidRequests, bidderRequest); }, interpretResponse: function (serverResponse) { - if (!(serverResponse && serverResponse.body && (serverResponse.body.auctionConfigs || serverResponse.body.bids))) { + if (!(serverResponse && serverResponse.body && serverResponse.body.bids)) { return []; } const serverResponseBody = serverResponse.body; - let bids = []; - let fledgeAuctionConfigs = null; - if (serverResponseBody.bids.length) { - bids = handleBidResponseByMediaType(serverResponseBody.bids); - } - - if (serverResponseBody.auctionConfigs) { - const auctionConfigs = serverResponseBody.auctionConfigs; - const bidIdList = Object.keys(auctionConfigs); - if (bidIdList.length) { - bidIdList.forEach((bidId) => { - fledgeAuctionConfigs = [{ - 'bidId': bidId, - 'config': auctionConfigs[bidId] - }]; - }) - } - } + const bids = handleBidResponseByMediaType(serverResponseBody.bids); - if (!fledgeAuctionConfigs) { - return bids; - } - - return { - bids, - paapi: fledgeAuctionConfigs - }; + return bids; } }; diff --git a/modules/userId/eids.md b/modules/userId/eids.md index f6f62229f53..bdd8a0bb3e8 100644 --- a/modules/userId/eids.md +++ b/modules/userId/eids.md @@ -25,6 +25,14 @@ userIdAsEids = [ }] }, + { + source: 'locid.com', + uids: [{ + id: 'some-random-id-value', + atype: 1 + }] + }, + { source: 'adserver.org', uids: [{ diff --git a/modules/userId/userId.md b/modules/userId/userId.md index 8ffd8f83043..f7bea8fd9f8 100644 --- a/modules/userId/userId.md +++ b/modules/userId/userId.md @@ -91,6 +91,16 @@ pbjs.setConfig({ name: '_li_pbid', expires: 60 } + }, { + name: 'locId', + params: { + endpoint: 'https://id.example.com/locid' + }, + storage: { + type: 'html5', + name: '_locid', + expires: 7 + } }, { name: 'criteo', storage: { // It is best not to specify this parameter since the module needs to be called as many times as possible diff --git a/modules/verbenBidAdapter.js b/modules/verbenBidAdapter.js new file mode 100644 index 00000000000..867f669e93e --- /dev/null +++ b/modules/verbenBidAdapter.js @@ -0,0 +1,17 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { isBidRequestValid, buildRequests, interpretResponse } from '../libraries/teqblazeUtils/bidderUtils.js'; + +const BIDDER_CODE = 'verben'; +const AD_URL = 'https://east-node.verben.com/pbjs'; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid: isBidRequestValid(), + buildRequests: buildRequests(AD_URL), + interpretResponse +}; + +registerBidder(spec); diff --git a/modules/verbenBidAdapter.md b/modules/verbenBidAdapter.md new file mode 100644 index 00000000000..2d0978bc52f --- /dev/null +++ b/modules/verbenBidAdapter.md @@ -0,0 +1,79 @@ +# Overview + +``` +Module Name: Verben Bidder Adapter +Module Type: Verben Bidder Adapter +Maintainer: support_trading@verben.com +``` + +# Description + +Connects to Verben exchange for bids. +Verben bid adapter supports Banner, Video (instream and outstream) and Native. + +# Test Parameters +``` + var adUnits = [ + // Will return static test banner + { + code: 'adunit1', + mediaTypes: { + banner: { + sizes: [ [300, 250], [320, 50] ], + } + }, + bids: [ + { + bidder: 'verben', + params: { + placementId: 'testBanner', + } + } + ] + }, + { + code: 'addunit2', + mediaTypes: { + video: { + playerSize: [ [640, 480] ], + context: 'instream', + minduration: 5, + maxduration: 60, + } + }, + bids: [ + { + bidder: 'verben', + params: { + placementId: 'testVideo', + } + } + ] + }, + { + code: 'addunit3', + mediaTypes: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + }, + bids: [ + { + bidder: 'verben', + params: { + placementId: 'testNative', + } + } + ] + } + ]; +``` diff --git a/modules/wurflRtdProvider.md b/modules/wurflRtdProvider.md index 30651a6ddea..4bc088303e0 100644 --- a/modules/wurflRtdProvider.md +++ b/modules/wurflRtdProvider.md @@ -8,10 +8,11 @@ ## Description -The WURFL RTD module enriches the OpenRTB 2.0 device data with [WURFL data](https://www.scientiamobile.com/wurfl-js-business-edition-at-the-intersection-of-javascript-and-enterprise/). -The module sets the WURFL data in `device.ext.wurfl` and all the bidder adapters will always receive the low entry capabilities like `is_mobile`, `complete_device_name` and `form_factor`, and the `wurfl_id`. +The WURFL RTD module enriches Prebid.js bid requests with comprehensive device detection data. -For a more detailed analysis bidders can subscribe to detect iPhone and iPad models and receive additional [WURFL device capabilities](https://www.scientiamobile.com/capabilities/?products%5B%5D=wurfl-js). +The WURFL RTD module relies on localStorage caching and local client-side detection, providing instant device enrichment on every page load. + +The module enriches `ortb2.device` with complete device information and adds extended WURFL capabilities to `device.ext.wurfl`, ensuring all bidder adapters have immediate access to enriched device data. **Note:** This module loads a dynamically generated JavaScript from prebid.wurflcloud.com @@ -34,10 +35,8 @@ Use `setConfig` to instruct Prebid.js to initilize the WURFL RTD module, as spec This module is configured as part of the `realTimeData.dataProviders` ```javascript -var TIMEOUT = 1000; pbjs.setConfig({ realTimeData: { - auctionDelay: TIMEOUT, dataProviders: [ { name: "wurfl", @@ -49,16 +48,14 @@ pbjs.setConfig({ ### Parameters -| Name | Type | Description | Default | -| :------------------ | :------ | :--------------------------------------------------------------- | :------------- | -| name | String | Real time data module name | Always 'wurfl' | -| waitForIt | Boolean | Should be `true` if there's an `auctionDelay` defined (optional) | `false` | -| params | Object | | | -| params.altHost | String | Alternate host to connect to WURFL.js | | -| params.abTest | Boolean | Enable A/B testing mode | `false` | -| params.abName | String | A/B test name identifier | `'unknown'` | -| params.abSplit | Number | Fraction of users in treatment group (0-1) | `0.5` | -| params.abExcludeLCE | Boolean | Don't apply A/B testing to LCE bids | `true` | +| Name | Type | Description | Default | +| :------------- | :------ | :----------------------------------------- | :------------- | +| name | String | Real time data module name | Always 'wurfl' | +| params | Object | | | +| params.altHost | String | Alternate host to connect to WURFL.js | | +| params.abTest | Boolean | Enable A/B testing mode | `false` | +| params.abName | String | A/B test name identifier | `'unknown'` | +| params.abSplit | Number | Fraction of users in treatment group (0-1) | `0.5` | ### A/B Testing @@ -67,15 +64,13 @@ The WURFL RTD module supports A/B testing to measure the impact of WURFL enrichm ```javascript pbjs.setConfig({ realTimeData: { - auctionDelay: 1000, dataProviders: [ { name: "wurfl", - waitForIt: true, params: { abTest: true, - abName: "pub_test_sept23", - abSplit: 0.5, // 50% treatment, 50% control + abName: "pub_test", + abSplit: 0.75, // 75% treatment, 25% control }, }, ], diff --git a/modules/yahooAdsBidAdapter.js b/modules/yahooAdsBidAdapter.js index b2b92c6ba95..16c9365b5d7 100644 --- a/modules/yahooAdsBidAdapter.js +++ b/modules/yahooAdsBidAdapter.js @@ -288,6 +288,7 @@ function generateOpenRtbObject(bidderRequest, bid) { } }, source: { + tid: bidderRequest.ortb2?.source?.tid, ext: { hb: 1, adapterver: ADAPTER_VERSION, diff --git a/modules/yahooAdsBidAdapter.md b/modules/yahooAdsBidAdapter.md index df9b71b2314..a26d24338c5 100644 --- a/modules/yahooAdsBidAdapter.md +++ b/modules/yahooAdsBidAdapter.md @@ -18,6 +18,7 @@ The Yahoo Advertising Bid Adapter is an OpenRTB interface that consolidates all * User ID Modules - ConnectId and others * First Party Data (ortb2 & ortb2Imp) * Custom TTL (time to live) +* Transaction ID (TID) support via ortb2.source.tid # Adapter Aliases Whilst the primary bidder code for this bid adapter is `yahooAds`, the aliases `yahoossp` and `yahooAdvertising` can be used to enable this adapter. If you wish to set Prebid configuration specifically for this bid adapter, then the configuration key _must_ match the used bidder code. All examples in this documentation use the primiry bidder code, but switching `yahooAds` with one of the relevant aliases may be required for your setup. Let's take [setting the request mode](#adapter-request-mode) as an example; if you used the `yahoossp` alias, then the corresponding `setConfig` API call would look like this: @@ -562,6 +563,40 @@ const adUnits = [{ ] ``` +## Transaction ID (TID) Support +The Yahoo Advertising bid adapter supports reading publisher-provided transaction IDs from `ortb2.source.tid` and including them in the OpenRTB request. This enables better bid request tracking and deduplication across the supply chain. + +**Important:** To use transaction IDs, you must enable TIDs in your Prebid configuration by setting `enableTIDs: true`. + +### Global Transaction ID (applies to all bidders) +```javascript +pbjs.setConfig({ + enableTIDs: true, // Required for TID support + ortb2: { + source: { + tid: "transaction-id-12345" + } + } +}); +``` + +### Bidder-Specific Transaction ID (Yahoo Ads only) +```javascript +pbjs.setBidderConfig({ + bidders: ['yahooAds'], + config: { + enableTIDs: true, // Required for TID support + ortb2: { + source: { + tid: "yahoo-specific-tid-67890" + } + } + } +}); +``` + +**Note:** If `enableTIDs` is not set to `true`, the transaction ID will not be available to the adapter, even if `ortb2.source.tid` is configured. When TID is not provided or not enabled, the adapter will not include the `source.tid` field in the OpenRTB request. + # Optional: Bidder bidOverride Parameters The Yahoo Advertising bid adapter allows passing override data to the outbound bid-request that overrides First Party Data. **Important!** We highly recommend using prebid modules to pass data instead of bidder speicifc overrides. diff --git a/modules/yaleoBidAdapter.md b/modules/yaleoBidAdapter.md new file mode 100644 index 00000000000..505d3617fd5 --- /dev/null +++ b/modules/yaleoBidAdapter.md @@ -0,0 +1,39 @@ +# Yaleo Bid Adapter + +# Overview + +``` +Module name: Yaleo Bid Adapter +Module Type: Bidder Adapter +Maintainer: alexandr.kim@audienzz.com +``` + +# Description + +Module that connects to Yaleo's demand sources. + +**Note:** the bid adapter requires correct setup and approval. For more information visit [yaleo.com](https://www.yaleo.com) or contact [hola@yaleo.com](mailto:hola@yaleo.com). + +# Test parameters + +**Note:** to receive bids when testing without proper integration with the demand source, enable Prebid.js debug mode. See [how to enable debug mode](https://docs.prebid.org/dev-docs/publisher-api-reference/setConfig.html#debugging) for details. + +```js +const adUnits = [ + { + code: "test-div-1", + mediaTypes: { + banner: { + sizes: [[300, 300], [300, 600]], + } + }, + bids: [{ + bidder: "yaleo", + params: { + placementId: "95a09f24-afb8-441c-977b-08b4039cb88e", + } + }] + } +]; +``` + diff --git a/modules/yaleoBidAdapter.ts b/modules/yaleoBidAdapter.ts new file mode 100755 index 00000000000..980d5634df7 --- /dev/null +++ b/modules/yaleoBidAdapter.ts @@ -0,0 +1,80 @@ +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { pbsExtensions } from '../libraries/pbsExtensions/pbsExtensions.js'; +import { BidderSpec, registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER } from '../src/mediaTypes.js'; + +interface YaleoBidParams { + /** + * Yaleo placement ID. + */ + placementId: string; + /** + * Member ID. + * @default 3927 + */ + memberId?: number; + /** + * Maximum CPM value. Bids with a CPM higher than the specified value will be rejected. + */ + maxCpm?: number; +} + +declare module '../src/adUnits' { + interface BidderParams { + [BIDDER_CODE]: YaleoBidParams; + } +} + +const BIDDER_CODE = 'yaleo'; +const AUDIENZZ_VENDOR_ID = 783; +const PREBID_URL = 'https://bidder.yaleo.com/prebid'; +const DEFAULT_TTL = 300; + +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: DEFAULT_TTL, + }, + processors: pbsExtensions, +}); + +const isBidRequestValid: BidderSpec['isBidRequestValid'] = (request) => { + if (!request.params || typeof request.params.placementId !== 'string') { + return false; + } + + return !!request.params.placementId; +}; + +const buildRequests: BidderSpec['buildRequests'] = (validBidRequests, bidderRequest) => { + const ortbRequest = converter.toORTB({ + bidRequests: validBidRequests, + bidderRequest, + }); + + return { + url: PREBID_URL, + method: 'POST', + data: ortbRequest, + }; +} + +const interpretResponse: BidderSpec['interpretResponse'] = (serverResponse, bidderRequest) => { + const response = converter.fromORTB({ + response: serverResponse.body, + request: bidderRequest.data, + }); + + return response; +}; + +export const spec: BidderSpec = { + buildRequests, + code: BIDDER_CODE, + gvlid: AUDIENZZ_VENDOR_ID, + interpretResponse, + isBidRequestValid, + supportedMediaTypes: [BANNER], +}; + +registerBidder(spec); diff --git a/modules/yandexBidAdapter.md b/modules/yandexBidAdapter.md index ac454285806..9a7684d2644 100644 --- a/modules/yandexBidAdapter.md +++ b/modules/yandexBidAdapter.md @@ -8,40 +8,42 @@ Maintainer: prebid@yandex-team.com # Description -The Yandex Prebid Adapter is designed for seamless integration with Yandex's advertising services. It facilitates effective bidding by leveraging Yandex's robust ad-serving technology, ensuring publishers can maximize their ad revenue through efficient and targeted ad placements. +The Yandex Prebid Adapter is designed for seamless integration with Yandex's advertising services. It facilitates effective bidding by leveraging Yandex's robust ad-serving technology, ensuring publishers can maximize their ad revenue through efficient and targeted ad placements. Please reach out to for the integration guide and more details. For comprehensive auction analytics, consider using the [Yandex Analytics Adapter](https://docs.prebid.org/dev-docs/analytics/yandex.html). This tool provides essential insights into auction dynamics and user interactions, empowering publishers to fine-tune their strategies for optimal ad performance. # Parameters -| Name | Required? | Description | Example | Type | -|---------------|--------------------------------------------|-------------|---------|-----------| -| `placementId` | Yes | Block ID | `123-1` | `String` | -| `pageId` | No
Deprecated. Please use `placementId` | Page ID | `123` | `Integer` | -| `impId` | No
Deprecated. Please use `placementId` | Imp ID | `1` | `Integer` | +| Name | Scope | Description | Example | Type | +|---------------|----------------------------------------|--------------|------------------|-----------| +| `placementId` | Required | Placement ID | `'R-X-123456-1'` | `String` | +| `cur` | Optional. Default value is `'EUR'` | Bid Currency | `'USD'` | `String` | +| `pageId` | `Deprecated`. Please use `placementId` | Page ID | `123` | `Integer` | +| `impId` | `Deprecated`. Please use `placementId` | Imp ID | `1` | `Integer` | # Test Parameters ```javascript var adUnits = [ - { // banner + { // banner example. please check if the 'placementId' is active in Yandex UI code: 'banner-1', mediaTypes: { banner: { - sizes: [[240, 400], [300, 600]], + sizes: [[300, 250], [300, 600]], } }, bids: [ { bidder: 'yandex', params: { - placementId: '346580-1' + placementId: 'R-A-346580-1', + cur: 'USD' }, } ], }, - { // video - code: 'banner-2', + { // video example. please check if the 'placementId' is active in Yandex UI + code: 'video-1', mediaTypes: { video: { sizes: [[640, 480]], @@ -57,13 +59,14 @@ var adUnits = [ { bidder: 'yandex', params: { - placementId: '346580-1' + placementId: 'R-V-346580-1', + cur: 'USD' }, } ], }, - { // native - code: 'banner-3',, + { // native example. please check if the 'placementId' is active in Yandex UI + code: 'native-1', mediaTypes: { native: { title: { @@ -84,7 +87,7 @@ var adUnits = [ len: 90 }, sponsoredBy: { - len: 25, + len: 25 } }, }, @@ -92,7 +95,8 @@ var adUnits = [ { bidder: 'yandex', params: { - placementId: '346580-1' + placementId: 'R-A-346580-2', + cur: 'USD' }, } ], diff --git a/modules/zeta_global_sspAnalyticsAdapter.js b/modules/zeta_global_sspAnalyticsAdapter.js index ed4971d39a7..46e7f9d5951 100644 --- a/modules/zeta_global_sspAnalyticsAdapter.js +++ b/modules/zeta_global_sspAnalyticsAdapter.js @@ -6,6 +6,7 @@ import {EVENTS} from '../src/constants.js'; import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js'; import {config} from '../src/config.js'; import {parseDomain} from '../src/refererDetection.js'; +import {BANNER, VIDEO} from "../src/mediaTypes.js"; const ZETA_GVL_ID = 833; const ADAPTER_CODE = 'zeta_global_ssp'; @@ -40,12 +41,14 @@ function adRenderSucceededHandler(args) { auctionId: args.bid?.auctionId, creativeId: args.bid?.creativeId, bidder: args.bid?.bidderCode, + dspId: args.bid?.dspId, mediaType: args.bid?.mediaType, size: args.bid?.size, adomain: args.bid?.adserverTargeting?.hb_adomain, timeToRespond: args.bid?.timeToRespond, cpm: args.bid?.cpm, - adUnitCode: args.bid?.adUnitCode + adUnitCode: args.bid?.adUnitCode, + floorData: args.bid?.floorData }, device: { ua: navigator.userAgent @@ -61,15 +64,35 @@ function auctionEndHandler(args) { bidderCode: br?.bidderCode, domain: br?.refererInfo?.domain, page: br?.refererInfo?.page, - bids: br?.bids?.map(b => ({ - bidId: b?.bidId, - auctionId: b?.auctionId, - bidder: b?.bidder, - mediaType: b?.mediaTypes?.video ? 'VIDEO' : (b?.mediaTypes?.banner ? 'BANNER' : undefined), - size: b?.sizes?.filter(s => s && s.length === 2).filter(s => Number.isInteger(s[0]) && Number.isInteger(s[1])).map(s => s[0] + 'x' + s[1]).find(s => s), - device: b?.ortb2?.device, - adUnitCode: b?.adUnitCode - })) + bids: br?.bids?.map(b => { + const mediaType = b?.mediaTypes?.video ? VIDEO : (b?.mediaTypes?.banner ? BANNER : undefined); + let floor; + if (typeof b?.getFloor === 'function') { + try { + const floorInfo = b.getFloor({ + currency: 'USD', + mediaType: mediaType, + size: '*' + }); + if (floorInfo && !isNaN(parseFloat(floorInfo.floor))) { + floor = parseFloat(floorInfo.floor); + } + } catch (e) { + // ignore floor lookup errors + } + } + + return { + bidId: b?.bidId, + auctionId: b?.auctionId, + bidder: b?.bidder, + mediaType: mediaType, + sizes: b?.sizes, + device: b?.ortb2?.device, + adUnitCode: b?.adUnitCode, + floor: floor + }; + }) })), bidsReceived: args.bidsReceived?.map(br => ({ adId: br?.adId, @@ -81,7 +104,8 @@ function auctionEndHandler(args) { adomain: br?.adserverTargeting?.hb_adomain, timeToRespond: br?.timeToRespond, cpm: br?.cpm, - adUnitCode: br?.adUnitCode + adUnitCode: br?.adUnitCode, + dspId: br?.dspId })) } sendEvent(EVENTS.AUCTION_END, event); @@ -92,16 +116,35 @@ function bidTimeoutHandler(args) { zetaParams: zetaParams, domain: args.find(t => t?.ortb2?.site?.domain)?.ortb2?.site?.domain, page: args.find(t => t?.ortb2?.site?.page)?.ortb2?.site?.page, - timeouts: args.map(t => ({ - bidId: t?.bidId, - auctionId: t?.auctionId, - bidder: t?.bidder, - mediaType: t?.mediaTypes?.video ? 'VIDEO' : (t?.mediaTypes?.banner ? 'BANNER' : undefined), - size: t?.sizes?.filter(s => s && s.length === 2).filter(s => Number.isInteger(s[0]) && Number.isInteger(s[1])).map(s => s[0] + 'x' + s[1]).find(s => s), - timeout: t?.timeout, - device: t?.ortb2?.device, - adUnitCode: t?.adUnitCode - })) + timeouts: args.map(t => { + const mediaType = t?.mediaTypes?.video ? VIDEO : (t?.mediaTypes?.banner ? BANNER : undefined); + let floor; + if (typeof t?.getFloor === 'function') { + try { + const floorInfo = t.getFloor({ + currency: 'USD', + mediaType: mediaType, + size: '*' + }); + if (floorInfo && !isNaN(parseFloat(floorInfo.floor))) { + floor = parseFloat(floorInfo.floor); + } + } catch (e) { + // ignore floor lookup errors + } + } + return { + bidId: t?.bidId, + auctionId: t?.auctionId, + bidder: t?.bidder, + mediaType: mediaType, + sizes: t?.sizes, + timeout: t?.timeout, + device: t?.ortb2?.device, + adUnitCode: t?.adUnitCode, + floor: floor + } + }) } sendEvent(EVENTS.BID_TIMEOUT, event); } diff --git a/package-lock.json b/package-lock.json index c7605928c52..85fc646f8bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prebid.js", - "version": "10.24.0-pre", + "version": "10.28.0-pre", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "prebid.js", - "version": "10.24.0-pre", + "version": "10.28.0-pre", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.28.4", @@ -2967,29 +2967,6 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -3981,24 +3958,34 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6534,11 +6521,10 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6567,9 +6553,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7194,8 +7180,9 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "node_modules/atob": { "version": "2.1.2", @@ -7223,14 +7210,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, - "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -7443,11 +7429,14 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/basic-auth": { @@ -7467,9 +7456,10 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.0.5", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "dev": true, - "license": "MIT", "engines": { "node": ">=10.0.0" } @@ -7725,15 +7715,15 @@ } }, "node_modules/browserstack-local": { - "version": "1.5.5", + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/browserstack-local/-/browserstack-local-1.5.11.tgz", + "integrity": "sha512-RNq0yrezPq7BXXxl/cvsbORfswUQi744po6ECkTEC2RkqNbdPyzewdy4VR9k4QHSzPHTkZx8PeH08veRtfFI8A==", "dev": true, - "license": "MIT", "dependencies": { "agent-base": "^6.0.2", "https-proxy-agent": "^5.0.1", "is-running": "^2.1.0", - "ps-tree": "=1.2.0", - "temp-fs": "^0.9.9" + "tree-kill": "^1.2.2" } }, "node_modules/browserstack/node_modules/agent-base": { @@ -7879,9 +7869,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", "funding": [ { "type": "opencollective", @@ -8220,8 +8210,9 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, - "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -9280,8 +9271,9 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -10509,6 +10501,27 @@ } } }, + "node_modules/eslint-plugin-import-x/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-import-x/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/eslint-plugin-import-x/node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -10528,16 +10541,15 @@ } }, "node_modules/eslint-plugin-import-x/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", "dev": true, - "license": "ISC", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11097,24 +11109,6 @@ "es5-ext": "~0.10.14" } }, - "node_modules/event-stream": { - "version": "3.3.4", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1", - "from": "~0", - "map-stream": "~0.1.0", - "pause-stream": "0.0.11", - "split": "0.3", - "stream-combiner": "~0.0.4", - "through": "~2.3.1" - } - }, - "node_modules/event-stream/node_modules/map-stream": { - "version": "0.1.0", - "dev": true - }, "node_modules/event-target-shim": { "version": "5.0.1", "dev": true, @@ -11447,10 +11441,22 @@ } ] }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, "node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "dev": true, "funding": [ { @@ -11459,7 +11465,8 @@ } ], "dependencies": { - "strnum": "^2.1.0" + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -11583,11 +11590,10 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -11731,7 +11737,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.6", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "dev": true, "funding": [ { @@ -11739,7 +11747,6 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -11814,11 +11821,10 @@ "license": "BSD" }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, - "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -11867,11 +11873,6 @@ "node": ">= 0.6" } }, - "node_modules/from": { - "version": "0.1.7", - "dev": true, - "license": "MIT" - }, "node_modules/fs-extra": { "version": "11.3.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", @@ -12269,22 +12270,34 @@ "node": ">= 10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -15480,6 +15493,15 @@ "webpack": "^5.0.0" } }, + "node_modules/karma-webpack/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/karma-webpack/node_modules/glob": { "version": "7.2.3", "dev": true, @@ -15500,9 +15522,10 @@ } }, "node_modules/karma-webpack/node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -15511,11 +15534,12 @@ } }, "node_modules/karma-webpack/node_modules/minimatch": { - "version": "9.0.4", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -15525,13 +15549,15 @@ } }, "node_modules/karma-webpack/node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/karma/node_modules/ansi-styles": { @@ -16167,9 +16193,10 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -16433,9 +16460,10 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -16645,22 +16673,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/multimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/multimatch/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/multimatch/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -17600,17 +17640,6 @@ "node": "*" } }, - "node_modules/pause-stream": { - "version": "0.0.11", - "dev": true, - "license": [ - "MIT", - "Apache2" - ], - "dependencies": { - "through": "~2.3" - } - }, "node_modules/pend": { "version": "1.2.0", "dev": true, @@ -17886,20 +17915,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ps-tree": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "event-stream": "=3.3.4" - }, - "bin": { - "ps-tree": "bin/ps-tree.js" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/pump": { "version": "3.0.0", "dev": true, @@ -18017,9 +18032,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dependencies": { "side-channel": "^1.1.0" }, @@ -18260,9 +18275,10 @@ } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -18764,9 +18780,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19411,17 +19427,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/split": { - "version": "0.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, "node_modules/split2": { "version": "4.2.0", "dev": true, @@ -19496,14 +19501,6 @@ "node": ">= 0.10.0" } }, - "node_modules/stream-combiner": { - "version": "0.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1" - } - }, "node_modules/stream-composer": { "version": "1.0.2", "dev": true, @@ -19851,17 +19848,16 @@ } }, "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } - ], - "license": "MIT" + ] }, "node_modules/supports-color": { "version": "5.5.0", @@ -19954,47 +19950,6 @@ "streamx": "^2.12.5" } }, - "node_modules/temp-fs": { - "version": "0.9.9", - "dev": true, - "license": "MIT", - "dependencies": { - "rimraf": "~2.5.2" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/temp-fs/node_modules/glob": { - "version": "7.2.3", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/temp-fs/node_modules/rimraf": { - "version": "2.5.4", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.0.5" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/ternary-stream": { "version": "3.0.0", "dev": true, @@ -20304,6 +20259,15 @@ "node": ">=6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "dev": true, @@ -24084,21 +24048,6 @@ "dev": true, "requires": {} }, - "@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true - }, - "@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "requires": { - "@isaacs/balanced-match": "^4.0.1" - } - }, "@isaacs/cliui": { "version": "8.0.2", "dev": true, @@ -24771,22 +24720,28 @@ "ts-api-utils": "^2.1.0" }, "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "requires": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" } }, "semver": { @@ -26481,9 +26436,9 @@ } }, "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -26501,9 +26456,9 @@ }, "dependencies": { "ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -26895,6 +26850,8 @@ }, "asynckit": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, "atob": { @@ -26909,13 +26866,13 @@ } }, "axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, "requires": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -27043,9 +27000,9 @@ "dev": true }, "baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==" + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==" }, "basic-auth": { "version": "2.0.1", @@ -27061,7 +27018,9 @@ } }, "basic-ftp": { - "version": "5.0.5", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "dev": true }, "batch": { @@ -27255,14 +27214,15 @@ } }, "browserstack-local": { - "version": "1.5.5", + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/browserstack-local/-/browserstack-local-1.5.11.tgz", + "integrity": "sha512-RNq0yrezPq7BXXxl/cvsbORfswUQi744po6ECkTEC2RkqNbdPyzewdy4VR9k4QHSzPHTkZx8PeH08veRtfFI8A==", "dev": true, "requires": { "agent-base": "^6.0.2", "https-proxy-agent": "^5.0.1", "is-running": "^2.1.0", - "ps-tree": "=1.2.0", - "temp-fs": "^0.9.9" + "tree-kill": "^1.2.2" } }, "buffer-crc32": { @@ -27338,9 +27298,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==" + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==" }, "chai": { "version": "4.4.1", @@ -27558,6 +27518,8 @@ }, "combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "requires": { "delayed-stream": "~1.0.0" @@ -28237,6 +28199,8 @@ }, "delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true }, "depd": { @@ -29175,6 +29139,21 @@ "unrs-resolver": "^1.9.2" }, "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, "debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -29185,12 +29164,12 @@ } }, "minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", "dev": true, "requires": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" } }, "ms": { @@ -29414,25 +29393,6 @@ "es5-ext": "~0.10.14" } }, - "event-stream": { - "version": "3.3.4", - "dev": true, - "requires": { - "duplexer": "~0.1.1", - "from": "~0", - "map-stream": "~0.1.0", - "pause-stream": "0.0.11", - "split": "0.3", - "stream-combiner": "~0.0.4", - "through": "~2.3.1" - }, - "dependencies": { - "map-stream": { - "version": "0.1.0", - "dev": true - } - } - }, "event-target-shim": { "version": "5.0.1", "dev": true @@ -29664,13 +29624,20 @@ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" }, + "fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "dev": true + }, "fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "dev": true, "requires": { - "strnum": "^2.1.0" + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" } }, "fastest-levenshtein": { @@ -29751,9 +29718,9 @@ } }, "minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dev": true, "requires": { "brace-expansion": "^2.0.1" @@ -29853,7 +29820,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.6", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "dev": true }, "for-each": { @@ -29893,9 +29862,9 @@ "dev": true }, "form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "requires": { "asynckit": "^0.4.0", @@ -29926,10 +29895,6 @@ "fresh": { "version": "0.5.2" }, - "from": { - "version": "0.1.7", - "dev": true - }, "fs-extra": { "version": "11.3.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", @@ -30144,20 +30109,28 @@ "path-scurry": "^1.11.1" }, "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "requires": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "9.0.5", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" } } } @@ -32351,6 +32324,12 @@ "webpack-merge": "^4.1.5" }, "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, "glob": { "version": "7.2.3", "dev": true, @@ -32364,7 +32343,9 @@ }, "dependencies": { "minimatch": { - "version": "3.1.2", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -32373,19 +32354,21 @@ } }, "minimatch": { - "version": "9.0.4", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "dependencies": { "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "requires": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" } } } @@ -32735,7 +32718,9 @@ } }, "minimatch": { - "version": "3.1.2", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -32903,7 +32888,9 @@ } }, "minimatch": { - "version": "5.1.6", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dev": true, "requires": { "brace-expansion": "^2.0.1" @@ -33038,22 +33025,28 @@ "minimatch": "^9.0.3" }, "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "requires": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" } }, "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" } } } @@ -33629,13 +33622,6 @@ "version": "1.1.1", "dev": true }, - "pause-stream": { - "version": "0.0.11", - "dev": true, - "requires": { - "through": "~2.3" - } - }, "pend": { "version": "1.2.0", "dev": true @@ -33810,13 +33796,6 @@ "version": "1.0.1", "dev": true }, - "ps-tree": { - "version": "1.2.0", - "dev": true, - "requires": { - "event-stream": "=3.3.4" - } - }, "pump": { "version": "3.0.0", "dev": true, @@ -33890,9 +33869,9 @@ "dev": true }, "qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "requires": { "side-channel": "^1.1.0" } @@ -34046,7 +34025,9 @@ } }, "minimatch": { - "version": "5.1.6", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dev": true, "requires": { "brace-expansion": "^2.0.1" @@ -34357,9 +34338,9 @@ }, "dependencies": { "ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -34803,13 +34784,6 @@ "version": "3.0.18", "dev": true }, - "split": { - "version": "0.3.3", - "dev": true, - "requires": { - "through": "2" - } - }, "split2": { "version": "4.2.0", "dev": true @@ -34856,13 +34830,6 @@ "version": "3.0.2", "dev": true }, - "stream-combiner": { - "version": "0.0.4", - "dev": true, - "requires": { - "duplexer": "~0.1.1" - } - }, "stream-composer": { "version": "1.0.2", "dev": true, @@ -35103,9 +35070,9 @@ "dev": true }, "strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "dev": true }, "supports-color": { @@ -35167,34 +35134,6 @@ "streamx": "^2.12.5" } }, - "temp-fs": { - "version": "0.9.9", - "dev": true, - "requires": { - "rimraf": "~2.5.2" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "rimraf": { - "version": "2.5.4", - "dev": true, - "requires": { - "glob": "^7.0.5" - } - } - } - }, "ternary-stream": { "version": "3.0.0", "dev": true, @@ -35398,6 +35337,12 @@ "version": "3.0.1", "dev": true }, + "tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true + }, "triple-beam": { "version": "1.4.1", "dev": true diff --git a/package.json b/package.json index c7ca0275009..c82ac4ff4f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prebid.js", - "version": "10.24.0-pre", + "version": "10.28.0-pre", "description": "Header Bidding Management Library", "main": "dist/src/prebid.public.ts", "exports": { diff --git a/plugins/eslint/approvedLoadExternalScriptPaths.js b/plugins/eslint/approvedLoadExternalScriptPaths.js new file mode 100644 index 00000000000..0eab8545924 --- /dev/null +++ b/plugins/eslint/approvedLoadExternalScriptPaths.js @@ -0,0 +1,48 @@ +// List of exact file paths or folder paths where loadExternalScript is allowed to be used. +// Folder paths (without file extension) allow all files in that folder. +const APPROVED_LOAD_EXTERNAL_SCRIPT_PATHS = [ + // Prebid maintained modules: + 'src/debugging.js', + 'src/Renderer.js', + // RTD modules: + 'modules/aaxBlockmeterRtdProvider.js', + 'modules/adagioRtdProvider.js', + 'modules/adlooxAnalyticsAdapter.js', + 'modules/arcspanRtdProvider.js', + 'modules/airgridRtdProvider.js', + 'modules/browsiRtdProvider.js', + 'modules/brandmetricsRtdProvider.js', + 'modules/cleanioRtdProvider.js', + 'modules/humansecurityMalvDefenseRtdProvider.js', + 'modules/humansecurityRtdProvider.ts', + 'modules/confiantRtdProvider.js', + 'modules/contxtfulRtdProvider.js', + 'modules/hadronRtdProvider.js', + 'modules/mediafilterRtdProvider.js', + 'modules/medianetRtdProvider.js', + 'modules/azerionedgeRtdProvider.js', + 'modules/a1MediaRtdProvider.js', + 'modules/geoedgeRtdProvider.js', + 'modules/qortexRtdProvider.js', + 'modules/dynamicAdBoostRtdProvider.js', + 'modules/51DegreesRtdProvider.js', + 'modules/symitriDapRtdProvider.js', + 'modules/wurflRtdProvider.js', + 'modules/nodalsAiRtdProvider.js', + 'modules/anonymisedRtdProvider.js', + 'modules/optableRtdProvider.js', + 'modules/oftmediaRtdProvider.js', + 'modules/panxoRtdProvider.js', + // UserId Submodules + 'modules/justIdSystem.js', + 'modules/tncIdSystem.js', + 'modules/ftrackIdSystem.js', + 'modules/id5IdSystem.js', + // Test files + '**/*spec.js', + '**/*spec.ts', + '**/test/**/*', +]; + +module.exports = APPROVED_LOAD_EXTERNAL_SCRIPT_PATHS; + diff --git a/src/activities/activities.js b/src/activities/activities.js index 53d73e26c3b..f436222603b 100644 --- a/src/activities/activities.js +++ b/src/activities/activities.js @@ -60,3 +60,8 @@ export const LOAD_EXTERNAL_SCRIPT = 'loadExternalScript'; * accessRequestCredentials: setting withCredentials flag in ajax request config */ export const ACTIVITY_ACCESS_REQUEST_CREDENTIALS = 'accessRequestCredentials'; + +/** + * acceptBid: a bid is about to be accepted. + */ +export const ACTIVITY_ADD_BID_RESPONSE = 'acceptBid'; diff --git a/src/adapterManager.ts b/src/adapterManager.ts index 1483899029f..7c0faf40d1d 100644 --- a/src/adapterManager.ts +++ b/src/adapterManager.ts @@ -93,7 +93,7 @@ config.getConfig('s2sConfig', config => { } }); -const activityParams = activityParamsBuilder((alias) => adapterManager.resolveAlias(alias)); +export const activityParams = activityParamsBuilder((alias) => adapterManager.resolveAlias(alias)); function getConfigName(s2sConfig) { // According to our docs, "module" bid (stored impressions) @@ -506,6 +506,45 @@ const adapterManager = { .filter(uniques) .forEach(incrementAuctionsCounter); + const ortb2 = ortb2Fragments.global || {}; + const bidderOrtb2 = ortb2Fragments.bidder || {}; + + const getTid = tidFactory(); + + const getCacheKey = (bidderCode: BidderCode, s2sActivityParams?): string => { + const s2sName = s2sActivityParams != null ? s2sActivityParams[ACTIVITY_PARAM_S2S_NAME] : ''; + return s2sName ? `${bidderCode}:${s2sName}` : `${bidderCode}:`; + }; + + const mergeBidderFpd = (() => { + const fpdCache: any = {}; + return function(auctionId: string, bidderCode: BidderCode, s2sActivityParams?) { + const cacheKey = getCacheKey(bidderCode, s2sActivityParams); + const redact = dep.redact( + s2sActivityParams != null + ? s2sActivityParams + : activityParams(MODULE_TYPE_BIDDER, bidderCode) + ); + if (fpdCache[cacheKey] !== undefined) { + return [fpdCache[cacheKey], redact]; + } + const [tid, tidSource] = getTid(bidderCode, auctionId, bidderOrtb2[bidderCode]?.source?.tid ?? ortb2.source?.tid); + const fpd = Object.freeze(redact.ortb2(mergeDeep( + {}, + ortb2, + bidderOrtb2[bidderCode], + { + source: { + tid, + ext: {tidSource} + } + } + ))); + fpdCache[cacheKey] = fpd; + return [fpd, redact]; + } + })(); + let {[PARTITIONS.CLIENT]: clientBidders, [PARTITIONS.SERVER]: serverBidders} = partitionBidders(adUnits, _s2sConfigs); const allowedBidders = new Set(); @@ -513,10 +552,22 @@ const adapterManager = { if (!isPlainObject(au.mediaTypes)) { au.mediaTypes = {}; } + // filter out bidders that cannot participate in the auction - au.bids = au.bids.filter((bid) => !bid.bidder || dep.isAllowed(ACTIVITY_FETCH_BIDS, activityParams(MODULE_TYPE_BIDDER, bid.bidder, { - isS2S: serverBidders.includes(bid.bidder) && !clientBidders.includes(bid.bidder) - }))) + au.bids = au.bids.filter((bid) => { + if (!bid.bidder) { + return true; + } + const [ortb2] = mergeBidderFpd(auctionId, bid.bidder); + const isS2S = serverBidders.includes(bid.bidder) && !clientBidders.includes(bid.bidder); + return dep.isAllowed(ACTIVITY_FETCH_BIDS, activityParams(MODULE_TYPE_BIDDER, bid.bidder, { + bid, + ortb2, + adUnit: au, + auctionId, + isS2S + })); + }); au.bids.forEach(bid => { allowedBidders.add(bid.bidder); }); @@ -535,29 +586,8 @@ const adapterManager = { const bidRequests: BidderRequest[] = []; - const ortb2 = ortb2Fragments.global || {}; - const bidderOrtb2 = ortb2Fragments.bidder || {}; - - const getTid = tidFactory(); - function addOrtb2>(bidderRequest: Partial, s2sActivityParams?): T { - const redact = dep.redact( - s2sActivityParams != null - ? s2sActivityParams - : activityParams(MODULE_TYPE_BIDDER, bidderRequest.bidderCode) - ); - const [tid, tidSource] = getTid(bidderRequest.bidderCode, bidderRequest.auctionId, bidderOrtb2[bidderRequest.bidderCode]?.source?.tid ?? ortb2.source?.tid); - const fpd = Object.freeze(redact.ortb2(mergeDeep( - {}, - ortb2, - bidderOrtb2[bidderRequest.bidderCode], - { - source: { - tid, - ext: {tidSource} - } - } - ))); + const [fpd, redact] = mergeBidderFpd(bidderRequest.auctionId, bidderRequest.bidderCode, s2sActivityParams); bidderRequest.ortb2 = fpd; bidderRequest.bids = bidderRequest.bids.map((bid) => { bid.ortb2 = fpd; diff --git a/src/adloader.js b/src/adloader.js index 098b78c211b..71f6698141e 100644 --- a/src/adloader.js +++ b/src/adloader.js @@ -5,45 +5,6 @@ import { isActivityAllowed } from './activities/rules.js'; import { insertElement, logError, logWarn, setScriptAttributes } from './utils.js'; const _requestCache = new WeakMap(); -// The below list contains modules or vendors whom Prebid allows to load external JS. -const _approvedLoadExternalJSList = [ - // Prebid maintained modules: - 'debugging', - 'outstream', - // RTD modules: - 'aaxBlockmeter', - 'adagio', - 'adloox', - 'arcspan', - 'airgrid', - 'browsi', - 'brandmetrics', - 'clean.io', - 'humansecurityMalvDefense', - 'humansecurity', - 'confiant', - 'contxtful', - 'hadron', - 'mediafilter', - 'medianet', - 'azerionedge', - 'a1Media', - 'geoedge', - 'qortex', - 'dynamicAdBoost', - '51Degrees', - 'symitridap', - 'wurfl', - 'nodalsAi', - 'anonymised', - 'optable', - 'oftmedia', - // UserId Submodules - 'justtag', - 'tncId', - 'ftrackId', - 'id5', -]; /** * Loads external javascript. Can only be used if external JS is approved by Prebid. See https://github.com/prebid/prebid-js-external-js-template#policy @@ -64,10 +25,7 @@ export function loadExternalScript(url, moduleType, moduleCode, callback, doc, a logError('cannot load external script without url and moduleCode'); return; } - if (!_approvedLoadExternalJSList.includes(moduleCode)) { - logError(`${moduleCode} not whitelisted for loading external JavaScript`); - return; - } + if (!doc) { doc = document; // provide a "valid" key for the WeakMap } diff --git a/src/auction.ts b/src/auction.ts index 97984dae443..a6912be69be 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -22,7 +22,7 @@ import {AUDIO, VIDEO} from './mediaTypes.js'; import {auctionManager} from './auctionManager.js'; import {bidderSettings} from './bidderSettings.js'; import * as events from './events.js'; -import adapterManager, {type BidderRequest, type BidRequest} from './adapterManager.js'; +import adapterManager, {activityParams, type BidderRequest, type BidRequest} from './adapterManager.js'; import {EVENTS, GRANULARITY_OPTIONS, JSON_MAPPING, REJECTION_REASON, S2S, TARGETING_KEYS} from './constants.js'; import {defer, PbPromise} from './utils/promise.js'; import {type Metrics, useMetrics} from './utils/perfMetrics.js'; @@ -36,6 +36,9 @@ import type {TargetingMap} from "./targeting.ts"; import type {AdUnit} from "./adUnits.ts"; import type {MediaType} from "./mediaTypes.ts"; import type {VideoContext} from "./video.ts"; +import { isActivityAllowed } from './activities/rules.js'; +import { ACTIVITY_ADD_BID_RESPONSE } from './activities/activities.js'; +import { MODULE_TYPE_BIDDER } from './activities/modules.ts'; const { syncUsers } = userSync; @@ -252,7 +255,7 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a done.resolve(); events.emit(EVENTS.AUCTION_END, getProperties()); - bidsBackCallback(_adUnits, function () { + bidsBackCallback(_adUnits, auctionId, function () { try { if (_callback != null) { const bids = _bidsReceived.toArray() @@ -469,7 +472,13 @@ declare module './hook' { */ export const addBidResponse = ignoreCallbackArg(hook('async', function(adUnitCode: string, bid: Partial, reject: (reason: (typeof REJECTION_REASON)[keyof typeof REJECTION_REASON]) => void): void { if (!isValidPrice(bid)) { - reject(REJECTION_REASON.PRICE_TOO_HIGH) + reject(REJECTION_REASON.PRICE_TOO_HIGH); + } else if (!isActivityAllowed(ACTIVITY_ADD_BID_RESPONSE, activityParams(MODULE_TYPE_BIDDER, bid.bidder || bid.bidderCode, { + bid, + ortb2: auctionManager.index.getOrtb2(bid), + adUnit: auctionManager.index.getAdUnit(bid), + }))) { + reject(REJECTION_REASON.BIDDER_DISALLOWED); } else { this.dispatch.call(null, adUnitCode, bid); } @@ -487,7 +496,7 @@ export const addBidderRequests = hook('sync', function(bidderRequests) { this.dispatch.call(this.context, bidderRequests); }, 'addBidderRequests'); -export const bidsBackCallback = hook('async', function (adUnits, callback) { +export const bidsBackCallback = hook('async', function (adUnits, auctionId, callback) { if (callback) { callback(); } diff --git a/src/auctionIndex.js b/src/auctionIndex.js index 0bf0fb88943..320e247de87 100644 --- a/src/auctionIndex.js +++ b/src/auctionIndex.js @@ -11,6 +11,7 @@ * Bid responses are not guaranteed to have a corresponding request. * @property {function({ requestId?: string }): *} getBidRequest Returns bidRequest object for requestId. * Bid responses are not guaranteed to have a corresponding request. + * @property {function} getOrtb2 Returns ortb2 object for bid */ /** diff --git a/src/bidfactory.ts b/src/bidfactory.ts index b87d3b14a26..8ce6bda3f23 100644 --- a/src/bidfactory.ts +++ b/src/bidfactory.ts @@ -135,7 +135,6 @@ export interface BaseBid extends ContextIdentifiers, Required; + /** + * List of fingerprinting APIs to disable. When an API is listed, the corresponding library + * returns a safe default instead of reading the real value. Supported: 'devicepixelratio', 'webdriver', 'resolvedoptions'. + */ + disableFingerprintingApis?: Array<'devicepixelratio' | 'webdriver' | 'resolvedoptions'>; } type PartialConfig = Partial & { [setting: string]: unknown }; diff --git a/src/utils.js b/src/utils.js index ec30b934a49..187e46c24c1 100644 --- a/src/utils.js +++ b/src/utils.js @@ -206,6 +206,18 @@ export function canAccessWindowTop() { } } +/** + * Returns the window to use for fingerprinting reads: win if provided, otherwise top or self. + * @param {Window} [win] + * @returns {Window} + */ +export function getFallbackWindow(win) { + if (win) { + return win; + } + return canAccessWindowTop() ? internal.getWindowTop() : internal.getWindowSelf(); +} + /** * Wrappers to console.(log | info | warn | error). Takes N arguments, the same as the native methods */ diff --git a/test/build-logic/no_3384_spec.mjs b/test/build-logic/no_3384_spec.mjs new file mode 100644 index 00000000000..94cc47227ef --- /dev/null +++ b/test/build-logic/no_3384_spec.mjs @@ -0,0 +1,47 @@ +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +describe('build hygiene checks', () => { + it('should not contain the forbidden legacy token in LocID files', () => { + const forbiddenToken = ['33', '84'].join(''); + const scopeArgs = [ + 'ls-files', + '--', + ':(glob)modules/**/locId*', + ':(glob)test/spec/**/locId*', + 'docs/modules/locid.md', + 'modules/locIdSystem.md' + ]; + const scopedPaths = execFileSync('git', scopeArgs, { cwd: repoRoot, encoding: 'utf8' }) + .split('\n') + .map(filePath => filePath.trim()) + .filter(Boolean); + + expect(scopedPaths.length, 'No LocID files were selected for the 3384 guard').to.be.greaterThan(0); + + const args = [ + 'grep', + '-n', + '-I', + '-E', + `\\b${forbiddenToken}\\b`, + '--', + ...scopedPaths + ]; + + try { + const output = execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }); + expect(output.trim(), `Unexpected ${forbiddenToken} matches:\n${output}`).to.equal(''); + } catch (e) { + if (e?.status === 1) { + return; + } + throw e; + } + }); +}); diff --git a/test/spec/activities/objectGuard_spec.js b/test/spec/activities/objectGuard_spec.js index 3f1474b0c46..78f911aba2a 100644 --- a/test/spec/activities/objectGuard_spec.js +++ b/test/spec/activities/objectGuard_spec.js @@ -11,9 +11,11 @@ describe('objectGuard', () => { paths: ['foo', 'outer.inner.foo'], name: 'testRule', applies: sinon.stub().callsFake(() => applies), - get(val) { return `repl${val}` }, - } - }) + get(val) { + return `repl${val}`; + }, + }; + }); it('should reject conflicting rules', () => { const crule = {...rule, paths: ['outer']}; @@ -25,7 +27,7 @@ describe('objectGuard', () => { const guard = objectGuard([rule])({outer: {inner: {foo: 'bar'}}}); expect(guard.outer).to.equal(guard.outer); expect(guard.outer.inner).to.equal(guard.outer.inner); - }) + }); it('can prevent top level read access', () => { const obj = objectGuard([rule])({'foo': 1, 'other': 2}); expect(obj).to.eql({ @@ -44,7 +46,7 @@ describe('objectGuard', () => { const guarded = objectGuard([rule])(obj); obj.foo = 'baz'; expect(guarded.foo).to.eql('replbaz'); - }) + }); it('does not prevent access if applies returns false', () => { applies = false; @@ -52,7 +54,7 @@ describe('objectGuard', () => { expect(obj).to.eql({ foo: 1 }); - }) + }); it('can prevent nested property access', () => { const obj = objectGuard([rule])({ @@ -78,13 +80,13 @@ describe('objectGuard', () => { foo: 3 } } - }) + }); }); it('prevents nested property access when a parent property is protected', () => { const guard = objectGuard([rule])({foo: {inner: 'value'}}); expect(guard.inner?.value).to.not.exist; - }) + }); it('does not call applies more than once', () => { JSON.stringify(objectGuard([rule])({ @@ -96,7 +98,7 @@ describe('objectGuard', () => { } })); expect(rule.applies.callCount).to.equal(1); - }) + }); }); describe('write protection', () => { @@ -118,6 +120,55 @@ describe('objectGuard', () => { expect(obj.foo).to.eql({nested: 'item'}); }); + it('should handle circular references in guarded properties', () => { + applies = false; + const obj = { + foo: {} + }; + const guard = objectGuard([rule])(obj); + guard.foo.inner = guard.foo; + expect(guard).to.eql({ + foo: { + inner: guard.foo + } + }); + }); + + it('should handle circular references in unguarded properties', () => { + const obj = {}; + const guard = objectGuard([rule])(obj); + const val = {}; + val.circular = val; + guard.prop = val; + expect(guard).to.eql({ + prop: val + }) + }); + + it('should allow for deferred modification', () => { + const obj = {}; + const guard = objectGuard([rule])(obj); + const prop = {}; + guard.prop = prop; + prop.val = 'foo'; + expect(obj).to.eql({ + prop: { + val: 'foo' + } + }); + }); + + it('should not choke on immutable objects', () => { + const obj = {}; + const guard = objectGuard([rule])(obj); + guard.prop = Object.freeze({val: 'foo'}); + expect(obj).to.eql({ + prop: { + val: 'foo' + } + }) + }) + it('should reject conflicting rules', () => { const crule = {...rule, paths: ['outer']}; expect(() => objectGuard([rule, crule])).to.throw(); @@ -128,12 +179,12 @@ describe('objectGuard', () => { const guard = objectGuard([rule])({outer: {inner: {foo: 'bar'}}}); expect(guard.outer).to.equal(guard.outer); expect(guard.outer.inner).to.equal(guard.outer.inner); - }) + }); it('does not mess up array reads', () => { const guard = objectGuard([rule])({foo: [{bar: 'baz'}]}); expect(guard.foo).to.eql([{bar: 'baz'}]); - }) + }); it('prevents array modification', () => { const obj = {foo: ['value']}; @@ -141,7 +192,7 @@ describe('objectGuard', () => { guard.foo.pop(); guard.foo.push('test'); expect(obj.foo).to.eql(['value']); - }) + }); it('allows array modification when not applicable', () => { applies = false; @@ -150,7 +201,7 @@ describe('objectGuard', () => { guard.foo.pop(); guard.foo.push('test'); expect(obj.foo).to.eql(['test']); - }) + }); it('should prevent top-level writes', () => { const obj = {bar: {nested: 'val'}, other: 'val'}; @@ -166,7 +217,7 @@ describe('objectGuard', () => { const guard = objectGuard([rule])({foo: {some: 'value'}}); guard.foo = {some: 'value'}; sinon.assert.notCalled(rule.applies); - }) + }); it('should prevent top-level deletes', () => { const obj = {foo: {nested: 'val'}, bar: 'val'}; @@ -174,7 +225,7 @@ describe('objectGuard', () => { delete guard.foo.nested; delete guard.bar; expect(guard).to.eql({foo: {nested: 'val'}, bar: 'val'}); - }) + }); it('should prevent nested writes', () => { const obj = {outer: {inner: {bar: {nested: 'val'}, other: 'val'}}}; @@ -192,7 +243,7 @@ describe('objectGuard', () => { other: 'allowed' } } - }) + }); }); it('should prevent writes if upper levels are protected', () => { @@ -200,29 +251,29 @@ describe('objectGuard', () => { const guard = objectGuard([rule])(obj); guard.foo.inner.prop = 'value'; expect(obj).to.eql({foo: {inner: {}}}); - }) + }); it('should prevent deletes if a higher level property is protected', () => { const obj = {foo: {inner: {prop: 'value'}}}; const guard = objectGuard([rule])(obj); delete guard.foo.inner.prop; expect(obj).to.eql({foo: {inner: {prop: 'value'}}}); - }) + }); it('should clean up top-level writes that would result in inner properties changing', () => { const guard = objectGuard([rule])({outer: {inner: {bar: 'baz'}}}); guard.outer = {inner: {bar: 'baz', foo: 'baz', prop: 'allowed'}}; expect(guard).to.eql({outer: {inner: {bar: 'baz', prop: 'allowed'}}}); - }) + }); it('should not prevent writes that are not protected', () => { const obj = {}; const guard = objectGuard([rule])(obj); guard.outer = { test: 'value' - } + }; expect(obj.outer.test).to.eql('value'); - }) + }); it('should not choke on type mismatch: overwrite object with scalar', () => { const obj = {outer: {inner: {}}}; @@ -236,21 +287,21 @@ describe('objectGuard', () => { const guard = objectGuard([rule])(obj); guard.outer = {inner: {bar: 'denied', other: 'allowed'}}; expect(obj).to.eql({outer: {inner: {other: 'allowed'}}}); - }) + }); it('should prevent nested deletes', () => { const obj = {outer: {inner: {foo: {nested: 'val'}, bar: 'val'}}}; const guard = objectGuard([rule])(obj); delete guard.outer.inner.foo.nested; delete guard.outer.inner.bar; - expect(guard).to.eql({outer: {inner: {foo: {nested: 'val'}, bar: 'val'}}}) + expect(guard).to.eql({outer: {inner: {foo: {nested: 'val'}, bar: 'val'}}}); }); it('should prevent higher level deletes that would result in inner properties changing', () => { const guard = objectGuard([rule])({outer: {inner: {bar: 'baz'}}}); delete guard.outer.inner; expect(guard).to.eql({outer: {inner: {bar: 'baz'}}}); - }) + }); it('should work on null properties', () => { const obj = {foo: null}; @@ -293,7 +344,7 @@ describe('objectGuard', () => { applies: () => true, }) ]; - }) + }); Object.entries({ 'simple value': 'val', 'object value': {inner: 'val'} @@ -303,8 +354,8 @@ describe('objectGuard', () => { expect(obj.foo).to.not.exist; obj.foo = {other: 'val'}; expect(obj.foo).to.not.exist; - }) - }) - }) - }) + }); + }); + }); + }); }); diff --git a/test/spec/fingerprinting_spec.js b/test/spec/fingerprinting_spec.js new file mode 100644 index 00000000000..f1a810fc751 --- /dev/null +++ b/test/spec/fingerprinting_spec.js @@ -0,0 +1,60 @@ +import { expect } from 'chai'; + +import { config } from 'src/config.js'; +import { getDevicePixelRatio } from 'libraries/devicePixelRatio/devicePixelRatio.js'; +import { isWebdriverEnabled } from 'libraries/webdriver/webdriver.js'; +import { getTimeZone } from 'libraries/timezone/timezone.js'; + +describe('disableFingerprintingApis', function () { + after(function () { + config.resetConfig(); + }); + + it('when devicepixelratio is disabled, getDevicePixelRatio returns 1 without reading window.devicePixelRatio', function () { + const devicePixelRatioSpy = sinon.spy(); + const mockWin = { + get devicePixelRatio() { + devicePixelRatioSpy(); + return 2; + } + }; + config.setConfig({ disableFingerprintingApis: ['devicepixelratio'] }); + const result = getDevicePixelRatio(mockWin); + expect(result).to.equal(1); + sinon.assert.notCalled(devicePixelRatioSpy); + }); + + it('when webdriver is disabled, isWebdriverEnabled returns false without reading navigator.webdriver', function () { + const webdriverSpy = sinon.spy(); + const mockWin = { + navigator: { + get webdriver() { + webdriverSpy(); + return true; + } + } + }; + config.setConfig({ disableFingerprintingApis: ['webdriver'] }); + const result = isWebdriverEnabled(mockWin); + expect(result).to.equal(false); + sinon.assert.notCalled(webdriverSpy); + }); + + it('when resolvedoptions is disabled, getTimeZone returns safe default without calling Intl.DateTimeFormat', function () { + const resolvedOptionsSpy = sinon.spy(); + const dateTimeFormatStub = sinon.stub(Intl, 'DateTimeFormat').returns({ + resolvedOptions: function () { + resolvedOptionsSpy(); + return { timeZone: 'America/New_York' }; + } + }); + try { + config.setConfig({ disableFingerprintingApis: ['resolvedoptions'] }); + const result = getTimeZone(); + expect(result).to.equal(''); + sinon.assert.notCalled(resolvedOptionsSpy); + } finally { + dateTimeFormatStub.restore(); + } + }); +}); diff --git a/test/spec/libraries/percentInView_spec.js b/test/spec/libraries/percentInView_spec.js new file mode 100644 index 00000000000..92c44edace8 --- /dev/null +++ b/test/spec/libraries/percentInView_spec.js @@ -0,0 +1,40 @@ +import {getViewportOffset} from '../../../libraries/percentInView/percentInView.js'; + +describe('percentInView', () => { + describe('getViewportOffset', () => { + function mockWindow(offsets = []) { + let win, leaf, child; + win = leaf = {}; + for (const [x, y] of offsets) { + win.frameElement = { + getBoundingClientRect() { + return {left: x, top: y}; + } + }; + child = win; + win = {}; + child.parent = win; + } + return leaf; + } + it('returns 0, 0 for the top window', () => { + expect(getViewportOffset(mockWindow())).to.eql({x: 0, y: 0}); + }); + + it('returns frame offset for a direct child', () => { + expect(getViewportOffset(mockWindow([[10, 20]]))).to.eql({x: 10, y: 20}); + }); + it('returns cumulative offests for descendants', () => { + expect(getViewportOffset(mockWindow([[10, 20], [20, 30]]))).to.eql({x: 30, y: 50}); + }); + it('does not choke when parent is not accessible', () => { + const win = mockWindow([[10, 20]]); + Object.defineProperty(win, 'frameElement', { + get() { + throw new Error(); + } + }); + expect(getViewportOffset(win)).to.eql({x: 0, y: 0}); + }); + }); +}); diff --git a/test/spec/libraries/placementPositionInfo_spec.js b/test/spec/libraries/placementPositionInfo_spec.js new file mode 100644 index 00000000000..9d8a57f4db4 --- /dev/null +++ b/test/spec/libraries/placementPositionInfo_spec.js @@ -0,0 +1,458 @@ +import { getPlacementPositionUtils } from '../../../libraries/placementPositionInfo/placementPositionInfo.js'; +import * as utils from '../../../src/utils.js'; +import * as boundingClientRectLib from '../../../libraries/boundingClientRect/boundingClientRect.js'; +import * as percentInViewLib from '../../../libraries/percentInView/percentInView.js'; +import * as winDimensions from 'src/utils/winDimensions.js'; + +import assert from 'assert'; +import sinon from 'sinon'; + +describe('placementPositionInfo', function () { + let sandbox; + let canAccessWindowTopStub; + let getWindowTopStub; + let getWindowSelfStub; + let getBoundingClientRectStub; + let percentInViewStub; + let cleanObjStub; + + let mockDocument; + let mockWindow; + let viewportOffset + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + mockDocument = { + getElementById: sandbox.stub().returns(null), + getElementsByTagName: sandbox.stub().returns([]), + body: { scrollHeight: 2000, offsetHeight: 1800 }, + documentElement: { clientHeight: 1900, scrollHeight: 2100, offsetHeight: 1950 }, + visibilityState: 'visible' + }; + + mockWindow = { + innerHeight: 800, + document: mockDocument + }; + + canAccessWindowTopStub = sandbox.stub(utils, 'canAccessWindowTop').returns(true); + getWindowTopStub = sandbox.stub(utils, 'getWindowTop').returns(mockWindow); + getWindowSelfStub = sandbox.stub(utils, 'getWindowSelf').returns(mockWindow); + getBoundingClientRectStub = sandbox.stub(boundingClientRectLib, 'getBoundingClientRect'); + percentInViewStub = sandbox.stub(percentInViewLib, 'getViewability'); + cleanObjStub = sandbox.stub(utils, 'cleanObj').callsFake(obj => obj); + sandbox.stub(winDimensions, 'getWinDimensions').returns(mockWindow); + viewportOffset = {x: 0, y: 0}; + sandbox.stub(percentInViewLib, 'getViewportOffset').callsFake(() => viewportOffset); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('getPlacementPositionUtils', function () { + it('should return an object with getPlacementInfo and getPlacementEnv functions', function () { + const result = getPlacementPositionUtils(); + + assert.strictEqual(typeof result.getPlacementInfo, 'function'); + assert.strictEqual(typeof result.getPlacementEnv, 'function'); + }); + + it('should use window top when accessible', function () { + canAccessWindowTopStub.returns(true); + getPlacementPositionUtils(); + + assert.ok(getWindowTopStub.called); + }); + + it('should use window self when top is not accessible', function () { + canAccessWindowTopStub.returns(false); + getPlacementPositionUtils(); + + assert.ok(getWindowSelfStub.called); + }); + }); + + describe('getPlacementInfo', function () { + let getPlacementInfo; + let mockElement; + + beforeEach(function () { + mockElement = { id: 'test-ad-unit' }; + mockDocument.getElementById.returns(mockElement); + + getBoundingClientRectStub.returns({ + top: 100, + bottom: 200, + height: 100, + width: 300 + }); + + percentInViewStub.returns(50); + + const placementUtils = getPlacementPositionUtils(); + getPlacementInfo = placementUtils.getPlacementInfo; + }); + + it('should return placement info with all required fields', function () { + const bidReq = { + adUnitCode: 'test-ad-unit', + auctionsCount: 5, + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.AuctionsCount, 5); + assert.strictEqual(typeof result.DistanceToView, 'number'); + assert.strictEqual(typeof result.PlacementPercentView, 'number'); + assert.strictEqual(typeof result.ElementHeight, 'number'); + }); + + it('should calculate distanceToView as 0 when element is in viewport', function () { + getBoundingClientRectStub.returns({ + top: 100, + bottom: 200, + height: 100 + }); + + const bidReq = { + adUnitCode: 'test-ad-unit', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.DistanceToView, 0); + }); + + it('should calculate positive distanceToView when element is below viewport', function () { + getBoundingClientRectStub.returns({ + top: 1000, + bottom: 1100, + height: 100 + }); + + const bidReq = { + adUnitCode: 'test-ad-unit', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.DistanceToView, 200); + }); + + it('should calculate negative distanceToView when element is above viewport', function () { + getBoundingClientRectStub.returns({ + top: -200, + bottom: -100, + height: 100 + }); + + const bidReq = { + adUnitCode: 'test-ad-unit', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.DistanceToView, -100); + }); + + it('should handle null element gracefully', function () { + mockDocument.getElementById.returns(null); + + const bidReq = { + adUnitCode: 'non-existent-unit', + sizes: [[300, 250]] + }; + + const placementUtils = getPlacementPositionUtils(); + const result = placementUtils.getPlacementInfo(bidReq); + + assert.strictEqual(result.DistanceToView, 0); + assert.strictEqual(result.ElementHeight, 1); + }); + + it('should not call getViewability when element is null', function () { + mockDocument.getElementById.returns(null); + + const bidReq = { + adUnitCode: 'non-existent-unit', + sizes: [[300, 250]] + }; + + const placementUtils = getPlacementPositionUtils(); + placementUtils.getPlacementInfo(bidReq); + + assert.ok(!percentInViewStub.called, 'getViewability should not be called with null element'); + }); + + it('should handle empty sizes array', function () { + const bidReq = { + adUnitCode: 'test-ad-unit', + sizes: [] + }; + + const result = getPlacementInfo(bidReq); + + assert.ok(!isNaN(result.PlacementPercentView), 'PlacementPercentView should not be NaN'); + }); + + it('should handle undefined sizes', function () { + const bidReq = { + adUnitCode: 'test-ad-unit' + }; + + const result = getPlacementInfo(bidReq); + + assert.ok(!isNaN(result.PlacementPercentView), 'PlacementPercentView should not be NaN'); + }); + + it('should select the smallest size by area', function () { + const bidReq = { + adUnitCode: 'test-ad-unit', + sizes: [[728, 90], [300, 250], [160, 600]] + }; + + getPlacementInfo(bidReq); + + const percentInViewCall = percentInViewStub.getCall(0); + const sizeArg = percentInViewCall.args[2]; + + assert.strictEqual(sizeArg.w, 728); + assert.strictEqual(sizeArg.h, 90); + }); + + it('should use ElementHeight from bounding rect', function () { + getBoundingClientRectStub.returns({ + top: 100, + bottom: 350, + height: 250 + }); + + const bidReq = { + adUnitCode: 'test-ad-unit', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.ElementHeight, 250); + }); + + it('should default ElementHeight to 1 when height is 0', function () { + getBoundingClientRectStub.returns({ + top: 100, + bottom: 100, + height: 0 + }); + + const bidReq = { + adUnitCode: 'test-ad-unit', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.ElementHeight, 1); + }); + }); + + describe('getPlacementEnv', function () { + let getPlacementEnv; + let performanceNowStub; + + beforeEach(function () { + performanceNowStub = sandbox.stub(performance, 'now').returns(1234.567); + + const placementUtils = getPlacementPositionUtils(); + getPlacementEnv = placementUtils.getPlacementEnv; + }); + + it('should return environment info with all required fields', function () { + const result = getPlacementEnv(); + + assert.strictEqual(typeof result.TimeFromNavigation, 'number'); + assert.strictEqual(typeof result.TabActive, 'boolean'); + assert.strictEqual(typeof result.PageHeight, 'number'); + assert.strictEqual(typeof result.ViewportHeight, 'number'); + }); + + it('should return TimeFromNavigation as floored performance.now()', function () { + performanceNowStub.returns(5678.999); + + const placementUtils = getPlacementPositionUtils(); + const result = placementUtils.getPlacementEnv(); + + assert.strictEqual(result.TimeFromNavigation, 5678); + }); + + it('should return TabActive as true when document is visible', function () { + const result = getPlacementEnv(); + + assert.strictEqual(result.TabActive, true); + }); + + it('should return TabActive as false when document is hidden', function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + + const hiddenMockDocument = { + getElementById: sandbox.stub().returns(null), + getElementsByTagName: sandbox.stub().returns([]), + body: { scrollHeight: 1000, offsetHeight: 1000 }, + documentElement: { clientHeight: 1000, scrollHeight: 1000, offsetHeight: 1000 }, + visibilityState: 'hidden' + }; + + const hiddenMockWindow = { + innerHeight: 800, + document: hiddenMockDocument + }; + + sandbox.stub(utils, 'canAccessWindowTop').returns(true); + sandbox.stub(utils, 'getWindowTop').returns(hiddenMockWindow); + sandbox.stub(utils, 'getWindowSelf').returns(hiddenMockWindow); + sandbox.stub(utils, 'cleanObj').callsFake(obj => obj); + sandbox.stub(performance, 'now').returns(1000); + + const freshUtils = getPlacementPositionUtils(); + const result = freshUtils.getPlacementEnv(); + + assert.strictEqual(result.TabActive, false); + }); + + it('should return ViewportHeight from window.innerHeight', function () { + const result = getPlacementEnv(); + + assert.strictEqual(result.ViewportHeight, 800); + }); + + it('should return max PageHeight from all document height properties', function () { + const result = getPlacementEnv(); + + assert.strictEqual(result.PageHeight, 2100); + }); + }); + + describe('getViewableDistance edge cases', function () { + let getPlacementInfo; + + beforeEach(function () { + mockDocument.getElementById.returns({ id: 'test' }); + percentInViewStub.returns(0); + + const placementUtils = getPlacementPositionUtils(); + getPlacementInfo = placementUtils.getPlacementInfo; + }); + + it('should handle getBoundingClientRect returning null', function () { + getBoundingClientRectStub.returns(null); + + const bidReq = { + adUnitCode: 'test', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.DistanceToView, 0); + assert.strictEqual(result.ElementHeight, 1); + }); + + it('should handle element exactly at viewport bottom edge', function () { + getBoundingClientRectStub.returns({ + top: 800, + bottom: 900, + height: 100 + }); + + const bidReq = { + adUnitCode: 'test', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.DistanceToView, 0); + }); + + it('should handle element exactly at viewport top edge', function () { + getBoundingClientRectStub.returns({ + top: 0, + bottom: 100, + height: 100 + }); + + const bidReq = { + adUnitCode: 'test', + sizes: [[300, 250]] + }; + + const result = getPlacementInfo(bidReq); + + assert.strictEqual(result.DistanceToView, 0); + }); + }); + + describe('iframe coordinate translation', function () { + beforeEach(() => { + mockDocument.getElementById = sandbox.stub().returns({id: 'test'}); + mockWindow.innerHeight = 1000; + mockDocument.body = { + scrollHeight: 2000, offsetHeight: 1800 + } + mockDocument.documentElement = { clientHeight: 1900, scrollHeight: 2100, offsetHeight: 1950 } + }); + it('should apply iframe offset when running inside a friendly iframe', function () { + viewportOffset = {y: 200}; + + getBoundingClientRectStub.callsFake((el) => { + return { top: 100, bottom: 200, height: 100 }; + }); + + const placementUtils = getPlacementPositionUtils(); + const result = placementUtils.getPlacementInfo({ + adUnitCode: 'test', + sizes: [[300, 250]] + }); + + assert.strictEqual(result.DistanceToView, 0); + }); + + it('should calculate correct distance when element is below viewport with iframe offset', function () { + viewportOffset = {y: 500}; + + getBoundingClientRectStub.callsFake((el) => { + return { top: 600, bottom: 700, height: 100 }; + }); + + const placementUtils = getPlacementPositionUtils(); + const result = placementUtils.getPlacementInfo({ + adUnitCode: 'test', + sizes: [[300, 250]] + }); + + assert.strictEqual(result.DistanceToView, 100); + }); + + it('should calculate negative distance when element is above viewport with iframe offset', function () { + viewportOffset = {y: -600}; + + getBoundingClientRectStub.callsFake((el) => { + return { top: 100, bottom: 200, height: 100 }; + }); + + const placementUtils = getPlacementPositionUtils(); + const result = placementUtils.getPlacementInfo({ + adUnitCode: 'test', + sizes: [[300, 250]] + }); + + assert.strictEqual(result.DistanceToView, -400); + }); + }); +}); diff --git a/test/spec/libraries/teqblazeUtils/bidderUtils_spec.js b/test/spec/libraries/teqblazeUtils/bidderUtils_spec.js index 85ea1131db4..73055521549 100644 --- a/test/spec/libraries/teqblazeUtils/bidderUtils_spec.js +++ b/test/spec/libraries/teqblazeUtils/bidderUtils_spec.js @@ -516,7 +516,7 @@ describe('TeqBlazeBidderUtils', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -525,9 +525,7 @@ describe('TeqBlazeBidderUtils', function () { expect(syncData[0].url).to.equal(`https://${DOMAIN}/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0`) }); it('Should return array of objects with proper sync config , include CCPA', function () { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -536,7 +534,7 @@ describe('TeqBlazeBidderUtils', function () { expect(syncData[0].url).to.equal(`https://${DOMAIN}/image?pbjs=1&ccpa_consent=1---&coppa=0`) }); it('Should return array of objects with proper sync config , include GPP', function () { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/33acrossIdSystem_spec.js b/test/spec/modules/33acrossIdSystem_spec.js index 3cf5995bb17..3a1bf6da2b3 100644 --- a/test/spec/modules/33acrossIdSystem_spec.js +++ b/test/spec/modules/33acrossIdSystem_spec.js @@ -260,6 +260,7 @@ describe('33acrossIdSystem', () => { const removeDataFromLocalStorage = sinon.stub(storage, 'removeDataFromLocalStorage'); const setCookie = sinon.stub(storage, 'setCookie'); + const cookiesAreEnabled = sinon.stub(storage, 'cookiesAreEnabled').returns(true); sinon.stub(domainUtils, 'domainOverride').returns('foo.com'); request.respond(200, { @@ -277,6 +278,7 @@ describe('33acrossIdSystem', () => { removeDataFromLocalStorage.restore(); setCookie.restore(); + cookiesAreEnabled.restore(); domainUtils.domainOverride.restore(); }); }); @@ -419,6 +421,7 @@ describe('33acrossIdSystem', () => { const removeDataFromLocalStorage = sinon.stub(storage, 'removeDataFromLocalStorage'); const setCookie = sinon.stub(storage, 'setCookie'); + const cookiesAreEnabled = sinon.stub(storage, 'cookiesAreEnabled').returns(true); sinon.stub(domainUtils, 'domainOverride').returns('foo.com'); request.respond(200, { @@ -436,6 +439,7 @@ describe('33acrossIdSystem', () => { removeDataFromLocalStorage.restore(); setCookie.restore(); + cookiesAreEnabled.restore(); domainUtils.domainOverride.restore(); }); }); diff --git a/test/spec/modules/360playvidBidAdapter_spec.js b/test/spec/modules/360playvidBidAdapter_spec.js index 393afbf4545..7718c0c2eca 100644 --- a/test/spec/modules/360playvidBidAdapter_spec.js +++ b/test/spec/modules/360playvidBidAdapter_spec.js @@ -483,7 +483,7 @@ describe('360PlayVidBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -492,9 +492,7 @@ describe('360PlayVidBidAdapter', function () { expect(syncData[0].url).to.equal('https://cookie.360playvid.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -503,7 +501,7 @@ describe('360PlayVidBidAdapter', function () { expect(syncData[0].url).to.equal('https://cookie.360playvid.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/aceexBidAdapter_spec.js b/test/spec/modules/aceexBidAdapter_spec.js new file mode 100644 index 00000000000..cfb695f69e5 --- /dev/null +++ b/test/spec/modules/aceexBidAdapter_spec.js @@ -0,0 +1,248 @@ +import { expect } from 'chai'; +import { spec } from '../../../modules/aceexBidAdapter.js'; + +describe('aceexBidAdapter', function () { + const makeBidderRequest = (ortb2 = {}) => ({ + bidderCode: 'aceex', + auctionId: 'auction-1', + bidderRequestId: 'br-1', + ortb2 + }); + + const makeBid = (overrides = {}) => ({ + bidId: overrides.bidId || 'bid-1', + bidder: 'aceex', + params: { + publisherId: 'pub-1', + trafficType: 'banner', + internalKey: 'ik-1', + bidfloor: 0.1, + ...overrides.params, + }, + mediaTypes: overrides.mediaTypes, + sizes: overrides.sizes, + ...overrides, + }); + + describe('isBidRequestValid', function () { + it('should return true when bidId, params.publisherId and params.trafficType are present', function () { + const bid = makeBid({ + bidId: 'bid-123', + params: { publisherId: 'pub-123', trafficType: 'banner' } + }); + + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when bidId is missing', function () { + const bid = makeBid({ bidId: undefined }); + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when params.publisherId is missing', function () { + const bid = makeBid({ params: { trafficType: 'banner' } }); + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when params.trafficType is missing', function () { + const bid = makeBid({ params: { publisherId: 'pub-1' } }); + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests', function () { + it('should build a single request object', function () { + const bidderRequest = makeBidderRequest(); + const validBidRequests = [makeBid({ bidId: 'bid-1' })]; + + const req = spec.buildRequests(validBidRequests, bidderRequest); + + expect(req).to.be.an('object'); + + expect(req).to.have.property('data'); + }); + + it('should map ortb2 fields into request.data (cat, keywords, badv, wseat, bseat)', function () { + const bidderRequest = makeBidderRequest({ + cat: ['IAB1', 'IAB1-1'], + keywords: { key1: ['v1', 'v2'] }, + badv: ['bad.com'], + wseat: ['seat1'], + bseat: ['seat2'], + }); + + const validBidRequests = [makeBid({ bidId: 'bid-1' })]; + const req = spec.buildRequests(validBidRequests, bidderRequest); + + expect(req.data.cat).to.deep.equal(['IAB1', 'IAB1-1']); + expect(req.data.keywords).to.deep.equal({ key1: ['v1', 'v2'] }); + expect(req.data.badv).to.deep.equal(['bad.com']); + expect(req.data.wseat).to.deep.equal(['seat1']); + expect(req.data.bseat).to.deep.equal(['seat2']); + }); + + it('should not throw if ortb2 fields are missing', function () { + const bidderRequest = makeBidderRequest(); + const validBidRequests = [makeBid({ bidId: 'bid-1' })]; + + expect(() => spec.buildRequests(validBidRequests, bidderRequest)).to.not.throw(); + }); + }); + + describe('interpretResponse', function () { + it('should return [] when serverResponse/body is missing', function () { + expect(spec.interpretResponse(null, {})).to.deep.equal([]); + expect(spec.interpretResponse({}, {})).to.deep.equal([]); + expect(spec.interpretResponse({ body: null }, {})).to.deep.equal([]); + }); + + it('should interpret banner bid', function () { + const bidRequest = { + data: { + placements: [ + { bidId: 'resp-bid-1', adFormat: 'banner' } + ] + } + }; + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + id: 'resp-bid-1', + price: 1.23, + crid: 'cr-1', + dealid: 'deal-1', + h: 250, + w: 300, + adm: '

price=1.23
', + nurl: 'https://win.example.com?c=1.23', + adomain: [ 'test.com' ] + }] + }] + } + }; + + const out = spec.interpretResponse(serverResponse, bidRequest); + expect(out).to.have.lengthOf(1); + + const b = out[0]; + expect(b.requestId).to.equal('resp-bid-1'); + expect(b.cpm).to.equal(1.23); + expect(b.mediaType).to.equal('banner'); + expect(b.width).to.equal(300); + expect(b.height).to.equal(250); + expect(b.ad).to.include('1.23'); + expect(b.meta.advertiserDomains[0]).to.equal('test.com'); + }); + + it('should interpret video bid as vastXml', function () { + const bidRequest = { + data: { + placements: [ + { bidId: 'resp-bid-2', adFormat: 'video' } + ] + } + }; + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + id: 'resp-bid-2', + price: 5, + crid: 'cr-v', + dealid: 'deal-v', + h: 360, + w: 640, + adm: '', + nurl: 'https://win.example.com?c=5', + adomain: ['test.com'] + }] + }] + } + }; + + const out = spec.interpretResponse(serverResponse, bidRequest); + expect(out).to.have.lengthOf(1); + + const b = out[0]; + expect(b.mediaType).to.equal('video'); + expect(b.vastXml).to.equal(''); + expect(b.ad).to.equal(undefined); + }); + + it('should interpret native bid into native.ortb', function () { + const bidRequest = { + data: { + placements: [ + { bidId: 'resp-bid-3', adFormat: 'native' } + ] + } + }; + + const nativeAdm = JSON.stringify({ + native: { + assets: [{ id: 1, title: { text: 'Hello' } }], + imptrackers: ['https://imp.example.com/1'], + link: { url: 'https://click.example.com' } + } + }); + + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + id: 'resp-bid-3', + price: 2.5, + crid: 'cr-n', + dealid: 'deal-n', + h: 1, + w: 1, + adm: nativeAdm, + nurl: 'https://win.example.com?c=5', + adomain: ['test.com'] + }] + }] + } + }; + + const out = spec.interpretResponse(serverResponse, bidRequest); + expect(out).to.have.lengthOf(1); + + const b = out[0]; + expect(b.mediaType).to.equal('native'); + expect(b).to.have.property('native'); + expect(b.native).to.have.property('ortb'); + + expect(b.native.ortb.assets).to.deep.equal([{ id: 1, title: { text: 'Hello' } }]); + expect(b.native.ortb.imptrackers).to.deep.equal(['https://imp.example.com/1']); + expect(b.native.ortb.link).to.deep.equal({ url: 'https://click.example.com' }); + }); + + it('should handle multiple seatbids and multiple bids', function () { + const bidRequest = { + data: { + placements: [ + { bidId: 'b1', adFormat: 'banner' }, + { bidId: 'b2', adFormat: 'video' } + ] + } + }; + + const serverResponse = { + body: { + seatbid: [ + { bid: [{ id: 'b1', price: 1, crid: 'c1', dealid: 'd1', h: 250, w: 300, adm: '
', nurl: '', adomain: ['test.com'] }] }, + { bid: [{ id: 'b2', price: 2, crid: 'c2', dealid: 'd2', h: 360, w: 640, adm: '', nurl: '', adomain: ['test.com'] }] } + ] + } + }; + + const out = spec.interpretResponse(serverResponse, bidRequest); + expect(out).to.have.lengthOf(2); + expect(out.find(x => x.requestId === 'b1').mediaType).to.equal('banner'); + expect(out.find(x => x.requestId === 'b2').mediaType).to.equal('video'); + }); + }); +}); diff --git a/test/spec/modules/acuityadsBidAdapter_spec.js b/test/spec/modules/acuityadsBidAdapter_spec.js index e37b6913ced..f7979c969df 100644 --- a/test/spec/modules/acuityadsBidAdapter_spec.js +++ b/test/spec/modules/acuityadsBidAdapter_spec.js @@ -531,7 +531,7 @@ describe('AcuityAdsBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -540,9 +540,7 @@ describe('AcuityAdsBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.admanmedia.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -551,7 +549,7 @@ describe('AcuityAdsBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.admanmedia.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/adclusterBidAdapter_spec.js b/test/spec/modules/adclusterBidAdapter_spec.js new file mode 100644 index 00000000000..d068a0b5944 --- /dev/null +++ b/test/spec/modules/adclusterBidAdapter_spec.js @@ -0,0 +1,415 @@ +// test/spec/modules/adclusterBidAdapter_spec.js + +import { expect } from "chai"; +import { spec } from "modules/adclusterBidAdapter.js"; // adjust path if needed +import { BANNER, VIDEO } from "src/mediaTypes.js"; + +const BIDDER_CODE = "adcluster"; +const ENDPOINT = "https://core.adcluster.com.tr/bid"; + +describe("adclusterBidAdapter", function () { + // ---------- Test Fixtures (immutable) ---------- + const baseBid = Object.freeze({ + bidder: BIDDER_CODE, + bidId: "2f5d", + bidderRequestId: "breq-1", + auctionId: "auc-1", + transactionId: "txn-1", + adUnitCode: "div-1", + adUnitId: "adunit-1", + params: { unitId: "61884b5c-9420-4f15-871f-2dcc2fa1cff5" }, + }); + + const gdprConsent = Object.freeze({ + gdprApplies: true, + consentString: "BOJ/P2HOJ/P2HABABMAAAAAZ+A==", + }); + + const uspConsent = "1---"; + const gpp = "DBABLA.."; + const gppSid = [7, 8]; + + const bidderRequestBase = Object.freeze({ + auctionId: "auc-1", + bidderCode: BIDDER_CODE, + bidderRequestId: "breq-1", + auctionStart: 1111111111111, + timeout: 2000, + start: 1111111111112, + ortb2: { regs: { gpp, gpp_sid: gppSid } }, + gdprConsent, + uspConsent, + }); + + // helpers return fresh objects to avoid cross-test mutation + function mkBidBanner(extra = {}) { + return { + ...baseBid, + mediaTypes: { + banner: { + sizes: [ + [300, 250], + [300, 600], + ], + }, + }, + getFloor: ({ mediaType }) => { + if (mediaType === "banner") return { currency: "USD", floor: 0.5 }; + return { currency: "USD", floor: 0.0 }; + }, + userIdAsEids: [ + { source: "example.com", uids: [{ id: "abc", atype: 1 }] }, + ], + ortb2: { + source: { ext: { schain: { ver: "1.0", complete: 1, nodes: [] } } }, + }, + ...extra, + }; + } + + function mkBidVideo(extra = {}) { + return { + ...baseBid, + mediaTypes: { + video: { + context: "instream", + playerSize: [640, 360], + minduration: 5, + maxduration: 30, + }, + }, + getFloor: ({ mediaType, size }) => { + if (mediaType === "video" && Array.isArray(size)) { + return { currency: "USD", floor: 1.2 }; + } + return { currency: "USD", floor: 0.0 }; + }, + userIdAsEids: [ + { source: "example.com", uids: [{ id: "xyz", atype: 1 }] }, + ], + ortb2: { + source: { ext: { schain: { ver: "1.0", complete: 1, nodes: [] } } }, + }, + ...extra, + }; + } + + describe("isBidRequestValid", function () { + it("returns true when params.unitId is present", function () { + // Arrange + const bid = { ...baseBid }; + + // Act + const valid = spec.isBidRequestValid(bid); + + // Assert + expect(valid).to.equal(true); + }); + + it("returns false when params.unitId is missing", function () { + // Arrange + const bid = { ...baseBid, params: {} }; + + // Act + const valid = spec.isBidRequestValid(bid); + + // Assert + expect(valid).to.equal(false); + }); + + it("returns false when params is undefined", function () { + // Arrange + const bid = { ...baseBid, params: undefined }; + + // Act + const valid = spec.isBidRequestValid(bid); + + // Assert + expect(valid).to.equal(false); + }); + }); + + describe("buildRequests", function () { + it("builds a POST request with JSON body to the right endpoint", function () { + // Arrange + const br = { ...bidderRequestBase }; + const bids = [mkBidBanner(), mkBidVideo()]; + + // Act + const req = spec.buildRequests(bids, br); + + // Assert + expect(req.method).to.equal("POST"); + expect(req.url).to.equal(ENDPOINT); + expect(req.options).to.deep.equal({ contentType: "text/plain" }); + expect(req.data).to.be.an("object"); + expect(req.data.bidderCode).to.equal(BIDDER_CODE); + expect(req.data.auctionId).to.equal(br.auctionId); + expect(req.data.bids).to.be.an("array").with.length(2); + }); + + it("includes privacy signals (GDPR, USP, GPP) when present", function () { + // Arrange + const br = { ...bidderRequestBase }; + const bids = [mkBidBanner()]; + + // Act + const req = spec.buildRequests(bids, br); + + // Assert + const { regs, user } = req.data; + expect(regs).to.be.an("object"); + expect(regs.ext.gdpr).to.equal(1); + expect(user.ext.consent).to.equal(gdprConsent.consentString); + expect(regs.ext.us_privacy).to.equal(uspConsent); + expect(regs.ext.gpp).to.equal(gpp); + expect(regs.ext.gppSid).to.deep.equal(gppSid); + }); + + it("omits privacy fields when not provided", function () { + // Arrange + const minimalBR = { + auctionId: "auc-2", + bidderCode: BIDDER_CODE, + bidderRequestId: "breq-2", + auctionStart: 1, + timeout: 1000, + start: 2, + }; + const bids = [mkBidBanner()]; + + // Act + const req = spec.buildRequests(bids, minimalBR); + + // Assert + // regs.ext should exist but contain no privacy flags + expect(req.data.regs).to.be.an("object"); + expect(req.data.regs.ext).to.deep.equal({}); + // user.ext.consent must be undefined when no GDPR + expect(req.data.user).to.be.an("object"); + expect(req.data.user.ext).to.be.an("object"); + expect(req.data.user.ext.consent).to.be.undefined; + // allow eids to be present (they come from bids) + // don't assert deep-equality on user.ext, just ensure no privacy fields + expect(req.data.user.ext.gdpr).to.be.undefined; + }); + + it("passes userIdAsEids and schain when provided", function () { + // Arrange + const br = { ...bidderRequestBase }; + const bids = [mkBidBanner()]; + + // Act + const req = spec.buildRequests(bids, br); + + // Assert + expect(req.data.user.ext.eids).to.be.an("array").with.length(1); + expect(req.data.source.ext.schain).to.be.an("object"); + }); + + it("sets banner dimensions from first size and includes floors ext", function () { + // Arrange + const br = { ...bidderRequestBase }; + const bids = [mkBidBanner()]; + + // Act + const req = spec.buildRequests(bids, br); + + // Assert + const imp = req.data.bids[0]; + expect(imp.width).to.equal(300); + expect(imp.height).to.equal(250); + expect(imp.ext).to.have.property("floors"); + expect(imp.ext.floors.banner).to.equal(0.5); + }); + + it("sets video sizes from playerSize and includes video floors", function () { + // Arrange + const br = { ...bidderRequestBase }; + const bids = [mkBidVideo()]; + + // Act + const req = spec.buildRequests(bids, br); + + // Assert + const imp = req.data.bids[0]; + expect(imp.width).to.equal(640); + expect(imp.height).to.equal(360); + expect(imp.video).to.be.an("object"); + expect(imp.video.minduration).to.equal(5); + expect(imp.video.maxduration).to.equal(30); + expect(imp.video.ext.context).to.equal("instream"); + expect(imp.video.ext.floor).to.equal(1.2); + expect(imp.ext.floors.video).to.equal(1.2); + }); + + it("gracefully handles missing getFloor", function () { + // Arrange + const br = { ...bidderRequestBase }; + const bids = [mkBidBanner({ getFloor: undefined })]; + + // Act + const req = spec.buildRequests(bids, br); + + // Assert + expect(req.data.bids[0].ext.floors.banner).to.equal(null); + }); + + it("passes previewMediaId when provided", function () { + // Arrange + const br = { ...bidderRequestBase }; + const bids = [ + mkBidVideo({ params: { unitId: "x", previewMediaId: "media-123" } }), + ]; + + // Act + const req = spec.buildRequests(bids, br); + + // Assert + expect(req.data.bids[0].params.previewMediaId).to.equal("media-123"); + }); + }); + + describe("interpretResponse", function () { + it("returns empty array when body is missing or not an array", function () { + // Arrange + const missing = { body: null }; + const notArray = { body: {} }; + + // Act + const out1 = spec.interpretResponse(missing); + const out2 = spec.interpretResponse(notArray); + + // Assert + expect(out1).to.deep.equal([]); + expect(out2).to.deep.equal([]); + }); + + it("maps banner responses to Prebid bids", function () { + // Arrange + const serverBody = [ + { + requestId: "2f5d", + cpm: 1.23, + currency: "USD", + width: 300, + height: 250, + creativeId: "cr-1", + ttl: 300, + netRevenue: true, + mediaType: "banner", + ad: "
creative
", + meta: { advertiserDomains: ["advertiser.com"] }, + }, + ]; + + // Act + const out = spec.interpretResponse({ body: serverBody }); + + // Assert + expect(out).to.have.length(1); + const b = out[0]; + expect(b.requestId).to.equal("2f5d"); + expect(b.cpm).to.equal(1.23); + expect(b.mediaType).to.equal(BANNER); + expect(b.ad).to.be.a("string"); + expect(b.meta.advertiserDomains).to.deep.equal(["advertiser.com"]); + }); + + it("maps video responses to Prebid bids (vastUrl)", function () { + // Arrange + const serverBody = [ + { + requestId: "vid-1", + cpm: 2.5, + currency: "USD", + width: 640, + height: 360, + creativeId: "cr-v", + ttl: 300, + netRevenue: true, + mediaType: "video", + ad: "https://vast.tag/url.xml", + meta: { advertiserDomains: ["brand.com"] }, // mediaType hint optional + }, + ]; + + // Act + const out = spec.interpretResponse({ body: serverBody }); + + // Assert + expect(out).to.have.length(1); + const b = out[0]; + expect(b.requestId).to.equal("vid-1"); + expect(b.mediaType).to.equal(VIDEO); + expect(b.vastUrl).to.equal("https://vast.tag/url.xml"); + expect(b.ad).to.be.undefined; + }); + + it("handles missing meta.advertiserDomains safely", function () { + // Arrange + const serverBody = [ + { + requestId: "2f5d", + cpm: 0.2, + currency: "USD", + width: 300, + height: 250, + creativeId: "cr-2", + ttl: 120, + netRevenue: true, + ad: "
", + meta: {}, + }, + ]; + + // Act + const out = spec.interpretResponse({ body: serverBody }); + + // Assert + expect(out[0].meta.advertiserDomains).to.deep.equal([]); + }); + + it("supports multiple mixed responses", function () { + // Arrange + const serverBody = [ + { + requestId: "b-1", + cpm: 0.8, + currency: "USD", + width: 300, + height: 250, + creativeId: "cr-b", + ttl: 300, + netRevenue: true, + ad: "
banner
", + mediaType: "banner", + meta: { advertiserDomains: [] }, + }, + { + requestId: "v-1", + cpm: 3.1, + currency: "USD", + width: 640, + height: 360, + creativeId: "cr-v", + ttl: 300, + netRevenue: true, + mediaType: "video", + ad: "https://vast.example/vast.xml", + meta: { advertiserDomains: ["x.com"] }, + }, + ]; + + // Act + const out = spec.interpretResponse({ body: serverBody }); + + // Assert + expect(out).to.have.length(2); + const [b, v] = out; + expect(b.mediaType).to.equal(BANNER); + expect(v.mediaType).to.equal(VIDEO); + expect(v.vastUrl).to.match(/^https:\/\/vast\.example/); + }); + }); +}); diff --git a/test/spec/modules/adminationBidAdapter_spec.js b/test/spec/modules/adminationBidAdapter_spec.js new file mode 100644 index 00000000000..077a6b50fed --- /dev/null +++ b/test/spec/modules/adminationBidAdapter_spec.js @@ -0,0 +1,775 @@ +import {expect} from 'chai'; +import { + spec as adapter, + createDomain, + storage, +} from 'modules/adnimationBidAdapter'; +import * as utils from 'src/utils.js'; +import {version} from 'package.json'; +import {useFakeTimers} from 'sinon'; +import {BANNER, VIDEO} from '../../../src/mediaTypes.js'; +import {config} from '../../../src/config.js'; +import { + hashCode, + extractPID, + extractCID, + extractSubDomain, + getStorageItem, + setStorageItem, + tryParseJSON, + getUniqueDealId, +} from '../../../libraries/vidazooUtils/bidderUtils.js'; +import {getGlobal} from '../../../src/prebidGlobal.js'; + +export const TEST_ID_SYSTEMS = ['britepoolid', 'criteoId', 'id5id', 'idl_env', 'lipb', 'netId', 'parrableId', 'pubcid', 'tdid', 'pubProvidedId']; + +const SUB_DOMAIN = 'exchange'; + +const BID = { + 'bidId': '2d52001cabd527', + 'adUnitCode': 'div-gpt-ad-12345-0', + 'params': { + 'subDomain': SUB_DOMAIN, + 'cId': '59db6b3b4ffaa70004f45cdc', + 'pId': '59ac17c192832d0011283fe3', + 'bidFloor': 0.1, + 'ext': { + 'param1': 'loremipsum', + 'param2': 'dolorsitamet' + } + }, + 'placementCode': 'div-gpt-ad-1460505748561-0', + 'transactionId': 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + 'sizes': [[300, 250], [300, 600]], + 'bidderRequestId': '1fdb5ff1b6eaa7', + 'auctionId': 'auction_id', + 'bidRequestsCount': 4, + 'bidderRequestsCount': 3, + 'bidderWinsCount': 1, + 'requestId': 'b0777d85-d061-450e-9bc7-260dd54bbb7a', + 'schain': 'a0819c69-005b-41ed-af06-1be1e0aefefc', + 'mediaTypes': [BANNER], + 'ortb2Imp': { + 'ext': { + 'gpid': '0123456789' + } + } +}; + +const VIDEO_BID = { + 'bidId': '2d52001cabd527', + 'adUnitCode': '63550ad1ff6642d368cba59dh5884270560', + 'bidderRequestId': '12a8ae9ada9c13', + 'transactionId': '56e184c6-bde9-497b-b9b9-cf47a61381ee', + 'auctionId': 'auction_id', + 'bidRequestsCount': 4, + 'bidderRequestsCount': 3, + 'bidderWinsCount': 1, + 'schain': 'a0819c69-005b-41ed-af06-1be1e0aefefc', + 'params': { + 'subDomain': SUB_DOMAIN, + 'cId': '635509f7ff6642d368cb9837', + 'pId': '59ac17c192832d0011283fe3', + 'bidFloor': 0.1 + }, + 'sizes': [[545, 307]], + 'mediaTypes': { + 'video': { + 'playerSize': [[545, 307]], + 'context': 'instream', + 'mimes': [ + 'video/mp4', + 'application/javascript' + ], + 'protocols': [2, 3, 5, 6], + 'maxduration': 60, + 'minduration': 0, + 'startdelay': 0, + 'linearity': 1, + 'api': [2], + 'placement': 1 + } + } +} + +const ORTB2_DEVICE = { + sua: { + 'source': 2, + 'platform': { + 'brand': 'Android', + 'version': ['8', '0', '0'] + }, + 'browsers': [ + {'brand': 'Not_A Brand', 'version': ['99', '0', '0', '0']}, + {'brand': 'Google Chrome', 'version': ['109', '0', '5414', '119']}, + {'brand': 'Chromium', 'version': ['109', '0', '5414', '119']} + ], + 'mobile': 1, + 'model': 'SM-G955U', + 'bitness': '64', + 'architecture': '' + }, + w: 980, + h: 1720, + dnt: 0, + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/125.0.6422.80 Mobile/15E148 Safari/604.1', + language: 'en', + devicetype: 1, + make: 'Apple', + model: 'iPhone 12 Pro Max', + os: 'iOS', + osv: '17.4', + ext: {fiftyonedegrees_deviceId: '17595-133085-133468-18092'}, +}; + +const BIDDER_REQUEST = { + 'gdprConsent': { + 'consentString': 'consent_string', + 'gdprApplies': true + }, + 'gppString': 'gpp_string', + 'gppSid': [7], + 'uspConsent': 'consent_string', + 'refererInfo': { + 'page': 'https://www.greatsite.com', + 'ref': 'https://www.somereferrer.com' + }, + 'ortb2': { + 'site': { + 'content': { + 'language': 'en' + } + }, + 'regs': { + 'gpp': 'gpp_string', + 'gpp_sid': [7], + 'coppa': 0 + }, + 'device': ORTB2_DEVICE, + } +}; + +const SERVER_RESPONSE = { + body: { + cid: 'testcid123', + results: [{ + 'ad': '', + 'price': 0.8, + 'creativeId': '12610997325162499419', + 'exp': 30, + 'width': 300, + 'height': 250, + 'advertiserDomains': ['securepubads.g.doubleclick.net'], + 'cookies': [{ + 'src': 'https://sync.com', + 'type': 'iframe' + }, { + 'src': 'https://sync.com', + 'type': 'img' + }] + }] + } +}; + +const VIDEO_SERVER_RESPONSE = { + body: { + 'cid': '635509f7ff6642d368cb9837', + 'results': [{ + 'ad': '', + 'advertiserDomains': ['adnimation.com'], + 'exp': 60, + 'width': 545, + 'height': 307, + 'mediaType': 'video', + 'creativeId': '12610997325162499419', + 'price': 2, + 'cookies': [] + }] + } +}; + +const ORTB2_OBJ = { + "device": ORTB2_DEVICE, + "regs": {"coppa": 0, "gpp": "gpp_string", "gpp_sid": [7]}, + "site": {"content": {"language": "en"} + } +}; + +const REQUEST = { + data: { + width: 300, + height: 250, + bidId: '2d52001cabd527' + } +}; + +function getTopWindowQueryParams() { + try { + const parsedUrl = utils.parseUrl(window.top.document.URL, {decodeSearchAsString: true}); + return parsedUrl.search; + } catch (e) { + return ''; + } +} + +describe('adnimationBidAdapter', function () { + before(() => config.resetConfig()); + after(() => config.resetConfig()); + + describe('validate spec', function () { + it('exists and is a function', function () { + expect(adapter.isBidRequestValid).to.exist.and.to.be.a('function'); + }); + + it('exists and is a function', function () { + expect(adapter.buildRequests).to.exist.and.to.be.a('function'); + }); + + it('exists and is a function', function () { + expect(adapter.interpretResponse).to.exist.and.to.be.a('function'); + }); + + it('exists and is a function', function () { + expect(adapter.getUserSyncs).to.exist.and.to.be.a('function'); + }); + + it('exists and is a string', function () { + expect(adapter.code).to.exist.and.to.be.a('string'); + }); + + it('exists and contains media types', function () { + expect(adapter.supportedMediaTypes).to.exist.and.to.be.an('array').with.length(2); + expect(adapter.supportedMediaTypes).to.contain.members([BANNER, VIDEO]); + }); + }); + + describe('validate bid requests', function () { + it('should require cId', function () { + const isValid = adapter.isBidRequestValid({ + params: { + pId: 'pid' + } + }); + expect(isValid).to.be.false; + }); + + it('should require pId', function () { + const isValid = adapter.isBidRequestValid({ + params: { + cId: 'cid' + } + }); + expect(isValid).to.be.false; + }); + + it('should validate correctly', function () { + const isValid = adapter.isBidRequestValid({ + params: { + cId: 'cid', + pId: 'pid' + } + }); + expect(isValid).to.be.true; + }); + }); + + describe('build requests', function () { + let sandbox; + before(function () { + getGlobal().bidderSettings = { + adnimation: { + storageAllowed: true + } + }; + sandbox = sinon.createSandbox(); + sandbox.stub(Date, 'now').returns(1000); + }); + + it('should build video request', function () { + const hashUrl = hashCode(BIDDER_REQUEST.refererInfo.page); + config.setConfig({ + bidderTimeout: 3000 + }); + const requests = adapter.buildRequests([VIDEO_BID], BIDDER_REQUEST); + expect(requests).to.have.length(1); + expect(requests[0]).to.deep.equal({ + method: 'POST', + url: `${createDomain(SUB_DOMAIN)}/prebid/multi/635509f7ff6642d368cb9837`, + data: { + adUnitCode: '63550ad1ff6642d368cba59dh5884270560', + bidFloor: 0.1, + bidId: '2d52001cabd527', + bidderVersion: adapter.version, + bidderRequestId: '12a8ae9ada9c13', + cb: 1000, + gdpr: 1, + gdprConsent: 'consent_string', + usPrivacy: 'consent_string', + gppString: 'gpp_string', + gppSid: [7], + prebidVersion: version, + transactionId: '56e184c6-bde9-497b-b9b9-cf47a61381ee', + auctionId: 'auction_id', + bidRequestsCount: 4, + bidderRequestsCount: 3, + bidderWinsCount: 1, + bidderTimeout: 3000, + publisherId: '59ac17c192832d0011283fe3', + url: 'https%3A%2F%2Fwww.greatsite.com', + referrer: 'https://www.somereferrer.com', + res: `${window.top.screen.width}x${window.top.screen.height}`, + schain: VIDEO_BID.schain, + sizes: ['545x307'], + sua: { + 'source': 2, + 'platform': { + 'brand': 'Android', + 'version': ['8', '0', '0'] + }, + 'browsers': [ + {'brand': 'Not_A Brand', 'version': ['99', '0', '0', '0']}, + {'brand': 'Google Chrome', 'version': ['109', '0', '5414', '119']}, + {'brand': 'Chromium', 'version': ['109', '0', '5414', '119']} + ], + 'mobile': 1, + 'model': 'SM-G955U', + 'bitness': '64', + 'architecture': '' + }, + device: ORTB2_DEVICE, + uniqueDealId: `${hashUrl}_${Date.now().toString()}`, + uqs: getTopWindowQueryParams(), + mediaTypes: { + video: { + api: [2], + context: 'instream', + linearity: 1, + maxduration: 60, + mimes: [ + 'video/mp4', + 'application/javascript' + ], + minduration: 0, + placement: 1, + playerSize: [[545, 307]], + protocols: [2, 3, 5, 6], + startdelay: 0 + } + }, + gpid: '', + cat: [], + contentLang: 'en', + contentData: [], + isStorageAllowed: true, + pagecat: [], + ortb2: ORTB2_OBJ, + userData: [], + coppa: 0 + } + }); + }); + + it('should build banner request for each size', function () { + const hashUrl = hashCode(BIDDER_REQUEST.refererInfo.page); + config.setConfig({ + bidderTimeout: 3000 + }); + const requests = adapter.buildRequests([BID], BIDDER_REQUEST); + expect(requests).to.have.length(1); + expect(requests[0]).to.deep.equal({ + method: 'POST', + url: `${createDomain(SUB_DOMAIN)}/prebid/multi/59db6b3b4ffaa70004f45cdc`, + data: { + gdprConsent: 'consent_string', + gdpr: 1, + gppString: 'gpp_string', + gppSid: [7], + usPrivacy: 'consent_string', + transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + auctionId: 'auction_id', + bidRequestsCount: 4, + bidderRequestsCount: 3, + bidderWinsCount: 1, + bidderTimeout: 3000, + bidderRequestId: '1fdb5ff1b6eaa7', + sizes: ['300x250', '300x600'], + sua: { + 'source': 2, + 'platform': { + 'brand': 'Android', + 'version': ['8', '0', '0'] + }, + 'browsers': [ + {'brand': 'Not_A Brand', 'version': ['99', '0', '0', '0']}, + {'brand': 'Google Chrome', 'version': ['109', '0', '5414', '119']}, + {'brand': 'Chromium', 'version': ['109', '0', '5414', '119']} + ], + 'mobile': 1, + 'model': 'SM-G955U', + 'bitness': '64', + 'architecture': '' + }, + device: ORTB2_DEVICE, + url: 'https%3A%2F%2Fwww.greatsite.com', + referrer: 'https://www.somereferrer.com', + cb: 1000, + bidFloor: 0.1, + bidId: '2d52001cabd527', + adUnitCode: 'div-gpt-ad-12345-0', + publisherId: '59ac17c192832d0011283fe3', + uniqueDealId: `${hashUrl}_${Date.now().toString()}`, + bidderVersion: adapter.version, + prebidVersion: version, + schain: BID.schain, + res: `${window.top.screen.width}x${window.top.screen.height}`, + mediaTypes: [BANNER], + gpid: '0123456789', + uqs: getTopWindowQueryParams(), + 'ext.param1': 'loremipsum', + 'ext.param2': 'dolorsitamet', + cat: [], + contentLang: 'en', + contentData: [], + isStorageAllowed: true, + pagecat: [], + ortb2Imp: BID.ortb2Imp, + ortb2: ORTB2_OBJ, + userData: [], + coppa: 0 + } + }); + }); + + after(function () { + getGlobal().bidderSettings = {}; + sandbox.restore(); + }); + }); + describe('getUserSyncs', function () { + it('should have valid user sync with iframeEnabled', function () { + const result = adapter.getUserSyncs({iframeEnabled: true}, [SERVER_RESPONSE]); + + expect(result).to.deep.equal([{ + type: 'iframe', + url: 'https://sync.adnimation.com/api/sync/iframe/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=0' + }]); + }); + + it('should have valid user sync with cid on response', function () { + const result = adapter.getUserSyncs({iframeEnabled: true}, [SERVER_RESPONSE]); + expect(result).to.deep.equal([{ + type: 'iframe', + url: 'https://sync.adnimation.com/api/sync/iframe/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=0' + }]); + }); + + it('should have valid user sync with pixelEnabled', function () { + const result = adapter.getUserSyncs({pixelEnabled: true}, [SERVER_RESPONSE]); + + expect(result).to.deep.equal([{ + 'url': 'https://sync.adnimation.com/api/sync/image/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=0', + 'type': 'image' + }]); + }); + + it('should have valid user sync with coppa on response', function () { + config.setConfig({ + coppa: 1 + }); + const result = adapter.getUserSyncs({iframeEnabled: true}, [SERVER_RESPONSE]); + expect(result).to.deep.equal([{ + type: 'iframe', + url: 'https://sync.adnimation.com/api/sync/iframe/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=1' + }]); + }); + + it('should generate url with consent data', function () { + const gdprConsent = { + gdprApplies: true, + consentString: 'consent_string' + }; + const uspConsent = 'usp_string'; + const gppConsent = { + gppString: 'gpp_string', + applicableSections: [7] + } + + const result = adapter.getUserSyncs({pixelEnabled: true}, [SERVER_RESPONSE], gdprConsent, uspConsent, gppConsent); + + expect(result).to.deep.equal([{ + 'url': 'https://sync.adnimation.com/api/sync/image/?cid=testcid123&gdpr=1&gdpr_consent=consent_string&us_privacy=usp_string&coppa=1&gpp=gpp_string&gpp_sid=7', + 'type': 'image' + }]); + }); + }); + + describe('interpret response', function () { + it('should return empty array when there is no response', function () { + const responses = adapter.interpretResponse(null); + expect(responses).to.be.empty; + }); + + it('should return empty array when there is no ad', function () { + const responses = adapter.interpretResponse({price: 1, ad: ''}); + expect(responses).to.be.empty; + }); + + it('should return empty array when there is no price', function () { + const responses = adapter.interpretResponse({price: null, ad: 'great ad'}); + expect(responses).to.be.empty; + }); + + it('should return an array of interpreted banner responses', function () { + const responses = adapter.interpretResponse(SERVER_RESPONSE, REQUEST); + expect(responses).to.have.length(1); + expect(responses[0]).to.deep.equal({ + requestId: '2d52001cabd527', + cpm: 0.8, + width: 300, + height: 250, + creativeId: '12610997325162499419', + currency: 'USD', + netRevenue: true, + ttl: 30, + ad: '', + meta: { + advertiserDomains: ['securepubads.g.doubleclick.net'] + } + }); + }); + + it('should get meta from response metaData', function () { + const serverResponse = utils.deepClone(SERVER_RESPONSE); + serverResponse.body.results[0].metaData = { + advertiserDomains: ['adnimation.com'], + agencyName: 'Agency Name', + }; + const responses = adapter.interpretResponse(serverResponse, REQUEST); + expect(responses[0].meta).to.deep.equal({ + advertiserDomains: ['adnimation.com'], + agencyName: 'Agency Name' + }); + }); + + it('should return an array of interpreted video responses', function () { + const responses = adapter.interpretResponse(VIDEO_SERVER_RESPONSE, REQUEST); + expect(responses).to.have.length(1); + expect(responses[0]).to.deep.equal({ + requestId: '2d52001cabd527', + cpm: 2, + width: 545, + height: 307, + mediaType: 'video', + creativeId: '12610997325162499419', + currency: 'USD', + netRevenue: true, + ttl: 60, + vastXml: '', + meta: { + advertiserDomains: ['adnimation.com'] + } + }); + }); + + it('should take default TTL', function () { + const serverResponse = utils.deepClone(SERVER_RESPONSE); + delete serverResponse.body.results[0].exp; + const responses = adapter.interpretResponse(serverResponse, REQUEST); + expect(responses).to.have.length(1); + expect(responses[0].ttl).to.equal(300); + }); + }); + + describe('user id system', function () { + TEST_ID_SYSTEMS.forEach((idSystemProvider) => { + const id = Date.now().toString(); + const bid = utils.deepClone(BID); + + const userId = (function () { + switch (idSystemProvider) { + case 'lipb': + return {lipbid: id}; + case 'id5id': + return {uid: id}; + default: + return id; + } + })(); + + bid.userId = { + [idSystemProvider]: userId + }; + + it(`should include 'uid.${idSystemProvider}' in request params`, function () { + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data[`uid.${idSystemProvider}`]).to.equal(id); + }); + }); + // testing bid.userIdAsEids handling + it("should include user ids from bid.userIdAsEids (length=1)", function() { + const bid = utils.deepClone(BID); + bid.userIdAsEids = [ + { + "source": "audigent.com", + "uids": [{"id": "fakeidi6j6dlc6e"}] + } + ] + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.audigent.com']).to.equal("fakeidi6j6dlc6e"); + }) + it("should include user ids from bid.userIdAsEids (length=2)", function() { + const bid = utils.deepClone(BID); + bid.userIdAsEids = [ + { + "source": "audigent.com", + "uids": [{"id": "fakeidi6j6dlc6e"}] + }, + { + "source": "rwdcntrl.net", + "uids": [{"id": "fakeid6f35197d5c", "atype": 1}] + } + ] + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.audigent.com']).to.equal("fakeidi6j6dlc6e"); + expect(requests[0].data['uid.rwdcntrl.net']).to.equal("fakeid6f35197d5c"); + }) + // testing user.ext.eid handling + it("should include user ids from user.ext.eid (length=1)", function() { + const bid = utils.deepClone(BID); + bid.user = { + ext: { + eids: [ + { + "source": "pubcid.org", + "uids": [{"id": "fakeid8888dlc6e"}] + } + ] + } + } + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.pubcid.org']).to.equal("fakeid8888dlc6e"); + }) + it("should include user ids from user.ext.eid (length=2)", function() { + const bid = utils.deepClone(BID); + bid.user = { + ext: { + eids: [ + { + "source": "pubcid.org", + "uids": [{"id": "fakeid8888dlc6e"}] + }, + { + "source": "adserver.org", + "uids": [{"id": "fakeid495ff1"}] + } + ] + } + } + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.pubcid.org']).to.equal("fakeid8888dlc6e"); + expect(requests[0].data['uid.adserver.org']).to.equal("fakeid495ff1"); + }) + }); + + describe('alternate param names extractors', function () { + it('should return undefined when param not supported', function () { + const cid = extractCID({'c_id': '1'}); + const pid = extractPID({'p_id': '1'}); + const subDomain = extractSubDomain({'sub_domain': 'prebid'}); + expect(cid).to.be.undefined; + expect(pid).to.be.undefined; + expect(subDomain).to.be.undefined; + }); + + it('should return value when param supported', function () { + const cid = extractCID({'cID': '1'}); + const pid = extractPID({'Pid': '2'}); + const subDomain = extractSubDomain({'subDOMAIN': 'prebid'}); + expect(cid).to.be.equal('1'); + expect(pid).to.be.equal('2'); + expect(subDomain).to.be.equal('prebid'); + }); + }); + + describe('unique deal id', function () { + before(function () { + getGlobal().bidderSettings = { + adnimation: { + storageAllowed: true + } + }; + }); + after(function () { + getGlobal().bidderSettings = {}; + }); + const key = 'myKey'; + let uniqueDealId; + beforeEach(() => { + uniqueDealId = getUniqueDealId(storage, key, 0); + }) + + it('should get current unique deal id', function (done) { + // waiting some time so `now` will become past + setTimeout(() => { + const current = getUniqueDealId(storage, key); + expect(current).to.be.equal(uniqueDealId); + done(); + }, 200); + }); + + it('should get new unique deal id on expiration', function (done) { + setTimeout(() => { + const current = getUniqueDealId(storage, key, 100); + expect(current).to.not.be.equal(uniqueDealId); + done(); + }, 200) + }); + }); + + describe('storage utils', function () { + before(function () { + getGlobal().bidderSettings = { + adnimation: { + storageAllowed: true + } + }; + }); + after(function () { + getGlobal().bidderSettings = {}; + }); + it('should get value from storage with create param', function () { + const now = Date.now(); + const clock = useFakeTimers({ + shouldAdvanceTime: true, + now + }); + setStorageItem(storage, 'myKey', 2020); + const {value, created} = getStorageItem(storage, 'myKey'); + expect(created).to.be.equal(now); + expect(value).to.be.equal(2020); + expect(typeof value).to.be.equal('number'); + expect(typeof created).to.be.equal('number'); + clock.restore(); + }); + + it('should get external stored value', function () { + const value = 'superman' + window.localStorage.setItem('myExternalKey', value); + const item = getStorageItem(storage, 'myExternalKey'); + expect(item).to.be.equal(value); + }); + + it('should parse JSON value', function () { + const data = JSON.stringify({event: 'send'}); + const {event} = tryParseJSON(data); + expect(event).to.be.equal('send'); + }); + + it('should get original value on parse fail', function () { + const value = 21; + const parsed = tryParseJSON(value); + expect(typeof parsed).to.be.equal('number'); + expect(parsed).to.be.equal(value); + }); + }); +}); diff --git a/test/spec/modules/adprimeBidAdapter_spec.js b/test/spec/modules/adprimeBidAdapter_spec.js index a917d85d6ab..d49281d79c6 100644 --- a/test/spec/modules/adprimeBidAdapter_spec.js +++ b/test/spec/modules/adprimeBidAdapter_spec.js @@ -436,7 +436,7 @@ describe('AdprimeBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -445,9 +445,7 @@ describe('AdprimeBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.adprime.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -456,7 +454,7 @@ describe('AdprimeBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.adprime.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/ads_interactiveBidAdapter_spec.js b/test/spec/modules/ads_interactiveBidAdapter_spec.js index c16f5a5a7b5..c3d27008182 100644 --- a/test/spec/modules/ads_interactiveBidAdapter_spec.js +++ b/test/spec/modules/ads_interactiveBidAdapter_spec.js @@ -482,7 +482,7 @@ describe('AdsInteractiveBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -491,9 +491,7 @@ describe('AdsInteractiveBidAdapter', function () { expect(syncData[0].url).to.equal('https://cstb.adsinteractive.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -502,7 +500,7 @@ describe('AdsInteractiveBidAdapter', function () { expect(syncData[0].url).to.equal('https://cstb.adsinteractive.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/apesterBidAdapter_spec.js b/test/spec/modules/apesterBidAdapter_spec.js new file mode 100644 index 00000000000..25aa0d4003c --- /dev/null +++ b/test/spec/modules/apesterBidAdapter_spec.js @@ -0,0 +1,775 @@ +import {expect} from 'chai'; +import { + spec as adapter, + createDomain, + storage, +} from 'modules/apesterBidAdapter'; +import * as utils from 'src/utils.js'; +import {version} from 'package.json'; +import {useFakeTimers} from 'sinon'; +import {BANNER, VIDEO} from '../../../src/mediaTypes.js'; +import {config} from '../../../src/config.js'; +import { + hashCode, + extractPID, + extractCID, + extractSubDomain, + getStorageItem, + setStorageItem, + tryParseJSON, + getUniqueDealId, +} from '../../../libraries/vidazooUtils/bidderUtils.js'; +import {getGlobal} from '../../../src/prebidGlobal.js'; + +export const TEST_ID_SYSTEMS = ['britepoolid', 'criteoId', 'id5id', 'idl_env', 'lipb', 'netId', 'parrableId', 'pubcid', 'tdid', 'pubProvidedId']; + +const SUB_DOMAIN = 'exchange'; + +const BID = { + 'bidId': '2d52001cabd527', + 'adUnitCode': 'div-gpt-ad-12345-0', + 'params': { + 'subDomain': SUB_DOMAIN, + 'cId': '59db6b3b4ffaa70004f45cdc', + 'pId': '59ac17c192832d0011283fe3', + 'bidFloor': 0.1, + 'ext': { + 'param1': 'loremipsum', + 'param2': 'dolorsitamet' + } + }, + 'placementCode': 'div-gpt-ad-1460505748561-0', + 'transactionId': 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + 'sizes': [[300, 250], [300, 600]], + 'bidderRequestId': '1fdb5ff1b6eaa7', + 'auctionId': 'auction_id', + 'bidRequestsCount': 4, + 'bidderRequestsCount': 3, + 'bidderWinsCount': 1, + 'requestId': 'b0777d85-d061-450e-9bc7-260dd54bbb7a', + 'schain': 'a0819c69-005b-41ed-af06-1be1e0aefefc', + 'mediaTypes': [BANNER], + 'ortb2Imp': { + 'ext': { + 'gpid': '0123456789' + } + } +}; + +const VIDEO_BID = { + 'bidId': '2d52001cabd527', + 'adUnitCode': '63550ad1ff6642d368cba59dh5884270560', + 'bidderRequestId': '12a8ae9ada9c13', + 'transactionId': '56e184c6-bde9-497b-b9b9-cf47a61381ee', + 'auctionId': 'auction_id', + 'bidRequestsCount': 4, + 'bidderRequestsCount': 3, + 'bidderWinsCount': 1, + 'schain': 'a0819c69-005b-41ed-af06-1be1e0aefefc', + 'params': { + 'subDomain': SUB_DOMAIN, + 'cId': '635509f7ff6642d368cb9837', + 'pId': '59ac17c192832d0011283fe3', + 'bidFloor': 0.1 + }, + 'sizes': [[545, 307]], + 'mediaTypes': { + 'video': { + 'playerSize': [[545, 307]], + 'context': 'instream', + 'mimes': [ + 'video/mp4', + 'application/javascript' + ], + 'protocols': [2, 3, 5, 6], + 'maxduration': 60, + 'minduration': 0, + 'startdelay': 0, + 'linearity': 1, + 'api': [2], + 'placement': 1 + } + } +} + +const ORTB2_DEVICE = { + sua: { + 'source': 2, + 'platform': { + 'brand': 'Android', + 'version': ['8', '0', '0'] + }, + 'browsers': [ + {'brand': 'Not_A Brand', 'version': ['99', '0', '0', '0']}, + {'brand': 'Google Chrome', 'version': ['109', '0', '5414', '119']}, + {'brand': 'Chromium', 'version': ['109', '0', '5414', '119']} + ], + 'mobile': 1, + 'model': 'SM-G955U', + 'bitness': '64', + 'architecture': '' + }, + w: 980, + h: 1720, + dnt: 0, + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/125.0.6422.80 Mobile/15E148 Safari/604.1', + language: 'en', + devicetype: 1, + make: 'Apple', + model: 'iPhone 12 Pro Max', + os: 'iOS', + osv: '17.4', + ext: {fiftyonedegrees_deviceId: '17595-133085-133468-18092'}, +}; + +const BIDDER_REQUEST = { + 'gdprConsent': { + 'consentString': 'consent_string', + 'gdprApplies': true + }, + 'gppString': 'gpp_string', + 'gppSid': [7], + 'uspConsent': 'consent_string', + 'refererInfo': { + 'page': 'https://www.greatsite.com', + 'ref': 'https://www.somereferrer.com' + }, + 'ortb2': { + 'site': { + 'content': { + 'language': 'en' + } + }, + 'regs': { + 'gpp': 'gpp_string', + 'gpp_sid': [7], + 'coppa': 0 + }, + 'device': ORTB2_DEVICE, + } +}; + +const SERVER_RESPONSE = { + body: { + cid: 'testcid123', + results: [{ + 'ad': '', + 'price': 0.8, + 'creativeId': '12610997325162499419', + 'exp': 30, + 'width': 300, + 'height': 250, + 'advertiserDomains': ['securepubads.g.doubleclick.net'], + 'cookies': [{ + 'src': 'https://sync.com', + 'type': 'iframe' + }, { + 'src': 'https://sync.com', + 'type': 'img' + }] + }] + } +}; + +const VIDEO_SERVER_RESPONSE = { + body: { + 'cid': '635509f7ff6642d368cb9837', + 'results': [{ + 'ad': '', + 'advertiserDomains': ['bidder.apester.com'], + 'exp': 60, + 'width': 545, + 'height': 307, + 'mediaType': 'video', + 'creativeId': '12610997325162499419', + 'price': 2, + 'cookies': [] + }] + } +}; + +const ORTB2_OBJ = { + "device": ORTB2_DEVICE, + "regs": {"coppa": 0, "gpp": "gpp_string", "gpp_sid": [7]}, + "site": {"content": {"language": "en"} + } +}; + +const REQUEST = { + data: { + width: 300, + height: 250, + bidId: '2d52001cabd527' + } +}; + +function getTopWindowQueryParams() { + try { + const parsedUrl = utils.parseUrl(window.top.document.URL, {decodeSearchAsString: true}); + return parsedUrl.search; + } catch (e) { + return ''; + } +} + +describe('apesterBidAdapter', function () { + before(() => config.resetConfig()); + after(() => config.resetConfig()); + + describe('validate spec', function () { + it('exists and is a function', function () { + expect(adapter.isBidRequestValid).to.exist.and.to.be.a('function'); + }); + + it('exists and is a function', function () { + expect(adapter.buildRequests).to.exist.and.to.be.a('function'); + }); + + it('exists and is a function', function () { + expect(adapter.interpretResponse).to.exist.and.to.be.a('function'); + }); + + it('exists and is a function', function () { + expect(adapter.getUserSyncs).to.exist.and.to.be.a('function'); + }); + + it('exists and is a string', function () { + expect(adapter.code).to.exist.and.to.be.a('string'); + }); + + it('exists and contains media types', function () { + expect(adapter.supportedMediaTypes).to.exist.and.to.be.an('array').with.length(2); + expect(adapter.supportedMediaTypes).to.contain.members([BANNER, VIDEO]); + }); + }); + + describe('validate bid requests', function () { + it('should require cId', function () { + const isValid = adapter.isBidRequestValid({ + params: { + pId: 'pid' + } + }); + expect(isValid).to.be.false; + }); + + it('should require pId', function () { + const isValid = adapter.isBidRequestValid({ + params: { + cId: 'cid' + } + }); + expect(isValid).to.be.false; + }); + + it('should validate correctly', function () { + const isValid = adapter.isBidRequestValid({ + params: { + cId: 'cid', + pId: 'pid' + } + }); + expect(isValid).to.be.true; + }); + }); + + describe('build requests', function () { + let sandbox; + before(function () { + getGlobal().bidderSettings = { + apester: { + storageAllowed: true + } + }; + sandbox = sinon.createSandbox(); + sandbox.stub(Date, 'now').returns(1000); + }); + + it('should build video request', function () { + const hashUrl = hashCode(BIDDER_REQUEST.refererInfo.page); + config.setConfig({ + bidderTimeout: 3000 + }); + const requests = adapter.buildRequests([VIDEO_BID], BIDDER_REQUEST); + expect(requests).to.have.length(1); + expect(requests[0]).to.deep.equal({ + method: 'POST', + url: `${createDomain(SUB_DOMAIN)}/prebid/multi/635509f7ff6642d368cb9837`, + data: { + adUnitCode: '63550ad1ff6642d368cba59dh5884270560', + bidFloor: 0.1, + bidId: '2d52001cabd527', + bidderVersion: adapter.version, + bidderRequestId: '12a8ae9ada9c13', + cb: 1000, + gdpr: 1, + gdprConsent: 'consent_string', + usPrivacy: 'consent_string', + gppString: 'gpp_string', + gppSid: [7], + prebidVersion: version, + transactionId: '56e184c6-bde9-497b-b9b9-cf47a61381ee', + auctionId: 'auction_id', + bidRequestsCount: 4, + bidderRequestsCount: 3, + bidderWinsCount: 1, + bidderTimeout: 3000, + publisherId: '59ac17c192832d0011283fe3', + url: 'https%3A%2F%2Fwww.greatsite.com', + referrer: 'https://www.somereferrer.com', + res: `${window.top.screen.width}x${window.top.screen.height}`, + schain: VIDEO_BID.schain, + sizes: ['545x307'], + sua: { + 'source': 2, + 'platform': { + 'brand': 'Android', + 'version': ['8', '0', '0'] + }, + 'browsers': [ + {'brand': 'Not_A Brand', 'version': ['99', '0', '0', '0']}, + {'brand': 'Google Chrome', 'version': ['109', '0', '5414', '119']}, + {'brand': 'Chromium', 'version': ['109', '0', '5414', '119']} + ], + 'mobile': 1, + 'model': 'SM-G955U', + 'bitness': '64', + 'architecture': '' + }, + device: ORTB2_DEVICE, + uniqueDealId: `${hashUrl}_${Date.now().toString()}`, + uqs: getTopWindowQueryParams(), + mediaTypes: { + video: { + api: [2], + context: 'instream', + linearity: 1, + maxduration: 60, + mimes: [ + 'video/mp4', + 'application/javascript' + ], + minduration: 0, + placement: 1, + playerSize: [[545, 307]], + protocols: [2, 3, 5, 6], + startdelay: 0 + } + }, + gpid: '', + cat: [], + contentLang: 'en', + contentData: [], + isStorageAllowed: true, + pagecat: [], + ortb2: ORTB2_OBJ, + userData: [], + coppa: 0 + } + }); + }); + + it('should build banner request for each size', function () { + const hashUrl = hashCode(BIDDER_REQUEST.refererInfo.page); + config.setConfig({ + bidderTimeout: 3000 + }); + const requests = adapter.buildRequests([BID], BIDDER_REQUEST); + expect(requests).to.have.length(1); + expect(requests[0]).to.deep.equal({ + method: 'POST', + url: `${createDomain(SUB_DOMAIN)}/prebid/multi/59db6b3b4ffaa70004f45cdc`, + data: { + gdprConsent: 'consent_string', + gdpr: 1, + gppString: 'gpp_string', + gppSid: [7], + usPrivacy: 'consent_string', + transactionId: 'c881914b-a3b5-4ecf-ad9c-1c2f37c6aabf', + auctionId: 'auction_id', + bidRequestsCount: 4, + bidderRequestsCount: 3, + bidderWinsCount: 1, + bidderTimeout: 3000, + bidderRequestId: '1fdb5ff1b6eaa7', + sizes: ['300x250', '300x600'], + sua: { + 'source': 2, + 'platform': { + 'brand': 'Android', + 'version': ['8', '0', '0'] + }, + 'browsers': [ + {'brand': 'Not_A Brand', 'version': ['99', '0', '0', '0']}, + {'brand': 'Google Chrome', 'version': ['109', '0', '5414', '119']}, + {'brand': 'Chromium', 'version': ['109', '0', '5414', '119']} + ], + 'mobile': 1, + 'model': 'SM-G955U', + 'bitness': '64', + 'architecture': '' + }, + device: ORTB2_DEVICE, + url: 'https%3A%2F%2Fwww.greatsite.com', + referrer: 'https://www.somereferrer.com', + cb: 1000, + bidFloor: 0.1, + bidId: '2d52001cabd527', + adUnitCode: 'div-gpt-ad-12345-0', + publisherId: '59ac17c192832d0011283fe3', + uniqueDealId: `${hashUrl}_${Date.now().toString()}`, + bidderVersion: adapter.version, + prebidVersion: version, + schain: BID.schain, + res: `${window.top.screen.width}x${window.top.screen.height}`, + mediaTypes: [BANNER], + gpid: '0123456789', + uqs: getTopWindowQueryParams(), + 'ext.param1': 'loremipsum', + 'ext.param2': 'dolorsitamet', + cat: [], + contentLang: 'en', + contentData: [], + isStorageAllowed: true, + pagecat: [], + ortb2Imp: BID.ortb2Imp, + ortb2: ORTB2_OBJ, + userData: [], + coppa: 0 + } + }); + }); + + after(function () { + getGlobal().bidderSettings = {}; + sandbox.restore(); + }); + }); + describe('getUserSyncs', function () { + it('should have valid user sync with iframeEnabled', function () { + const result = adapter.getUserSyncs({iframeEnabled: true}, [SERVER_RESPONSE]); + + expect(result).to.deep.equal([{ + type: 'iframe', + url: 'https://sync.apester.com/api/sync/iframe/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=0' + }]); + }); + + it('should have valid user sync with cid on response', function () { + const result = adapter.getUserSyncs({iframeEnabled: true}, [SERVER_RESPONSE]); + expect(result).to.deep.equal([{ + type: 'iframe', + url: 'https://sync.apester.com/api/sync/iframe/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=0' + }]); + }); + + it('should have valid user sync with pixelEnabled', function () { + const result = adapter.getUserSyncs({pixelEnabled: true}, [SERVER_RESPONSE]); + + expect(result).to.deep.equal([{ + 'url': 'https://sync.apester.com/api/sync/image/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=0', + 'type': 'image' + }]); + }); + + it('should have valid user sync with coppa on response', function () { + config.setConfig({ + coppa: 1 + }); + const result = adapter.getUserSyncs({iframeEnabled: true}, [SERVER_RESPONSE]); + expect(result).to.deep.equal([{ + type: 'iframe', + url: 'https://sync.apester.com/api/sync/iframe/?cid=testcid123&gdpr=0&gdpr_consent=&us_privacy=&coppa=1' + }]); + }); + + it('should generate url with consent data', function () { + const gdprConsent = { + gdprApplies: true, + consentString: 'consent_string' + }; + const uspConsent = 'usp_string'; + const gppConsent = { + gppString: 'gpp_string', + applicableSections: [7] + } + + const result = adapter.getUserSyncs({pixelEnabled: true}, [SERVER_RESPONSE], gdprConsent, uspConsent, gppConsent); + + expect(result).to.deep.equal([{ + 'url': 'https://sync.apester.com/api/sync/image/?cid=testcid123&gdpr=1&gdpr_consent=consent_string&us_privacy=usp_string&coppa=1&gpp=gpp_string&gpp_sid=7', + 'type': 'image' + }]); + }); + }); + + describe('interpret response', function () { + it('should return empty array when there is no response', function () { + const responses = adapter.interpretResponse(null); + expect(responses).to.be.empty; + }); + + it('should return empty array when there is no ad', function () { + const responses = adapter.interpretResponse({price: 1, ad: ''}); + expect(responses).to.be.empty; + }); + + it('should return empty array when there is no price', function () { + const responses = adapter.interpretResponse({price: null, ad: 'great ad'}); + expect(responses).to.be.empty; + }); + + it('should return an array of interpreted banner responses', function () { + const responses = adapter.interpretResponse(SERVER_RESPONSE, REQUEST); + expect(responses).to.have.length(1); + expect(responses[0]).to.deep.equal({ + requestId: '2d52001cabd527', + cpm: 0.8, + width: 300, + height: 250, + creativeId: '12610997325162499419', + currency: 'USD', + netRevenue: true, + ttl: 30, + ad: '', + meta: { + advertiserDomains: ['securepubads.g.doubleclick.net'] + } + }); + }); + + it('should get meta from response metaData', function () { + const serverResponse = utils.deepClone(SERVER_RESPONSE); + serverResponse.body.results[0].metaData = { + advertiserDomains: ['bidder.apester.com'], + agencyName: 'Agency Name', + }; + const responses = adapter.interpretResponse(serverResponse, REQUEST); + expect(responses[0].meta).to.deep.equal({ + advertiserDomains: ['bidder.apester.com'], + agencyName: 'Agency Name' + }); + }); + + it('should return an array of interpreted video responses', function () { + const responses = adapter.interpretResponse(VIDEO_SERVER_RESPONSE, REQUEST); + expect(responses).to.have.length(1); + expect(responses[0]).to.deep.equal({ + requestId: '2d52001cabd527', + cpm: 2, + width: 545, + height: 307, + mediaType: 'video', + creativeId: '12610997325162499419', + currency: 'USD', + netRevenue: true, + ttl: 60, + vastXml: '', + meta: { + advertiserDomains: ['bidder.apester.com'] + } + }); + }); + + it('should take default TTL', function () { + const serverResponse = utils.deepClone(SERVER_RESPONSE); + delete serverResponse.body.results[0].exp; + const responses = adapter.interpretResponse(serverResponse, REQUEST); + expect(responses).to.have.length(1); + expect(responses[0].ttl).to.equal(300); + }); + }); + + describe('user id system', function () { + TEST_ID_SYSTEMS.forEach((idSystemProvider) => { + const id = Date.now().toString(); + const bid = utils.deepClone(BID); + + const userId = (function () { + switch (idSystemProvider) { + case 'lipb': + return {lipbid: id}; + case 'id5id': + return {uid: id}; + default: + return id; + } + })(); + + bid.userId = { + [idSystemProvider]: userId + }; + + it(`should include 'uid.${idSystemProvider}' in request params`, function () { + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data[`uid.${idSystemProvider}`]).to.equal(id); + }); + }); + // testing bid.userIdAsEids handling + it("should include user ids from bid.userIdAsEids (length=1)", function() { + const bid = utils.deepClone(BID); + bid.userIdAsEids = [ + { + "source": "audigent.com", + "uids": [{"id": "fakeidi6j6dlc6e"}] + } + ] + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.audigent.com']).to.equal("fakeidi6j6dlc6e"); + }) + it("should include user ids from bid.userIdAsEids (length=2)", function() { + const bid = utils.deepClone(BID); + bid.userIdAsEids = [ + { + "source": "audigent.com", + "uids": [{"id": "fakeidi6j6dlc6e"}] + }, + { + "source": "rwdcntrl.net", + "uids": [{"id": "fakeid6f35197d5c", "atype": 1}] + } + ] + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.audigent.com']).to.equal("fakeidi6j6dlc6e"); + expect(requests[0].data['uid.rwdcntrl.net']).to.equal("fakeid6f35197d5c"); + }) + // testing user.ext.eid handling + it("should include user ids from user.ext.eid (length=1)", function() { + const bid = utils.deepClone(BID); + bid.user = { + ext: { + eids: [ + { + "source": "pubcid.org", + "uids": [{"id": "fakeid8888dlc6e"}] + } + ] + } + } + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.pubcid.org']).to.equal("fakeid8888dlc6e"); + }) + it("should include user ids from user.ext.eid (length=2)", function() { + const bid = utils.deepClone(BID); + bid.user = { + ext: { + eids: [ + { + "source": "pubcid.org", + "uids": [{"id": "fakeid8888dlc6e"}] + }, + { + "source": "adserver.org", + "uids": [{"id": "fakeid495ff1"}] + } + ] + } + } + const requests = adapter.buildRequests([bid], BIDDER_REQUEST); + expect(requests[0].data['uid.pubcid.org']).to.equal("fakeid8888dlc6e"); + expect(requests[0].data['uid.adserver.org']).to.equal("fakeid495ff1"); + }) + }); + + describe('alternate param names extractors', function () { + it('should return undefined when param not supported', function () { + const cid = extractCID({'c_id': '1'}); + const pid = extractPID({'p_id': '1'}); + const subDomain = extractSubDomain({'sub_domain': 'prebid'}); + expect(cid).to.be.undefined; + expect(pid).to.be.undefined; + expect(subDomain).to.be.undefined; + }); + + it('should return value when param supported', function () { + const cid = extractCID({'cID': '1'}); + const pid = extractPID({'Pid': '2'}); + const subDomain = extractSubDomain({'subDOMAIN': 'prebid'}); + expect(cid).to.be.equal('1'); + expect(pid).to.be.equal('2'); + expect(subDomain).to.be.equal('prebid'); + }); + }); + + describe('unique deal id', function () { + before(function () { + getGlobal().bidderSettings = { + apester: { + storageAllowed: true + } + }; + }); + after(function () { + getGlobal().bidderSettings = {}; + }); + const key = 'myKey'; + let uniqueDealId; + beforeEach(() => { + uniqueDealId = getUniqueDealId(storage, key, 0); + }) + + it('should get current unique deal id', function (done) { + // waiting some time so `now` will become past + setTimeout(() => { + const current = getUniqueDealId(storage, key); + expect(current).to.be.equal(uniqueDealId); + done(); + }, 200); + }); + + it('should get new unique deal id on expiration', function (done) { + setTimeout(() => { + const current = getUniqueDealId(storage, key, 100); + expect(current).to.not.be.equal(uniqueDealId); + done(); + }, 200) + }); + }); + + describe('storage utils', function () { + before(function () { + getGlobal().bidderSettings = { + apester: { + storageAllowed: true + } + }; + }); + after(function () { + getGlobal().bidderSettings = {}; + }); + it('should get value from storage with create param', function () { + const now = Date.now(); + const clock = useFakeTimers({ + shouldAdvanceTime: true, + now + }); + setStorageItem(storage, 'myKey', 2020); + const {value, created} = getStorageItem(storage, 'myKey'); + expect(created).to.be.equal(now); + expect(value).to.be.equal(2020); + expect(typeof value).to.be.equal('number'); + expect(typeof created).to.be.equal('number'); + clock.restore(); + }); + + it('should get external stored value', function () { + const value = 'superman' + window.localStorage.setItem('myExternalKey', value); + const item = getStorageItem(storage, 'myExternalKey'); + expect(item).to.be.equal(value); + }); + + it('should parse JSON value', function () { + const data = JSON.stringify({event: 'send'}); + const {event} = tryParseJSON(data); + expect(event).to.be.equal('send'); + }); + + it('should get original value on parse fail', function () { + const value = 21; + const parsed = tryParseJSON(value); + expect(typeof parsed).to.be.equal('number'); + expect(parsed).to.be.equal(value); + }); + }); +}); diff --git a/test/spec/modules/appStockSSPBidAdapter_spec.js b/test/spec/modules/appStockSSPBidAdapter_spec.js index 7ee7d739dd3..64dac5fe8e7 100644 --- a/test/spec/modules/appStockSSPBidAdapter_spec.js +++ b/test/spec/modules/appStockSSPBidAdapter_spec.js @@ -499,7 +499,7 @@ describe('AppStockSSPBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -508,9 +508,7 @@ describe('AppStockSSPBidAdapter', function () { expect(syncData[0].url).to.equal('https://csync.al-ad.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -519,7 +517,7 @@ describe('AppStockSSPBidAdapter', function () { expect(syncData[0].url).to.equal('https://csync.al-ad.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/beopBidAdapter_spec.js b/test/spec/modules/beopBidAdapter_spec.js index 08e14b76182..3e4a439286d 100644 --- a/test/spec/modules/beopBidAdapter_spec.js +++ b/test/spec/modules/beopBidAdapter_spec.js @@ -389,6 +389,107 @@ describe('BeOp Bid Adapter tests', () => { expect(payload.fg).to.exist; }) }) + describe('slot name normalization', function () { + it('should preserve non-GPT adUnitCode unchanged (case-sensitive)', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = '/Network/TopBanner'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('/Network/TopBanner'); + }); + + it('should preserve mixed-case custom adUnitCode unchanged', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'InArticleSlot'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('InArticleSlot'); + }); + + it('should normalize GPT auto-generated adUnitCode by removing prefix', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-article_top'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('article_top'); + }); + + it('should remove long numeric suffix from GPT adUnitCode', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-sidebar_123456'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('sidebar'); + }); + + it('should remove timestamp-like suffix from GPT adUnitCode', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-header-1678459238475'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('header'); + }); + + it('should preserve short numeric suffix in GPT adUnitCode', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-topbanner-1'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('topbanner-1'); + }); + + it('should preserve short numeric suffix like -2 in GPT adUnitCode', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-article_slot-2'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('article_slot-2'); + }); + + it('should handle GPT adUnitCode with underscore separator', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad_content_main'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('content_main'); + }); + + it('should return undefined for too short GPT slot names', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-ab'; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.be.undefined; + }); + + it('should prefer gpid over adUnitCode', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-fallback'; + bid.ortb2Imp = { ext: { gpid: '/123/preferred-slot' } }; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('/123/preferred-slot'); + }); + + it('should prefer adslot over adUnitCode', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-fallback'; + bid.ortb2Imp = { ext: { data: { adslot: '/456/adslot-name' } } }; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('/456/adslot-name'); + }); + + it('should prefer tagid over normalized adUnitCode', function () { + const bid = Object.assign({}, validBid); + bid.adUnitCode = 'div-gpt-ad-fallback'; + bid.ortb2Imp = { tagid: 'custom-tagid' }; + const request = spec.buildRequests([bid], {}); + const payload = JSON.parse(request.data); + expect(payload.slts[0].name).to.equal('custom-tagid'); + }); + }); + describe('getUserSyncs', function () { it('should return iframe sync when iframeEnabled and syncFrame provided', function () { const syncOptions = { iframeEnabled: true, pixelEnabled: false }; diff --git a/test/spec/modules/beyondmediaBidAdapter_spec.js b/test/spec/modules/beyondmediaBidAdapter_spec.js index 79bf88cb6be..5ee8d73207b 100644 --- a/test/spec/modules/beyondmediaBidAdapter_spec.js +++ b/test/spec/modules/beyondmediaBidAdapter_spec.js @@ -431,7 +431,7 @@ describe('AndBeyondMediaBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -440,9 +440,7 @@ describe('AndBeyondMediaBidAdapter', function () { expect(syncData[0].url).to.equal('https://cookies.andbeyond.media/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -451,7 +449,7 @@ describe('AndBeyondMediaBidAdapter', function () { expect(syncData[0].url).to.equal('https://cookies.andbeyond.media/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/bidResponseFilter_spec.js b/test/spec/modules/bidResponseFilter_spec.js index c37003bde50..16acee9b87e 100644 --- a/test/spec/modules/bidResponseFilter_spec.js +++ b/test/spec/modules/bidResponseFilter_spec.js @@ -69,7 +69,8 @@ describe('bidResponseFilter', () => { advertiserDomains: ['domain1.com', 'domain2.com'], primaryCatId: 'EXAMPLE-CAT-ID', attr: 'attr', - mediaType: 'banner' + mediaType: 'banner', + cattax: 1 } }; @@ -85,7 +86,8 @@ describe('bidResponseFilter', () => { meta: { advertiserDomains: ['domain1.com', 'domain2.com'], primaryCatId: 'BANNED_CAT1', - attr: 'attr' + attr: 'attr', + cattax: 1 } }; mockAuctionIndex.getOrtb2 = () => ({ @@ -96,6 +98,109 @@ describe('bidResponseFilter', () => { sinon.assert.calledWith(reject, BID_CATEGORY_REJECTION_REASON); }); + describe('cattax (category taxonomy) match', () => { + it('should reject with BID_CATEGORY_REJECTION_REASON when cattax matches and primaryCatId is in bcat blocklist', () => { + const reject = sinon.stub(); + const call = sinon.stub(); + const bid = { + meta: { + advertiserDomains: ['domain1.com'], + primaryCatId: 'BANNED_CAT1', + attr: 1, + mediaType: 'banner', + cattax: 1 + } + }; + mockAuctionIndex.getOrtb2 = () => ({ + badv: [], bcat: ['BANNED_CAT1'], cattax: 1 + }); + mockAuctionIndex.getBidRequest = () => ({ + mediaTypes: { banner: {} }, + ortb2Imp: {} + }); + + addBidResponseHook(call, 'adcode', bid, reject, mockAuctionIndex); + sinon.assert.calledWith(reject, BID_CATEGORY_REJECTION_REASON); + sinon.assert.notCalled(call); + }); + + it('should pass when cattax matches and primaryCatId is not in bcat blocklist', () => { + const reject = sinon.stub(); + const call = sinon.stub(); + const bid = { + meta: { + advertiserDomains: ['domain1.com'], + primaryCatId: 'ALLOWED_CAT', + attr: 1, + mediaType: 'banner', + cattax: 1 + } + }; + mockAuctionIndex.getOrtb2 = () => ({ + badv: [], bcat: ['BANNED_CAT1'], cattax: 1 + }); + mockAuctionIndex.getBidRequest = () => ({ + mediaTypes: { banner: {} }, + ortb2Imp: {} + }); + + addBidResponseHook(call, 'adcode', bid, reject, mockAuctionIndex); + sinon.assert.notCalled(reject); + sinon.assert.calledOnce(call); + }); + + it('should reject with BID_CATEGORY_REJECTION_REASON when cattax does not match (treat primaryCatId as unknown)', () => { + const reject = sinon.stub(); + const call = sinon.stub(); + const bid = { + meta: { + advertiserDomains: ['domain1.com'], + primaryCatId: 'ALLOWED_CAT', + attr: 1, + mediaType: 'banner', + cattax: 2 + } + }; + mockAuctionIndex.getOrtb2 = () => ({ + badv: [], bcat: ['BANNED_CAT1'], cattax: 1 + }); + mockAuctionIndex.getBidRequest = () => ({ + mediaTypes: { banner: {} }, + ortb2Imp: {} + }); + + addBidResponseHook(call, 'adcode', bid, reject, mockAuctionIndex); + sinon.assert.calledWith(reject, BID_CATEGORY_REJECTION_REASON); + sinon.assert.notCalled(call); + }); + + it('should pass when cattax does not match and blockUnknown is false (do not treat as unknown)', () => { + const reject = sinon.stub(); + const call = sinon.stub(); + const bid = { + meta: { + advertiserDomains: ['domain1.com'], + primaryCatId: 'BANNED_CAT1', + attr: 1, + mediaType: 'banner', + cattax: 2 + } + }; + mockAuctionIndex.getOrtb2 = () => ({ + badv: [], bcat: ['BANNED_CAT1'], cattax: 1 + }); + mockAuctionIndex.getBidRequest = () => ({ + mediaTypes: { banner: {} }, + ortb2Imp: {} + }); + config.setConfig({ [MODULE_NAME]: { cat: { blockUnknown: false } } }); + + addBidResponseHook(call, 'adcode', bid, reject, mockAuctionIndex); + sinon.assert.notCalled(reject); + sinon.assert.calledOnce(call); + }); + }); + it('should reject the bid after failed ortb2 adv domains rule validation', () => { const rejection = sinon.stub(); const call = sinon.stub(); @@ -103,7 +208,8 @@ describe('bidResponseFilter', () => { meta: { advertiserDomains: ['domain1.com', 'domain2.com'], primaryCatId: 'VALID_CAT', - attr: 'attr' + attr: 'attr', + cattax: 1 } }; mockAuctionIndex.getOrtb2 = () => ({ @@ -121,7 +227,8 @@ describe('bidResponseFilter', () => { meta: { advertiserDomains: ['validdomain1.com', 'validdomain2.com'], primaryCatId: 'VALID_CAT', - attr: 'BANNED_ATTR' + attr: 'BANNED_ATTR', + cattax: 1 }, mediaType: 'video' }; @@ -149,6 +256,7 @@ describe('bidResponseFilter', () => { primaryCatId: 'BANNED_CAT1', attr: 'valid_attr', mediaType: 'banner', + cattax: 1 } }; @@ -177,7 +285,8 @@ describe('bidResponseFilter', () => { advertiserDomains: ['validdomain1.com', 'validdomain2.com'], primaryCatId: undefined, attr: 'valid_attr', - mediaType: 'banner' + mediaType: 'banner', + cattax: 1 } }; @@ -207,7 +316,8 @@ describe('bidResponseFilter', () => { advertiserDomains: ['validdomain1.com', 'validdomain2.com'], primaryCatId: 'VALID_CAT', attr: 6, - mediaType: 'audio' + mediaType: 'audio', + cattax: 1 }, }; diff --git a/test/spec/modules/bidfuseBidAdapter_spec.js b/test/spec/modules/bidfuseBidAdapter_spec.js index 95c84dcdf87..3c247c17b43 100644 --- a/test/spec/modules/bidfuseBidAdapter_spec.js +++ b/test/spec/modules/bidfuseBidAdapter_spec.js @@ -337,7 +337,7 @@ describe('BidfuseBidAdapter', function () { const result = spec.getUserSyncs({pixelEnabled: true}, [serverResponse], gdprConsent, uspConsent, gppConsent); expect(result).to.deep.equal([{ - 'url': 'https://syncbf.bidfuse.com/image?pbjs=1&gdpr=1&gdpr_consent=consent_string&gpp=gpp_string&gpp_sid=7&coppa=1', + 'url': 'https://syncbf.bidfuse.com/image?pbjs=1&gdpr=1&gdpr_consent=consent_string&ccpa_consent=usp_string&gpp=gpp_string&gpp_sid=7&coppa=1', 'type': 'image' }]); }); diff --git a/test/spec/modules/boldwinBidAdapter_spec.js b/test/spec/modules/boldwinBidAdapter_spec.js index 5d8c7fab9fd..fdaa0f19f3f 100644 --- a/test/spec/modules/boldwinBidAdapter_spec.js +++ b/test/spec/modules/boldwinBidAdapter_spec.js @@ -434,7 +434,7 @@ describe('BoldwinBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -443,9 +443,7 @@ describe('BoldwinBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.videowalldirect.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -454,7 +452,7 @@ describe('BoldwinBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.videowalldirect.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/chromeAiRtdProvider_spec.js b/test/spec/modules/chromeAiRtdProvider_spec.js index 744f09ed4df..dfbf454bff9 100644 --- a/test/spec/modules/chromeAiRtdProvider_spec.js +++ b/test/spec/modules/chromeAiRtdProvider_spec.js @@ -135,6 +135,71 @@ describe('Chrome AI RTD Provider', function () { expect(chromeAiRtdProvider.CONSTANTS.SUBMODULE_NAME).to.equal('chromeAi'); expect(chromeAiRtdProvider.CONSTANTS.STORAGE_KEY).to.equal('chromeAi_detected_data'); expect(chromeAiRtdProvider.CONSTANTS.MIN_TEXT_LENGTH).to.be.a('number'); + expect(chromeAiRtdProvider.CONSTANTS.MAX_TEXT_LENGTH).to.be.a('number'); + expect(chromeAiRtdProvider.CONSTANTS.MAX_TEXT_LENGTH).to.equal(1000); + }); + }); + + // Test getPageText and text truncation + describe('getPageText (text truncation)', function () { + // Override document.body.textContent via Object.defineProperty so we can + // control the value returned to getPageText() without mutating the actual + // DOM (which would break the Karma test-runner UI). + function setBodyText(text) { + Object.defineProperty(document.body, 'textContent', { + get: () => text, + configurable: true + }); + } + + afterEach(function () { + // Remove the instance-level override to restore the inherited getter + delete document.body.textContent; + }); + + it('should return null for text shorter than MIN_TEXT_LENGTH', function () { + setBodyText('short'); + const result = chromeAiRtdProvider.getPageText(); + expect(result).to.be.null; + expect(logMessageStub.calledWith(sinon.match('Not enough text content'))).to.be.true; + }); + + it('should return null for empty text', function () { + setBodyText(''); + const result = chromeAiRtdProvider.getPageText(); + expect(result).to.be.null; + }); + + it('should return full text when length is between MIN and MAX', function () { + const text = 'A'.repeat(500); + setBodyText(text); + const result = chromeAiRtdProvider.getPageText(); + expect(result).to.equal(text); + expect(result).to.have.lengthOf(500); + }); + + it('should return text at exactly MAX_TEXT_LENGTH without truncating', function () { + const exactText = 'B'.repeat(chromeAiRtdProvider.CONSTANTS.MAX_TEXT_LENGTH); + setBodyText(exactText); + const result = chromeAiRtdProvider.getPageText(); + expect(result).to.equal(exactText); + expect(logMessageStub.calledWith(sinon.match('Truncating'))).to.be.false; + }); + + it('should truncate text exceeding MAX_TEXT_LENGTH', function () { + const longText = 'C'.repeat(2000); + setBodyText(longText); + const result = chromeAiRtdProvider.getPageText(); + expect(result).to.have.lengthOf(chromeAiRtdProvider.CONSTANTS.MAX_TEXT_LENGTH); + expect(result).to.equal('C'.repeat(1000)); + }); + + it('should log a message when truncating text', function () { + setBodyText('D'.repeat(2000)); + chromeAiRtdProvider.getPageText(); + expect(logMessageStub.calledWith( + sinon.match('Truncating text from 2000 to 1000') + )).to.be.true; }); }); diff --git a/test/spec/modules/colossussspBidAdapter_spec.js b/test/spec/modules/colossussspBidAdapter_spec.js index 9c92661fd54..cc926e61b9e 100644 --- a/test/spec/modules/colossussspBidAdapter_spec.js +++ b/test/spec/modules/colossussspBidAdapter_spec.js @@ -447,7 +447,7 @@ describe('ColossussspAdapter', function () { }) describe('getUserSyncs', function () { - const userSync = spec.getUserSyncs({}, {}, { consentString: 'xxx', gdprApplies: 1 }, { consentString: '1YN-' }); + const userSync = spec.getUserSyncs({}, {}, { consentString: 'xxx', gdprApplies: 1 }, '1YN-'); it('Returns valid URL and type', function () { expect(userSync).to.be.an('array').with.lengthOf(1); expect(userSync[0].type).to.exist; diff --git a/test/spec/modules/compassBidAdapter_spec.js b/test/spec/modules/compassBidAdapter_spec.js index 8d0e1cc5715..57412de4379 100644 --- a/test/spec/modules/compassBidAdapter_spec.js +++ b/test/spec/modules/compassBidAdapter_spec.js @@ -482,7 +482,7 @@ describe('CompassBidAdapter', function () { const syncData = config.runWithBidder(bidder, () => spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {})); + }, undefined)); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -491,9 +491,7 @@ describe('CompassBidAdapter', function () { expect(syncData[0].url).to.equal('https://sa-cs.deliverimp.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = config.runWithBidder(bidder, () => spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - })); + const syncData = config.runWithBidder(bidder, () => spec.getUserSyncs({}, {}, {}, '1---')); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -502,7 +500,7 @@ describe('CompassBidAdapter', function () { expect(syncData[0].url).to.equal('https://sa-cs.deliverimp.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = config.runWithBidder(bidder, () => spec.getUserSyncs({}, {}, {}, {}, { + const syncData = config.runWithBidder(bidder, () => spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] })); diff --git a/test/spec/modules/conceptxBidAdapter_spec.js b/test/spec/modules/conceptxBidAdapter_spec.js index 8e9bd2f8cc0..02780045496 100644 --- a/test/spec/modules/conceptxBidAdapter_spec.js +++ b/test/spec/modules/conceptxBidAdapter_spec.js @@ -1,70 +1,75 @@ -// import or require modules necessary for the test, e.g.: -import { expect } from 'chai'; // may prefer 'assert' in place of 'expect' +import { expect } from 'chai'; import { spec } from 'modules/conceptxBidAdapter.js'; import { newBidder } from 'src/adapters/bidderFactory.js'; -// import { config } from 'src/config.js'; describe('conceptxBidAdapter', function () { - const URL = 'https://conceptx.cncpt-central.com/openrtb'; - - const ENDPOINT_URL = `${URL}`; - const ENDPOINT_URL_CONSENT = `${URL}?gdpr_applies=true&consentString=ihaveconsented`; + const ENDPOINT_URL = 'https://cxba-s2s.cncpt.dk/openrtb2/auction'; + const ENDPOINT_URL_CONSENT = + ENDPOINT_URL + '?gdpr_applies=1&gdpr_consent=ihaveconsented'; const adapter = newBidder(spec); const bidderRequests = [ { bidId: '123', bidder: 'conceptx', + adUnitCode: 'div-1', + auctionId: 'auc-1', params: { - site: 'example', - adunit: 'some-id-3' + site: 'example.com', + adunit: 'some-id-3', }, mediaTypes: { banner: { sizes: [[930, 180]], - } + }, }, - } - ] - - const singleBidRequest = { - bid: [ - { - bidId: '123', - } - ] - } + }, + ]; const serverResponse = { body: { - 'bidResponses': [ + id: 'resp-1', + cur: 'DKK', + seatbid: [ { - 'ads': [ + seat: 'conceptx', + bid: [ { - 'referrer': 'http://localhost/prebidpage_concept_bidder.html', - 'ttl': 360, - 'html': '

DUMMY

', - 'requestId': '214dfadd1f8826', - 'cpm': 46, - 'currency': 'DKK', - 'width': 930, - 'height': 180, - 'creativeId': 'FAKE-ID', - 'meta': { - 'mediaType': 'banner' - }, - 'netRevenue': true, - 'destinationUrls': { - 'destination': 'https://concept.dk' - } - } + id: 'bid-1', + impid: '123', + price: 46, + w: 930, + h: 180, + crid: 'FAKE-ID', + adm: '

DUMMY

', + }, ], - 'matchedAdCount': 1, - 'targetId': '214dfadd1f8826' - } - ] - } - } + }, + ], + }, + }; + + const requestPayload = { + data: JSON.stringify({ + id: 'auc-1', + site: { id: 'example.com', domain: 'example.com', page: 'example.com' }, + imp: [ + { + id: '123', + ext: { + prebid: { + storedrequest: { id: 'some-id-3' }, + }, + }, + }, + ], + ext: { + prebid: { + storedrequest: { id: 'cx_global' }, + }, + }, + }), + }; describe('inherited functions', function () { it('exists and is a function', function () { @@ -73,52 +78,105 @@ describe('conceptxBidAdapter', function () { }); describe('isBidRequestValid', function () { - it('should return true when required params found', function () { + it('should return true when bidId and params.adunit are present', function () { expect(spec.isBidRequestValid(bidderRequests[0])).to.equal(true); }); + + it('should return false when params.adunit is missing', function () { + expect( + spec.isBidRequestValid({ + bidId: '123', + bidder: 'conceptx', + params: { site: 'example' }, + }) + ).to.equal(false); + }); + + it('should return false when bidId is missing', function () { + expect( + spec.isBidRequestValid({ + bidder: 'conceptx', + params: { site: 'example', adunit: 'id-1' }, + }) + ).to.equal(false); + }); }); describe('buildRequests', function () { - it('Test requests', function () { - const request = spec.buildRequests(bidderRequests, {}); - expect(request.length).to.equal(1); - expect(request[0]).to.have.property('data'); - const bid = JSON.parse(request[0].data).adUnits[0] - expect(bid.site).to.equal('example'); - expect(bid.adunit).to.equal('some-id-3'); - expect(JSON.stringify(bid.dimensions)).to.equal(JSON.stringify([ - [930, 180]])); + it('should build OpenRTB request with stored requests', function () { + const requests = spec.buildRequests(bidderRequests, {}); + expect(requests).to.have.lengthOf(1); + expect(requests[0]).to.have.property('method', 'POST'); + expect(requests[0]).to.have.property('url', ENDPOINT_URL); + expect(requests[0]).to.have.property('data'); + + const payload = JSON.parse(requests[0].data); + expect(payload).to.have.property('site'); + expect(payload.site).to.have.property('id', 'example.com'); + expect(payload).to.have.property('imp'); + expect(payload.imp).to.have.lengthOf(1); + expect(payload.imp[0].ext.prebid.storedrequest).to.deep.equal({ + id: 'some-id-3', + }); + expect(payload.ext.prebid.storedrequest).to.deep.equal({ + id: 'cx_global', + }); + expect(payload.imp[0].banner.format).to.deep.equal([{ w: 930, h: 180 }]); + }); + + it('should include withCredentials in options', function () { + const requests = spec.buildRequests(bidderRequests, {}); + expect(requests[0].options).to.deep.include({ withCredentials: true }); }); }); describe('user privacy', function () { - it('should NOT send GDPR Consent data if gdprApplies equals undefined', function () { - const request = spec.buildRequests(bidderRequests, { gdprConsent: { gdprApplies: undefined, consentString: 'iDoNotConsent' } }); - expect(request.length).to.equal(1); - expect(request[0]).to.have.property('url') - expect(request[0].url).to.equal(ENDPOINT_URL); + it('should NOT add GDPR params to URL when gdprApplies is undefined', function () { + const requests = spec.buildRequests(bidderRequests, { + gdprConsent: { gdprApplies: undefined, consentString: 'iDoNotConsent' }, + }); + expect(requests[0].url).to.equal(ENDPOINT_URL); }); - it('should send GDPR Consent data if gdprApplies', function () { - const request = spec.buildRequests(bidderRequests, { gdprConsent: { gdprApplies: true, consentString: 'ihaveconsented' } }); - expect(request.length).to.equal(1); - expect(request[0]).to.have.property('url') - expect(request[0].url).to.equal(ENDPOINT_URL_CONSENT); + + it('should add gdpr_applies and gdpr_consent to URL when GDPR applies', function () { + const requests = spec.buildRequests(bidderRequests, { + gdprConsent: { gdprApplies: true, consentString: 'ihaveconsented' }, + }); + expect(requests[0].url).to.include('gdpr_applies=1'); + expect(requests[0].url).to.include('gdpr_consent=ihaveconsented'); }); }); describe('interpretResponse', function () { - it('should return valid response when passed valid server response', function () { - const interpretedResponse = spec.interpretResponse(serverResponse, singleBidRequest); - const ad = serverResponse.body.bidResponses[0].ads[0] - expect(interpretedResponse).to.have.lengthOf(1); - expect(interpretedResponse[0].cpm).to.equal(ad.cpm); - expect(interpretedResponse[0].width).to.equal(Number(ad.width)); - expect(interpretedResponse[0].height).to.equal(Number(ad.height)); - expect(interpretedResponse[0].creativeId).to.equal(ad.creativeId); - expect(interpretedResponse[0].currency).to.equal(ad.currency); - expect(interpretedResponse[0].netRevenue).to.equal(true); - expect(interpretedResponse[0].ad).to.equal(ad.html); - expect(interpretedResponse[0].ttl).to.equal(360); + it('should return valid bids from PBS seatbid format', function () { + const interpreted = spec.interpretResponse(serverResponse, requestPayload); + expect(interpreted).to.have.lengthOf(1); + expect(interpreted[0].requestId).to.equal('123'); + expect(interpreted[0].cpm).to.equal(46); + expect(interpreted[0].width).to.equal(930); + expect(interpreted[0].height).to.equal(180); + expect(interpreted[0].creativeId).to.equal('FAKE-ID'); + expect(interpreted[0].currency).to.equal('DKK'); + expect(interpreted[0].netRevenue).to.equal(true); + expect(interpreted[0].ad).to.equal('

DUMMY

'); + expect(interpreted[0].ttl).to.equal(300); + }); + + it('should return empty array when no seatbid', function () { + const emptyResponse = { body: { seatbid: [] } }; + expect(spec.interpretResponse(emptyResponse, {})).to.deep.equal([]); + }); + + it('should return empty array when seatbid is missing', function () { + const noSeatbid = { body: {} }; + expect(spec.interpretResponse(noSeatbid, {})).to.deep.equal([]); + }); + }); + + describe('getUserSyncs', function () { + it('should return empty array (sync handled by runPbsCookieSync)', function () { + const syncs = spec.getUserSyncs({ iframeEnabled: true, pixelEnabled: true }, [], {}, '', {}); + expect(syncs).to.deep.equal([]); }); }); }); diff --git a/test/spec/modules/connatixBidAdapter_spec.js b/test/spec/modules/connatixBidAdapter_spec.js index 35e60c403af..aa9bdac75bd 100644 --- a/test/spec/modules/connatixBidAdapter_spec.js +++ b/test/spec/modules/connatixBidAdapter_spec.js @@ -142,10 +142,12 @@ describe('connatixBidAdapter', function () { let element; let getBoundingClientRectStub; let topWinMock; + let sandbox; beforeEach(() => { + sandbox = sinon.createSandbox(); element = document.createElement('div'); - getBoundingClientRectStub = sinon.stub(element, 'getBoundingClientRect'); + getBoundingClientRectStub = sandbox.stub(element, 'getBoundingClientRect'); topWinMock = { document: { @@ -154,10 +156,18 @@ describe('connatixBidAdapter', function () { innerWidth: 800, innerHeight: 600 }; + sandbox.stub(winDimensions, 'getWinDimensions').callsFake(() => ({ + document: { + documentElement: { + clientWidth: topWinMock.innerWidth, + clientHeight: topWinMock.innerHeight + } + } + })); }); afterEach(() => { - getBoundingClientRectStub.restore(); + sandbox.restore(); }); it('should return 0 if the document is not visible', () => { @@ -180,25 +190,18 @@ describe('connatixBidAdapter', function () { it('should return the correct percentage if the element is partially in view', () => { const boundingBox = { left: 700, top: 500, right: 900, bottom: 700, width: 200, height: 200 }; getBoundingClientRectStub.returns(boundingBox); - const getWinDimensionsStub = sinon.stub(winDimensions, 'getWinDimensions'); - getWinDimensionsStub.returns({ innerWidth: topWinMock.innerWidth, innerHeight: topWinMock.innerHeight}); - const viewability = connatixGetViewability(element, topWinMock); expect(viewability).to.equal(25); // 100x100 / 200x200 = 0.25 -> 25% - getWinDimensionsStub.restore(); }); it('should return 0% if the element is not in view', () => { - const getWinDimensionsStub = sinon.stub(winDimensions, 'getWinDimensions'); - getWinDimensionsStub.returns({ innerWidth: topWinMock.innerWidth, innerHeight: topWinMock.innerHeight}); const boundingBox = { left: 900, top: 700, right: 1100, bottom: 900, width: 200, height: 200 }; getBoundingClientRectStub.returns(boundingBox); const viewability = connatixGetViewability(element, topWinMock); expect(viewability).to.equal(0); - getWinDimensionsStub.restore(); }); it('should use provided width and height if element dimensions are zero', () => { @@ -218,10 +221,12 @@ describe('connatixBidAdapter', function () { let topWinMock; let querySelectorStub; let getElementByIdStub; + let sandbox; beforeEach(() => { + sandbox = sinon.createSandbox(); element = document.createElement('div'); - getBoundingClientRectStub = sinon.stub(element, 'getBoundingClientRect'); + getBoundingClientRectStub = sandbox.stub(element, 'getBoundingClientRect'); topWinMock = { document: { @@ -231,14 +236,22 @@ describe('connatixBidAdapter', function () { innerHeight: 600 }; - querySelectorStub = sinon.stub(window.top.document, 'querySelector'); - getElementByIdStub = sinon.stub(document, 'getElementById'); + querySelectorStub = sandbox.stub(window.top.document, 'querySelector'); + getElementByIdStub = sandbox.stub(document, 'getElementById'); + sandbox.stub(winDimensions, 'getWinDimensions').callsFake(() => ( + { + document: { + documentElement: { + clientWidth: topWinMock.innerWidth, + clientHeight: topWinMock.innerHeight + } + } + } + )); }); afterEach(() => { - getBoundingClientRectStub.restore(); - querySelectorStub.restore(); - getElementByIdStub.restore(); + sandbox.restore(); }); it('should return 100% viewability when the element is fully within view and has a valid viewabilityContainerIdentifier', () => { diff --git a/test/spec/modules/contentexchangeBidAdapter_spec.js b/test/spec/modules/contentexchangeBidAdapter_spec.js index 12a4c6c5de2..cdf5be50250 100644 --- a/test/spec/modules/contentexchangeBidAdapter_spec.js +++ b/test/spec/modules/contentexchangeBidAdapter_spec.js @@ -481,7 +481,7 @@ describe('ContentexchangeBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -490,9 +490,7 @@ describe('ContentexchangeBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync2.adnetwork.agency/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -501,7 +499,7 @@ describe('ContentexchangeBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync2.adnetwork.agency/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/copper6sspBidAdapter_spec.js b/test/spec/modules/copper6sspBidAdapter_spec.js index 3c32750cbc0..888291c5997 100644 --- a/test/spec/modules/copper6sspBidAdapter_spec.js +++ b/test/spec/modules/copper6sspBidAdapter_spec.js @@ -484,7 +484,7 @@ describe('Copper6SSPBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -493,9 +493,7 @@ describe('Copper6SSPBidAdapter', function () { expect(syncData[0].url).to.equal('https://сsync.copper6.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -504,7 +502,7 @@ describe('Copper6SSPBidAdapter', function () { expect(syncData[0].url).to.equal('https://сsync.copper6.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/dpaiBidAdapter_spec.js b/test/spec/modules/dpaiBidAdapter_spec.js new file mode 100644 index 00000000000..f81c3aa7c95 --- /dev/null +++ b/test/spec/modules/dpaiBidAdapter_spec.js @@ -0,0 +1,511 @@ +import { expect } from 'chai'; +import { spec } from '../../../modules/dpaiBidAdapter.js'; +import { BANNER, VIDEO, NATIVE } from '../../../src/mediaTypes.js'; +import { getUniqueIdentifierStr } from '../../../src/utils.js'; + +const bidder = 'dpai'; + +describe('DpaiBidAdapter', function () { + const userIdAsEids = [{ + source: 'test.org', + uids: [{ + id: '01**********', + atype: 1, + ext: { + third: '01***********' + } + }] + }]; + const bids = [ + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + placementId: 'testBanner', + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [VIDEO]: { + playerSize: [[300, 300]], + minduration: 5, + maxduration: 60 + } + }, + params: { + placementId: 'testVideo', + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [NATIVE]: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + } + }, + params: { + placementId: 'testNative' + }, + userIdAsEids + } + ]; + + const invalidBid = { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + + } + } + + const bidderRequest = { + uspConsent: '1---', + gdprConsent: { + consentString: 'COvFyGBOvFyGBAbAAAENAPCAAOAAAAAAAAAAAEEUACCKAAA.IFoEUQQgAIQwgIwQABAEAAAAOIAACAIAAAAQAIAgEAACEAAAAAgAQBAAAAAAAGBAAgAAAAAAAFAAECAAAgAAQARAEQAAAAAJAAIAAgAAAYQEAAAQmAgBC3ZAYzUw', + vendorData: {} + }, + refererInfo: { + referer: 'https://test.com', + page: 'https://test.com' + }, + ortb2: { + device: { + w: 1512, + h: 982, + language: 'en-UK', + } + }, + timeout: 500 + }; + + describe('isBidRequestValid', function () { + it('Should return true if there are bidId, params and key parameters present', function () { + expect(spec.isBidRequestValid(bids[0])).to.be.true; + }); + it('Should return false if at least one of parameters is not present', function () { + expect(spec.isBidRequestValid(invalidBid)).to.be.false; + }); + }); + + describe('buildRequests', function () { + let serverRequest = spec.buildRequests(bids, bidderRequest); + + it('Creates a ServerRequest object with method, URL and data', function () { + expect(serverRequest).to.exist; + expect(serverRequest.method).to.exist; + expect(serverRequest.url).to.exist; + expect(serverRequest.data).to.exist; + }); + + it('Returns POST method', function () { + expect(serverRequest.method).to.equal('POST'); + }); + + it('Returns general data valid', function () { + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.all.keys( + 'deviceWidth', + 'deviceHeight', + 'device', + 'language', + 'secure', + 'host', + 'page', + 'placements', + 'coppa', + 'ccpa', + 'gdpr', + 'tmax', + 'bcat', + 'badv', + 'bapp', + 'battr' + ); + expect(data.deviceWidth).to.be.a('number'); + expect(data.deviceHeight).to.be.a('number'); + expect(data.language).to.be.a('string'); + expect(data.secure).to.be.within(0, 1); + expect(data.host).to.be.a('string'); + expect(data.page).to.be.a('string'); + expect(data.coppa).to.be.a('number'); + expect(data.gdpr).to.be.a('object'); + expect(data.ccpa).to.be.a('string'); + expect(data.tmax).to.be.a('number'); + expect(data.placements).to.have.lengthOf(3); + }); + + it('Returns valid placements', function () { + const { placements } = serverRequest.data; + for (let i = 0, len = placements.length; i < len; i++) { + const placement = placements[i]; + expect(placement.placementId).to.be.oneOf(['testBanner', 'testVideo', 'testNative']); + expect(placement.adFormat).to.be.oneOf([BANNER, VIDEO, NATIVE]); + expect(placement.bidId).to.be.a('string'); + expect(placement.schain).to.be.an('object'); + expect(placement.bidfloor).to.exist.and.to.equal(0); + expect(placement.type).to.exist.and.to.equal('publisher'); + expect(placement.eids).to.exist.and.to.be.deep.equal(userIdAsEids); + + if (placement.adFormat === BANNER) { + expect(placement.sizes).to.be.an('array'); + } + switch (placement.adFormat) { + case BANNER: + expect(placement.sizes).to.be.an('array'); + break; + case VIDEO: + expect(placement.playerSize).to.be.an('array'); + expect(placement.minduration).to.be.an('number'); + expect(placement.maxduration).to.be.an('number'); + break; + case NATIVE: + expect(placement.native).to.be.an('object'); + break; + } + } + }); + + it('Returns valid endpoints', function () { + const bids = [ + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + endpointId: 'testBanner', + }, + userIdAsEids + } + ]; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + + const { placements } = serverRequest.data; + for (let i = 0, len = placements.length; i < len; i++) { + const placement = placements[i]; + expect(placement.endpointId).to.be.oneOf(['testBanner', 'testVideo', 'testNative']); + expect(placement.adFormat).to.be.oneOf([BANNER, VIDEO, NATIVE]); + expect(placement.bidId).to.be.a('string'); + expect(placement.schain).to.be.an('object'); + expect(placement.bidfloor).to.exist.and.to.equal(0); + expect(placement.type).to.exist.and.to.equal('network'); + expect(placement.eids).to.exist.and.to.be.deep.equal(userIdAsEids); + + if (placement.adFormat === BANNER) { + expect(placement.sizes).to.be.an('array'); + } + switch (placement.adFormat) { + case BANNER: + expect(placement.sizes).to.be.an('array'); + break; + case VIDEO: + expect(placement.playerSize).to.be.an('array'); + expect(placement.minduration).to.be.an('number'); + expect(placement.maxduration).to.be.an('number'); + break; + case NATIVE: + expect(placement.native).to.be.an('object'); + break; + } + } + }); + + it('Returns data with gdprConsent and without uspConsent', function () { + delete bidderRequest.uspConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.gdpr).to.exist; + expect(data.gdpr).to.be.a('object'); + expect(data.gdpr).to.have.property('consentString'); + expect(data.gdpr).to.not.have.property('vendorData'); + expect(data.gdpr.consentString).to.equal(bidderRequest.gdprConsent.consentString); + expect(data.ccpa).to.not.exist; + delete bidderRequest.gdprConsent; + }); + + it('Returns data with uspConsent and without gdprConsent', function () { + bidderRequest.uspConsent = '1---'; + delete bidderRequest.gdprConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.ccpa).to.exist; + expect(data.ccpa).to.be.a('string'); + expect(data.ccpa).to.equal(bidderRequest.uspConsent); + expect(data.gdpr).to.not.exist; + }); + }); + + describe('gpp consent', function () { + it('bidderRequest.gppConsent', () => { + bidderRequest.gppConsent = { + gppString: 'abc123', + applicableSections: [8] + }; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + + delete bidderRequest.gppConsent; + }) + + it('bidderRequest.ortb2.regs.gpp', () => { + bidderRequest.ortb2 = bidderRequest.ortb2 || {}; + bidderRequest.ortb2.regs = bidderRequest.ortb2.regs || {}; + bidderRequest.ortb2.regs.gpp = 'abc123'; + bidderRequest.ortb2.regs.gpp_sid = [8]; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + }) + }); + + describe('interpretResponse', function () { + it('Should interpret banner response', function () { + const banner = { + body: [{ + mediaType: 'banner', + width: 300, + height: 250, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const bannerResponses = spec.interpretResponse(banner); + expect(bannerResponses).to.be.an('array').that.is.not.empty; + const dataItem = bannerResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'ad', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal(banner.body[0].requestId); + expect(dataItem.cpm).to.equal(banner.body[0].cpm); + expect(dataItem.width).to.equal(banner.body[0].width); + expect(dataItem.height).to.equal(banner.body[0].height); + expect(dataItem.ad).to.equal(banner.body[0].ad); + expect(dataItem.ttl).to.equal(banner.body[0].ttl); + expect(dataItem.creativeId).to.equal(banner.body[0].creativeId); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal(banner.body[0].currency); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret video response', function () { + const video = { + body: [{ + vastUrl: 'test.com', + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const videoResponses = spec.interpretResponse(video); + expect(videoResponses).to.be.an('array').that.is.not.empty; + + const dataItem = videoResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'vastUrl', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.5); + expect(dataItem.vastUrl).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret native response', function () { + const native = { + body: [{ + mediaType: 'native', + native: { + clickUrl: 'test.com', + title: 'Test', + image: 'test.com', + impressionTrackers: ['test.com'], + }, + ttl: 120, + cpm: 0.4, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const nativeResponses = spec.interpretResponse(native); + expect(nativeResponses).to.be.an('array').that.is.not.empty; + + const dataItem = nativeResponses[0]; + expect(dataItem).to.have.keys('requestId', 'cpm', 'ttl', 'creativeId', 'netRevenue', 'currency', 'mediaType', 'native', 'meta'); + expect(dataItem.native).to.have.keys('clickUrl', 'impressionTrackers', 'title', 'image') + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.4); + expect(dataItem.native.clickUrl).to.equal('test.com'); + expect(dataItem.native.title).to.equal('Test'); + expect(dataItem.native.image).to.equal('test.com'); + expect(dataItem.native.impressionTrackers).to.be.an('array').that.is.not.empty; + expect(dataItem.native.impressionTrackers[0]).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should return an empty array if invalid banner response is passed', function () { + const invBanner = { + body: [{ + width: 300, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + + const serverResponses = spec.interpretResponse(invBanner); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid video response is passed', function () { + const invVideo = { + body: [{ + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invVideo); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid native response is passed', function () { + const invNative = { + body: [{ + mediaType: 'native', + clickUrl: 'test.com', + title: 'Test', + impressionTrackers: ['test.com'], + ttl: 120, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + }] + }; + const serverResponses = spec.interpretResponse(invNative); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid response is passed', function () { + const invalid = { + body: [{ + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invalid); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + }); + + describe('getUserSyncs', function() { + it('Should return array of objects with proper sync config , include GDPR', function() { + const syncData = spec.getUserSyncs({}, {}, { + consentString: 'ALL', + gdprApplies: true, + }, undefined); + expect(syncData).to.be.an('array').which.is.not.empty; + expect(syncData[0]).to.be.an('object') + expect(syncData[0].type).to.be.a('string') + expect(syncData[0].type).to.equal('image') + expect(syncData[0].url).to.be.a('string') + expect(syncData[0].url).to.equal('https://sync.drift-pixel.ai/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') + }); + it('Should return array of objects with proper sync config , include CCPA', function() { + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); + expect(syncData).to.be.an('array').which.is.not.empty; + expect(syncData[0]).to.be.an('object') + expect(syncData[0].type).to.be.a('string') + expect(syncData[0].type).to.equal('image') + expect(syncData[0].url).to.be.a('string') + expect(syncData[0].url).to.equal('https://sync.drift-pixel.ai/image?pbjs=1&ccpa_consent=1---&coppa=0') + }); + it('Should return array of objects with proper sync config , include GPP', function() { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { + gppString: 'abc123', + applicableSections: [8] + }); + expect(syncData).to.be.an('array').which.is.not.empty; + expect(syncData[0]).to.be.an('object') + expect(syncData[0].type).to.be.a('string') + expect(syncData[0].type).to.equal('image') + expect(syncData[0].url).to.be.a('string') + expect(syncData[0].url).to.equal('https://sync.drift-pixel.ai/image?pbjs=1&gpp=abc123&gpp_sid=8&coppa=0') + }); + }); +}); diff --git a/test/spec/modules/emtvBidAdapter_spec.js b/test/spec/modules/emtvBidAdapter_spec.js index 0e91f3fa719..a75522607c1 100644 --- a/test/spec/modules/emtvBidAdapter_spec.js +++ b/test/spec/modules/emtvBidAdapter_spec.js @@ -485,7 +485,7 @@ describe('EMTVBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -494,9 +494,7 @@ describe('EMTVBidAdapter', function () { expect(syncData[0].url).to.equal(`${syncUrl}/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0`) }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -505,7 +503,7 @@ describe('EMTVBidAdapter', function () { expect(syncData[0].url).to.equal(`${syncUrl}/image?pbjs=1&ccpa_consent=1---&coppa=0`) }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/floxisBidAdapter_spec.js b/test/spec/modules/floxisBidAdapter_spec.js new file mode 100644 index 00000000000..9cc8fe96c46 --- /dev/null +++ b/test/spec/modules/floxisBidAdapter_spec.js @@ -0,0 +1,501 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { spec } from 'modules/floxisBidAdapter.js'; +import { BANNER, NATIVE, VIDEO } from 'src/mediaTypes.js'; +import * as utils from 'src/utils.js'; + +describe('floxisBidAdapter', function () { + const DEFAULT_PARAMS = { seat: 'Gmtb', region: 'us-e', partner: 'floxis' }; + + const validBannerBid = { + bidId: 'bid-1', + bidder: 'floxis', + adUnitCode: 'adunit-banner', + mediaTypes: { banner: { sizes: [[300, 250], [728, 90]] } }, + params: { ...DEFAULT_PARAMS } + }; + + const validVideoBid = { + bidId: 'bid-2', + bidder: 'floxis', + adUnitCode: 'adunit-video', + mediaTypes: { + video: { + playerSize: [[640, 480]], + mimes: ['video/mp4'], + protocols: [2, 3], + context: 'instream' + } + }, + params: { ...DEFAULT_PARAMS } + }; + + const validNativeBid = { + bidId: 'bid-3', + bidder: 'floxis', + adUnitCode: 'adunit-native', + mediaTypes: { + native: { + image: { required: true, sizes: [150, 50] }, + title: { required: true, len: 80 } + } + }, + params: { ...DEFAULT_PARAMS } + }; + + describe('isBidRequestValid', function () { + it('should return true for valid banner bid', function () { + expect(spec.isBidRequestValid(validBannerBid)).to.be.true; + }); + + it('should return true for valid video bid', function () { + expect(spec.isBidRequestValid(validVideoBid)).to.be.true; + }); + + it('should return true for valid native bid', function () { + expect(spec.isBidRequestValid(validNativeBid)).to.be.true; + }); + + it('should return false when seat is missing', function () { + const bid = { ...validBannerBid, params: { region: 'us-e' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when seat is empty string', function () { + const bid = { ...validBannerBid, params: { seat: '', region: 'us-e' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return true when region is missing (default region applies)', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb' } }; + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + + it('should return false when region is empty string', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb', region: '' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return true when partner is missing (default partner applies)', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb', region: 'us-e' } }; + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + + it('should return false when partner is empty string', function () { + const bid = { ...validBannerBid, params: { seat: 'Gmtb', region: 'us-e', partner: '' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when params is missing', function () { + const bid = { bidId: 'x', mediaTypes: { banner: { sizes: [[300, 250]] } } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when seat is not a string', function () { + const bid = { ...validBannerBid, params: { seat: 123, region: 'us-e', partner: 'floxis' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when region is not in whitelist', function () { + const bid = { ...validBannerBid, params: { ...DEFAULT_PARAMS, region: 'eu-w' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when partner is not in whitelist', function () { + const bid = { ...validBannerBid, params: { ...DEFAULT_PARAMS, partner: 'mypartner' } }; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return true with default partner', function () { + const bid = { ...validBannerBid, params: { ...DEFAULT_PARAMS, partner: 'floxis' } }; + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + }); + + describe('supportedMediaTypes', function () { + it('should include banner, video, and native', function () { + expect(spec.supportedMediaTypes).to.deep.equal([BANNER, VIDEO, NATIVE]); + }); + }); + + describe('buildRequests', function () { + const bidderRequest = { + bidderCode: 'floxis', + auctionId: 'auction-123', + timeout: 3000 + }; + + it('should return an array with one POST request', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + expect(requests).to.be.an('array').with.lengthOf(1); + expect(requests[0].method).to.equal('POST'); + }); + + it('should build URL without partner prefix when partner is floxis', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + }); + + it('should return no requests for non-whitelisted partner', function () { + const bidWithPartner = { + ...validBannerBid, + params: { ...DEFAULT_PARAMS, partner: 'mypartner' } + }; + const requests = spec.buildRequests([bidWithPartner], bidderRequest); + expect(requests).to.be.an('array').that.is.empty; + }); + + it('should default region to us-e when missing', function () { + const bidWithoutRegion = { + ...validBannerBid, + params: { seat: 'Gmtb', partner: 'floxis' } + }; + const requests = spec.buildRequests([bidWithoutRegion], bidderRequest); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + }); + + it('should default partner to floxis when missing', function () { + const bidWithoutPartner = { + ...validBannerBid, + params: { seat: 'Gmtb', region: 'us-e' } + }; + const requests = spec.buildRequests([bidWithoutPartner], bidderRequest); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + }); + + it('should return empty array for empty bid requests', function () { + const requests = spec.buildRequests([], bidderRequest); + expect(requests).to.be.an('array').that.is.empty; + }); + + it('should produce valid ORTB request payload', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const data = requests[0].data; + expect(data).to.be.an('object'); + expect(data.imp).to.be.an('array').with.lengthOf(1); + expect(data.at).to.equal(1); + }); + + it('should set ext with adapter info', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const data = requests[0].data; + expect(data.ext.prebid.adapter).to.equal('floxis'); + expect(data.ext.prebid.adapterVersion).to.be.undefined; + expect(data.ext.prebid.version).to.equal('$prebid.version$'); + }); + + it('should build banner imp correctly', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp).to.have.property('banner'); + expect(imp.banner.format).to.be.an('array'); + expect(imp.secure).to.equal(1); + }); + + if (FEATURES.VIDEO) { + it('should build video imp correctly', function () { + const requests = spec.buildRequests([validVideoBid], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp).to.have.property('video'); + expect(imp.video.mimes).to.deep.equal(['video/mp4']); + expect(imp.video.protocols).to.deep.equal([2, 3]); + }); + } + + it('should handle multiple bids in single request', function () { + const requests = spec.buildRequests([validBannerBid, validVideoBid], bidderRequest); + expect(requests).to.have.lengthOf(1); + expect(requests[0].data.imp).to.have.lengthOf(2); + }); + + it('should split requests by seat when using allowed defaults', function () { + const mixedBid = { + ...validVideoBid, + params: { + seat: 'Seat2', + region: 'us-e', + partner: 'floxis' + } + }; + + const requests = spec.buildRequests([validBannerBid, mixedBid], bidderRequest); + expect(requests).to.have.lengthOf(2); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + expect(requests[1].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Seat2'); + expect(requests[0].data.imp).to.have.lengthOf(1); + expect(requests[1].data.imp).to.have.lengthOf(1); + }); + + it('should ignore non-whitelisted bids in mixed request arrays', function () { + const invalidBid = { + ...validVideoBid, + params: { + seat: 'Seat2', + region: 'eu-w', + partner: 'mypartner' + } + }; + + const requests = spec.buildRequests([validBannerBid, invalidBid], bidderRequest); + expect(requests).to.have.lengthOf(1); + expect(requests[0].url).to.equal('https://us-e.floxis.tech/pbjs?seat=Gmtb'); + expect(requests[0].data.imp).to.have.lengthOf(1); + }); + + it('should set withCredentials option', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + expect(requests[0].options.withCredentials).to.be.true; + }); + + describe('Floors Module support', function () { + it('should set bidfloor from getFloor', function () { + const bidWithFloor = { + ...validBannerBid, + getFloor: function () { + return { floor: 2.5, currency: 'USD' }; + } + }; + const requests = spec.buildRequests([bidWithFloor], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp.bidfloor).to.equal(2.5); + expect(imp.bidfloorcur).to.equal('USD'); + }); + + it('should not set bidfloor when getFloor is not present', function () { + const requests = spec.buildRequests([validBannerBid], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp.bidfloor).to.be.undefined; + }); + + it('should handle getFloor throwing an error gracefully', function () { + const bidBrokenFloor = { + ...validBannerBid, + getFloor: function () { + throw new Error('floor error'); + } + }; + const requests = spec.buildRequests([bidBrokenFloor], bidderRequest); + const imp = requests[0].data.imp[0]; + expect(imp.bidfloor).to.be.undefined; + }); + }); + + describe('ortb2 passthrough', function () { + it('should merge ortb2 data into the ORTB request', function () { + const ortb2BidderRequest = { + ...bidderRequest, + ortb2: { + regs: { ext: { gdpr: 1 } }, + user: { ext: { consent: 'consent-string-123' } } + } + }; + const requests = spec.buildRequests([validBannerBid], ortb2BidderRequest); + const data = requests[0].data; + expect(data.regs.ext.gdpr).to.equal(1); + expect(data.user.ext.consent).to.equal('consent-string-123'); + }); + + it('should merge ortb2 USP data into the ORTB request', function () { + const uspBidderRequest = { + ...bidderRequest, + ortb2: { + regs: { ext: { us_privacy: '1YNN' } } + } + }; + const requests = spec.buildRequests([validBannerBid], uspBidderRequest); + const data = requests[0].data; + expect(data.regs.ext.us_privacy).to.equal('1YNN'); + }); + }); + }); + + describe('interpretResponse', function () { + function buildRequest() { + return spec.buildRequests([validBannerBid], { + bidderCode: 'floxis', + auctionId: 'auction-123' + })[0]; + } + + it('should parse valid banner ORTB response', function () { + const request = buildRequest(); + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validBannerBid.bidId, + price: 1.23, + w: 300, + h: 250, + crid: 'creative-1', + adm: '
ad
', + mtype: 1 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, request); + expect(bids).to.be.an('array').with.lengthOf(1); + expect(bids[0].cpm).to.equal(1.23); + expect(bids[0].width).to.equal(300); + expect(bids[0].height).to.equal(250); + expect(bids[0].creativeId).to.equal('creative-1'); + expect(bids[0].ad).to.equal('
ad
'); + expect(bids[0].requestId).to.equal(validBannerBid.bidId); + expect(bids[0].netRevenue).to.equal(true); + expect(bids[0].currency).to.equal('USD'); + }); + + if (FEATURES.VIDEO) { + it('should parse valid video ORTB response', function () { + const videoRequest = spec.buildRequests([validVideoBid], { + bidderCode: 'floxis', + auctionId: 'auction-456' + })[0]; + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validVideoBid.bidId, + price: 5.00, + w: 640, + h: 480, + crid: 'video-creative-1', + adm: '', + mtype: 2 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, videoRequest); + expect(bids).to.be.an('array').with.lengthOf(1); + expect(bids[0].cpm).to.equal(5.00); + expect(bids[0].vastXml).to.equal(''); + expect(bids[0].mediaType).to.equal(VIDEO); + }); + } + + it('should return empty array for empty response', function () { + const request = buildRequest(); + const bids = spec.interpretResponse({ body: {} }, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return empty array for null response body', function () { + const request = buildRequest(); + const bids = spec.interpretResponse({ body: null }, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return empty array for undefined response', function () { + const request = buildRequest(); + const bids = spec.interpretResponse(undefined, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return empty array for undefined request', function () { + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validBannerBid.bidId, + price: 1.23, + w: 300, + h: 250, + crid: 'creative-1', + adm: '
ad
', + mtype: 1 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, undefined); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should handle multiple bids in seatbid', function () { + const bids2 = [ + { ...validBannerBid, bidId: 'bid-a' }, + { ...validBannerBid, bidId: 'bid-b', adUnitCode: 'adunit-2' } + ]; + const request = spec.buildRequests(bids2, { bidderCode: 'floxis', auctionId: 'a1' })[0]; + const serverResponse = { + body: { + seatbid: [{ + bid: [ + { impid: 'bid-a', price: 1.0, w: 300, h: 250, crid: 'c1', adm: '
1
', mtype: 1 }, + { impid: 'bid-b', price: 2.0, w: 300, h: 250, crid: 'c2', adm: '
2
', mtype: 1 } + ] + }] + } + }; + const result = spec.interpretResponse(serverResponse, request); + expect(result).to.have.lengthOf(2); + expect(result[0].cpm).to.equal(1.0); + expect(result[1].cpm).to.equal(2.0); + }); + + it('should set advertiserDomains from adomain', function () { + const request = buildRequest(); + const serverResponse = { + body: { + seatbid: [{ + bid: [{ + impid: validBannerBid.bidId, + price: 1.0, + w: 300, + h: 250, + crid: 'c1', + adm: '
ad
', + adomain: ['adv.com'], + mtype: 1 + }] + }] + } + }; + const bids = spec.interpretResponse(serverResponse, request); + expect(bids[0].meta.advertiserDomains).to.deep.equal(['adv.com']); + }); + }); + + describe('getUserSyncs', function () { + it('should return empty array', function () { + expect(spec.getUserSyncs()).to.be.an('array').that.is.empty; + }); + }); + + describe('onBidWon', function () { + let triggerPixelStub; + + beforeEach(function () { + triggerPixelStub = sinon.stub(utils, 'triggerPixel'); + }); + + afterEach(function () { + triggerPixelStub.restore(); + }); + + it('should fire burl pixel', function () { + spec.onBidWon({ burl: 'https://example.com/burl' }); + expect(triggerPixelStub.calledWith('https://example.com/burl')).to.be.true; + }); + + it('should fire nurl pixel', function () { + spec.onBidWon({ nurl: 'https://example.com/nurl' }); + expect(triggerPixelStub.calledWith('https://example.com/nurl')).to.be.true; + }); + + it('should fire both burl and nurl pixels', function () { + spec.onBidWon({ + burl: 'https://example.com/burl', + nurl: 'https://example.com/nurl' + }); + expect(triggerPixelStub.callCount).to.equal(2); + }); + + it('should not fire pixels when no urls present', function () { + spec.onBidWon({}); + expect(triggerPixelStub.called).to.be.false; + }); + }); +}); diff --git a/test/spec/modules/gamAdServerVideo_spec.js b/test/spec/modules/gamAdServerVideo_spec.js index a8ce01c1457..1004b4b5aae 100644 --- a/test/spec/modules/gamAdServerVideo_spec.js +++ b/test/spec/modules/gamAdServerVideo_spec.js @@ -17,6 +17,7 @@ import {AuctionIndex} from '../../../src/auctionIndex.js'; import { getVastXml } from '../../../modules/gamAdServerVideo.js'; import { server } from '../../mocks/xhr.js'; import { generateUUID } from '../../../src/utils.js'; +import { uspDataHandler, gppDataHandler } from '../../../src/consentHandler.js'; describe('The DFP video support module', function () { before(() => { @@ -870,4 +871,300 @@ describe('The DFP video support module', function () { .finally(config.resetConfig); server.respond(); }); + + describe('Retrieve US Privacy string from GPP when using the IMA player and downloading VAST XMLs', () => { + beforeEach(() => { + config.setConfig({cache: { useLocal: true }}); + // Install a fake IMA object, because the us_privacy is only set when IMA is available + window.google = { + ima: { + VERSION: '2.3.37' + } + } + }) + afterEach(() => { + config.resetConfig(); + }) + + async function obtainUsPrivacyInVastXmlRequest() { + const url = 'https://pubads.g.doubleclick.net/gampad/ads' + const bidCacheUrl = 'https://prebid-test-cache-server.org/cache?uuid=4536229c-eddb-45b3-a919-89d889e925aa'; + const gamWrapper = ( + `` + + `` + + `` + + `prebid.org wrapper` + + `` + + `` + + `` + + `` + ); + server.respondWith(gamWrapper); + + const result = getVastXml({url, adUnit: {}, bid: {}}, []).then(() => { + const request = server.requests[0]; + const url = new URL(request.url); + return url.searchParams.get('us_privacy'); + }); + server.respond(); + + return result; + } + + function mockGpp(gpp) { + sandbox.stub(gppDataHandler, 'getConsentData').returns(gpp) + } + + function wrapParsedSectionsIntoGPPData(parsedSections) { + return { + gppData: { + parsedSections: parsedSections + } + } + } + + it('should use usp when available, even when gpp is available', async () => { + const usPrivacy = '1YYY'; + sandbox.stub(uspDataHandler, 'getConsentData').returns(usPrivacy); + mockGpp(wrapParsedSectionsIntoGPPData({ + "uspv1": { + "Version": 1, + "Notice": "Y", + "OptOutSale": "N", + "LspaCovered": "Y" + } + })); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal(usPrivacy) + }) + + it('no us_privacy when neither usp nor gpp is present', async () => { + const usPrivacyFromRequqest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequqest).to.be.null; + }) + + it('can retrieve from usp section in gpp', async () => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "uspv1": { + "Version": 1, + "Notice": "Y", + "OptOutSale": "N", + "LspaCovered": "Y" + } + })); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YNY') + }) + it('can retrieve from usnat section in gpp', async () => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": { + "Version": 1, + "SharingNotice": 2, + "SaleOptOutNotice": 1, + "SharingOptOutNotice": 0, + "TargetedAdvertisingOptOutNotice": 2, + "SensitiveDataProcessingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": 1, + "SharingOptOut": 2, + "TargetedAdvertisingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0, + 0 + ], + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 1, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + "GpcSegmentType": 1, + "Gpc": false + } + })); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YYY'); + }) + it('can retrieve from usnat section in gpp when usnat is an array', async() => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": [ + { + "Version": 1, + "SharingNotice": 2, + "SaleOptOutNotice": 1, + "SharingOptOutNotice": 1, + "TargetedAdvertisingOptOutNotice": 1, + "SensitiveDataProcessingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 0, + "SaleOptOut": 2, + "SharingOptOut": 2, + "TargetedAdvertisingOptOut": 2, + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 0, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + }, { + "GpcSegmentType": 1, + "Gpc": false + } + ] + })) + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YNY'); + }) + it('no us_privacy when either SaleOptOutNotice or SaleOptOut is missing', async () => { + // Missing SaleOptOutNotice + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": { + "Version": 1, + "SharingNotice": 2, + "SharingOptOutNotice": 0, + "TargetedAdvertisingOptOutNotice": 2, + "SensitiveDataProcessingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": 1, + "SharingOptOut": 2, + "TargetedAdvertisingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0, + 0 + ], + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 1, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + "GpcSegmentType": 1, + "Gpc": false + } + })); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.be.null; + }) + it('no us_privacy when either SaleOptOutNotice or SaleOptOut is null', async () => { + // null SaleOptOut + mockGpp(wrapParsedSectionsIntoGPPData({ + "usnat": { + "Version": 1, + "SharingNotice": 2, + "SaleOptOutNotice": 1, + "SharingOptOutNotice": 0, + "TargetedAdvertisingOptOutNotice": 2, + "SensitiveDataProcessingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": null, + "SharingOptOut": 2, + "TargetedAdvertisingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0, + 0 + ], + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 1, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + "GpcSegmentType": 1, + "Gpc": false + } + })); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.be.null; + }) + + it('can retrieve from usca section in gpp', async () => { + mockGpp(wrapParsedSectionsIntoGPPData({ + "usca": { + "Version": 1, + "SaleOptOutNotice": 1, + "SharingOptOutNotice": 1, + "SensitiveDataLimitUseNotice": 1, + "SaleOptOut": 2, + "SharingOptOut": 2, + "SensitiveDataProcessing": [ + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "KnownChildSensitiveDataConsents": [ + 0, + 0 + ], + "PersonalDataConsents": 0, + "MspaCoveredTransaction": 2, + "MspaOptOutOptionMode": 0, + "MspaServiceProviderMode": 0, + "GpcSegmentType": 1, + "Gpc": false + }})); + + const usPrivacyFromRequest = await obtainUsPrivacyInVastXmlRequest(); + expect(usPrivacyFromRequest).to.equal('1YNY'); + }) + }); }); diff --git a/test/spec/modules/gumgumBidAdapter_spec.js b/test/spec/modules/gumgumBidAdapter_spec.js index 1ceaf4f2646..67caf24dba3 100644 --- a/test/spec/modules/gumgumBidAdapter_spec.js +++ b/test/spec/modules/gumgumBidAdapter_spec.js @@ -100,6 +100,39 @@ describe('gumgumAdapter', function () { describe('buildRequests', function () { const sizesArray = [[300, 250], [300, 600]]; + const id5Eid = { + source: 'id5-sync.com', + uids: [{ + id: 'uid-string', + ext: { + linkType: 2 + } + }] + }; + const pubProvidedIdEids = [ + { + uids: [ + { + ext: { + stype: 'ppuid', + }, + id: 'aac4504f-ef89-401b-a891-ada59db44336', + }, + ], + source: 'audigent.com', + }, + { + uids: [ + { + ext: { + stype: 'ppuid', + }, + id: 'y-zqTHmW9E2uG3jEETC6i6BjGcMhPXld2F~A', + }, + ], + source: 'crwdcntrl.net', + }, + ]; const bidderRequest = { ortb2: { site: { @@ -136,38 +169,7 @@ describe('gumgumAdapter', function () { sizes: sizesArray } }, - userId: { - id5id: { - uid: 'uid-string', - ext: { - linkType: 2 - } - } - }, - pubProvidedId: [ - { - uids: [ - { - ext: { - stype: 'ppuid', - }, - id: 'aac4504f-ef89-401b-a891-ada59db44336', - }, - ], - source: 'sonobi.com', - }, - { - uids: [ - { - ext: { - stype: 'ppuid', - }, - id: 'y-zqTHmW9E2uG3jEETC6i6BjGcMhPXld2F~A', - }, - ], - source: 'aol.com', - }, - ], + userIdAsEids: [id5Eid, ...pubProvidedIdEids], adUnitCode: 'adunit-code', sizes: sizesArray, bidId: '30b31c1838de1e', @@ -227,13 +229,139 @@ describe('gumgumAdapter', function () { it('should set pubProvidedId if the uid and pubProvidedId are available', function () { const request = { ...bidRequests[0] }; const bidRequest = spec.buildRequests([request])[0]; - expect(bidRequest.data.pubProvidedId).to.equal(JSON.stringify(bidRequests[0].userId.pubProvidedId)); + expect(bidRequest.data.pubProvidedId).to.equal(JSON.stringify(pubProvidedIdEids)); + }); + it('should filter pubProvidedId entries by allowed sources', function () { + const filteredRequest = { + ...bidRequests[0], + userIdAsEids: [ + { + source: 'audigent.com', + uids: [{ id: 'ppid-1', ext: { stype: 'ppuid' } }] + }, + { + source: 'sonobi.com', + uids: [{ id: 'ppid-2', ext: { stype: 'ppuid' } }] + } + ] + }; + const bidRequest = spec.buildRequests([filteredRequest])[0]; + const pubProvidedIds = JSON.parse(bidRequest.data.pubProvidedId); + expect(pubProvidedIds.length).to.equal(1); + expect(pubProvidedIds[0].source).to.equal('audigent.com'); + }); + it('should not set pubProvidedId when all sources are filtered out', function () { + const filteredRequest = { + ...bidRequests[0], + userIdAsEids: [{ + source: 'sonobi.com', + uids: [{ id: 'ppid-2', ext: { stype: 'ppuid' } }] + }] + }; + const bidRequest = spec.buildRequests([filteredRequest])[0]; + expect(bidRequest.data.pubProvidedId).to.equal(undefined); }); it('should set id5Id and id5IdLinkType if the uid and linkType are available', function () { const request = { ...bidRequests[0] }; const bidRequest = spec.buildRequests([request])[0]; - expect(bidRequest.data.id5Id).to.equal(bidRequests[0].userId.id5id.uid); - expect(bidRequest.data.id5IdLinkType).to.equal(bidRequests[0].userId.id5id.ext.linkType); + expect(bidRequest.data.id5Id).to.equal(id5Eid.uids[0].id); + expect(bidRequest.data.id5IdLinkType).to.equal(id5Eid.uids[0].ext.linkType); + }); + it('should use bidderRequest.ortb2.user.ext.eids when bid-level eids are not available', function () { + const request = { ...bidRequests[0], userIdAsEids: undefined }; + const fakeBidderRequest = { + ...bidderRequest, + ortb2: { + ...bidderRequest.ortb2, + user: { + ext: { + eids: [{ + source: 'liveramp.com', + uids: [{ + id: 'fallback-idl-env' + }] + }] + } + } + } + }; + const bidRequest = spec.buildRequests([request], fakeBidderRequest)[0]; + expect(bidRequest.data.idl_env).to.equal('fallback-idl-env'); + }); + it('should prioritize bidderRequest.ortb2.user.ext.eids over bid-level eids', function () { + const request = { + ...bidRequests[0], + userIdAsEids: [{ + source: 'liveramp.com', + uids: [{ id: 'bid-level-idl-env' }] + }] + }; + const fakeBidderRequest = { + ...bidderRequest, + ortb2: { + ...bidderRequest.ortb2, + user: { + ext: { + eids: [{ + source: 'liveramp.com', + uids: [{ id: 'ortb2-level-idl-env' }] + }] + } + } + } + }; + const bidRequest = spec.buildRequests([request], fakeBidderRequest)[0]; + expect(bidRequest.data.idl_env).to.equal('ortb2-level-idl-env'); + }); + it('should keep identity output consistent for prebid10 ortb2 eids input', function () { + const request = { ...bidRequests[0], userIdAsEids: undefined }; + const fakeBidderRequest = { + ...bidderRequest, + ortb2: { + ...bidderRequest.ortb2, + user: { + ext: { + eids: [ + { + source: 'uidapi.com', + uids: [{ id: 'uid2-token', atype: 3 }] + }, + { + source: 'liveramp.com', + uids: [{ id: 'idl-envelope', atype: 1 }] + }, + { + source: 'adserver.org', + uids: [{ id: 'tdid-value', atype: 1, ext: { rtiPartner: 'TDID' } }] + }, + { + source: 'id5-sync.com', + uids: [{ id: 'id5-value', atype: 1, ext: { linkType: 2 } }] + }, + { + source: 'audigent.com', + uids: [{ id: 'ppid-1', atype: 1, ext: { stype: 'ppuid' } }] + }, + { + source: 'sonobi.com', + uids: [{ id: 'ppid-2', atype: 1, ext: { stype: 'ppuid' } }] + } + ] + } + } + } + }; + const bidRequest = spec.buildRequests([request], fakeBidderRequest)[0]; + + // Expected identity payload shape from legacy GumGum request fields. + expect(bidRequest.data.uid2).to.equal('uid2-token'); + expect(bidRequest.data.idl_env).to.equal('idl-envelope'); + expect(bidRequest.data.tdid).to.equal('tdid-value'); + expect(bidRequest.data.id5Id).to.equal('id5-value'); + expect(bidRequest.data.id5IdLinkType).to.equal(2); + const pubProvidedId = JSON.parse(bidRequest.data.pubProvidedId); + expect(pubProvidedId.length).to.equal(1); + expect(pubProvidedId[0].source).to.equal('audigent.com'); }); it('should set pubId param if found', function () { @@ -614,7 +742,7 @@ describe('gumgumAdapter', function () { it('should set pubProvidedId if the uid and pubProvidedId are available', function () { const request = { ...bidRequests[0] }; const bidRequest = spec.buildRequests([request])[0]; - expect(bidRequest.data.pubProvidedId).to.equal(JSON.stringify(bidRequests[0].userId.pubProvidedId)); + expect(bidRequest.data.pubProvidedId).to.equal(JSON.stringify(pubProvidedIdEids)); }); it('should add gdpr consent parameters if gdprConsent is present', function () { @@ -714,14 +842,40 @@ describe('gumgumAdapter', function () { expect(bidRequest.data.uspConsent).to.eq(uspConsentObj.uspConsent); }); it('should add a tdid parameter if request contains unified id from TradeDesk', function () { - const unifiedId = { - 'userId': { - 'tdid': 'tradedesk-id' - } - } - const request = Object.assign(unifiedId, bidRequests[0]); + const tdidEid = { + source: 'adserver.org', + uids: [{ + id: 'tradedesk-id', + ext: { + rtiPartner: 'TDID' + } + }] + }; + const request = Object.assign({}, bidRequests[0], { userIdAsEids: [...bidRequests[0].userIdAsEids, tdidEid] }); + const bidRequest = spec.buildRequests([request])[0]; + expect(bidRequest.data.tdid).to.eq(tdidEid.uids[0].id); + }); + it('should add a tdid parameter when TDID uid is not the first uid in adserver.org', function () { + const tdidEid = { + source: 'adserver.org', + uids: [ + { + id: 'non-tdid-first', + ext: { + rtiPartner: 'NOT_TDID' + } + }, + { + id: 'tradedesk-id', + ext: { + rtiPartner: 'TDID' + } + } + ] + }; + const request = Object.assign({}, bidRequests[0], { userIdAsEids: [tdidEid] }); const bidRequest = spec.buildRequests([request])[0]; - expect(bidRequest.data.tdid).to.eq(unifiedId.userId.tdid); + expect(bidRequest.data.tdid).to.eq('tradedesk-id'); }); it('should not add a tdid parameter if unified id is not found', function () { const request = spec.buildRequests(bidRequests)[0]; @@ -729,7 +883,8 @@ describe('gumgumAdapter', function () { }); it('should send IDL envelope ID if available', function () { const idl_env = 'abc123'; - const request = { ...bidRequests[0], userId: { idl_env } }; + const idlEid = { source: 'liveramp.com', uids: [{ id: idl_env }] }; + const request = { ...bidRequests[0], userIdAsEids: [idlEid] }; const bidRequest = spec.buildRequests([request])[0]; expect(bidRequest.data).to.have.property('idl_env'); @@ -743,7 +898,8 @@ describe('gumgumAdapter', function () { }); it('should add a uid2 parameter if request contains uid2 id', function () { const uid2 = { id: 'sample-uid2' }; - const request = { ...bidRequests[0], userId: { uid2 } }; + const uid2Eid = { source: 'uidapi.com', uids: [{ id: uid2.id }] }; + const request = { ...bidRequests[0], userIdAsEids: [uid2Eid] }; const bidRequest = spec.buildRequests([request])[0]; expect(bidRequest.data).to.have.property('uid2'); diff --git a/test/spec/modules/harionBidAdapter_spec.js b/test/spec/modules/harionBidAdapter_spec.js new file mode 100644 index 00000000000..d5a1dd509a6 --- /dev/null +++ b/test/spec/modules/harionBidAdapter_spec.js @@ -0,0 +1,475 @@ +import { expect } from 'chai'; +import { spec } from '../../../modules/harionBidAdapter.js'; +import { BANNER, VIDEO, NATIVE } from '../../../src/mediaTypes.js'; +import { getUniqueIdentifierStr } from '../../../src/utils.js'; + +const bidder = 'Harion'; + +describe('HarionBidAdapter', function () { + const userIdAsEids = [{ + source: 'test.org', + uids: [{ + id: '01**********', + atype: 1, + ext: { + third: '01***********' + } + }] + }]; + const bids = [ + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + placementId: 'testBanner', + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [VIDEO]: { + playerSize: [[300, 300]], + minduration: 5, + maxduration: 60 + } + }, + params: { + placementId: 'testVideo', + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [NATIVE]: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + } + }, + params: { + placementId: 'testNative' + }, + userIdAsEids + } + ]; + + const invalidBid = { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + + } + } + + const bidderRequest = { + uspConsent: '1---', + gdprConsent: { + consentString: 'COvFyGBOvFyGBAbAAAENAPCAAOAAAAAAAAAAAEEUACCKAAA.IFoEUQQgAIQwgIwQABAEAAAAOIAACAIAAAAQAIAgEAACEAAAAAgAQBAAAAAAAGBAAgAAAAAAAFAAECAAAgAAQARAEQAAAAAJAAIAAgAAAYQEAAAQmAgBC3ZAYzUw', + vendorData: {} + }, + refererInfo: { + referer: 'https://test.com', + page: 'https://test.com' + }, + ortb2: { + device: { + w: 1512, + h: 982, + language: 'en-UK', + } + }, + timeout: 500 + }; + + describe('isBidRequestValid', function () { + it('Should return true if there are bidId, params and key parameters present', function () { + expect(spec.isBidRequestValid(bids[0])).to.be.true; + }); + it('Should return false if at least one of parameters is not present', function () { + expect(spec.isBidRequestValid(invalidBid)).to.be.false; + }); + }); + + describe('buildRequests', function () { + let serverRequest = spec.buildRequests(bids, bidderRequest); + + it('Creates a ServerRequest object with method, URL and data', function () { + expect(serverRequest).to.exist; + expect(serverRequest.method).to.exist; + expect(serverRequest.url).to.exist; + expect(serverRequest.data).to.exist; + }); + + it('Returns POST method', function () { + expect(serverRequest.method).to.equal('POST'); + }); + + it('Returns general data valid', function () { + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.all.keys( + 'deviceWidth', + 'deviceHeight', + 'device', + 'language', + 'secure', + 'host', + 'page', + 'placements', + 'coppa', + 'ccpa', + 'gdpr', + 'tmax', + 'bcat', + 'badv', + 'bapp', + 'battr' + ); + expect(data.deviceWidth).to.be.a('number'); + expect(data.deviceHeight).to.be.a('number'); + expect(data.language).to.be.a('string'); + expect(data.secure).to.be.within(0, 1); + expect(data.host).to.be.a('string'); + expect(data.page).to.be.a('string'); + expect(data.coppa).to.be.a('number'); + expect(data.gdpr).to.be.a('object'); + expect(data.ccpa).to.be.a('string'); + expect(data.tmax).to.be.a('number'); + expect(data.placements).to.have.lengthOf(3); + }); + + it('Returns valid placements', function () { + const { placements } = serverRequest.data; + for (let i = 0, len = placements.length; i < len; i++) { + const placement = placements[i]; + expect(placement.placementId).to.be.oneOf(['testBanner', 'testVideo', 'testNative']); + expect(placement.adFormat).to.be.oneOf([BANNER, VIDEO, NATIVE]); + expect(placement.bidId).to.be.a('string'); + expect(placement.schain).to.be.an('object'); + expect(placement.bidfloor).to.exist.and.to.equal(0); + expect(placement.type).to.exist.and.to.equal('publisher'); + expect(placement.eids).to.exist.and.to.be.deep.equal(userIdAsEids); + + if (placement.adFormat === BANNER) { + expect(placement.sizes).to.be.an('array'); + } + switch (placement.adFormat) { + case BANNER: + expect(placement.sizes).to.be.an('array'); + break; + case VIDEO: + expect(placement.playerSize).to.be.an('array'); + expect(placement.minduration).to.be.an('number'); + expect(placement.maxduration).to.be.an('number'); + break; + case NATIVE: + expect(placement.native).to.be.an('object'); + break; + } + } + }); + + it('Returns valid endpoints', function () { + const bids = [ + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + endpointId: 'testBanner', + }, + userIdAsEids + } + ]; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + + const { placements } = serverRequest.data; + for (let i = 0, len = placements.length; i < len; i++) { + const placement = placements[i]; + expect(placement.endpointId).to.be.oneOf(['testBanner', 'testVideo', 'testNative']); + expect(placement.adFormat).to.be.oneOf([BANNER, VIDEO, NATIVE]); + expect(placement.bidId).to.be.a('string'); + expect(placement.schain).to.be.an('object'); + expect(placement.bidfloor).to.exist.and.to.equal(0); + expect(placement.type).to.exist.and.to.equal('network'); + expect(placement.eids).to.exist.and.to.be.deep.equal(userIdAsEids); + + if (placement.adFormat === BANNER) { + expect(placement.sizes).to.be.an('array'); + } + switch (placement.adFormat) { + case BANNER: + expect(placement.sizes).to.be.an('array'); + break; + case VIDEO: + expect(placement.playerSize).to.be.an('array'); + expect(placement.minduration).to.be.an('number'); + expect(placement.maxduration).to.be.an('number'); + break; + case NATIVE: + expect(placement.native).to.be.an('object'); + break; + } + } + }); + + it('Returns data with gdprConsent and without uspConsent', function () { + delete bidderRequest.uspConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.gdpr).to.exist; + expect(data.gdpr).to.be.a('object'); + expect(data.gdpr).to.have.property('consentString'); + expect(data.gdpr).to.not.have.property('vendorData'); + expect(data.gdpr.consentString).to.equal(bidderRequest.gdprConsent.consentString); + expect(data.ccpa).to.not.exist; + delete bidderRequest.gdprConsent; + }); + + it('Returns data with uspConsent and without gdprConsent', function () { + bidderRequest.uspConsent = '1---'; + delete bidderRequest.gdprConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.ccpa).to.exist; + expect(data.ccpa).to.be.a('string'); + expect(data.ccpa).to.equal(bidderRequest.uspConsent); + expect(data.gdpr).to.not.exist; + }); + }); + + describe('gpp consent', function () { + it('bidderRequest.gppConsent', () => { + bidderRequest.gppConsent = { + gppString: 'abc123', + applicableSections: [8] + }; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + + delete bidderRequest.gppConsent; + }) + + it('bidderRequest.ortb2.regs.gpp', () => { + bidderRequest.ortb2 = bidderRequest.ortb2 || {}; + bidderRequest.ortb2.regs = bidderRequest.ortb2.regs || {}; + bidderRequest.ortb2.regs.gpp = 'abc123'; + bidderRequest.ortb2.regs.gpp_sid = [8]; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + }) + }); + + describe('interpretResponse', function () { + it('Should interpret banner response', function () { + const banner = { + body: [{ + mediaType: 'banner', + width: 300, + height: 250, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const bannerResponses = spec.interpretResponse(banner); + expect(bannerResponses).to.be.an('array').that.is.not.empty; + const dataItem = bannerResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'ad', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal(banner.body[0].requestId); + expect(dataItem.cpm).to.equal(banner.body[0].cpm); + expect(dataItem.width).to.equal(banner.body[0].width); + expect(dataItem.height).to.equal(banner.body[0].height); + expect(dataItem.ad).to.equal(banner.body[0].ad); + expect(dataItem.ttl).to.equal(banner.body[0].ttl); + expect(dataItem.creativeId).to.equal(banner.body[0].creativeId); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal(banner.body[0].currency); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret video response', function () { + const video = { + body: [{ + vastUrl: 'test.com', + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const videoResponses = spec.interpretResponse(video); + expect(videoResponses).to.be.an('array').that.is.not.empty; + + const dataItem = videoResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'vastUrl', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.5); + expect(dataItem.vastUrl).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret native response', function () { + const native = { + body: [{ + mediaType: 'native', + native: { + clickUrl: 'test.com', + title: 'Test', + image: 'test.com', + impressionTrackers: ['test.com'], + }, + ttl: 120, + cpm: 0.4, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const nativeResponses = spec.interpretResponse(native); + expect(nativeResponses).to.be.an('array').that.is.not.empty; + + const dataItem = nativeResponses[0]; + expect(dataItem).to.have.keys('requestId', 'cpm', 'ttl', 'creativeId', 'netRevenue', 'currency', 'mediaType', 'native', 'meta'); + expect(dataItem.native).to.have.keys('clickUrl', 'impressionTrackers', 'title', 'image') + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.4); + expect(dataItem.native.clickUrl).to.equal('test.com'); + expect(dataItem.native.title).to.equal('Test'); + expect(dataItem.native.image).to.equal('test.com'); + expect(dataItem.native.impressionTrackers).to.be.an('array').that.is.not.empty; + expect(dataItem.native.impressionTrackers[0]).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should return an empty array if invalid banner response is passed', function () { + const invBanner = { + body: [{ + width: 300, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + + const serverResponses = spec.interpretResponse(invBanner); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid video response is passed', function () { + const invVideo = { + body: [{ + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invVideo); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid native response is passed', function () { + const invNative = { + body: [{ + mediaType: 'native', + clickUrl: 'test.com', + title: 'Test', + impressionTrackers: ['test.com'], + ttl: 120, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + }] + }; + const serverResponses = spec.interpretResponse(invNative); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid response is passed', function () { + const invalid = { + body: [{ + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invalid); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + }); +}); diff --git a/test/spec/modules/humansecurityRtdProvider_spec.js b/test/spec/modules/humansecurityRtdProvider_spec.js index c6ad163c544..2104ffc6edd 100644 --- a/test/spec/modules/humansecurityRtdProvider_spec.js +++ b/test/spec/modules/humansecurityRtdProvider_spec.js @@ -10,10 +10,7 @@ const { SUBMODULE_NAME, SCRIPT_URL, main, - load, - onImplLoaded, - onImplMessage, - onGetBidRequestData + load } = __TEST__; describe('humansecurity RTD module', function () { @@ -23,20 +20,20 @@ describe('humansecurity RTD module', function () { const sonarStubId = `sonar_${stubUuid}`; const stubWindow = { [sonarStubId]: undefined }; - beforeEach(function() { + beforeEach(function () { sandbox = sinon.createSandbox(); sandbox.stub(utils, 'getWindowSelf').returns(stubWindow); sandbox.stub(utils, 'generateUUID').returns(stubUuid); sandbox.stub(refererDetection, 'getRefererInfo').returns({ domain: 'example.com' }); }); - afterEach(function() { + afterEach(function () { sandbox.restore(); }); describe('Initialization step', function () { let sandbox2; let connectSpy; - beforeEach(function() { + beforeEach(function () { sandbox2 = sinon.createSandbox(); connectSpy = sandbox.spy(); // Once the impl script is loaded, it registers the API using session ID @@ -46,6 +43,17 @@ describe('humansecurity RTD module', function () { sandbox2.restore(); }); + it('should connect to the implementation script once it loads', function () { + load({}); + + expect(loadExternalScriptStub.calledOnce).to.be.true; + const callback = loadExternalScriptStub.getCall(0).args[3]; + expect(callback).to.be.a('function'); + const args = connectSpy.getCall(0).args; + expect(args[0]).to.haveOwnProperty('cmd'); // pbjs global + expect(args[0]).to.haveOwnProperty('que'); + }); + it('should accept valid configurations', function () { // Default configuration - empty expect(() => load({})).to.not.throw(); @@ -62,14 +70,19 @@ describe('humansecurity RTD module', function () { }); it('should insert implementation script', () => { - load({ }); + load({}); expect(loadExternalScriptStub.calledOnce).to.be.true; const args = loadExternalScriptStub.getCall(0).args; - expect(args[0]).to.be.equal(`${SCRIPT_URL}?r=example.com`); + expect(args[0]).to.include(`${SCRIPT_URL}?r=example.com`); + const mvMatch = args[0].match(/[?&]mv=([^&]+)/); + expect(mvMatch).to.not.equal(null); + const mvValue = Number(mvMatch[1]); + expect(Number.isFinite(mvValue)).to.equal(true); + expect(mvValue).to.be.greaterThan(0); expect(args[2]).to.be.equal(SUBMODULE_NAME); - expect(args[3]).to.be.equal(onImplLoaded); + expect(args[3]).to.be.a('function'); expect(args[4]).to.be.equal(null); expect(args[5]).to.be.deep.equal({ 'data-sid': 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }); }); @@ -80,90 +93,14 @@ describe('humansecurity RTD module', function () { expect(loadExternalScriptStub.calledOnce).to.be.true; const args = loadExternalScriptStub.getCall(0).args; - expect(args[0]).to.be.equal(`${SCRIPT_URL}?r=example.com&c=customer123`); - }); - - it('should connect to the implementation script once it loads', function () { - load({ }); - - expect(loadExternalScriptStub.calledOnce).to.be.true; - expect(connectSpy.calledOnce).to.be.true; - - const args = connectSpy.getCall(0).args; - expect(args[0]).to.haveOwnProperty('cmd'); // pbjs global - expect(args[0]).to.haveOwnProperty('que'); - expect(args[1]).to.be.equal(onImplMessage); - }); - }); - - describe('Bid enrichment step', function () { - const hmnsData = { 'v1': 'sometoken' }; - - let sandbox2; - let callbackSpy; - let reqBidsConfig; - beforeEach(function() { - sandbox2 = sinon.createSandbox(); - callbackSpy = sandbox2.spy(); - reqBidsConfig = { ortb2Fragments: { bidder: {}, global: {} } }; - }); - afterEach(function () { - sandbox2.restore(); - }); - - it('should add empty device.ext.hmns to global ortb2 when data is yet to be received from the impl script', () => { - load({ }); - - onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); - - expect(callbackSpy.calledOnce).to.be.true; - expect(reqBidsConfig.ortb2Fragments.global).to.have.own.property('device'); - expect(reqBidsConfig.ortb2Fragments.global.device).to.have.own.property('ext'); - expect(reqBidsConfig.ortb2Fragments.global.device.ext).to.have.own.property('hmns').which.is.an('object').that.deep.equals({}); - }); - - it('should add the default device.ext.hmns to global ortb2 when no "hmns" data was yet received', () => { - load({ }); - - onImplMessage({ type: 'info', data: 'not a hmns message' }); - onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); - - expect(callbackSpy.calledOnce).to.be.true; - expect(reqBidsConfig.ortb2Fragments.global).to.have.own.property('device'); - expect(reqBidsConfig.ortb2Fragments.global.device).to.have.own.property('ext'); - expect(reqBidsConfig.ortb2Fragments.global.device.ext).to.have.own.property('hmns').which.is.an('object').that.deep.equals({}); - }); - - it('should add device.ext.hmns with received tokens to global ortb2 when the data was received', () => { - load({ }); - - onImplMessage({ type: 'hmns', data: hmnsData }); - onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); - - expect(callbackSpy.calledOnce).to.be.true; - expect(reqBidsConfig.ortb2Fragments.global).to.have.own.property('device'); - expect(reqBidsConfig.ortb2Fragments.global.device).to.have.own.property('ext'); - expect(reqBidsConfig.ortb2Fragments.global.device.ext).to.have.own.property('hmns').which.is.an('object').that.deep.equals(hmnsData); - }); - - it('should update device.ext.hmns with new data', () => { - load({ }); - - onImplMessage({ type: 'hmns', data: { 'v1': 'should be overwritten' } }); - onImplMessage({ type: 'hmns', data: hmnsData }); - onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); - - expect(callbackSpy.calledOnce).to.be.true; - expect(reqBidsConfig.ortb2Fragments.global).to.have.own.property('device'); - expect(reqBidsConfig.ortb2Fragments.global.device).to.have.own.property('ext'); - expect(reqBidsConfig.ortb2Fragments.global.device.ext).to.have.own.property('hmns').which.is.an('object').that.deep.equals(hmnsData); + expect(args[0]).to.include(`${SCRIPT_URL}?r=example.com&c=customer123`); }); }); - describe('Sumbodule execution', function() { + describe('Submodule execution', function () { let sandbox2; let submoduleStub; - beforeEach(function() { + beforeEach(function () { sandbox2 = sinon.createSandbox(); submoduleStub = sandbox2.stub(hook, 'submodule'); }); @@ -203,7 +140,7 @@ describe('humansecurity RTD module', function () { it('should commence initialization on default initialization', function () { const { init } = getModule(); - expect(init({ })).to.equal(true); + expect(init({})).to.equal(true); expect(loadExternalScriptStub.calledOnce).to.be.true; }); }); diff --git a/test/spec/modules/id5IdSystem_spec.js b/test/spec/modules/id5IdSystem_spec.js index 7249560c8c9..5964b683372 100644 --- a/test/spec/modules/id5IdSystem_spec.js +++ b/test/spec/modules/id5IdSystem_spec.js @@ -1361,13 +1361,8 @@ describe('ID5 ID System', function () { setTargetingStub = sinon.stub(); window.googletag = { cmd: [], - pubads: function () { - return { - setTargeting: setTargetingStub - }; - } + setConfig: setTargetingStub }; - sinon.spy(window.googletag, 'pubads'); storedObject = utils.deepClone(ID5_STORED_OBJ); }); @@ -1391,14 +1386,16 @@ describe('ID5 ID System', function () { for (const [tagName, tagValue] of Object.entries(tagsObj)) { const fullTagName = `${targetingEnabledConfig.params.gamTargetingPrefix}_${tagName}`; - const matchingCall = setTargetingStub.getCalls().find(call => call.args[0] === fullTagName); + const matchingCall = setTargetingStub.getCalls().find(call => { + const config = call.args[0]; + return config.targeting && config.targeting[fullTagName] !== undefined; + }); expect(matchingCall, `Tag ${fullTagName} was not set`).to.exist; - expect(matchingCall.args[1]).to.equal(tagValue); + expect(matchingCall.args[0].targeting[fullTagName]).to.equal(tagValue); } window.googletag.cmd = []; setTargetingStub.reset(); - window.googletag.pubads.resetHistory(); } it('should not set GAM targeting if it is not enabled', function () { @@ -1435,6 +1432,306 @@ describe('ID5 ID System', function () { }) }) + describe('Decode should also expose targeting via id5tags if configured', function () { + let origId5tags, storedObject; + const exposeTargetingConfig = getId5FetchConfig(); + exposeTargetingConfig.params.gamTargetingPrefix = 'id5'; + exposeTargetingConfig.params.exposeTargeting = true; + + beforeEach(function () { + delete window.id5tags; + storedObject = utils.deepClone(ID5_STORED_OBJ); + }); + + afterEach(function () { + delete window.id5tags; + id5System.id5IdSubmodule._reset(); + }); + + it('should not expose targeting if exposeTargeting is not enabled', function () { + const config = getId5FetchConfig(); + config.params.gamTargetingPrefix = 'id5'; + // exposeTargeting is not set + const testObj = { + ...storedObject, + 'tags': { + 'id': 'y', + 'ab': 'n' + } + }; + id5System.id5IdSubmodule.decode(testObj, config); + expect(window.id5tags).to.be.undefined; + }); + + it('should not expose targeting if tags not returned from server', function () { + // tags is not in the response + id5System.id5IdSubmodule.decode(storedObject, exposeTargetingConfig); + expect(window.id5tags).to.be.undefined; + }); + + it('should create id5tags.cmd when it does not exist pre-decode', function () { + const testObj = { + ...storedObject, + 'tags': { + 'id': 'y', + 'ab': 'n' + } + }; + id5System.id5IdSubmodule.decode(testObj, exposeTargetingConfig); + + expect(window.id5tags).to.exist; + expect(window.id5tags.cmd).to.be.an('array'); + expect(window.id5tags.tags).to.deep.equal({ + 'id': 'y', + 'ab': 'n' + }); + }); + + it('should execute queued functions when cmd was created earlier', async function () { + const testTags = { + 'id': 'y', + 'ab': 'n', + 'enrich': 'y' + }; + const testObj = { + ...storedObject, + 'tags': testTags + }; + + const callTracker = []; + let resolvePromise; + const callbackPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + // Pre-create id5tags with queued functions + window.id5tags = { + cmd: [ + (tags) => callTracker.push({call: 1, tags: tags}), + (tags) => callTracker.push({call: 2, tags: tags}), + (tags) => { + callTracker.push({call: 3, tags: tags}); + resolvePromise(); + } + ] + }; + + id5System.id5IdSubmodule.decode(testObj, exposeTargetingConfig); + + await callbackPromise; + + // Verify all queued functions were called with the tags + expect(callTracker).to.have.lengthOf(3); + expect(callTracker[0]).to.deep.equal({call: 1, tags: testTags}); + expect(callTracker[1]).to.deep.equal({call: 2, tags: testTags}); + expect(callTracker[2]).to.deep.equal({call: 3, tags: testTags}); + + // Verify tags were stored + expect(window.id5tags.tags).to.deep.equal(testTags); + }); + + it('should override push method to execute functions immediately', function () { + const testTags = { + 'id': 'y', + 'ab': 'n' + }; + const testObj = { + ...storedObject, + 'tags': testTags + }; + + id5System.id5IdSubmodule.decode(testObj, exposeTargetingConfig); + + // Now push a new function and verify it executes immediately + let callResult = null; + window.id5tags.cmd.push((tags) => { + callResult = {executed: true, tags: tags}; + }); + + expect(callResult).to.not.be.null; + expect(callResult.executed).to.be.true; + expect(callResult.tags).to.deep.equal(testTags); + }); + + it('should retrigger functions when tags are different but not when tags are the same', async function () { + const firstTags = { + 'id': 'y', + 'ab': 'n' + }; + const secondTags = { + 'id': 'y', + 'ab': 'y', + 'enrich': 'y' + }; + + const firstObj = { + ...storedObject, + 'tags': firstTags + }; + + const callTracker = []; + + // First decode + let resolveFirstPromise; + const firstCallbackPromise = new Promise((resolve) => { + resolveFirstPromise = resolve; + }); + + window.id5tags = { + cmd: [ + (tags) => { + callTracker.push({call: 'decode', tags: utils.deepClone(tags)}); + resolveFirstPromise(); + } + ] + }; + + id5System.id5IdSubmodule.decode(firstObj, exposeTargetingConfig); + + await firstCallbackPromise; + + expect(callTracker).to.have.lengthOf(1); + expect(callTracker[0].tags).to.deep.equal(firstTags); + + // Second decode with different tags - should retrigger + const secondObj = { + ...storedObject, + 'tags': secondTags + }; + + let resolveSecondPromise; + const secondCallbackPromise = new Promise((resolve) => { + resolveSecondPromise = resolve; + }); + + // Update the callback to resolve when called again + window.id5tags.cmd[0] = (tags) => { + callTracker.push({call: 'decode', tags: utils.deepClone(tags)}); + resolveSecondPromise(); + }; + + id5System.id5IdSubmodule.decode(secondObj, exposeTargetingConfig); + + await secondCallbackPromise; + + // The queued function should be called again with new tags + expect(callTracker).to.have.lengthOf(2); + expect(callTracker[1].tags).to.deep.equal(secondTags); + expect(window.id5tags.tags).to.deep.equal(secondTags); + + // Third decode with identical tags content (but different object reference) - should NOT retrigger + const thirdObj = { + ...storedObject, + 'tags': { + 'id': 'y', + 'ab': 'y', + 'enrich': 'y' + } + }; + + id5System.id5IdSubmodule.decode(thirdObj, exposeTargetingConfig); + + // Give it a small delay to ensure it doesn't retrigger + await new Promise(resolve => setTimeout(resolve, 50)); + + // With deepEqual, this should NOT retrigger since content is the same as secondTags + expect(callTracker).to.have.lengthOf(2); + expect(window.id5tags.tags).to.deep.equal(secondTags); + }); + + it('should handle when someone else has set id5tags.cmd earlier', async function () { + const testTags = { + 'id': 'y', + 'ab': 'n' + }; + const testObj = { + ...storedObject, + 'tags': testTags + }; + + const externalCallTracker = []; + let resolvePromise; + const callbackPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + // External script creates id5tags + window.id5tags = { + cmd: [], + externalData: 'some-external-value' + }; + + // Add external function + window.id5tags.cmd.push((tags) => { + externalCallTracker.push({external: true, tags: tags}); + resolvePromise(); + }); + + id5System.id5IdSubmodule.decode(testObj, exposeTargetingConfig); + + await callbackPromise; + + // External function should be called + expect(externalCallTracker).to.have.lengthOf(1); + expect(externalCallTracker[0].external).to.be.true; + expect(externalCallTracker[0].tags).to.deep.equal(testTags); + + // External data should be preserved + expect(window.id5tags.externalData).to.equal('some-external-value'); + + // Tags should be set + expect(window.id5tags.tags).to.deep.equal(testTags); + }); + + it('should work with both gamTargetingPrefix and exposeTargeting enabled', async function () { + // Setup googletag + const origGoogletag = window.googletag; + window.googletag = { + cmd: [], + setConfig: sinon.stub() + }; + + const testTags = { + 'id': 'y', + 'ab': 'n' + }; + const testObj = { + ...storedObject, + 'tags': testTags + }; + + const callTracker = []; + let resolvePromise; + const callbackPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + window.id5tags = { + cmd: [(tags) => { + callTracker.push(tags); + resolvePromise(); + }] + }; + + id5System.id5IdSubmodule.decode(testObj, exposeTargetingConfig); + + await callbackPromise; + + // Both mechanisms should work + expect(window.googletag.cmd.length).to.be.at.least(1); + expect(callTracker).to.have.lengthOf(1); + expect(callTracker[0]).to.deep.equal(testTags); + expect(window.id5tags.tags).to.deep.equal(testTags); + + // Restore + if (origGoogletag) { + window.googletag = origGoogletag; + } else { + delete window.googletag; + } + }); + }); + describe('A/B Testing', function () { const expectedDecodedObjectWithIdAbOff = {id5id: {uid: ID5_STORED_ID, ext: {linkType: ID5_STORED_LINK_TYPE}}}; const expectedDecodedObjectWithIdAbOn = { diff --git a/test/spec/modules/insticatorBidAdapter_spec.js b/test/spec/modules/insticatorBidAdapter_spec.js index cee99ca8c38..6350b5e1de7 100644 --- a/test/spec/modules/insticatorBidAdapter_spec.js +++ b/test/spec/modules/insticatorBidAdapter_spec.js @@ -812,6 +812,136 @@ describe('InsticatorBidAdapter', function () { const data = JSON.parse(requests[0].data); expect(data.site.publisher).to.not.an('object'); }); + + it('should include publisherId as query parameter in endpoint URL', function () { + const tempBiddRequest = { + ...bidRequest, + } + tempBiddRequest.params = { + ...tempBiddRequest.params, + publisherId: '86dd03a1-053f-4e3e-90e7-389070a0c62c' + } + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.include('publisherId=86dd03a1-053f-4e3e-90e7-389070a0c62c'); + }); + + it('should not include publisherId query param if publisherId is not present', function () { + const tempBiddRequest = { + ...bidRequest, + } + // Ensure no publisherId in params + delete tempBiddRequest.params.publisherId; + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.not.include('publisherId'); + }); + + it('should not include publisherId query param if publisherId is empty string', function () { + const tempBiddRequest = { + ...bidRequest, + } + tempBiddRequest.params = { + ...tempBiddRequest.params, + publisherId: '' + } + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.not.include('publisherId'); + }); + + it('should include publisherId query param with custom endpoint URL', function () { + const tempBiddRequest = { + ...bidRequest, + } + tempBiddRequest.params = { + ...tempBiddRequest.params, + publisherId: 'test-publisher-123', + bid_endpoint_request_url: 'https://custom.endpoint.com/v1/bid' + } + const requests = spec.buildRequests([tempBiddRequest], bidderRequest); + expect(requests[0].url).to.equal('https://custom.endpoint.com/v1/bid?publisherId=test-publisher-123'); + }); + + // ORTB 2.6 Ad Pod video params tests + describe('Ad Pod video params', function () { + it('should include Ad Pod params when present in video mediaType', function () { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4', 'video/mpeg'], + w: 640, + h: 480, + podid: 'pod-123', + podseq: 1, + poddur: 300, + slotinpod: 1, + mincpmpersec: 0.02, + maxseq: 5, + rqddurs: [15, 30, 60], + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.have.property('podid', 'pod-123'); + expect(data.imp[0].video).to.have.property('podseq', 1); + expect(data.imp[0].video).to.have.property('poddur', 300); + expect(data.imp[0].video).to.have.property('slotinpod', 1); + expect(data.imp[0].video).to.have.property('mincpmpersec', 0.02); + expect(data.imp[0].video).to.have.property('maxseq', 5); + expect(data.imp[0].video).to.have.property('rqddurs').that.deep.equals([15, 30, 60]); + }); + + it('should not include invalid ORTB 2.6 video params', function () { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 480, + podid: '', // invalid - empty string + podseq: -1, // invalid - negative + poddur: 0, // invalid - zero + slotinpod: 5, // invalid - not in [-1, 0, 1, 2] + mincpmpersec: -0.5, // invalid - negative + maxseq: 0, // invalid - zero + rqddurs: [0, -15], // invalid - contains non-positive values + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.not.have.property('podid'); + expect(data.imp[0].video).to.not.have.property('podseq'); + expect(data.imp[0].video).to.not.have.property('poddur'); + expect(data.imp[0].video).to.not.have.property('slotinpod'); + expect(data.imp[0].video).to.not.have.property('mincpmpersec'); + expect(data.imp[0].video).to.not.have.property('maxseq'); + expect(data.imp[0].video).to.not.have.property('rqddurs'); + }); + + it('should validate slotinpod accepts valid values [-1, 0, 1, 2]', function () { + [-1, 0, 1, 2].forEach(slotValue => { + const adPodBidRequest = { + ...bidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 480, + slotinpod: slotValue, + }, + }, + }; + const requests = spec.buildRequests([adPodBidRequest], bidderRequest); + const data = JSON.parse(requests[0].data); + + expect(data.imp[0].video).to.have.property('slotinpod', slotValue); + }); + }); + }); }); describe('interpretResponse', function () { @@ -929,7 +1059,7 @@ describe('InsticatorBidAdapter', function () { cpm: 0.5, currency: 'USD', netRevenue: true, - ttl: 60, + ttl: 60, // MIN(60, 300) = 60 - bid.exp is upper bound width: 300, height: 200, mediaType: 'banner', @@ -937,7 +1067,8 @@ describe('InsticatorBidAdapter', function () { adUnitCode: 'adunit-code-1', meta: { advertiserDomains: ['test1.com'], - test: 1 + test: 1, + seat: 'some-dsp' } }, { @@ -953,7 +1084,8 @@ describe('InsticatorBidAdapter', function () { meta: { advertiserDomains: [ 'test2.com' - ] + ], + seat: 'some-dsp' }, ad: 'adm2', adUnitCode: 'adunit-code-2', @@ -971,7 +1103,8 @@ describe('InsticatorBidAdapter', function () { meta: { advertiserDomains: [ 'test3.com' - ] + ], + seat: 'some-dsp' }, ad: 'adm3', adUnitCode: 'adunit-code-3', @@ -996,6 +1129,515 @@ describe('InsticatorBidAdapter', function () { delete response.body.seatbid; expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); }); + + it('should return empty response for 204 No Content (undefined body)', function () { + const response = { body: undefined }; + expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); + }); + + it('should return empty response for 204 No Content (null body)', function () { + const response = { body: null }; + expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); + }); + + it('should return empty response for empty object body', function () { + const response = { body: {} }; + expect(spec.interpretResponse(response, bidRequests)).to.have.length(0); + }); + + // ORTB 2.6 Response Fields Tests + describe('ORTB 2.6 response fields', function () { + const ortb26BidRequests = { + method: 'POST', + url: 'https://ex.ingage.tech/v1/openrtb', + options: { + contentType: 'application/json', + withCredentials: true, + }, + data: '', + bidderRequest: { + bidderRequestId: '22edbae2733bf6', + auctionId: '74f78609-a92d-4cf1-869f-1b244bbfb5d2', + timeout: 300, + bids: [ + { + bidder: 'insticator', + params: { + adUnitId: '1a2b3c4d5e6f1a2b3c4d' + }, + adUnitCode: 'adunit-code-1', + sizes: [[300, 250]], + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bidId: 'bid1', + } + ] + } + }; + + it('should map category (cat) to meta.primaryCatId and meta.secondaryCatIds', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + cat: ['IAB1', 'IAB2-1', 'IAB3'], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('primaryCatId', 'IAB1'); + expect(bidResponse.meta).to.have.property('secondaryCatIds').that.deep.equals(['IAB2-1', 'IAB3']); + }); + + it('should map single category without secondaryCatIds', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + cat: ['IAB1'], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('primaryCatId', 'IAB1'); + expect(bidResponse.meta).to.not.have.property('secondaryCatIds'); + }); + + it('should map seat to meta.seat', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-seat-123', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + adomain: ['test.com'], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('seat', 'dsp-seat-123'); + }); + + it('should map creative attributes (attr) to meta.attr', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + attr: [1, 2, 3], + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse.meta).to.have.property('attr').that.deep.equals([1, 2, 3]); + }); + + it('should map dealid to bidResponse.dealId', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + dealid: 'deal-abc-123', + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse).to.have.property('dealId', 'deal-abc-123'); + }); + + it('should map billing URL (burl) to bidResponse.burl', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + burl: 'https://billing.example.com/win?price=${AUCTION_PRICE}', + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse).to.have.property('burl', 'https://billing.example.com/win?price=${AUCTION_PRICE}'); + }); + + it('should map notice URL (nurl) to bidResponse.nurl', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + nurl: 'https://win.example.com/notify?price=${AUCTION_PRICE}', + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + expect(bidResponse).to.have.property('nurl', 'https://win.example.com/notify?price=${AUCTION_PRICE}'); + }); + + it('should map video duration (dur) to bidResponse.video.durationSeconds', function () { + const videoBidRequests = { + ...ortb26BidRequests, + bidderRequest: { + ...ortb26BidRequests.bidderRequest, + bids: [{ + ...ortb26BidRequests.bidderRequest.bids[0], + mediaTypes: { + video: { + mimes: ['video/mp4'], + playerSize: [[640, 480]], + } + } + }] + } + }; + + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 640, + h: 480, + adm: '', + dur: 30, + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, videoBidRequests)[0]; + + expect(bidResponse).to.have.property('video'); + expect(bidResponse.video).to.have.property('durationSeconds', 30); + }); + + it('should set video.durationSeconds and not set video.context for instream video', function () { + const instreamBidRequests = { + ...ortb26BidRequests, + bidderRequest: { + ...ortb26BidRequests.bidderRequest, + bids: [{ + ...ortb26BidRequests.bidderRequest.bids[0], + mediaTypes: { + video: { + mimes: ['video/mp4'], + playerSize: [[640, 480]], + context: 'instream', + } + } + }] + } + }; + + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 640, + h: 480, + adm: '', + dur: 30, + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, instreamBidRequests)[0]; + + expect(bidResponse).to.have.property('video'); + expect(bidResponse.video).to.have.property('durationSeconds', 30); + expect(bidResponse.video).to.not.have.property('context'); + }); + + it('should use MIN of bid.exp and BID_TTL for ttl (bid.exp is upper bound)', function () { + // When bid.exp (60) is less than BID_TTL (300), use 60 + const responseWithLowExp = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + exp: 60, + }] + }] + } + }; + const bidResponseLow = spec.interpretResponse(responseWithLowExp, ortb26BidRequests)[0]; + expect(bidResponseLow.ttl).to.equal(60); // MIN(60, 300) = 60 + + // When bid.exp (600) is greater than BID_TTL (300), use 300 + const responseWithHighExp = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + exp: 600, + }] + }] + } + }; + const bidResponseHigh = spec.interpretResponse(responseWithHighExp, ortb26BidRequests)[0]; + expect(bidResponseHigh.ttl).to.equal(300); // MIN(600, 300) = 300 + }); + + it('should default ttl to BID_TTL when bid.exp is not provided', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'adm1', + // no exp field + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.ttl).to.equal(300); // defaults to configTTL when no bid.exp + }); + + it('should include all ORTB 2.6 fields in a single response', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'full-dsp', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 2.5, + w: 300, + h: 250, + adm: 'adm1', + adomain: ['advertiser.com'], + cat: ['IAB1', 'IAB2'], + attr: [1, 2], + dealid: 'premium-deal', + burl: 'https://billing.example.com/win', + exp: 450, + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + + // Check all ORTB 2.6 fields + expect(bidResponse.meta.advertiserDomains).to.deep.equal(['advertiser.com']); + expect(bidResponse.meta.primaryCatId).to.equal('IAB1'); + expect(bidResponse.meta.secondaryCatIds).to.deep.equal(['IAB2']); + expect(bidResponse.meta.seat).to.equal('full-dsp'); + expect(bidResponse.meta.attr).to.deep.equal([1, 2]); + expect(bidResponse.dealId).to.equal('premium-deal'); + expect(bidResponse.burl).to.equal('https://billing.example.com/win'); + expect(bidResponse.ttl).to.equal(300); // MIN(450, 300) = 300 - bid.exp is upper bound + }); + + // Media Type Detection Tests + describe('media type detection', function () { + it('should detect video using mtype=2 (ORTB 2.6 standard)', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: 'some non-vast content', + mtype: 2, // video + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('video'); + }); + + it('should detect banner using mtype=1 (ORTB 2.6 standard)', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '', // VAST content but mtype says banner + mtype: 1, // banner + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('banner'); + }); + + it('should detect video using case-insensitive VAST detection', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '', // lowercase vast + // no mtype + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('video'); + }); + + it('should default to banner when no video signals present', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '
banner ad
', + // no mtype, no VAST + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('banner'); + }); + + it('should detect banner when VAST-like content is inside script tag', function () { + const response = { + body: { + id: '22edbae2733bf6', + seatbid: [{ + seat: 'dsp-1', + bid: [{ + impid: 'bid1', + crid: 'crid1', + price: 1.0, + w: 300, + h: 250, + adm: '
banner
', + // no mtype + }] + }] + } + }; + const bidResponse = spec.interpretResponse(response, ortb26BidRequests)[0]; + expect(bidResponse.mediaType).to.equal('banner'); + }); + }); + }); }); describe('getUserSyncs', function () { diff --git a/test/spec/modules/insuradsBidAdapter_spec.js b/test/spec/modules/insuradsBidAdapter_spec.js new file mode 100644 index 00000000000..0207477b8e4 --- /dev/null +++ b/test/spec/modules/insuradsBidAdapter_spec.js @@ -0,0 +1,737 @@ +import { expect } from 'chai'; +import { + spec, STORAGE, getInsurAdsLocalStorage, getGzipSetting, +} from 'modules/insuradsBidAdapter.js'; +import sinon from 'sinon'; +import { getAmxId } from '../../../libraries/nexx360Utils/index.js'; +const sandbox = sinon.createSandbox(); + +describe('InsurAds bid adapter tests', () => { + const DEFAULT_OPTIONS = { + gdprConsent: { + gdprApplies: true, + consentString: 'BOzZdA0OzZdA0AGABBENDJ-AAAAvh7_______9______9uz_Ov_v_f__33e8__9v_l_7_-___u_-33d4-_1vf99yfm1-7ftr3tp_87ues2_Xur__79__3z3_9pxP78k89r7337Mw_v-_v-b7JCPN_Y3v-8Kg', + vendorData: {}, + }, + refererInfo: { + referer: 'https://www.prebid.org', + canonicalUrl: 'https://www.prebid.org/the/link/to/the/page', + }, + uspConsent: '1112223334', + userId: { id5id: { uid: '1111' } }, + schain: { + ver: '1.0', + complete: 1, + nodes: [{ + asi: 'exchange1.com', + sid: '1234', + hp: 1, + rid: 'bid-request-1', + name: 'publisher', + domain: 'publisher.com', + }], + }, + }; + + it('We test getGzipSettings', () => { + const output = getGzipSetting(); + expect(output).to.be.a('boolean'); + }); + + describe('isBidRequestValid()', () => { + let bannerBid; + beforeEach(() => { + bannerBid = { + bidder: 'nexx360', + mediaTypes: { banner: { sizes: [[300, 250], [300, 600]] } }, + adUnitCode: 'div-1', + transactionId: '70bdc37e-9475-4b27-8c74-4634bdc2ee66', + sizes: [[300, 250], [300, 600]], + bidId: '4906582fc87d0c', + bidderRequestId: '332fda16002dbe', + auctionId: '98932591-c822-42e3-850e-4b3cf748d063', + } + }); + + it('We verify isBidRequestValid with unvalid adUnitName', () => { + bannerBid.params = { adUnitName: 1 }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(false); + }); + + it('We verify isBidRequestValid with empty adUnitName', () => { + bannerBid.params = { adUnitName: '' }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(false); + }); + + it('We verify isBidRequestValid with unvalid adUnitPath', () => { + bannerBid.params = { adUnitPath: 1 }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(false); + }); + + it('We verify isBidRequestValid with unvalid divId', () => { + bannerBid.params = { divId: 1 }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(false); + }); + + it('We verify isBidRequestValid unvalid allBids', () => { + bannerBid.params = { allBids: 1 }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(false); + }); + + it('We verify isBidRequestValid with uncorrect tagid', () => { + bannerBid.params = { 'tagid': 'luvxjvgn' }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(false); + }); + + it('We verify isBidRequestValid with correct tagId', () => { + bannerBid.params = { 'tagId': 'luvxjvgn' }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(true); + }); + + it('We verify isBidRequestValid with correct placement', () => { + bannerBid.params = { 'placement': 'testad' }; + expect(spec.isBidRequestValid(bannerBid)).to.be.equal(true); + }); + }); + + describe('getInsurAdsLocalStorage disabled', () => { + before(() => { + sandbox.stub(STORAGE, 'localStorageIsEnabled').callsFake(() => false); + }); + it('We test if we get the nexx360Id', () => { + const output = getInsurAdsLocalStorage(); + expect(output).to.be.eql(null); + }); + after(() => { + sandbox.restore() + }); + }); + + describe('getInsurAdsLocalStorage enabled but nothing', () => { + before(() => { + sandbox.stub(STORAGE, 'localStorageIsEnabled').callsFake(() => true); + sandbox.stub(STORAGE, 'setDataInLocalStorage'); + sandbox.stub(STORAGE, 'getDataFromLocalStorage').callsFake((key) => null); + }); + it('We test if we get the nexx360Id', () => { + const output = getInsurAdsLocalStorage(); + expect(typeof output.nexx360Id).to.be.eql('string'); + }); + after(() => { + sandbox.restore() + }); + }); + + describe('getInsurAdsLocalStorage enabled but wrong payload', () => { + before(() => { + sandbox.stub(STORAGE, 'localStorageIsEnabled').callsFake(() => true); + sandbox.stub(STORAGE, 'setDataInLocalStorage'); + sandbox.stub(STORAGE, 'getDataFromLocalStorage').callsFake((key) => '{"nexx360Id":"5ad89a6e-7801-48e7-97bb-fe6f251f6cb4",}'); + }); + it('We test if we get the nexx360Id', () => { + const output = getInsurAdsLocalStorage(); + expect(output).to.be.eql(null); + }); + after(() => { + sandbox.restore() + }); + }); + + describe('getInsurAdsLocalStorage enabled', () => { + before(() => { + sandbox.stub(STORAGE, 'localStorageIsEnabled').callsFake(() => true); + sandbox.stub(STORAGE, 'setDataInLocalStorage'); + sandbox.stub(STORAGE, 'getDataFromLocalStorage').callsFake((key) => '{"nexx360Id":"5ad89a6e-7801-48e7-97bb-fe6f251f6cb4"}'); + }); + it('We test if we get the nexx360Id', () => { + const output = getInsurAdsLocalStorage(); + expect(output.nexx360Id).to.be.eql('5ad89a6e-7801-48e7-97bb-fe6f251f6cb4'); + }); + after(() => { + sandbox.restore() + }); + }); + + describe('getAmxId() with localStorage enabled and data not set', () => { + before(() => { + sandbox.stub(STORAGE, 'localStorageIsEnabled').callsFake(() => true); + sandbox.stub(STORAGE, 'setDataInLocalStorage'); + sandbox.stub(STORAGE, 'getDataFromLocalStorage').callsFake((key) => null); + }); + it('We test if we get the amxId', () => { + const output = getAmxId(STORAGE, 'nexx360'); + expect(output).to.be.eql(null); + }); + after(() => { + sandbox.restore() + }); + }); + + describe('getAmxId() with localStorage enabled and data set', () => { + before(() => { + sandbox.stub(STORAGE, 'localStorageIsEnabled').callsFake(() => true); + sandbox.stub(STORAGE, 'setDataInLocalStorage'); + sandbox.stub(STORAGE, 'getDataFromLocalStorage').callsFake((key) => 'abcdef'); + }); + it('We test if we get the amxId', () => { + const output = getAmxId(STORAGE, 'nexx360'); + expect(output).to.be.eql('abcdef'); + }); + after(() => { + sandbox.restore() + }); + }); + + describe('buildRequests()', () => { + before(() => { + const documentStub = sandbox.stub(document, 'getElementById'); + documentStub.withArgs('div-1').returns({ + offsetWidth: 200, + offsetHeight: 250, + style: { + maxWidth: '400px', + maxHeight: '350px', + }, + getBoundingClientRect() { return { width: 200, height: 250 }; } + }); + sandbox.stub(STORAGE, 'localStorageIsEnabled').callsFake(() => true); + sandbox.stub(STORAGE, 'setDataInLocalStorage'); + sandbox.stub(STORAGE, 'getDataFromLocalStorage').callsFake((key) => 'abcdef'); + }); + describe('We test with a multiple display bids', () => { + const sampleBids = [ + { + bidder: 'nexx360', + params: { + tagId: 'luvxjvgn', + divId: 'div-1', + adUnitName: 'header-ad', + adUnitPath: '/12345/nexx360/Homepage/HP/Header-Ad', + }, + ortb2Imp: { + ext: { + gpid: '/12345/nexx360/Homepage/HP/Header-Ad', + } + }, + adUnitCode: 'header-ad-1234', + transactionId: '469a570d-f187-488d-b1cb-48c1a2009be9', + sizes: [[300, 250], [300, 600]], + bidId: '44a2706ac3574', + bidderRequestId: '359bf8a3c06b2e', + auctionId: '2e684815-b44e-4e04-b812-56da54adbe74', + }, + { + bidder: 'nexx360', + params: { + placement: 'testPlacement', + allBids: true, + }, + mediaTypes: { + banner: { + sizes: [[728, 90], [970, 250]] + } + }, + + adUnitCode: 'div-2-abcd', + transactionId: '6196885d-4e76-40dc-a09c-906ed232626b', + sizes: [[728, 90], [970, 250]], + bidId: '5ba94555219a03', + bidderRequestId: '359bf8a3c06b2e', + auctionId: '2e684815-b44e-4e04-b812-56da54adbe74', + } + ]; + const bidderRequest = { + bidderCode: 'nexx360', + auctionId: '2e684815-b44e-4e04-b812-56da54adbe74', + bidderRequestId: '359bf8a3c06b2e', + refererInfo: { + reachedTop: true, + isAmp: false, + numIframes: 0, + stack: [ + 'https://test.nexx360.io/adapter/index.html' + ], + topmostLocation: 'https://test.nexx360.io/adapter/index.html', + location: 'https://test.nexx360.io/adapter/index.html', + canonicalUrl: null, + page: 'https://test.nexx360.io/adapter/index.html', + domain: 'test.nexx360.io', + ref: null, + legacy: { + reachedTop: true, + isAmp: false, + numIframes: 0, + stack: [ + 'https://test.nexx360.io/adapter/index.html' + ], + referer: 'https://test.nexx360.io/adapter/index.html', + canonicalUrl: null + }, + }, + gdprConsent: { + gdprApplies: true, + consentString: 'CPhdLUAPhdLUAAKAsAENCmCsAP_AAE7AAAqIJFNd_H__bW9r-f5_aft0eY1P9_r37uQzDhfNk-8F3L_W_LwX52E7NF36tq4KmR4ku1LBIUNlHMHUDUmwaokVryHsak2cpzNKJ7BEknMZOydYGF9vmxtj-QKY7_5_d3bx2D-t_9v239z3z81Xn3d53-_03LCdV5_9Dfn9fR_bc9KPt_58v8v8_____3_e__3_7997BIiAaADgAJYBnwEeAJXAXmAwQBj4DtgHcgPBAeKBIgAA.YAAAAAAAAAAA', + } + }; + it('We perform a test with 2 display adunits', () => { + const displayBids = structuredClone(sampleBids); + displayBids[0].mediaTypes = { + banner: { + sizes: [[300, 250], [300, 600]] + } + }; + const request = spec.buildRequests(displayBids, bidderRequest); + const requestContent = request.data; + expect(request).to.have.property('method').and.to.equal('POST'); + const expectedRequest = { + imp: [ + { + id: '44a2706ac3574', + banner: { + topframe: 0, + format: [ + { w: 300, h: 250 }, + { w: 300, h: 600 }, + ], + }, + secure: 1, + tagid: 'header-ad-1234', + ext: { + adUnitCode: 'header-ad-1234', + gpid: '/12345/nexx360/Homepage/HP/Header-Ad', + divId: 'div-1', + dimensions: { + slotW: 200, + slotH: 250, + cssMaxW: '400px', + cssMaxH: '350px', + }, + nexx360: { + tagId: 'luvxjvgn', + adUnitName: 'header-ad', + adUnitPath: '/12345/nexx360/Homepage/HP/Header-Ad', + divId: 'div-1', + }, + adUnitName: 'header-ad', + adUnitPath: '/12345/nexx360/Homepage/HP/Header-Ad', + }, + }, + { + id: '5ba94555219a03', + banner: { + topframe: 0, + format: [ + { w: 728, h: 90 }, + { w: 970, h: 250 }, + ], + }, + secure: 1, + tagid: 'div-2-abcd', + ext: { + adUnitCode: 'div-2-abcd', + divId: 'div-2-abcd', + nexx360: { + placement: 'testPlacement', + divId: 'div-2-abcd', + allBids: true, + }, + }, + }, + ], + id: requestContent.id, + test: 0, + ext: { + version: requestContent.ext.version, + source: 'prebid.js', + pageViewId: requestContent.ext.pageViewId, + bidderVersion: '7.1', + localStorage: { amxId: 'abcdef'}, + sessionId: requestContent.ext.sessionId, + requestCounter: 0, + }, + cur: [ + 'USD', + ], + user: { + ext: { + eids: [ + { + source: 'amxdt.net', + uids: [ + { + id: 'abcdef', + atype: 1, + } + ] + } + ] + } + }, + }; + expect(requestContent).to.be.eql(expectedRequest); + }); + + if (FEATURES.VIDEO) { + it('We perform a test with a multiformat adunit', () => { + const multiformatBids = structuredClone(sampleBids); + multiformatBids[0].mediaTypes = { + banner: { + sizes: [[300, 250], [300, 600]] + }, + video: { + context: 'outstream', + playerSize: [640, 480], + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4, 5, 6, 7, 8], + playbackmethod: [2], + skip: 1, + playback_method: ['auto_play_sound_off'] + } + }; + const request = spec.buildRequests(multiformatBids, bidderRequest); + const video = request.data.imp[0].video; + const expectedVideo = { + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4, 5, 6, 7, 8], + playbackmethod: [2], + skip: 1, + w: 640, + h: 480, + ext: { + playerSize: [640, 480], + context: 'outstream', + }, + }; + expect(video).to.eql(expectedVideo); + }); + + it('We perform a test with a instream adunit', () => { + const videoBids = structuredClone(sampleBids); + videoBids[0].mediaTypes = { + video: { + context: 'instream', + playerSize: [640, 480], + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4, 5, 6], + playbackmethod: [2], + skip: 1 + } + }; + const request = spec.buildRequests(videoBids, bidderRequest); + const requestContent = request.data; + expect(request).to.have.property('method').and.to.equal('POST'); + expect(requestContent.imp[0].video.ext.context).to.be.eql('instream'); + expect(requestContent.imp[0].video.playbackmethod[0]).to.be.eql(2); + }); + } + }); + after(() => { + sandbox.restore() + }); + }); + + describe('We test interpretResponse', () => { + it('empty response', () => { + const response = { + body: '' + }; + const output = spec.interpretResponse(response); + expect(output.length).to.be.eql(0); + }); + it('banner responses with adm', () => { + const response = { + body: { + id: 'a8d3a675-a4ba-4d26-807f-c8f2fad821e0', + cur: 'USD', + seatbid: [ + { + bid: [ + { + id: '4427551302944024629', + impid: '226175918ebeda', + price: 1.5, + adomain: [ + 'http://prebid.org', + ], + crid: '98493581', + ssp: 'appnexus', + h: 600, + w: 300, + adm: '
TestAd
', + cat: [ + 'IAB3-1', + ], + ext: { + adUnitCode: 'div-1', + mediaType: 'banner', + adUrl: 'https://fast.nexx360.io/cache?uuid=fdddcebc-1edf-489d-880d-1418d8bdc493', + ssp: 'appnexus', + }, + }, + ], + seat: 'appnexus', + }, + ], + ext: { + id: 'de3de7c7-e1cf-4712-80a9-94eb26bfc718', + cookies: [], + }, + }, + }; + const output = spec.interpretResponse(response); + const expectedOutput = [{ + requestId: '226175918ebeda', + cpm: 1.5, + width: 300, + height: 600, + creativeId: '98493581', + currency: 'USD', + netRevenue: true, + ttl: 120, + mediaType: 'banner', + meta: { + advertiserDomains: [ + 'http://prebid.org', + ], + demandSource: 'appnexus', + }, + ad: '
TestAd
', + }]; + expect(output).to.eql(expectedOutput); + }); + + it('instream responses', () => { + const response = { + body: { + id: '2be64380-ba0c-405a-ab53-51f51c7bde51', + cur: 'USD', + seatbid: [ + { + bid: [ + { + id: '8275140264321181514', + impid: '263cba3b8bfb72', + price: 5, + adomain: [ + 'appnexus.com', + ], + crid: '97517771', + h: 1, + w: 1, + adm: 'vast', + ext: { + mediaType: 'instream', + ssp: 'appnexus', + adUnitCode: 'video1', + }, + }, + ], + seat: 'appnexus', + }, + ], + ext: { + cookies: [], + }, + }, + }; + + const output = spec.interpretResponse(response); + const expectedOutput = [{ + requestId: '263cba3b8bfb72', + cpm: 5, + width: 1, + height: 1, + creativeId: '97517771', + currency: 'USD', + netRevenue: true, + ttl: 120, + mediaType: 'video', + meta: { advertiserDomains: ['appnexus.com'], demandSource: 'appnexus' }, + vastXml: 'vast', + }]; + expect(output).to.eql(expectedOutput); + }); + + it('outstream responses', () => { + const response = { + body: { + id: '40c23932-135e-4602-9701-ca36f8d80c07', + cur: 'USD', + seatbid: [ + { + bid: [ + { + id: '1186971142548769361', + impid: '4ce809b61a3928', + price: 5, + adomain: [ + 'appnexus.com', + ], + crid: '97517771', + h: 1, + w: 1, + adm: 'vast', + ext: { + mediaType: 'outstream', + ssp: 'appnexus', + adUnitCode: 'div-1', + divId: 'div-1', + }, + }, + ], + seat: 'appnexus', + }, + ], + ext: { + cookies: [], + }, + }, + }; + + const output = spec.interpretResponse(response); + const expectedOutut = [{ + requestId: '4ce809b61a3928', + cpm: 5, + width: 1, + height: 1, + creativeId: '97517771', + currency: 'USD', + netRevenue: true, + divId: 'div-1', + ttl: 120, + mediaType: 'video', + meta: { advertiserDomains: ['appnexus.com'], demandSource: 'appnexus' }, + vastXml: 'vast', + renderer: output[0].renderer, + }]; + expect(output).to.eql(expectedOutut); + }); + + it('native responses', () => { + const response = { + body: { + id: '3c0290c1-6e75-4ef7-9e37-17f5ebf3bfa3', + cur: 'USD', + seatbid: [ + { + bid: [ + { + id: '6624930625245272225', + impid: '23e11d845514bb', + price: 10, + adomain: [ + 'prebid.org', + ], + crid: '97494204', + h: 1, + w: 1, + cat: [ + 'IAB3-1', + ], + ext: { + mediaType: 'native', + ssp: 'appnexus', + adUnitCode: '/19968336/prebid_native_example_1', + }, + adm: '{"ver":"1.2","assets":[{"id":1,"img":{"url":"https:\\/\\/vcdn.adnxs.com\\/p\\/creative-image\\/f8\\/7f\\/0f\\/13\\/f87f0f13-230c-4f05-8087-db9216e393de.jpg","w":989,"h":742,"ext":{"appnexus":{"prevent_crop":0}}}},{"id":0,"title":{"text":"This is a Prebid Native Creative"}},{"id":2,"data":{"value":"Prebid.org"}}],"link":{"url":"https:\\/\\/ams3-ib.adnxs.com\\/click?AAAAAAAAJEAAAAAAAAAkQAAAAAAAACRAAAAAAAAAJEAAAAAAAAAkQKZS4ZZl5vVbR6p-A-MwnyTZ7QVkAAAAAOLoyQBtJAAAbSQAAAIAAAC8pM8FnPgWAAAAAABVU0QAVVNEAAEAAQBNXQAAAAABAgMCAAAAALoAURe69gAAAAA.\\/bcr=AAAAAAAA8D8=\\/pp=${AUCTION_PRICE}\\/cnd=%21JBC72Aj8-LwKELzJvi4YnPFbIAQoADEAAAAAAAAkQDoJQU1TMzo2MTM1QNAwSQAAAAAAAPA_UQAAAAAAAAAAWQAAAAAAAAAAYQAAAAAAAAAAaQAAAAAAAAAAcQAAAAAAAAAAeACJAQAAAAAAAAAA\\/cca=OTMyNSNBTVMzOjYxMzU=\\/bn=97062\\/clickenc=http%3A%2F%2Fprebid.org%2Fdev-docs%2Fshow-native-ads.html"},"eventtrackers":[{"event":1,"method":1,"url":"https:\\/\\/ams3-ib.adnxs.com\\/it?an_audit=0&referrer=https%3A%2F%2Ftest.nexx360.io%2Fadapter%2Fnative%2Ftest.html&e=wqT_3QKJCqAJBQAAAwDWAAUBCNnbl6AGEKalhbfZzPn6WxjH1PqbsJzMzyQqNgkAAAECCCRAEQEHEAAAJEAZEQkAIREJACkRCQAxEQmoMOLRpwY47UhA7UhIAlC8yb4uWJzxW2AAaM26dXim9gWAAQGKAQNVU0SSAQEG9F4BmAEBoAEBqAEBsAEAuAECwAEDyAEC0AEJ2AEA4AEA8AEAigIpdWYoJ2EnLCAyNTI5ODg1LCAwKTt1ZigncicsIDk3NDk0MjA0LCAwKTuSAvEDIS0xRDNJQWo4LUx3S0VMekp2aTRZQUNDYzhWc3dBRGdBUUFSSTdVaFE0dEduQmxnQVlQX19fXzhQYUFCd0FYZ0JnQUVCaUFFQmtBRUJtQUVCb0FFQnFBRURzQUVBdVFIenJXcWtBQUFrUU1FQjg2MXFwQUFBSkVESkFYSUtWbWViSmZJXzJRRUFBQUFBQUFEd1AtQUJBUFVCQUFBQUFKZ0NBS0FDQUxVQ0FBQUFBTDBDQUFBQUFNQUNBY2dDQWRBQ0FkZ0NBZUFDQU9nQ0FQZ0NBSUFEQVpnREFib0RDVUZOVXpNNk5qRXpOZUFEMERDSUJBQ1FCQUNZQkFIQkJBQUFBQUFBQUFBQXlRUUFBCQscQUFOZ0VBUEURlSxBQUFDSUJmY3ZxUVUBDQRBQQGoCDdFRgEKCQEMREJCUQkKAQEAeRUoAUwyKAAAWi4oALg0QVhBaEQzd0JhTEQzd0w0QmQyMG1nR0NCZ05WVTBTSUJnQ1FCZ0dZQmdDaEJnQQFONEFBQ1JBcUFZQnNnWWtDHXQARR0MAEcdDABJHQw8dUFZS5oClQEhSkJDNzJBajL1ASRuUEZiSUFRb0FEFfhUa1FEb0pRVTFUTXpvMk1UTTFRTkF3UxFRDFBBX1URDAxBQUFXHQwAWR0MAGEdDABjHQwQZUFDSkEdEMjYAvfpA-ACrZhI6gIwaHR0cHM6Ly90ZXN0Lm5leHgzNjAuaW8vYWRhcHRlci9uYXRpdmUJH_CaaHRtbIADAIgDAZADAJgDFKADAaoDAMAD4KgByAMA2AMA4AMA6AMA-AMDgAQAkgQJL29wZW5ydGIymAQAqAQAsgQMCAAQABgAIAAwADgAuAQAwASA2rgiyAQA0gQOOTMyNSNBTVMzOjYxMzXaBAIIAeAEAPAEvMm-LvoEEgkAAABAPG1IQBEAAACgV8oCQIgFAZgFAKAF______8BBbABqgUkM2MwMjkwYzEtNmU3NS00ZWY3LTllMzctMTdmNWViZjNiZmEzwAUAyQWJFxTwP9IFCQkJDHgAANgFAeAFAfAFmfQh-gUECAAQAJAGAZgGALgGAMEGCSUo8D_QBvUv2gYWChAJERkBAdpg4AYM8gYCCACABwGIBwCgB0HIB6b2BdIHDRVkASYI2gcGAV1oGADgBwDqBwIIAPAHAIoIAhAAlQgAAIA_mAgB&s=ccf63f2e483a37091d2475d895e7cf7c911d1a78&pp=${AUCTION_PRICE}"}]}', + }, + ], + seat: 'appnexus', + }, + ], + ext: { + cookies: [], + }, + }, + }; + + const output = spec.interpretResponse(response); + const expectOutput = [{ + requestId: '23e11d845514bb', + cpm: 10, + width: 1, + height: 1, + creativeId: '97494204', + currency: 'USD', + netRevenue: true, + ttl: 120, + mediaType: 'native', + meta: { + advertiserDomains: [ + 'prebid.org', + ], + demandSource: 'appnexus', + }, + native: { + ortb: { + ver: '1.2', + assets: [ + { + id: 1, + img: { + url: 'https://vcdn.adnxs.com/p/creative-image/f8/7f/0f/13/f87f0f13-230c-4f05-8087-db9216e393de.jpg', + w: 989, + h: 742, + ext: { + appnexus: { + prevent_crop: 0, + }, + }, + }, + }, + { + id: 0, + title: { + text: 'This is a Prebid Native Creative', + }, + }, + { + id: 2, + data: { + value: 'Prebid.org', + }, + }, + ], + link: { + url: 'https://ams3-ib.adnxs.com/click?AAAAAAAAJEAAAAAAAAAkQAAAAAAAACRAAAAAAAAAJEAAAAAAAAAkQKZS4ZZl5vVbR6p-A-MwnyTZ7QVkAAAAAOLoyQBtJAAAbSQAAAIAAAC8pM8FnPgWAAAAAABVU0QAVVNEAAEAAQBNXQAAAAABAgMCAAAAALoAURe69gAAAAA./bcr=AAAAAAAA8D8=/pp=${AUCTION_PRICE}/cnd=%21JBC72Aj8-LwKELzJvi4YnPFbIAQoADEAAAAAAAAkQDoJQU1TMzo2MTM1QNAwSQAAAAAAAPA_UQAAAAAAAAAAWQAAAAAAAAAAYQAAAAAAAAAAaQAAAAAAAAAAcQAAAAAAAAAAeACJAQAAAAAAAAAA/cca=OTMyNSNBTVMzOjYxMzU=/bn=97062/clickenc=http%3A%2F%2Fprebid.org%2Fdev-docs%2Fshow-native-ads.html', + }, + eventtrackers: [ + { + event: 1, + method: 1, + url: 'https://ams3-ib.adnxs.com/it?an_audit=0&referrer=https%3A%2F%2Ftest.nexx360.io%2Fadapter%2Fnative%2Ftest.html&e=wqT_3QKJCqAJBQAAAwDWAAUBCNnbl6AGEKalhbfZzPn6WxjH1PqbsJzMzyQqNgkAAAECCCRAEQEHEAAAJEAZEQkAIREJACkRCQAxEQmoMOLRpwY47UhA7UhIAlC8yb4uWJzxW2AAaM26dXim9gWAAQGKAQNVU0SSAQEG9F4BmAEBoAEBqAEBsAEAuAECwAEDyAEC0AEJ2AEA4AEA8AEAigIpdWYoJ2EnLCAyNTI5ODg1LCAwKTt1ZigncicsIDk3NDk0MjA0LCAwKTuSAvEDIS0xRDNJQWo4LUx3S0VMekp2aTRZQUNDYzhWc3dBRGdBUUFSSTdVaFE0dEduQmxnQVlQX19fXzhQYUFCd0FYZ0JnQUVCaUFFQmtBRUJtQUVCb0FFQnFBRURzQUVBdVFIenJXcWtBQUFrUU1FQjg2MXFwQUFBSkVESkFYSUtWbWViSmZJXzJRRUFBQUFBQUFEd1AtQUJBUFVCQUFBQUFKZ0NBS0FDQUxVQ0FBQUFBTDBDQUFBQUFNQUNBY2dDQWRBQ0FkZ0NBZUFDQU9nQ0FQZ0NBSUFEQVpnREFib0RDVUZOVXpNNk5qRXpOZUFEMERDSUJBQ1FCQUNZQkFIQkJBQUFBQUFBQUFBQXlRUUFBCQscQUFOZ0VBUEURlSxBQUFDSUJmY3ZxUVUBDQRBQQGoCDdFRgEKCQEMREJCUQkKAQEAeRUoAUwyKAAAWi4oALg0QVhBaEQzd0JhTEQzd0w0QmQyMG1nR0NCZ05WVTBTSUJnQ1FCZ0dZQmdDaEJnQQFONEFBQ1JBcUFZQnNnWWtDHXQARR0MAEcdDABJHQw8dUFZS5oClQEhSkJDNzJBajL1ASRuUEZiSUFRb0FEFfhUa1FEb0pRVTFUTXpvMk1UTTFRTkF3UxFRDFBBX1URDAxBQUFXHQwAWR0MAGEdDABjHQwQZUFDSkEdEMjYAvfpA-ACrZhI6gIwaHR0cHM6Ly90ZXN0Lm5leHgzNjAuaW8vYWRhcHRlci9uYXRpdmUJH_CaaHRtbIADAIgDAZADAJgDFKADAaoDAMAD4KgByAMA2AMA4AMA6AMA-AMDgAQAkgQJL29wZW5ydGIymAQAqAQAsgQMCAAQABgAIAAwADgAuAQAwASA2rgiyAQA0gQOOTMyNSNBTVMzOjYxMzXaBAIIAeAEAPAEvMm-LvoEEgkAAABAPG1IQBEAAACgV8oCQIgFAZgFAKAF______8BBbABqgUkM2MwMjkwYzEtNmU3NS00ZWY3LTllMzctMTdmNWViZjNiZmEzwAUAyQWJFxTwP9IFCQkJDHgAANgFAeAFAfAFmfQh-gUECAAQAJAGAZgGALgGAMEGCSUo8D_QBvUv2gYWChAJERkBAdpg4AYM8gYCCACABwGIBwCgB0HIB6b2BdIHDRVkASYI2gcGAV1oGADgBwDqBwIIAPAHAIoIAhAAlQgAAIA_mAgB&s=ccf63f2e483a37091d2475d895e7cf7c911d1a78&pp=${AUCTION_PRICE}', + }, + ], + }, + }, + }]; + expect(output).to.eql(expectOutput); + }); + }); + + describe('getUserSyncs()', () => { + const response = { body: { cookies: [] } }; + it('Verifies user sync without cookie in bid response', () => { + const syncs = spec.getUserSyncs({}, [response], DEFAULT_OPTIONS.gdprConsent, DEFAULT_OPTIONS.uspConsent); + expect(syncs).to.eql([]); + }); + it('Verifies user sync with cookies in bid response', () => { + response.body.ext = { + cookies: [{'type': 'image', 'url': 'http://www.cookie.sync.org/'}] + }; + const syncs = spec.getUserSyncs({}, [response], DEFAULT_OPTIONS.gdprConsent); + const expectedSyncs = [{ type: 'image', url: 'http://www.cookie.sync.org/' }]; + expect(syncs).to.eql(expectedSyncs); + }); + it('Verifies user sync with no bid response', () => { + var syncs = spec.getUserSyncs({}, null, DEFAULT_OPTIONS.gdprConsent, DEFAULT_OPTIONS.uspConsent); + expect(syncs).to.eql([]); + }); + it('Verifies user sync with no bid body response', () => { + let syncs = spec.getUserSyncs({}, [], DEFAULT_OPTIONS.gdprConsent, DEFAULT_OPTIONS.uspConsent); + expect(syncs).to.eql([]); + syncs = spec.getUserSyncs({}, [{}], DEFAULT_OPTIONS.gdprConsent, DEFAULT_OPTIONS.uspConsent); + expect(syncs).to.eql([]); + }); + }); +}); diff --git a/test/spec/modules/iqzoneBidAdapter_spec.js b/test/spec/modules/iqzoneBidAdapter_spec.js index c14b85b2c8b..7f48b7077bf 100644 --- a/test/spec/modules/iqzoneBidAdapter_spec.js +++ b/test/spec/modules/iqzoneBidAdapter_spec.js @@ -480,7 +480,7 @@ describe('IQZoneBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -489,9 +489,7 @@ describe('IQZoneBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.iqzone.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -500,7 +498,7 @@ describe('IQZoneBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.iqzone.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/kiviadsBidAdapter_spec.js b/test/spec/modules/kiviadsBidAdapter_spec.js index d7f9a233c9d..d7cbd189782 100644 --- a/test/spec/modules/kiviadsBidAdapter_spec.js +++ b/test/spec/modules/kiviadsBidAdapter_spec.js @@ -483,7 +483,7 @@ describe('KiviAdsBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -492,9 +492,7 @@ describe('KiviAdsBidAdapter', function () { expect(syncData[0].url).to.equal(`${syncUrl}/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0`) }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -503,7 +501,7 @@ describe('KiviAdsBidAdapter', function () { expect(syncData[0].url).to.equal(`${syncUrl}/image?pbjs=1&ccpa_consent=1---&coppa=0`) }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/koblerBidAdapter_spec.js b/test/spec/modules/koblerBidAdapter_spec.js index 4e70ac6f9f3..8b771e3eef7 100644 --- a/test/spec/modules/koblerBidAdapter_spec.js +++ b/test/spec/modules/koblerBidAdapter_spec.js @@ -26,9 +26,9 @@ function createBidderRequest(auctionId, timeout, pageUrl, gdprVendorData = {}, p }; } -function createValidBidRequest(params, bidId, sizes) { +function createValidBidRequest(params, bidId, sizes, adUnitCode) { const validBidRequest = { - adUnitCode: 'adunit-code', + adUnitCode: adUnitCode || 'adunit-code', bidId: bidId || '22c4871113f461', bidder: 'kobler', bidderRequestId: '15246a574e859f', @@ -451,7 +451,8 @@ describe('KoblerAdapter', function () { dealIds: ['623472534328234'] }, '953ee65d-d18a-484f-a840-d3056185a060', - [[400, 600]] + [[400, 600]], + 'ad-unit-1' ), createValidBidRequest( { @@ -459,12 +460,14 @@ describe('KoblerAdapter', function () { dealIds: ['92368234753283', '263845832942'] }, '8320bf79-9d90-4a17-87c6-5d505706a921', - [[400, 500], [200, 250], [300, 350]] + [[400, 500], [200, 250], [300, 350]], + 'ad-unit-2' ), createValidBidRequest( undefined, 'd0de713b-32e3-4191-a2df-a007f08ffe72', - [[800, 900]] + [[800, 900]], + 'ad-unit-3' ) ]; const bidderRequest = createBidderRequest( @@ -520,6 +523,11 @@ describe('KoblerAdapter', function () { id: '623472534328234' } ] + }, + ext: { + prebid: { + adunitcode: 'ad-unit-1' + } } }, { @@ -553,6 +561,11 @@ describe('KoblerAdapter', function () { id: '263845832942' } ] + }, + ext: { + prebid: { + adunitcode: 'ad-unit-2' + } } }, { @@ -569,7 +582,12 @@ describe('KoblerAdapter', function () { }, bidfloor: 0, bidfloorcur: 'USD', - pmp: {} + pmp: {}, + ext: { + prebid: { + adunitcode: 'ad-unit-3' + } + } } ], device: { diff --git a/test/spec/modules/krushmediaBidAdapter_spec.js b/test/spec/modules/krushmediaBidAdapter_spec.js index 98bdbcbb855..044d9d4f26b 100644 --- a/test/spec/modules/krushmediaBidAdapter_spec.js +++ b/test/spec/modules/krushmediaBidAdapter_spec.js @@ -483,7 +483,7 @@ describe('KrushmediabBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -492,9 +492,7 @@ describe('KrushmediabBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.krushmedia.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -503,7 +501,7 @@ describe('KrushmediabBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.krushmedia.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/leagueMBidAdapter_spec.js b/test/spec/modules/leagueMBidAdapter_spec.js new file mode 100644 index 00000000000..4c0f9a53529 --- /dev/null +++ b/test/spec/modules/leagueMBidAdapter_spec.js @@ -0,0 +1,441 @@ +import {expect} from 'chai'; +import {config} from 'src/config.js'; +import {spec} from 'modules/leagueMBidAdapter.js'; +import {deepClone} from 'src/utils'; +import {getBidFloor} from '../../../libraries/xeUtils/bidderUtils.js'; + +const ENDPOINT = 'https://pbjs.league-m.media'; + +const defaultRequest = { + tmax: 0, + adUnitCode: 'test', + bidId: '1', + requestId: 'qwerty', + ortb2: { + source: { + tid: 'auctionId' + } + }, + ortb2Imp: { + ext: { + tid: 'tr1', + } + }, + mediaTypes: { + banner: { + sizes: [ + [300, 250], + [300, 200] + ] + } + }, + bidder: 'leagueM', + params: { + pid: 'aa8217e20131c095fe9dba67981040b0', + ext: {} + }, + bidRequestsCount: 1 +}; + +const defaultRequestVideo = deepClone(defaultRequest); +defaultRequestVideo.mediaTypes = { + video: { + playerSize: [640, 480], + context: 'instream', + skippable: true + } +}; + +const videoBidderRequest = { + bidderCode: 'leagueM', + bids: [{mediaTypes: {video: {}}, bidId: 'qwerty'}] +}; + +const displayBidderRequest = { + bidderCode: 'leagueM', + bids: [{bidId: 'qwerty'}] +}; + +describe('leagueMBidAdapter', () => { + describe('isBidRequestValid', function () { + it('should return false when request params is missing', function () { + const invalidRequest = deepClone(defaultRequest); + delete invalidRequest.params; + expect(spec.isBidRequestValid(invalidRequest)).to.equal(false); + }); + + it('should return false when required pid param is missing', function () { + const invalidRequest = deepClone(defaultRequest); + delete invalidRequest.params.pid; + expect(spec.isBidRequestValid(invalidRequest)).to.equal(false); + }); + + it('should return false when video.playerSize is missing', function () { + const invalidRequest = deepClone(defaultRequestVideo); + delete invalidRequest.mediaTypes.video.playerSize; + expect(spec.isBidRequestValid(invalidRequest)).to.equal(false); + }); + + it('should return true when required params found', function () { + expect(spec.isBidRequestValid(defaultRequest)).to.equal(true); + }); + }); + + describe('buildRequests', function () { + beforeEach(function () { + config.resetConfig(); + }); + + it('should send request with correct structure', function () { + const request = spec.buildRequests([defaultRequest], {}); + expect(request.method).to.equal('POST'); + expect(request.url).to.equal(ENDPOINT + '/bid'); + expect(request.options).to.have.property('contentType').and.to.equal('application/json'); + expect(request).to.have.property('data'); + }); + + it('should build basic request structure', function () { + const request = JSON.parse(spec.buildRequests([defaultRequest], {}).data)[0]; + expect(request).to.have.property('tmax').and.to.equal(defaultRequest.tmax); + expect(request).to.have.property('bidId').and.to.equal(defaultRequest.bidId); + expect(request).to.have.property('auctionId').and.to.equal(defaultRequest.ortb2.source.tid); + expect(request).to.have.property('transactionId').and.to.equal(defaultRequest.ortb2Imp.ext.tid); + expect(request).to.have.property('tz').and.to.equal(new Date().getTimezoneOffset()); + expect(request).to.have.property('bc').and.to.equal(1); + expect(request).to.have.property('floor').and.to.equal(null); + expect(request).to.have.property('banner').and.to.deep.equal({sizes: [[300, 250], [300, 200]]}); + expect(request).to.have.property('gdprConsent').and.to.deep.equal({}); + expect(request).to.have.property('userEids').and.to.deep.equal([]); + expect(request).to.have.property('usPrivacy').and.to.equal(''); + expect(request).to.have.property('sizes').and.to.deep.equal(['300x250', '300x200']); + expect(request).to.have.property('ext').and.to.deep.equal({}); + expect(request).to.have.property('env').and.to.deep.equal({ + pid: 'aa8217e20131c095fe9dba67981040b0' + }); + expect(request).to.have.property('device').and.to.deep.equal({ + ua: navigator.userAgent, + lang: navigator.language + }); + }); + + it('should build request with schain', function () { + const schainRequest = deepClone(defaultRequest); + const bidderRequest = { + ortb2: { + source: { + ext: { + schain: { + ver: '1.0' + } + } + } + } + }; + const request = JSON.parse(spec.buildRequests([schainRequest], bidderRequest).data)[0]; + expect(request).to.have.property('schain').and.to.deep.equal({ + ver: '1.0' + }); + }); + + it('should build request with location', function () { + const bidderRequest = { + refererInfo: { + page: 'page', + location: 'location', + domain: 'domain', + ref: 'ref', + isAmp: false + } + }; + const request = JSON.parse(spec.buildRequests([defaultRequest], bidderRequest).data)[0]; + expect(request).to.have.property('location'); + const location = request.location; + expect(location).to.have.property('page').and.to.equal('page'); + expect(location).to.have.property('location').and.to.equal('location'); + expect(location).to.have.property('domain').and.to.equal('domain'); + expect(location).to.have.property('ref').and.to.equal('ref'); + expect(location).to.have.property('isAmp').and.to.equal(false); + }); + + it('should build request with ortb2 info', function () { + const ortb2Request = deepClone(defaultRequest); + ortb2Request.ortb2 = { + site: { + name: 'name' + } + }; + const request = JSON.parse(spec.buildRequests([ortb2Request], {}).data)[0]; + expect(request).to.have.property('ortb2').and.to.deep.equal({ + site: { + name: 'name' + } + }); + }); + + it('should build request with ortb2Imp info', function () { + const ortb2ImpRequest = deepClone(defaultRequest); + ortb2ImpRequest.ortb2Imp = { + ext: { + data: { + pbadslot: 'home1', + adUnitSpecificAttribute: '1' + } + } + }; + const request = JSON.parse(spec.buildRequests([ortb2ImpRequest], {}).data)[0]; + expect(request).to.have.property('ortb2Imp').and.to.deep.equal({ + ext: { + data: { + pbadslot: 'home1', + adUnitSpecificAttribute: '1' + } + } + }); + }); + + it('should build request with valid bidfloor', function () { + const bfRequest = deepClone(defaultRequest); + bfRequest.getFloor = () => ({floor: 5, currency: 'USD'}); + const request = JSON.parse(spec.buildRequests([bfRequest], {}).data)[0]; + expect(request).to.have.property('floor').and.to.equal(5); + }); + + it('should build request with usp consent data if applies', function () { + const bidderRequest = { + uspConsent: '1YA-' + }; + const request = JSON.parse(spec.buildRequests([defaultRequest], bidderRequest).data)[0]; + expect(request).to.have.property('usPrivacy').and.equals('1YA-'); + }); + + it('should build request with extended ids', function () { + const idRequest = deepClone(defaultRequest); + idRequest.userIdAsEids = [ + {source: 'adserver.org', uids: [{id: 'TTD_ID_FROM_USER_ID_MODULE', atype: 1, ext: {rtiPartner: 'TDID'}}]}, + {source: 'pubcid.org', uids: [{id: 'pubCommonId_FROM_USER_ID_MODULE', atype: 1}]} + ]; + const request = JSON.parse(spec.buildRequests([idRequest], {}).data)[0]; + expect(request).to.have.property('userEids').and.deep.equal(idRequest.userIdAsEids); + }); + + it('should build request with video', function () { + const request = JSON.parse(spec.buildRequests([defaultRequestVideo], {}).data)[0]; + expect(request).to.have.property('video').and.to.deep.equal({ + playerSize: [640, 480], + context: 'instream', + skippable: true + }); + expect(request).to.have.property('sizes').and.to.deep.equal(['640x480']); + }); + }); + + describe('interpretResponse', function () { + it('should return empty bids', function () { + const serverResponse = { + body: { + data: null + } + }; + + const invalidResponse = spec.interpretResponse(serverResponse, {}); + expect(invalidResponse).to.be.an('array').that.is.empty; + }); + + it('should interpret valid response', function () { + const serverResponse = { + body: { + data: [{ + requestId: 'qwerty', + cpm: 1, + currency: 'USD', + width: 300, + height: 250, + ttl: 600, + meta: { + advertiserDomains: ['leagueM'] + }, + ext: { + pixels: [ + ['iframe', 'surl1'], + ['image', 'surl2'], + ] + } + }] + } + }; + + const validResponse = spec.interpretResponse(serverResponse, {bidderRequest: displayBidderRequest}); + const bid = validResponse[0]; + expect(validResponse).to.be.an('array').that.is.not.empty; + expect(bid.requestId).to.equal('qwerty'); + expect(bid.cpm).to.equal(1); + expect(bid.currency).to.equal('USD'); + expect(bid.width).to.equal(300); + expect(bid.height).to.equal(250); + expect(bid.ttl).to.equal(600); + expect(bid.meta).to.deep.equal({advertiserDomains: ['leagueM']}); + }); + + it('should interpret valid banner response', function () { + const serverResponse = { + body: { + data: [{ + requestId: 'qwerty', + cpm: 1, + currency: 'USD', + width: 300, + height: 250, + ttl: 600, + mediaType: 'banner', + creativeId: 'demo-banner', + ad: 'ad', + meta: {} + }] + } + }; + + const validResponseBanner = spec.interpretResponse(serverResponse, {bidderRequest: displayBidderRequest}); + const bid = validResponseBanner[0]; + expect(validResponseBanner).to.be.an('array').that.is.not.empty; + expect(bid.mediaType).to.equal('banner'); + expect(bid.creativeId).to.equal('demo-banner'); + expect(bid.ad).to.equal('ad'); + }); + + it('should interpret valid video response', function () { + const serverResponse = { + body: { + data: [{ + requestId: 'qwerty', + cpm: 1, + currency: 'USD', + width: 600, + height: 480, + ttl: 600, + mediaType: 'video', + creativeId: 'demo-video', + ad: 'vast-xml', + meta: {} + }] + } + }; + + const validResponseBanner = spec.interpretResponse(serverResponse, {bidderRequest: videoBidderRequest}); + const bid = validResponseBanner[0]; + expect(validResponseBanner).to.be.an('array').that.is.not.empty; + expect(bid.mediaType).to.equal('video'); + expect(bid.creativeId).to.equal('demo-video'); + expect(bid.ad).to.equal('vast-xml'); + }); + }); + + describe('getUserSyncs', function () { + it('should handle no params', function () { + const opts = spec.getUserSyncs({}, []); + expect(opts).to.be.an('array').that.is.empty; + }); + + it('should return empty if sync is not allowed', function () { + const opts = spec.getUserSyncs({iframeEnabled: false, pixelEnabled: false}); + expect(opts).to.be.an('array').that.is.empty; + }); + + it('should allow iframe sync', function () { + const opts = spec.getUserSyncs({iframeEnabled: true, pixelEnabled: false}, [{ + body: { + data: [{ + requestId: 'qwerty', + ext: { + pixels: [ + ['iframe', 'surl1?a=b'], + ['image', 'surl2?a=b'], + ] + } + }] + } + }]); + expect(opts.length).to.equal(1); + expect(opts[0].type).to.equal('iframe'); + expect(opts[0].url).to.equal('surl1?a=b&us_privacy=&gdpr=0&gdpr_consent='); + }); + + it('should allow pixel sync', function () { + const opts = spec.getUserSyncs({iframeEnabled: false, pixelEnabled: true}, [{ + body: { + data: [{ + requestId: 'qwerty', + ext: { + pixels: [ + ['iframe', 'surl1?a=b'], + ['image', 'surl2?a=b'], + ] + } + }] + } + }]); + expect(opts.length).to.equal(1); + expect(opts[0].type).to.equal('image'); + expect(opts[0].url).to.equal('surl2?a=b&us_privacy=&gdpr=0&gdpr_consent='); + }); + + it('should allow pixel sync and parse consent params', function () { + const opts = spec.getUserSyncs({iframeEnabled: false, pixelEnabled: true}, [{ + body: { + data: [{ + requestId: 'qwerty', + ext: { + pixels: [ + ['iframe', 'surl1?a=b'], + ['image', 'surl2?a=b'], + ] + } + }] + } + }], { + gdprApplies: 1, + consentString: '1YA-' + }); + expect(opts.length).to.equal(1); + expect(opts[0].type).to.equal('image'); + expect(opts[0].url).to.equal('surl2?a=b&us_privacy=&gdpr=1&gdpr_consent=1YA-'); + }); + }); + + describe('getBidFloor', function () { + it('should return null when getFloor is not a function', () => { + const bid = {getFloor: 2}; + const result = getBidFloor(bid); + expect(result).to.be.null; + }); + + it('should return null when getFloor doesnt return an object', () => { + const bid = {getFloor: () => 2}; + const result = getBidFloor(bid); + expect(result).to.be.null; + }); + + it('should return null when floor is not a number', () => { + const bid = { + getFloor: () => ({floor: 'string', currency: 'USD'}) + }; + const result = getBidFloor(bid); + expect(result).to.be.null; + }); + + it('should return null when currency is not USD', () => { + const bid = { + getFloor: () => ({floor: 5, currency: 'EUR'}) + }; + const result = getBidFloor(bid); + expect(result).to.be.null; + }); + + it('should return floor value when everything is correct', () => { + const bid = { + getFloor: () => ({floor: 5, currency: 'USD'}) + }; + const result = getBidFloor(bid); + expect(result).to.equal(5); + }); + }); +}); diff --git a/test/spec/modules/limelightDigitalBidAdapter_spec.js b/test/spec/modules/limelightDigitalBidAdapter_spec.js index 31b8530c7eb..a4cff04599b 100644 --- a/test/spec/modules/limelightDigitalBidAdapter_spec.js +++ b/test/spec/modules/limelightDigitalBidAdapter_spec.js @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { spec } from '../../../modules/limelightDigitalBidAdapter.js'; +import { deepAccess } from '../../../src/utils.js'; describe('limelightDigitalAdapter', function () { const bid1 = { @@ -42,25 +43,9 @@ describe('limelightDigitalAdapter', function () { } ] } - ], - ortb2: { - source: { - ext: { - schain: { - ver: '1.0', - complete: 1, - nodes: [ - { - asi: 'example.com', - sid: '1', - hp: 1 - } - ] - } - } - } - } - } + ] + }; + const bid2 = { bidId: '58ee9870c3164a', bidder: 'limelightDigital', @@ -77,13 +62,17 @@ describe('limelightDigitalAdapter', function () { }, placementCode: 'placement_1', auctionId: '482f88de-29ab-45c8-981a-d25e39454a34', - sizes: [[350, 200]], + mediaTypes: { + banner: { + sizes: [[350, 200]] + } + }, ortb2Imp: { ext: { - gpid: '/1111/homepage#300x250', + gpid: '/1111/homepage#350x200', tid: '738d5915-6651-43b9-9b6b-d50517350917', data: { - 'pbadslot': '/1111/homepage#300x250' + 'pbadslot': '/1111/homepage#350x200' } } }, @@ -96,30 +85,9 @@ describe('limelightDigitalAdapter', function () { } ] } - ], - ortb2: { - source: { - ext: { - schain: { - ver: '1.0', - complete: 1, - nodes: [ - { - asi: 'example.com', - sid: '1', - hp: 1 - }, - { - asi: 'example1.com', - sid: '2', - hp: 1 - } - ] - } - } - } - } - } + ] + }; + const bid3 = { bidId: '019645c7d69460', bidder: 'limelightDigital', @@ -137,103 +105,77 @@ describe('limelightDigitalAdapter', function () { }, placementCode: 'placement_2', auctionId: 'e4771143-6aa7-41ec-8824-ced4342c96c8', - sizes: [[800, 600]], - ortb2Imp: { - ext: { - gpid: '/1111/homepage#300x250', - tid: '738d5915-6651-43b9-9b6b-d50517350917', - data: { - 'pbadslot': '/1111/homepage#300x250' - } - } - }, - userIdAsEids: [ - { - source: 'test3.org', - uids: [ - { - id: '345', - }, - { - id: '456', - } - ] - } - ], - ortb2: { - source: { - ext: { - schain: { - ver: '1.0', - complete: 1, - nodes: [ - { - asi: 'example.com', - sid: '1', - hp: 1 - } - ] - } - } + mediaTypes: { + video: { + context: 'instream', + playerSize: [[800, 600]], + mimes: ['video/mp4', 'application/javascript'], + protocols: [2, 3, 5, 6], + maxduration: 60, + minduration: 3, + api: [2], + playbackmethod: [1] } - } - } - const bid4 = { - bidId: '019645c7d69460', - bidder: 'limelightDigital', - bidderRequestId: 'f2b15f89e77ba6', - params: { - host: 'exchange.ortb.net', - adUnitId: 789, - adUnitType: 'video', - custom1: 'custom1', - custom2: 'custom2', - custom3: 'custom3', - custom4: 'custom4', - custom5: 'custom5' - }, - placementCode: 'placement_2', - auctionId: 'e4771143-6aa7-41ec-8824-ced4342c96c8', - video: { - playerSize: [800, 600] }, ortb2Imp: { ext: { - gpid: '/1111/homepage#300x250', + gpid: '/1111/homepage#800x600', tid: '738d5915-6651-43b9-9b6b-d50517350917', data: { - 'pbadslot': '/1111/homepage#300x250' + 'pbadslot': '/1111/homepage#800x600' } } }, userIdAsEids: [ { - source: 'test.org', + source: 'test3.org', uids: [ { - id: '111', + id: '345', } ] } - ], - ortb2: { - source: { - ext: { - schain: { - ver: '1.0', - complete: 1, - nodes: [ - { - asi: 'example.com', - sid: '1', - hp: 1 - } - ] - } - } - } - } - } + ] + }; + + describe('isBidRequestValid', function() { + it('should return true when required params found', function() { + expect(spec.isBidRequestValid(bid1)).to.equal(true); + expect(spec.isBidRequestValid(bid2)).to.equal(true); + expect(spec.isBidRequestValid(bid3)).to.equal(true); + }); + + it('should return true when adUnitId is zero', function() { + const bidWithZeroId = { ...bid1, params: { ...bid1.params, adUnitId: 0 } }; + expect(spec.isBidRequestValid(bidWithZeroId)).to.equal(true); + }); + + it('should return false when required params are not passed', function() { + const bidFailed = { + bidder: 'limelightDigital', + bidderRequestId: '145e1d6a7837c9', + params: { + adUnitId: 123, + adUnitType: 'banner' + }, + placementCode: 'placement_0', + auctionId: '74f78609-a92d-4cf1-869f-1b244bbfb5d2' + }; + expect(spec.isBidRequestValid(bidFailed)).to.equal(false); + }); + + it('should return false when host is missing', function() { + const bidWithoutHost = { ...bid1, params: { ...bid1.params } }; + delete bidWithoutHost.params.host; + expect(spec.isBidRequestValid(bidWithoutHost)).to.equal(false); + }); + + it('should return false when adUnitType is missing', function() { + const bidWithoutType = { ...bid1, params: { ...bid1.params } }; + delete bidWithoutType.params.adUnitType; + expect(spec.isBidRequestValid(bidWithoutType)).to.equal(false); + }); + }); describe('buildRequests', function () { const bidderRequest = { @@ -245,620 +187,636 @@ describe('limelightDigitalAdapter', function () { mobile: 1, architecture: 'arm' } + }, + site: { + page: 'https://example.com/page' } }, refererInfo: { - page: 'testPage' - } - } - const serverRequests = spec.buildRequests([bid1, bid2, bid3, bid4], bidderRequest) - it('Creates two ServerRequests', function() { - expect(serverRequests).to.exist - expect(serverRequests).to.have.lengthOf(2) - }) - serverRequests.forEach(serverRequest => { - it('Creates a ServerRequest object with method, URL and data', function () { - expect(serverRequest).to.exist - expect(serverRequest.method).to.exist - expect(serverRequest.url).to.exist - expect(serverRequest.data).to.exist - }) - it('Returns POST method', function () { - expect(serverRequest.method).to.equal('POST') - }) - it('Returns valid data if array of bids is valid', function () { - const data = serverRequest.data; - expect(data).to.be.an('object'); - expect(data).to.have.all.keys( - 'deviceWidth', - 'deviceHeight', - 'secure', - 'adUnits', - 'sua', - 'page', - 'ortb2', - 'refererInfo' - ); - expect(data.deviceWidth).to.be.a('number'); - expect(data.deviceHeight).to.be.a('number'); - expect(data.secure).to.be.a('boolean'); - data.adUnits.forEach(adUnit => { - expect(adUnit).to.have.all.keys( - 'id', - 'bidId', - 'type', - 'sizes', - 'transactionId', - 'publisherId', - 'userIdAsEids', - 'supplyChain', - 'custom1', - 'custom2', - 'custom3', - 'custom4', - 'custom5', - 'ortb2Imp' - ); - expect(adUnit.id).to.be.a('number'); - expect(adUnit.bidId).to.be.a('string'); - expect(adUnit.type).to.be.a('string'); - expect(adUnit.transactionId).to.be.a('string'); - expect(adUnit.sizes).to.be.an('array'); - expect(adUnit.userIdAsEids).to.be.an('array'); - expect(adUnit.supplyChain).to.be.an('object'); - expect(adUnit.custom1).to.be.a('string'); - expect(adUnit.custom2).to.be.a('string'); - expect(adUnit.custom3).to.be.a('string'); - expect(adUnit.custom4).to.be.a('string'); - expect(adUnit.custom5).to.be.a('string'); - expect(adUnit.ortb2Imp).to.be.an('object'); - }) - expect(data.sua.browsers).to.be.a('array'); - expect(data.sua.platform).to.be.a('array'); - expect(data.sua.mobile).to.be.a('number'); - expect(data.sua.architecture).to.be.a('string'); - expect(data.page).to.be.a('string'); - expect(data.page).to.be.equal('testPage'); - expect(data.ortb2).to.be.an('object'); - }) - }) - it('Returns valid URL', function () { - expect(serverRequests[0].url).to.equal('https://exchange.ortb.net/hb') - expect(serverRequests[1].url).to.equal('https://ads.project-limelight.com/hb') - }) - it('Returns valid adUnits', function () { - validateAdUnit(serverRequests[0].data.adUnits[0], bid1) - validateAdUnit(serverRequests[1].data.adUnits[0], bid2) - validateAdUnit(serverRequests[0].data.adUnits[1], bid3) - }) - it('Returns empty data if no valid requests are passed', function () { - const serverRequests = spec.buildRequests([]) - expect(serverRequests).to.be.an('array').that.is.empty - }) - it('Returns request with page field value from ortb2 object if ortb2 has page field', function () { - bidderRequest.ortb2.site = { - page: 'testSitePage' + page: 'https://example.com/page' } - const serverRequests = spec.buildRequests([bid1], bidderRequest) - expect(serverRequests).to.have.lengthOf(1) - serverRequests.forEach(serverRequest => { - expect(serverRequest.data.page).to.be.a('string'); - expect(serverRequest.data.page).to.be.equal('testSitePage'); - }) - }) - }) - describe('interpretBannerResponse', function () { - const resObject = { - body: [ { - requestId: '123', - cpm: 0.3, - width: 320, - height: 50, - ad: '

Hello ad

', - ttl: 1000, - creativeId: '123asd', - netRevenue: true, - currency: 'USD', - meta: { - advertiserDomains: ['example.com'], - mediaType: 'banner' - } - } ] }; - let serverResponses = spec.interpretResponse(resObject); - it('Returns an array of valid server responses if response object is valid', function () { - expect(serverResponses).to.be.an('array').that.is.not.empty; - for (let i = 0; i < serverResponses.length; i++) { - const dataItem = serverResponses[i]; - expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'ad', 'ttl', 'creativeId', - 'netRevenue', 'currency', 'meta'); - expect(dataItem.requestId).to.be.a('string'); - expect(dataItem.cpm).to.be.a('number'); - expect(dataItem.width).to.be.a('number'); - expect(dataItem.height).to.be.a('number'); - expect(dataItem.ad).to.be.a('string'); - expect(dataItem.ttl).to.be.a('number'); - expect(dataItem.creativeId).to.be.a('string'); - expect(dataItem.netRevenue).to.be.a('boolean'); - expect(dataItem.currency).to.be.a('string'); - expect(dataItem.meta.advertiserDomains).to.be.an('array'); - expect(dataItem.meta.mediaType).to.be.a('string'); + + it('should create two server requests for different hosts', function() { + const serverRequests = spec.buildRequests([bid1, bid2, bid3], bidderRequest); + expect(serverRequests).to.exist; + expect(serverRequests).to.have.lengthOf(2); + }); + + it('should create ServerRequest objects with method, URL and data', function () { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + serverRequests.forEach(serverRequest => { + expect(serverRequest).to.exist; + expect(serverRequest.method).to.exist; + expect(serverRequest.url).to.exist; + expect(serverRequest.data).to.exist; + }); + }); + + it('should return POST method', function () { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + serverRequests.forEach(serverRequest => { + expect(serverRequest.method).to.equal('POST'); + }); + }); + + it('should return valid OpenRTB request structure', function () { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const data = serverRequests[0].data; + + expect(data).to.be.an('object'); + expect(data).to.have.property('imp'); + expect(data).to.have.property('site'); + expect(data).to.have.property('device'); + expect(data).to.have.property('id'); + expect(data.imp).to.be.an('array'); + }); + + it('should include custom fields in imp.ext', function() { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const imp = serverRequests[0].data.imp[0]; + + expect(deepAccess(imp, 'ext.c1')).to.equal('custom1'); + expect(deepAccess(imp, 'ext.c2')).to.equal('custom2'); + expect(deepAccess(imp, 'ext.c3')).to.equal('custom3'); + expect(deepAccess(imp, 'ext.c4')).to.equal('custom4'); + expect(deepAccess(imp, 'ext.c5')).to.equal('custom5'); + }); + + it('should include adUnitId in imp.ext', function() { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const imp = serverRequests[0].data.imp[0]; + + expect(deepAccess(imp, 'ext.adUnitId')).to.equal(123); + }); + + it('should return valid URLs for different hosts', function () { + const serverRequests = spec.buildRequests([bid1, bid2, bid3], bidderRequest); + + const exchangeRequest = serverRequests.find(req => req.url.includes('exchange.ortb.net')); + const adsRequest = serverRequests.find(req => req.url.includes('ads.project-limelight.com')); + + expect(exchangeRequest.url).to.equal('https://exchange.ortb.net/ortbhb'); + expect(adsRequest.url).to.equal('https://ads.project-limelight.com/ortbhb'); + }); + + it('should group bids by host correctly', function() { + const serverRequests = spec.buildRequests([bid1, bid2, bid3], bidderRequest); + + const exchangeRequest = serverRequests.find(req => req.url.includes('exchange.ortb.net')); + const adsRequest = serverRequests.find(req => req.url.includes('ads.project-limelight.com')); + + expect(exchangeRequest.data.imp).to.have.lengthOf(2); + expect(adsRequest.data.imp).to.have.lengthOf(1); + }); + + it('should return empty array if no valid requests are passed', function () { + const serverRequests = spec.buildRequests([], bidderRequest); + expect(serverRequests).to.be.an('array').that.is.empty; + }); + + it('should include banner format in OpenRTB request', function() { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const imp = serverRequests[0].data.imp[0]; + + expect(imp.banner).to.exist; + expect(imp.banner.format).to.be.an('array'); + expect(imp.banner.format[0]).to.have.property('w', 300); + expect(imp.banner.format[0]).to.have.property('h', 250); + }); + + it('should include video object in OpenRTB request for video bid', function() { + const serverRequests = spec.buildRequests([bid3], bidderRequest); + const imp = serverRequests[0].data.imp[0]; + if (FEATURES.VIDEO) { + expect(imp.video).to.exist; + expect(imp.video).to.be.an('object'); + expect(imp.video.w).to.equal(800); + expect(imp.video.h).to.equal(600); } - it('Returns an empty array if invalid response is passed', function () { - serverResponses = spec.interpretResponse('invalid_response'); - expect(serverResponses).to.be.an('array').that.is.empty; + expect(deepAccess(imp, 'ext.adUnitId')).to.equal(789); + }); + + it('should skip custom fields if they are undefined', function() { + const bidWithoutCustom = { ...bid1, params: { ...bid1.params } }; + delete bidWithoutCustom.params.custom1; + delete bidWithoutCustom.params.custom2; + + const serverRequests = spec.buildRequests([bidWithoutCustom], bidderRequest); + const imp = serverRequests[0].data.imp[0]; + + expect(deepAccess(imp, 'ext.c1')).to.be.undefined; + expect(deepAccess(imp, 'ext.c2')).to.be.undefined; + expect(deepAccess(imp, 'ext.c3')).to.equal('custom3'); + }); + + it('should handle various refererInfo scenarios', function () { + const baseRequest = [{ + bidder: 'limelightDigital', + params: { host: 'exchange.example.com', adUnitId: 'test' }, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bidId: 'test-bid-id' + }]; + + let requests = spec.buildRequests(baseRequest, { + refererInfo: { page: 'https://test.com' }, + ortb2: {} }); + expect(requests[0].data.site.page).to.equal('https://test.com'); + + requests = spec.buildRequests(baseRequest, { + refererInfo: { page: 'https://referer.com' }, + ortb2: { site: { page: 'https://ortb2.com' } } + }); + expect(requests[0].data.site.page).to.equal('https://ortb2.com'); + + requests = spec.buildRequests(baseRequest, { ortb2: {} }); + expect(requests[0].data.site.page).to.be.undefined; }); - }); - describe('interpretVideoResponse', function () { - const resObject = { - body: [ { - requestId: '123', - cpm: 0.3, - width: 320, - height: 50, - vastXml: '', - ttl: 1000, - creativeId: '123asd', - netRevenue: true, - currency: 'USD', - meta: { - advertiserDomains: ['example.com'], - mediaType: 'video' + + describe('buildRequests - size handling', function () { + it('should handle mediaTypes.banner.sizes', function () { + const bidRequests = [{ + bidder: 'limelightDigital', + params: { + host: 'exchange.example.com', + adUnitId: 'test', + adUnitType: 'banner' + }, + mediaTypes: { + banner: { + sizes: [[300, 250], [728, 90]] + } + }, + adUnitCode: 'test-ad-unit', + bidId: 'test-bid-id' + }]; + + const bidderRequest = { + refererInfo: { page: 'https://test.com' }, + ortb2: { site: { domain: 'test.com' } } + }; + + const requests = spec.buildRequests(bidRequests, bidderRequest); + + expect(requests[0].data.imp[0].banner.format).to.deep.equal([ + { w: 300, h: 250 }, + { w: 728, h: 90 } + ]); + }); + + it('should handle legacy sizes without mediaTypes', function () { + const bidRequests = [{ + bidder: 'limelightDigital', + params: { + host: 'exchange.example.com', + adUnitId: 'test', + adUnitType: 'banner' + }, + sizes: [[300, 250], [728, 90]], + adUnitCode: 'test-ad-unit', + bidId: 'test-bid-id' + }]; + + const bidderRequest = { + refererInfo: { page: 'https://test.com' }, + ortb2: { site: { domain: 'test.com' } } + }; + + const requests = spec.buildRequests(bidRequests, bidderRequest); + + expect(requests[0].data.imp[0].banner.format).to.deep.equal([ + { w: 300, h: 250 }, + { w: 728, h: 90 } + ]); + }); + + it('should merge mediaTypes sizes with bidRequest.sizes', function () { + const bidRequests = [{ + bidder: 'limelightDigital', + params: { + host: 'exchange.example.com', + adUnitId: 'test', + adUnitType: 'banner' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + sizes: [[728, 90]], + adUnitCode: 'test-ad-unit', + bidId: 'test-bid-id' + }]; + + const bidderRequest = { + refererInfo: { page: 'https://test.com' }, + ortb2: { site: { domain: 'test.com' } } + }; + + const requests = spec.buildRequests(bidRequests, bidderRequest); + + const formats = requests[0].data.imp[0].banner.format; + expect(formats).to.have.lengthOf(2); + expect(formats).to.deep.include({ w: 300, h: 250 }); + expect(formats).to.deep.include({ w: 728, h: 90 }); + }); + + it('should handle video with playerSize', function () { + const bidRequests = [{ + bidder: 'limelightDigital', + params: { + host: 'exchange.example.com', + adUnitId: 'test', + adUnitType: 'video' + }, + mediaTypes: { + video: { + playerSize: [640, 480] + } + }, + adUnitCode: 'test-ad-unit', + bidId: 'test-bid-id' + }]; + + const bidderRequest = { + refererInfo: { page: 'https://test.com' }, + ortb2: { site: { domain: 'test.com' } } + }; + + const requests = spec.buildRequests(bidRequests, bidderRequest); + if (FEATURES.VIDEO) { + expect(requests[0].data.imp[0].video).to.exist; + expect(requests[0].data.imp[0].video.w).to.equal(640); + expect(requests[0].data.imp[0].video.h).to.equal(480); } - } ] - }; - let serverResponses = spec.interpretResponse(resObject); - it('Returns an array of valid server responses if response object is valid', function () { - expect(serverResponses).to.be.an('array').that.is.not.empty; - for (let i = 0; i < serverResponses.length; i++) { - const dataItem = serverResponses[i]; - expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'vastXml', 'ttl', 'creativeId', - 'netRevenue', 'currency', 'meta'); - expect(dataItem.requestId).to.be.a('string'); - expect(dataItem.cpm).to.be.a('number'); - expect(dataItem.width).to.be.a('number'); - expect(dataItem.height).to.be.a('number'); - expect(dataItem.vastXml).to.be.a('string'); - expect(dataItem.ttl).to.be.a('number'); - expect(dataItem.creativeId).to.be.a('string'); - expect(dataItem.netRevenue).to.be.a('boolean'); - expect(dataItem.currency).to.be.a('string'); - expect(dataItem.meta.advertiserDomains).to.be.an('array'); - expect(dataItem.meta.mediaType).to.be.a('string'); - } - it('should return an empty array if invalid response is passed', function () { - serverResponses = spec.interpretResponse('invalid_response'); - expect(serverResponses).to.be.an('array').that.is.empty; }); }); }); - describe('isBidRequestValid', function() { - const bid = { - bidId: '2dd581a2b6281d', - bidder: 'limelightDigital', - bidderRequestId: '145e1d6a7837c9', - params: { - host: 'exchange.ortb.net', - adUnitId: 123, - adUnitType: 'banner' - }, - placementCode: 'placement_0', - auctionId: '74f78609-a92d-4cf1-869f-1b244bbfb5d2', - sizes: [[300, 250]], - transactionId: '3bb2f6da-87a6-4029-aeb0-bfe951372e62' + + describe('interpretResponse - Banner', function () { + const bidderRequest = { + ortb2: { + site: { + page: 'https://example.com/page' + } + } }; - it('should return true when required params found', function() { - [bid, bid1, bid2, bid3].forEach(bid => { - expect(spec.isBidRequestValid(bid)).to.equal(true); - }); + it('should return array of valid bid responses', function () { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const request = serverRequests[0]; + + const ortbResponse = { + body: { + seatbid: [{ + bid: [{ + id: 'bid123', + impid: request.data.imp[0].id, + price: 0.3, + w: 300, + h: 250, + adm: '

Hello ad

', + crid: '123asd', + mtype: 1, + adomain: ['example.com'], + exp: 1000 + }] + }], + cur: 'USD' + } + }; + + const serverResponses = spec.interpretResponse(ortbResponse, request); + + expect(serverResponses).to.be.an('array').that.is.not.empty; + expect(serverResponses).to.have.lengthOf(1); + + const bidResponse = serverResponses[0]; + expect(bidResponse.requestId).to.be.a('string'); + expect(bidResponse.cpm).to.equal(0.3); + expect(bidResponse.width).to.equal(300); + expect(bidResponse.height).to.equal(250); + expect(bidResponse.ad).to.be.a('string'); + expect(bidResponse.ttl).to.be.a('number'); + expect(bidResponse.creativeId).to.be.a('string'); + expect(bidResponse.netRevenue).to.be.a('boolean'); + expect(bidResponse.currency).to.equal('USD'); + expect(bidResponse.mediaType).to.equal('banner'); }); - it('should return true when adUnitId is zero', function() { - bid.params.adUnitId = 0; - expect(spec.isBidRequestValid(bid)).to.equal(true); + it('should return empty array for invalid response', function () { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const request = serverRequests[0]; + + const serverResponses = spec.interpretResponse({ body: null }, request); + expect(serverResponses).to.be.an('array').that.is.empty; }); - it('should return false when required params are not passed', function() { - const bidFailed = { - bidder: 'limelightDigital', - bidderRequestId: '145e1d6a7837c9', - params: { - adUnitId: 123, - adUnitType: 'banner' - }, - placementCode: 'placement_0', - auctionId: '74f78609-a92d-4cf1-869f-1b244bbfb5d2', - sizes: [[300, 250]], - transactionId: '3bb2f6da-87a6-4029-aeb0-bfe951372e62' + it('should return empty array when response body is missing', function() { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const request = serverRequests[0]; + + const serverResponses = spec.interpretResponse({}, request); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + + it('should filter out invalid bids', function() { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const request = serverRequests[0]; + + const invalidResponse = { + body: { + seatbid: [{ + bid: [{ + id: 'bid123', + impid: request.data.imp[0].id, + price: 0.3, + crid: '123asd', + mtype: 1, + adomain: ['example.com'] + }] + }], + cur: 'USD' + } }; - expect(spec.isBidRequestValid(bidFailed)).to.equal(false); + + const serverResponses = spec.interpretResponse(invalidResponse, request); + expect(serverResponses).to.be.an('array').that.is.empty; }); }); - describe('interpretResponse', function() { - const resObject = { - requestId: '123', - cpm: 0.3, - width: 320, - height: 50, - ad: '

Hello ad

', - ttl: 1000, - creativeId: '123asd', - netRevenue: true, - currency: 'USD', - meta: { - advertiserDomains: ['example.com'], - mediaType: 'banner' + + describe('interpretResponse - Video', function () { + const bidderRequest = { + ortb2: { + site: { + page: 'https://example.com/page' + } } }; - it('should skip responses which do not contain required params', function() { - const bidResponses = { - body: [ { - cpm: 0.3, - ttl: 1000, - currency: 'USD', - meta: { - advertiserDomains: ['example.com'], - mediaType: 'banner' - } - }, resObject ] - } - expect(spec.interpretResponse(bidResponses)).to.deep.equal([ resObject ]); - }); - it('should skip responses which do not contain advertiser domains', function() { - const resObjectWithoutAdvertiserDomains = Object.assign({}, resObject); - resObjectWithoutAdvertiserDomains.meta = Object.assign({}, resObject.meta); - delete resObjectWithoutAdvertiserDomains.meta.advertiserDomains; - const bidResponses = { - body: [ resObjectWithoutAdvertiserDomains, resObject ] - } - expect(spec.interpretResponse(bidResponses)).to.deep.equal([ resObject ]); - }); - it('should return responses which contain empty advertiser domains', function() { - const resObjectWithEmptyAdvertiserDomains = Object.assign({}, resObject); - resObjectWithEmptyAdvertiserDomains.meta = Object.assign({}, resObject.meta); - resObjectWithEmptyAdvertiserDomains.meta.advertiserDomains = []; - const bidResponses = { - body: [ resObjectWithEmptyAdvertiserDomains, resObject ] + + it('should return array of valid video bid responses with mtype', function () { + const serverRequests = spec.buildRequests([bid3], bidderRequest); + const request = serverRequests[0]; + + const ortbResponse = { + body: { + seatbid: [{ + bid: [{ + id: 'bid456', + impid: request.data.imp[0].id, + price: 0.5, + w: 800, + h: 600, + adm: '', + crid: '456def', + mtype: 2, + adomain: ['example.com'] + }] + }], + cur: 'USD' + } + }; + + const serverResponses = spec.interpretResponse(ortbResponse, request); + + expect(serverResponses).to.be.an('array'); + if (serverResponses.length > 0) { + const bidResponse = serverResponses[0]; + expect(bidResponse.mediaType).to.equal('video'); + expect(bidResponse.vastXml).to.be.a('string'); } - expect(spec.interpretResponse(bidResponses)).to.deep.equal([resObjectWithEmptyAdvertiserDomains, resObject]); - }); - it('should skip responses which do not contain meta media type', function() { - const resObjectWithoutMetaMediaType = Object.assign({}, resObject); - resObjectWithoutMetaMediaType.meta = Object.assign({}, resObject.meta); - delete resObjectWithoutMetaMediaType.meta.mediaType; - const bidResponses = { - body: [ resObjectWithoutMetaMediaType, resObject ] + }); + + it('should return array of valid video bid responses with ext.mediaType fallback', function () { + const serverRequests = spec.buildRequests([bid3], bidderRequest); + const request = serverRequests[0]; + + const ortbResponse = { + body: { + seatbid: [{ + bid: [{ + id: 'bid456', + impid: request.data.imp[0].id, + price: 0.5, + w: 800, + h: 600, + adm: '', + crid: '456def', + ext: { + mediaType: 'video' + }, + adomain: ['example.com'] + }] + }], + cur: 'USD' + } + }; + + const serverResponses = spec.interpretResponse(ortbResponse, request); + + expect(serverResponses).to.be.an('array'); + if (serverResponses.length > 0) { + const bidResponse = serverResponses[0]; + expect(bidResponse.mediaType).to.equal('video'); + expect(bidResponse.vastXml).to.be.a('string'); } - expect(spec.interpretResponse(bidResponses)).to.deep.equal([ resObject ]); }); }); - describe('getUserSyncs', function () { - it('should return trackers for lm(only iframe) if server responses contain lm user sync header and iframe and image enabled', function () { - const serverResponses = [ - { - headers: { - get: function (header) { - if (header === 'x-pll-usersync-image') { - return 'https://tracker-lm.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-lm.ortb.net/sync.html'; - } - } - }, - body: [] + + describe('interpretResponse - mediaType fallback', function() { + const bidderRequest = { + ortb2: { + site: { + page: 'https://example.com/page' } - ]; - const syncOptions = { - iframeEnabled: true, - pixelEnabled: true - }; - expect(spec.getUserSyncs(syncOptions, serverResponses)).to.deep.equal([ - { - type: 'iframe', - url: 'https://tracker-lm.ortb.net/sync.html' + } + }; + + it('should infer mediaType from imp.banner when mtype is missing', function() { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const request = serverRequests[0]; + + const responseWithoutMtype = { + body: { + seatbid: [{ + bid: [{ + id: 'bid123', + impid: request.data.imp[0].id, + price: 0.3, + w: 300, + h: 250, + adm: '

Hello ad

', + crid: '123asd', + adomain: ['example.com'], + exp: 1000 + }] + }], + cur: 'USD' } - ]); + }; + + const serverResponses = spec.interpretResponse(responseWithoutMtype, request); + + expect(serverResponses).to.have.lengthOf(1); + expect(serverResponses[0].mediaType).to.equal('banner'); }); - it('should return empty array if all sync types are disabled', function () { - const serverResponses = [ - { - headers: { - get: function (header) { - if (header === 'x-pll-usersync-image') { - return 'https://tracker-1.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-1.ortb.net/sync.html'; - } - } - }, - body: [] + + it('should use ext.mediaType when available', function() { + const serverRequests = spec.buildRequests([bid1], bidderRequest); + const request = serverRequests[0]; + + const responseWithExtMediaType = { + body: { + seatbid: [{ + bid: [{ + id: 'bid123', + impid: request.data.imp[0].id, + price: 0.3, + w: 300, + h: 250, + adm: '

Hello ad

', + crid: '123asd', + ext: { + mediaType: 'banner' + }, + adomain: ['example.com'], + exp: 1000 + }] + }], + cur: 'USD' } - ]; - const syncOptions = { - iframeEnabled: false, - pixelEnabled: false }; - expect(spec.getUserSyncs(syncOptions, serverResponses)).to.be.an('array').that.is.empty; + + const serverResponses = spec.interpretResponse(responseWithExtMediaType, request); + + expect(serverResponses).to.have.lengthOf(1); + expect(serverResponses[0].mediaType).to.equal('banner'); }); - it('should return no pixels if iframe sync is enabled and headers are blank', function () { - const serverResponses = [ - { - headers: null, - body: [] - } - ]; - const syncOptions = { - iframeEnabled: true, - pixelEnabled: false + }); + + describe('onBidWon', function() { + it('should replace auction price macro in nurl', function() { + const bid = { + pbMg: 1.23, + nurl: 'https://example.com/win?price=${AUCTION_PRICE}' }; - expect(spec.getUserSyncs(syncOptions, serverResponses)).to.be.an('array').that.is.empty; + + expect(() => spec.onBidWon(bid)).to.not.throw(); }); - it('should return image sync urls for lm if pixel sync is enabled and headers have lm pixel', function () { - const serverResponses = [ - { - headers: { - get: function (header) { - if (header === 'x-pll-usersync-image') { - return 'https://tracker-lm.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-lm.ortb.net/sync.html'; - } + + it('should handle empty nurl', function() { + const bid = { + pbMg: 1.23, + nurl: '' + }; + + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + }); + + describe('getUserSyncs', function () { + it('should return iframe sync when available and enabled', function () { + const serverResponses = [{ + headers: { + get: function (header) { + if (header === 'x-pll-usersync-iframe') { + return 'https://tracker-lm.ortb.net/sync.html'; } - }, - body: [] - } - ]; + if (header === 'x-pll-usersync-image') { + return 'https://tracker-lm.ortb.net/sync'; + } + } + }, + body: {} + }]; + const syncOptions = { - iframeEnabled: false, + iframeEnabled: true, pixelEnabled: true }; - expect(spec.getUserSyncs(syncOptions, serverResponses)).to.deep.equal([ - { - type: 'image', - url: 'https://tracker-lm.ortb.net/sync' - } - ]); + + const syncs = spec.getUserSyncs(syncOptions, serverResponses); + expect(syncs).to.deep.equal([{ + type: 'iframe', + url: 'https://tracker-lm.ortb.net/sync.html' + }]); }); - it('should return image sync urls for client1 and clien2 if pixel sync is enabled and two responses and headers have two pixels', function () { - const serverResponses = [ - { - headers: { - get: function (header) { - if (header === 'x-pll-usersync-image') { - return 'https://tracker-1.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-1.ortb.net/sync.html'; - } + + it('should return image sync when iframe not available', function () { + const serverResponses = [{ + headers: { + get: function (header) { + if (header === 'x-pll-usersync-image') { + return 'https://tracker-lm.ortb.net/sync'; } - }, - body: [] + } }, - { - headers: { - get: function (header) { - if (header === 'x-pll-usersync-image') { - return 'https://tracker-2.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-2.ortb.net/sync.html'; - } - } - }, - body: [] - } - ]; + body: {} + }]; + const syncOptions = { iframeEnabled: false, pixelEnabled: true }; - expect(spec.getUserSyncs(syncOptions, serverResponses)).to.deep.equal([ - { - type: 'image', - url: 'https://tracker-1.ortb.net/sync' + + const syncs = spec.getUserSyncs(syncOptions, serverResponses); + expect(syncs).to.deep.equal([{ + type: 'image', + url: 'https://tracker-lm.ortb.net/sync' + }]); + }); + + it('should return empty array when all sync types disabled', function () { + const serverResponses = [{ + headers: { + get: function (header) { + return 'https://tracker.ortb.net/sync'; + } }, - { - type: 'image', - url: 'https://tracker-2.ortb.net/sync' - } - ]); + body: {} + }]; + + const syncOptions = { + iframeEnabled: false, + pixelEnabled: false + }; + + const syncs = spec.getUserSyncs(syncOptions, serverResponses); + expect(syncs).to.be.an('array').that.is.empty; }); - it('should return image sync url for pll if pixel sync is enabled and two responses and headers have two same pixels', function () { + + it('should deduplicate sync URLs', function() { const serverResponses = [ { headers: { get: function (header) { if (header === 'x-pll-usersync-image') { - return 'https://tracker-lm.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-lm.ortb.net/sync.html'; + return 'https://tracker.ortb.net/sync'; } } }, - body: [] + body: {} }, { headers: { get: function (header) { if (header === 'x-pll-usersync-image') { - return 'https://tracker-lm.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-lm.ortb.net/sync.html'; + return 'https://tracker.ortb.net/sync'; } } }, - body: [] + body: {} } ]; + const syncOptions = { iframeEnabled: false, pixelEnabled: true }; - expect(spec.getUserSyncs(syncOptions, serverResponses)).to.deep.equal([ - { - type: 'image', - url: 'https://tracker-lm.ortb.net/sync' - } - ]); - }); - it('should return iframe sync url for pll if pixel sync is enabled and iframe is enables and headers have both iframe and img pixels', function () { - const serverResponses = [ - { - headers: { - get: function (header) { - if (header === 'x-pll-usersync-image') { - return 'https://tracker-lm.ortb.net/sync'; - } - if (header === 'x-pll-usersync-iframe') { - return 'https://tracker-lm.ortb.net/sync.html'; - } - } - }, - body: [] - } - ]; - const syncOptions = { - iframeEnabled: true, - pixelEnabled: true - }; - expect(spec.getUserSyncs(syncOptions, serverResponses)).to.deep.equal([ - { - type: 'iframe', - url: 'https://tracker-lm.ortb.net/sync.html' - } - ]); - }); - }); - describe('getFloor support', function() { - const bidderRequest = { - ortb2: { - device: { - sua: { - browsers: [], - platform: [], - mobile: 1, - architecture: 'arm' - } - } - }, - refererInfo: { - page: 'testPage' - } - }; - it('should include floorInfo when getFloor is available', function() { - const bidWithFloor = { - ...bid1, - getFloor: function(params) { - if (params.size[0] === 300 && params.size[1] === 250) { - return { currency: 'USD', floor: 2.0 }; - } - return { currency: 'USD', floor: 0 }; - } - }; - - const serverRequests = spec.buildRequests([bidWithFloor], bidderRequest); - expect(serverRequests).to.have.lengthOf(1); - const adUnit = serverRequests[0].data.adUnits[0]; - expect(adUnit.sizes).to.have.lengthOf(1); - expect(adUnit.sizes[0].floorInfo).to.exist; - expect(adUnit.sizes[0].floorInfo.currency).to.equal('USD'); - expect(adUnit.sizes[0].floorInfo.floor).to.equal(2.0); - }); - it('should set floorInfo to null when getFloor is not available', function() { - const bidWithoutFloor = { ...bid1 }; - delete bidWithoutFloor.getFloor; - - const serverRequests = spec.buildRequests([bidWithoutFloor], bidderRequest); - expect(serverRequests).to.have.lengthOf(1); - expect(serverRequests[0].data.adUnits[0].sizes[0].floorInfo).to.be.null; - }); - it('should handle multiple sizes with different floors', function() { - const bidWithMultipleSizes = { - ...bid1, - mediaTypes: { - banner: { - sizes: [[300, 250], [728, 90]] - } - }, - getFloor: function(params) { - if (params.size[0] === 300 && params.size[1] === 250) { - return { currency: 'USD', floor: 1.5 }; - } - if (params.size[0] === 728 && params.size[1] === 90) { - return { currency: 'USD', floor: 2.0 }; - } - return { currency: 'USD', floor: 0 }; - } - }; - - const serverRequests = spec.buildRequests([bidWithMultipleSizes], bidderRequest); - expect(serverRequests).to.have.lengthOf(1); - const adUnit = serverRequests[0].data.adUnits[0]; - expect(adUnit.sizes).to.have.lengthOf(2); - expect(adUnit.sizes[0].floorInfo.floor).to.equal(1.5); - expect(adUnit.sizes[1].floorInfo.floor).to.equal(2.0); - }); - it('should set floorInfo to null when getFloor returns empty object', function() { - const bidWithEmptyFloor = { - ...bid1, - getFloor: function() { - return {}; - } - }; - - const serverRequests = spec.buildRequests([bidWithEmptyFloor], bidderRequest); - expect(serverRequests).to.have.lengthOf(1); - expect(serverRequests[0].data.adUnits[0].sizes[0].floorInfo).to.deep.equal({}); - }); - it('should handle getFloor errors and set floorInfo to null', function() { - const bidWithErrorFloor = { - ...bid1, - getFloor: function() { - throw new Error('Floor module error'); - } - }; - const serverRequests = spec.buildRequests([bidWithErrorFloor], bidderRequest); - expect(serverRequests).to.have.lengthOf(1); - const adUnit = serverRequests[0].data.adUnits[0]; - expect(adUnit.sizes[0].floorInfo).to.be.null; + const syncs = spec.getUserSyncs(syncOptions, serverResponses); + expect(syncs).to.have.lengthOf(1); }); }); }); - -function validateAdUnit(adUnit, bid) { - expect(adUnit.id).to.equal(bid.params.adUnitId); - expect(adUnit.bidId).to.equal(bid.bidId); - expect(adUnit.type).to.equal(bid.params.adUnitType.toUpperCase()); - expect(adUnit.transactionId).to.equal(bid.ortb2Imp.ext.tid); - let bidSizes = []; - if (bid.mediaTypes) { - if (bid.mediaTypes.video && bid.mediaTypes.video.playerSize) { - bidSizes = bidSizes.concat([bid.mediaTypes.video.playerSize]); - } - if (bid.mediaTypes.banner && bid.mediaTypes.banner.sizes) { - bidSizes = bidSizes.concat(bid.mediaTypes.banner.sizes); - } - } - if (bid.sizes) { - bidSizes = bidSizes.concat(bid.sizes || []); - } - expect(adUnit.sizes).to.deep.equal(bidSizes.map(size => { - return { - width: size[0], - height: size[1], - floorInfo: null - } - })); - expect(adUnit.publisherId).to.equal(bid.params.publisherId); - expect(adUnit.userIdAsEids).to.deep.equal(bid.userIdAsEids); - expect(adUnit.supplyChain).to.deep.equal(bid.ortb2.source.ext.schain); - expect(adUnit.ortb2Imp).to.deep.equal(bid.ortb2Imp); -} diff --git a/test/spec/modules/locIdSystem_spec.js b/test/spec/modules/locIdSystem_spec.js new file mode 100644 index 00000000000..1b2fc02ddc8 --- /dev/null +++ b/test/spec/modules/locIdSystem_spec.js @@ -0,0 +1,2090 @@ +/** + * This file is licensed under the Apache 2.0 license. + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { locIdSubmodule, storage } from 'modules/locIdSystem.js'; +import { createEidsArray } from 'modules/userId/eids.js'; +import { attachIdSystem } from 'modules/userId/index.js'; +import * as ajax from 'src/ajax.js'; +import { VENDORLESS_GVLID } from 'src/consentHandler.js'; +import { uspDataHandler, gppDataHandler } from 'src/adapterManager.js'; +import { expect } from 'chai/index.mjs'; +import sinon from 'sinon'; + +const TEST_ID = 'SYybozbTuRaZkgGqCD7L7EE0FncoNUcx-om4xTfhJt36TFIAES2tF1qPH'; +const TEST_ENDPOINT = 'https://id.example.com/locid'; +const TEST_CONNECTION_IP = '203.0.113.42'; + +describe('LocID System', () => { + let sandbox; + let ajaxStub; + let ajaxBuilderStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(uspDataHandler, 'getConsentData').returns(null); + sandbox.stub(gppDataHandler, 'getConsentData').returns(null); + sandbox.stub(storage, 'getDataFromLocalStorage').returns(null); + sandbox.stub(storage, 'setDataInLocalStorage'); + ajaxStub = sandbox.stub(); + ajaxBuilderStub = sandbox.stub(ajax, 'ajaxBuilder').returns(ajaxStub); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('module properties', () => { + it('should expose correct module name', () => { + expect(locIdSubmodule.name).to.equal('locId'); + }); + + it('should have eids configuration with correct defaults', () => { + expect(locIdSubmodule.eids).to.be.an('object'); + expect(locIdSubmodule.eids.locId).to.be.an('object'); + expect(locIdSubmodule.eids.locId.source).to.equal('locid.com'); + // atype 1 = AdCOM AgentTypeWeb + expect(locIdSubmodule.eids.locId.atype).to.equal(1); + }); + + it('should register as a vendorless TCF module', () => { + expect(locIdSubmodule.gvlid).to.equal(VENDORLESS_GVLID); + }); + + it('should have getValue function that extracts ID', () => { + const getValue = locIdSubmodule.eids.locId.getValue; + expect(getValue('test-id')).to.equal('test-id'); + expect(getValue({ id: 'id-shape' })).to.equal('id-shape'); + expect(getValue({ locId: 'test-id' })).to.equal('test-id'); + expect(getValue({ locid: 'legacy-id' })).to.equal('legacy-id'); + }); + }); + + describe('decode', () => { + it('should decode valid ID correctly', () => { + const result = locIdSubmodule.decode({ id: TEST_ID, connectionIp: TEST_CONNECTION_IP }); + expect(result).to.deep.equal({ locId: TEST_ID }); + }); + + it('should decode ID passed as object', () => { + const result = locIdSubmodule.decode({ id: TEST_ID, connectionIp: TEST_CONNECTION_IP }); + expect(result).to.deep.equal({ locId: TEST_ID }); + }); + + it('should return undefined for invalid values', () => { + [null, undefined, '', {}, [], 123, TEST_ID].forEach(value => { + expect(locIdSubmodule.decode(value)).to.be.undefined; + }); + }); + + it('should return undefined when connection_ip is missing', () => { + expect(locIdSubmodule.decode({ id: TEST_ID })).to.be.undefined; + }); + + it('should return undefined for IDs exceeding max length', () => { + const longId = 'a'.repeat(513); + expect(locIdSubmodule.decode({ id: longId, connectionIp: TEST_CONNECTION_IP })).to.be.undefined; + }); + + it('should accept ID at exactly MAX_ID_LENGTH (512 characters)', () => { + const maxLengthId = 'a'.repeat(512); + const result = locIdSubmodule.decode({ id: maxLengthId, connectionIp: TEST_CONNECTION_IP }); + expect(result).to.deep.equal({ locId: maxLengthId }); + }); + }); + + describe('getId', () => { + it('should return callback for async endpoint fetch', () => { + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + const result = locIdSubmodule.getId(config, {}); + expect(result).to.have.property('callback'); + expect(result.callback).to.be.a('function'); + }); + + it('should call endpoint and return tx_cloc on success', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.equal(TEST_ID); + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + expect(ajaxStub.calledOnce).to.be.true; + done(); + }); + }); + + it('should cache entry with null id when tx_cloc is missing but connection_ip is present', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ stable_cloc: 'stable-cloc-id-12345', connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.be.null; + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should prefer tx_cloc over stable_cloc when both present', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ + tx_cloc: TEST_ID, + stable_cloc: 'stable-fallback', + connection_ip: TEST_CONNECTION_IP + })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.equal(TEST_ID); + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should return undefined on endpoint error', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.error('Network error'); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.undefined; + done(); + }); + }); + + it('should reuse storedId when valid and IP cache matches', () => { + // Set up IP cache matching stored entry's IP + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + const config = { + params: { + endpoint: TEST_ENDPOINT + }, + storage: { + name: '_locid' + } + }; + const storedId = { + id: 'existing-id', + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + updatedAt: Date.now(), + expiresAt: Date.now() + 1000 + }; + const result = locIdSubmodule.getId(config, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + expect(ajaxStub.called).to.be.false; + }); + + it('should not reuse storedId when expired', () => { + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + const storedId = { + id: 'expired-id', + connectionIp: TEST_CONNECTION_IP, + createdAt: 1000, + updatedAt: 1000, + expiresAt: Date.now() - 1000 + }; + const result = locIdSubmodule.getId(config, {}, storedId); + expect(result).to.have.property('callback'); + }); + + it('should not reuse storedId when connectionIp is missing', () => { + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + const storedId = { + id: 'existing-id', + createdAt: 1000, + updatedAt: 1000, + expiresAt: 2000 + }; + const result = locIdSubmodule.getId(config, {}, storedId); + expect(result).to.have.property('callback'); + }); + + it('should pass x-api-key header when apiKey is configured', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(options.customHeaders).to.deep.equal({ 'x-api-key': 'test-api-key' }); + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + apiKey: 'test-api-key' + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should not include customHeaders when apiKey is not configured', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(options.customHeaders).to.not.exist; + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should pass withCredentials when configured', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(options.withCredentials).to.be.true; + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + withCredentials: true + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should default withCredentials to false', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(options.withCredentials).to.be.false; + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should use default timeout of 800ms when timeoutMs is not configured', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => { + expect(ajaxBuilderStub.calledWith(800)).to.be.true; + done(); + }); + }); + + it('should use custom timeout when timeoutMs is configured', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + timeoutMs: 1500 + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => { + expect(ajaxBuilderStub.calledWith(1500)).to.be.true; + done(); + }); + }); + + it('should always use GET method', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(options.method).to.equal('GET'); + expect(body).to.be.null; + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should append alt_id query parameter when altId is configured', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(url).to.equal(TEST_ENDPOINT + '?alt_id=user123'); + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + altId: 'user123' + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should use & separator when endpoint already has query params', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(url).to.equal(TEST_ENDPOINT + '?existing=param&alt_id=user456'); + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + '?existing=param', + altId: 'user456' + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should URL-encode altId value', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(url).to.equal(TEST_ENDPOINT + '?alt_id=user%40example.com'); + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + altId: 'user@example.com' + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should not append alt_id when altId is not configured', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(url).to.equal(TEST_ENDPOINT); + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should preserve URL fragment when appending alt_id', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(url).to.equal('https://id.example.com/locid?alt_id=user123#frag'); + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: 'https://id.example.com/locid#frag', + altId: 'user123' + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should preserve URL fragment when endpoint has existing query params', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + expect(url).to.equal('https://id.example.com/locid?x=1&alt_id=user456#frag'); + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: 'https://id.example.com/locid?x=1#frag', + altId: 'user456' + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => done()); + }); + + it('should return undefined via callback when endpoint is empty string', (done) => { + const config = { + params: { + endpoint: '' + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.undefined; + expect(ajaxStub.called).to.be.false; + done(); + }); + }); + }); + + describe('extendId', () => { + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + it('should return stored id when valid and IP cache is current', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 14400000 + })); + const storedId = { id: 'existing-id', connectionIp: TEST_CONNECTION_IP }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + }); + + it('should reuse storedId when refreshInSeconds is configured but not due', () => { + const now = Date.now(); + const refreshInSeconds = 60; + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: now, + expiresAt: now + 14400000 + })); + const storedId = { + id: 'existing-id', + connectionIp: TEST_CONNECTION_IP, + createdAt: now - ((refreshInSeconds - 10) * 1000), + expiresAt: now + 10000 + }; + const refreshConfig = { + params: { + endpoint: TEST_ENDPOINT + }, + storage: { + refreshInSeconds + } + }; + const result = locIdSubmodule.extendId(refreshConfig, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + }); + + it('should return undefined when refreshInSeconds is due', () => { + const now = Date.now(); + const refreshInSeconds = 60; + const storedId = { + id: 'existing-id', + connectionIp: TEST_CONNECTION_IP, + createdAt: now - ((refreshInSeconds + 10) * 1000), + expiresAt: now + 10000 + }; + const refreshConfig = { + params: { + endpoint: TEST_ENDPOINT + }, + storage: { + refreshInSeconds + } + }; + const result = locIdSubmodule.extendId(refreshConfig, {}, storedId); + expect(result).to.be.undefined; + }); + + it('should return undefined when refreshInSeconds is configured and createdAt is missing', () => { + const now = Date.now(); + const refreshInSeconds = 60; + const storedId = { + id: 'existing-id', + connectionIp: TEST_CONNECTION_IP, + expiresAt: now + 10000 + }; + const refreshConfig = { + params: { + endpoint: TEST_ENDPOINT + }, + storage: { + refreshInSeconds + } + }; + const result = locIdSubmodule.extendId(refreshConfig, {}, storedId); + expect(result).to.be.undefined; + }); + + it('should return undefined when storedId is a string', () => { + const result = locIdSubmodule.extendId(config, {}, 'existing-id'); + expect(result).to.be.undefined; + }); + + it('should return undefined when connectionIp is missing', () => { + const result = locIdSubmodule.extendId(config, {}, { id: 'existing-id' }); + expect(result).to.be.undefined; + }); + + it('should return undefined when stored entry is expired', () => { + const storedId = { + id: 'existing-id', + connectionIp: TEST_CONNECTION_IP, + expiresAt: Date.now() - 1000 + }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.be.undefined; + }); + }); + + describe('privacy framework handling', () => { + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + // --- Tests for no privacy signals (LI-based operation) --- + // LocID operates under Legitimate Interest and should proceed when no + // privacy framework is present, unless requirePrivacySignals is enabled. + + it('should proceed when no consentData provided at all (default LI-based operation)', () => { + // When no privacy signals are present, module should proceed by default + const result = locIdSubmodule.getId(config, undefined); + expect(result).to.have.property('callback'); + }); + + it('should proceed when consentData is null (default LI-based operation)', () => { + const result = locIdSubmodule.getId(config, null); + expect(result).to.have.property('callback'); + }); + + it('should proceed when consentData is empty object (default LI-based operation)', () => { + // Empty object has no privacy signals, so should proceed + const result = locIdSubmodule.getId(config, {}); + expect(result).to.have.property('callback'); + }); + + it('should return undefined when no consentData and requirePrivacySignals=true', () => { + const strictConfig = { + params: { + endpoint: TEST_ENDPOINT, + requirePrivacySignals: true + } + }; + const result = locIdSubmodule.getId(strictConfig, undefined); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should return undefined when empty consentData and requirePrivacySignals=true', () => { + const strictConfig = { + params: { + endpoint: TEST_ENDPOINT, + requirePrivacySignals: true + } + }; + const result = locIdSubmodule.getId(strictConfig, {}); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should return undefined when no consentData and privacyMode=requireSignals', () => { + const strictConfig = { + params: { + endpoint: TEST_ENDPOINT, + privacyMode: 'requireSignals' + } + }; + const result = locIdSubmodule.getId(strictConfig, undefined); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should proceed when no consentData and privacyMode=allowWithoutSignals', () => { + const permissiveConfig = { + params: { + endpoint: TEST_ENDPOINT, + privacyMode: 'allowWithoutSignals' + } + }; + const result = locIdSubmodule.getId(permissiveConfig, undefined); + expect(result).to.have.property('callback'); + }); + + it('should proceed with privacy signals present and requirePrivacySignals=true', () => { + const strictConfig = { + params: { + endpoint: TEST_ENDPOINT, + requirePrivacySignals: true + } + }; + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: {} + } + }; + const result = locIdSubmodule.getId(strictConfig, consentData); + expect(result).to.have.property('callback'); + }); + + it('should detect privacy signals from uspDataHandler and block on processing restriction', () => { + // Restore and re-stub to return USP processing restriction signal + uspDataHandler.getConsentData.restore(); + sandbox.stub(uspDataHandler, 'getConsentData').returns('1YY-'); + + // Even with empty consentData, handler provides privacy signal + const result = locIdSubmodule.getId(config, {}); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should detect privacy signals from gppDataHandler and block on processing restriction', () => { + // Restore and re-stub to return GPP processing restriction signal + gppDataHandler.getConsentData.restore(); + sandbox.stub(gppDataHandler, 'getConsentData').returns({ + applicableSections: [7], + parsedSections: { + usnat: { + KnownChildSensitiveDataConsents: [1] + } + } + }); + + // Even with empty consentData, handler provides privacy signal + const result = locIdSubmodule.getId(config, {}); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should proceed when uspDataHandler returns non-restrictive value', () => { + // Restore and re-stub to return non-restrictive USP + uspDataHandler.getConsentData.restore(); + sandbox.stub(uspDataHandler, 'getConsentData').returns('1NN-'); + + const result = locIdSubmodule.getId(config, {}); + expect(result).to.have.property('callback'); + }); + + // --- Tests for gdprApplies edge cases (LI-based operation) --- + // gdprApplies alone does NOT constitute a privacy signal. + // A GDPR signal requires actual CMP artifacts (consentString or vendorData). + + it('should proceed when gdprApplies=true but no consentString/vendorData (default LI mode)', () => { + // gdprApplies alone is not a signal - allows LI-based operation without CMP + const consentData = { + gdprApplies: true + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when gdprApplies=true and usp is present but no GDPR CMP artifacts', () => { + const consentData = { + gdprApplies: true, + usp: '1NN-' + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when gdprApplies=true and consentString is empty (treated as absent CMP artifact)', () => { + // Empty consentString is treated as not present, so LI-mode behavior applies. + const consentData = { + gdprApplies: true, + consentString: '' + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should return undefined when gdprApplies=true, no CMP artifacts, and strict mode', () => { + const strictConfig = { + params: { + endpoint: TEST_ENDPOINT, + requirePrivacySignals: true + } + }; + const consentData = { + gdprApplies: true + }; + const result = locIdSubmodule.getId(strictConfig, consentData); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should return undefined when gdprApplies=true, vendorData present but no consentString', () => { + // vendorData presence = CMP is present, so signals ARE present + // GDPR applies + signals present + no consentString → deny + const consentData = { + gdprApplies: true, + vendorData: { + vendor: {} + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should deny when gdprApplies=false and strict mode is enabled (gdprApplies alone is not a signal)', () => { + // gdprApplies=false alone is not a signal, so strict mode blocks + const strictConfig = { + params: { + endpoint: TEST_ENDPOINT, + requirePrivacySignals: true + } + }; + const consentData = { + gdprApplies: false + }; + const result = locIdSubmodule.getId(strictConfig, consentData); + // gdprApplies=false is not a signal, so strict mode denies + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + // --- Original tests for when privacy signals ARE present --- + + it('should proceed with valid GDPR framework data', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string' + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when GDPR does not apply (no CMP artifacts)', () => { + // gdprApplies=false alone is not a signal - LI operation allowed + const consentData = { + gdprApplies: false + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when GDPR applies with consentString and vendor flags deny', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: { 999: false }, + legitimateInterests: { 999: false } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should return undefined on US Privacy processing restriction and not call ajax', () => { + const consentData = { + usp: '1YY-' + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should return undefined on legacy US Privacy processing restriction and not call ajax', () => { + const consentData = { + uspConsent: '1YY-' + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should return undefined on GPP processing restriction and not call ajax', () => { + const consentData = { + gpp: { + applicableSections: [7], + parsedSections: { + usnat: { + KnownChildSensitiveDataConsents: [1] + } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should return undefined on legacy GPP processing restriction and not call ajax', () => { + const consentData = { + gppConsent: { + applicableSections: [7], + parsedSections: { + usnat: { + KnownChildSensitiveDataConsents: [1] + } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.be.undefined; + expect(ajaxStub.called).to.be.false; + }); + + it('should proceed when nested GDPR applies but no CMP artifacts (LI operation)', () => { + // Empty consentString in nested gdpr object is not a signal - LI operation allowed + const consentData = { + gdpr: { + gdprApplies: true, + consentString: '' + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed with valid nested GDPR framework data', () => { + const consentData = { + gdpr: { + gdprApplies: true, + consentString: 'valid-consent-string' + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when GDPR applies and vendorData is present', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: { 999: false }, + legitimateInterests: { 999: false } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when GDPR applies and vendor is missing from consents', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: { 1234: true }, + legitimateInterests: { 1234: true } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when GDPR applies and vendor consents object is present', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: { 999: true } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when GDPR applies and vendor legitimate interests object is present', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: { 999: false }, + legitimateInterests: { 999: true } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when vendorData is not available (cannot determine vendor permission)', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string' + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should check nested vendorData when using gdpr object shape', () => { + const consentData = { + gdpr: { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: { 999: true } + } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when nested vendorData has explicit deny flags', () => { + const consentData = { + gdpr: { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: { 999: false }, + legitimateInterests: {} + } + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when vendor consents is a function returning true', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: (id) => id === 999, + legitimateInterests: {} + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when vendor legitimateInterests is a function returning true', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: (id) => false, + legitimateInterests: (id) => id === 999 + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + + it('should proceed when vendor consent callbacks both return false', () => { + const consentData = { + gdprApplies: true, + consentString: 'valid-consent-string', + vendorData: { + vendor: { + consents: (id) => false, + legitimateInterests: (id) => false + } + } + }; + const result = locIdSubmodule.getId(config, consentData); + expect(result).to.have.property('callback'); + }); + }); + + describe('response parsing', () => { + it('should parse JSON string response', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success('{"tx_cloc":"parsed-id","connection_ip":"203.0.113.42"}'); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.equal('parsed-id'); + expect(id.connectionIp).to.equal('203.0.113.42'); + done(); + }); + }); + + it('should return undefined when connection_ip is missing', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.undefined; + done(); + }); + }); + + it('should return undefined for invalid JSON', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success('not valid json'); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.undefined; + done(); + }); + }); + + it('should cache entry with null id when tx_cloc is empty string', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ tx_cloc: '', connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.be.null; + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should cache entry with null id when tx_cloc is whitespace-only', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ tx_cloc: ' \n\t ', connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.be.null; + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should return undefined when tx_cloc is missing', (done) => { + ajaxStub.callsFake((url, callbacks, body, options) => { + callbacks.success(JSON.stringify({ other_field: 'value' })); + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT + } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.undefined; + done(); + }); + }); + }); + + describe('empty tx_cloc handling', () => { + it('should decode null id as undefined (no EID emitted)', () => { + const result = locIdSubmodule.decode({ id: null, connectionIp: TEST_CONNECTION_IP }); + expect(result).to.be.undefined; + }); + + it('should cache empty tx_cloc response for the full cache period', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { expires: 7 } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.be.null; + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + expect(id.expiresAt).to.be.a('number'); + expect(id.expiresAt).to.be.greaterThan(Date.now()); + done(); + }); + }); + + it('should reuse cached entry with null id on subsequent getId calls', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: null, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + updatedAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + expect(ajaxStub.called).to.be.false; + }); + + it('should not produce EID when tx_cloc is null', () => { + const decoded = locIdSubmodule.decode({ id: null, connectionIp: TEST_CONNECTION_IP }); + expect(decoded).to.be.undefined; + }); + + it('should write IP cache when endpoint returns empty tx_cloc', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => { + expect(storage.setDataInLocalStorage.called).to.be.true; + const callArgs = storage.setDataInLocalStorage.getCall(0).args; + expect(callArgs[0]).to.equal('_locid_ip'); + const ipEntry = JSON.parse(callArgs[1]); + expect(ipEntry.ip).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + }); + + describe('IP cache management', () => { + const ipCacheConfig = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + + it('should read valid IP cache entry', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + // If IP cache is valid and stored entry matches, should reuse + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.getId(ipCacheConfig, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + expect(ajaxStub.called).to.be.false; + }); + + it('should treat expired IP cache as missing', (done) => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now() - 20000, + expiresAt: Date.now() - 1000 + })); + + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.getId(ipCacheConfig, {}, storedId); + // Expired IP cache → calls main endpoint + expect(result).to.have.property('callback'); + result.callback((id) => { + expect(id).to.be.an('object'); + done(); + }); + }); + + it('should handle corrupted IP cache JSON gracefully', (done) => { + storage.getDataFromLocalStorage.returns('not-valid-json'); + + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const result = locIdSubmodule.getId(ipCacheConfig, {}); + expect(result).to.have.property('callback'); + result.callback((id) => { + expect(id).to.be.an('object'); + expect(id.id).to.equal(TEST_ID); + done(); + }); + }); + + it('should derive IP cache key from storage name', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: 'custom_key' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => { + const setCall = storage.setDataInLocalStorage.getCall(0); + expect(setCall.args[0]).to.equal('custom_key_ip'); + done(); + }); + }); + + it('should use custom ipCacheName when configured', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT, ipCacheName: 'my_ip_cache' }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => { + const setCall = storage.setDataInLocalStorage.getCall(0); + expect(setCall.args[0]).to.equal('my_ip_cache'); + done(); + }); + }); + + it('should use custom ipCacheTtlMs when configured', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT, ipCacheTtlMs: 7200000 }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback(() => { + const setCall = storage.setDataInLocalStorage.getCall(0); + const ipEntry = JSON.parse(setCall.args[1]); + // TTL should be ~2 hours + const ttl = ipEntry.expiresAt - ipEntry.fetchedAt; + expect(ttl).to.equal(7200000); + done(); + }); + }); + + it('should write IP cache on every successful main endpoint response', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: '10.0.0.1' })); + }); + + const result = locIdSubmodule.getId(ipCacheConfig, {}); + result.callback(() => { + expect(storage.setDataInLocalStorage.called).to.be.true; + const ipEntry = JSON.parse(storage.setDataInLocalStorage.getCall(0).args[1]); + expect(ipEntry.ip).to.equal('10.0.0.1'); + done(); + }); + }); + }); + + describe('getId with ipEndpoint (two-call optimization)', () => { + it('should call ipEndpoint first when IP cache is expired', (done) => { + let callCount = 0; + ajaxStub.callsFake((url, callbacks) => { + callCount++; + if (url === 'https://ip.example.com/check') { + callbacks.success(JSON.stringify({ ip: '10.0.0.1' })); + } else { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: '10.0.0.1' })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + // IP changed (no stored entry) → 2 calls: ipEndpoint + main endpoint + expect(callCount).to.equal(2); + expect(id.id).to.equal(TEST_ID); + done(); + }); + }); + + it('should reuse cached tx_cloc when ipEndpoint returns same IP', (done) => { + let callCount = 0; + ajaxStub.callsFake((url, callbacks) => { + callCount++; + if (url === 'https://ip.example.com/check') { + callbacks.success(JSON.stringify({ ip: TEST_CONNECTION_IP })); + } else { + callbacks.success(JSON.stringify({ tx_cloc: 'new-id', connection_ip: TEST_CONNECTION_IP })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + // IP unchanged → only 1 call (ipEndpoint), reuses stored tx_cloc + expect(callCount).to.equal(1); + expect(id.id).to.equal(TEST_ID); + done(); + }); + }); + + it('should call main endpoint when ipEndpoint returns different IP', (done) => { + let callCount = 0; + ajaxStub.callsFake((url, callbacks) => { + callCount++; + if (url === 'https://ip.example.com/check') { + callbacks.success(JSON.stringify({ ip: '10.0.0.99' })); + } else { + callbacks.success(JSON.stringify({ tx_cloc: 'new-id', connection_ip: '10.0.0.99' })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + // IP changed → 2 calls: ipEndpoint + main endpoint + expect(callCount).to.equal(2); + expect(id.id).to.equal('new-id'); + done(); + }); + }); + + it('should fall back to main endpoint when ipEndpoint fails', (done) => { + let callCount = 0; + ajaxStub.callsFake((url, callbacks) => { + callCount++; + if (url === 'https://ip.example.com/check') { + callbacks.error('Network error'); + } else { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(callCount).to.equal(2); + expect(id.id).to.equal(TEST_ID); + done(); + }); + }); + + it('should fall back to main endpoint when ipEndpoint returns invalid IP', (done) => { + let callCount = 0; + ajaxStub.callsFake((url, callbacks) => { + callCount++; + if (url === 'https://ip.example.com/check') { + callbacks.success('not-an-ip!!!'); + } else { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(callCount).to.equal(2); + expect(id.id).to.equal(TEST_ID); + done(); + }); + }); + + it('should pass apiKey header to ipEndpoint when configured', (done) => { + ajaxStub.callsFake((url, callbacks, _body, options) => { + if (url === 'https://ip.example.com/check') { + expect(options.customHeaders).to.deep.equal({ 'x-api-key': 'test-key-123' }); + callbacks.success(JSON.stringify({ ip: TEST_CONNECTION_IP })); + } else { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check', + apiKey: 'test-key-123' + }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + done(); + }); + }); + + it('should parse plain text IP from ipEndpoint', (done) => { + ajaxStub.callsFake((url, callbacks) => { + if (url === 'https://ip.example.com/check') { + callbacks.success(TEST_CONNECTION_IP); + } else { + callbacks.success(JSON.stringify({ tx_cloc: 'new-id', connection_ip: TEST_CONNECTION_IP })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + // IP same → reuses stored tx_cloc + expect(id.id).to.equal(TEST_ID); + done(); + }); + }); + }); + + describe('getId tx_cloc preservation (no churn)', () => { + it('should preserve existing tx_cloc when main endpoint returns same IP', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: 'fresh-id', connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + // IP unchanged → preserve existing tx_cloc (don't churn) + expect(id.id).to.equal(TEST_ID); + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should use fresh non-null tx_cloc when stored entry is null for same IP', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: 'fresh-id', connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: null, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + expect(id.id).to.equal('fresh-id'); + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should use fresh tx_cloc when main endpoint returns different IP', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: 'fresh-id', connection_ip: '10.0.0.99' })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + // IP changed → use fresh tx_cloc + expect(id.id).to.equal('fresh-id'); + expect(id.connectionIp).to.equal('10.0.0.99'); + done(); + }); + }); + + it('should use fresh tx_cloc when stored entry is expired even if IP matches', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: 'fresh-id', connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now() - 86400000, + expiresAt: Date.now() - 1000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + // tx_cloc expired → use fresh even though IP matches + expect(id.id).to.equal('fresh-id'); + done(); + }); + }); + + it('should honor null tx_cloc from main endpoint even when stored entry is reusable', (done) => { + // Server returns connection_ip but no tx_cloc → freshEntry.id === null. + // The stored entry has a valid tx_cloc for the same IP, but the server + // now indicates no ID for this IP. The null response must be honored. + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + + const result = locIdSubmodule.getId(config, {}, storedId); + result.callback((id) => { + // Server said no tx_cloc → stored entry must NOT be preserved + expect(id.id).to.be.null; + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should use fresh entry on first load (no stored entry)', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: TEST_CONNECTION_IP })); + }); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id.id).to.equal(TEST_ID); + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + }); + + describe('extendId with null id and IP cache', () => { + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + + it('should accept stored entry with null id (empty tx_cloc)', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 14400000 + })); + const storedId = { + id: null, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + }); + + it('should return refresh callback when IP cache shows different IP', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: 'fresh-id', connection_ip: '10.0.0.99' })); + }); + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: '10.0.0.99', + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.have.property('callback'); + result.callback((id) => { + expect(id.id).to.equal('fresh-id'); + expect(id.connectionIp).to.equal('10.0.0.99'); + done(); + }); + }); + + it('should extend when IP cache matches stored entry IP', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + }); + + it('should return refresh callback when IP cache is missing', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: 'fresh-id', connection_ip: TEST_CONNECTION_IP })); + }); + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.have.property('callback'); + result.callback((id) => { + expect(id.id).to.equal('fresh-id'); + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should return refresh callback when IP cache is expired', (done) => { + ajaxStub.callsFake((url, callbacks) => { + callbacks.success(JSON.stringify({ tx_cloc: 'fresh-id', connection_ip: TEST_CONNECTION_IP })); + }); + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now() - 14400000, + expiresAt: Date.now() - 1000 + })); + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.have.property('callback'); + result.callback((id) => { + expect(id.id).to.equal('fresh-id'); + expect(id.connectionIp).to.equal(TEST_CONNECTION_IP); + done(); + }); + }); + + it('should return undefined for undefined id (not null, not valid string)', () => { + const storedId = { + id: undefined, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.extendId(config, {}, storedId); + expect(result).to.be.undefined; + }); + }); + + describe('normalizeStoredId with null id', () => { + it('should preserve explicit null id', () => { + // getId is the public interface; test via decode which uses normalized values + const stored = { id: null, connectionIp: TEST_CONNECTION_IP }; + // decode returns undefined for null id (correct: no EID emitted) + const decoded = locIdSubmodule.decode(stored); + expect(decoded).to.be.undefined; + }); + + it('should preserve valid string id', () => { + const stored = { id: TEST_ID, connectionIp: TEST_CONNECTION_IP }; + const decoded = locIdSubmodule.decode(stored); + expect(decoded).to.deep.equal({ locId: TEST_ID }); + }); + + it('should fall back to tx_cloc when id key is absent', () => { + const stored = { tx_cloc: TEST_ID, connectionIp: TEST_CONNECTION_IP }; + const decoded = locIdSubmodule.decode(stored); + expect(decoded).to.deep.equal({ locId: TEST_ID }); + }); + + it('should handle connection_ip alias', () => { + const stored = { id: TEST_ID, connection_ip: TEST_CONNECTION_IP }; + const decoded = locIdSubmodule.decode(stored); + expect(decoded).to.deep.equal({ locId: TEST_ID }); + }); + }); + + describe('parseIpResponse via ipEndpoint', () => { + it('should parse JSON with ip field', (done) => { + ajaxStub.callsFake((url, callbacks) => { + if (url === 'https://ip.example.com/check') { + callbacks.success('{"ip":"1.2.3.4"}'); + } else { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: '1.2.3.4' })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + // IP fetched and used + expect(id).to.be.an('object'); + done(); + }); + }); + + it('should parse JSON with connection_ip field', (done) => { + ajaxStub.callsFake((url, callbacks) => { + if (url === 'https://ip.example.com/check') { + callbacks.success('{"connection_ip":"1.2.3.4"}'); + } else { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: '1.2.3.4' })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + done(); + }); + }); + + it('should parse plain text IP address', (done) => { + ajaxStub.callsFake((url, callbacks) => { + if (url === 'https://ip.example.com/check') { + callbacks.success('1.2.3.4\n'); + } else { + callbacks.success(JSON.stringify({ tx_cloc: TEST_ID, connection_ip: '1.2.3.4' })); + } + }); + + const config = { + params: { + endpoint: TEST_ENDPOINT, + ipEndpoint: 'https://ip.example.com/check' + }, + storage: { name: '_locid' } + }; + + const result = locIdSubmodule.getId(config, {}); + result.callback((id) => { + expect(id).to.be.an('object'); + done(); + }); + }); + }); + + describe('backward compatibility', () => { + it('should work with existing stored entries that have string ids', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.getId(config, {}, storedId); + expect(result).to.deep.equal({ id: storedId }); + }); + + it('should work with stored entries using connection_ip alias', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + id: TEST_ID, + connection_ip: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.getId(config, {}, storedId); + expect(result.id.connectionIp).to.equal(TEST_CONNECTION_IP); + }); + + it('should work with stored entries using tx_cloc alias', () => { + storage.getDataFromLocalStorage.returns(JSON.stringify({ + ip: TEST_CONNECTION_IP, + fetchedAt: Date.now(), + expiresAt: Date.now() + 1000 + })); + + const config = { + params: { endpoint: TEST_ENDPOINT }, + storage: { name: '_locid' } + }; + const storedId = { + tx_cloc: TEST_ID, + connectionIp: TEST_CONNECTION_IP, + createdAt: Date.now(), + expiresAt: Date.now() + 86400000 + }; + const result = locIdSubmodule.getId(config, {}, storedId); + expect(result.id.id).to.equal(TEST_ID); + }); + }); + + describe('EID round-trip integration', () => { + before(() => { + attachIdSystem(locIdSubmodule); + }); + + it('should produce a valid EID from decode output via createEidsArray', () => { + // Simulate the full Prebid pipeline: + // 1. decode() returns { locId: "string" } + // 2. Prebid extracts idObj["locId"] -> the string + // 3. createEidsArray({ locId: "string" }) should produce a valid EID + const stored = { id: TEST_ID, connectionIp: TEST_CONNECTION_IP }; + const decoded = locIdSubmodule.decode(stored); + expect(decoded).to.deep.equal({ locId: TEST_ID }); + + const eids = createEidsArray(decoded); + expect(eids.length).to.equal(1); + expect(eids[0]).to.deep.equal({ + source: 'locid.com', + uids: [{ id: TEST_ID, atype: 1 }] + }); + expect(eids[0].uids[0].atype).to.not.equal(Number('33' + '84')); + }); + + it('should not produce EID when decode returns undefined', () => { + const decoded = locIdSubmodule.decode(null); + expect(decoded).to.be.undefined; + }); + + it('should not produce EID when only stable_cloc is present', () => { + const decoded = locIdSubmodule.decode({ + stable_cloc: 'stable-only-value', + connectionIp: TEST_CONNECTION_IP + }); + expect(decoded).to.be.undefined; + + const eids = createEidsArray({ locId: decoded?.locId }); + expect(eids).to.deep.equal([]); + }); + + it('should not produce EID when tx_cloc is null', () => { + const decoded = locIdSubmodule.decode({ id: null, connectionIp: TEST_CONNECTION_IP }); + expect(decoded).to.be.undefined; + + const eids = createEidsArray({ locId: decoded?.locId }); + expect(eids).to.deep.equal([]); + }); + + it('should not produce EID when tx_cloc is missing', () => { + const decoded = locIdSubmodule.decode({ connectionIp: TEST_CONNECTION_IP }); + expect(decoded).to.be.undefined; + + const eids = createEidsArray({ locId: decoded?.locId }); + expect(eids).to.deep.equal([]); + }); + }); +}); diff --git a/test/spec/modules/lunamediahbBidAdapter_spec.js b/test/spec/modules/lunamediahbBidAdapter_spec.js index 79c9c0d6172..a0b4a02cc57 100644 --- a/test/spec/modules/lunamediahbBidAdapter_spec.js +++ b/test/spec/modules/lunamediahbBidAdapter_spec.js @@ -432,7 +432,7 @@ describe('LunamediaHBBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -441,9 +441,7 @@ describe('LunamediaHBBidAdapter', function () { expect(syncData[0].url).to.equal('https://cookie.lmgssp.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -452,7 +450,7 @@ describe('LunamediaHBBidAdapter', function () { expect(syncData[0].url).to.equal('https://cookie.lmgssp.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/marsmediaBidAdapter_spec.js b/test/spec/modules/marsmediaBidAdapter_spec.js index 30c68601767..c8a0109a94b 100644 --- a/test/spec/modules/marsmediaBidAdapter_spec.js +++ b/test/spec/modules/marsmediaBidAdapter_spec.js @@ -32,13 +32,15 @@ describe('marsmedia adapter tests', function () { }; win = { document: { - visibilityState: 'visible' + visibilityState: 'visible', + documentElement: { + clientWidth: 800, + clientHeight: 600 + } }, location: { href: 'http://location' }, - innerWidth: 800, - innerHeight: 600 }; this.defaultBidderRequest = { 'refererInfo': { @@ -506,6 +508,8 @@ describe('marsmedia adapter tests', function () { context('when element is fully in view', function() { it('returns 100', function() { + sandbox.stub(internal, 'getWindowTop').returns(win); + resetWinDimensions(); Object.assign(element, { width: 600, height: 400 }); const request = marsAdapter.buildRequests(this.defaultBidRequestList, this.defaultBidderRequest); const openrtbRequest = JSON.parse(request.data); @@ -530,7 +534,6 @@ describe('marsmedia adapter tests', function () { const request = marsAdapter.buildRequests(this.defaultBidRequestList, this.defaultBidderRequest); const openrtbRequest = JSON.parse(request.data); expect(openrtbRequest.imp[0].ext.viewability).to.equal(75); - internal.getWindowTop.restore(); }); }); diff --git a/test/spec/modules/mathildeadsBidAdapter_spec.js b/test/spec/modules/mathildeadsBidAdapter_spec.js index 05336197872..21b7b4a7d21 100644 --- a/test/spec/modules/mathildeadsBidAdapter_spec.js +++ b/test/spec/modules/mathildeadsBidAdapter_spec.js @@ -432,7 +432,7 @@ describe('MathildeAdsBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -441,9 +441,7 @@ describe('MathildeAdsBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs2.mathilde-ads.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -452,7 +450,7 @@ describe('MathildeAdsBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs2.mathilde-ads.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/mediasquareBidAdapter_spec.js b/test/spec/modules/mediasquareBidAdapter_spec.js index caa22f4da1d..962070e0e22 100644 --- a/test/spec/modules/mediasquareBidAdapter_spec.js +++ b/test/spec/modules/mediasquareBidAdapter_spec.js @@ -296,4 +296,25 @@ describe('MediaSquare bid adapter tests', function () { expect(bid).to.have.property('renderer'); delete BID_RESPONSE.body.responses[0].video; }); + it('Verifies burls in bid response', function () { + const request = spec.buildRequests(DEFAULT_PARAMS, DEFAULT_OPTIONS); + BID_RESPONSE.body.responses[0].burls = [{'url': 'http://myburl.com/track?bid=1.0'}]; + const response = spec.interpretResponse(BID_RESPONSE, request); + expect(response).to.have.lengthOf(1); + const bid = response[0]; + expect(bid.mediasquare).to.have.property('burls'); + expect(bid.mediasquare.burls).to.have.lengthOf(1); + expect(bid.mediasquare.burls[0]).to.have.property('url').and.to.equal('http://myburl.com/track?bid=1.0'); + delete BID_RESPONSE.body.responses[0].burls; + }); + it('Verifies burls bidwon', function () { + const request = spec.buildRequests(DEFAULT_PARAMS, DEFAULT_OPTIONS); + BID_RESPONSE.body.responses[0].burls = [{'url': 'http://myburl.com/track?bid=1.0'}]; + const response = spec.interpretResponse(BID_RESPONSE, request); + const won = spec.onBidWon(response[0]); + expect(won).to.equal(true); + expect(server.requests.length).to.equal(1); + expect(server.requests[0].url).to.equal('http://myburl.com/track?bid=1.0'); + delete BID_RESPONSE.body.responses[0].burls; + }); }); diff --git a/test/spec/modules/mgidRtdProvider_spec.js b/test/spec/modules/mgidRtdProvider_spec.js index 7fd41a3c4c5..b3f160b1530 100644 --- a/test/spec/modules/mgidRtdProvider_spec.js +++ b/test/spec/modules/mgidRtdProvider_spec.js @@ -33,7 +33,7 @@ describe('Mgid RTD submodule', () => { expect(mgidSubmodule.init({})).to.be.false; }); - it('getBidRequestData send all params to our endpoint and succesfully modifies ortb2', () => { + it('getBidRequestData send all params to our endpoint and successfully modifies ortb2', () => { const responseObj = { userSegments: ['100', '200'], userSegtax: 5, diff --git a/test/spec/modules/mileBidAdapter_spec.js b/test/spec/modules/mileBidAdapter_spec.js new file mode 100644 index 00000000000..34171f86114 --- /dev/null +++ b/test/spec/modules/mileBidAdapter_spec.js @@ -0,0 +1,691 @@ +import { expect } from 'chai'; +import { spec, siteIdTracker, publisherIdTracker } from 'modules/mileBidAdapter.js'; +import { BANNER } from 'src/mediaTypes.js'; +import * as ajax from 'src/ajax.js'; +import * as utils from 'src/utils.js'; + +describe('mileBidAdapter', function () { + describe('isBidRequestValid', function () { + let bid; + + beforeEach(function () { + bid = { + bidder: 'mile', + params: { + placementId: '12345', + siteId: 'site123', + publisherId: 'pub456' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + }; + }); + + it('should return true when all required params are present', function () { + expect(spec.isBidRequestValid(bid)).to.be.true; + }); + + it('should return false when placementId is missing', function () { + delete bid.params.placementId; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when siteId is missing', function () { + delete bid.params.siteId; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when publisherId is missing', function () { + delete bid.params.publisherId; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when params is missing', function () { + delete bid.params; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + + it('should return false when params is null', function () { + bid.params = null; + expect(spec.isBidRequestValid(bid)).to.be.false; + }); + }); + + describe('buildRequests', function () { + let validBidRequests, bidderRequest; + + beforeEach(function () { + validBidRequests = [{ + bidder: 'mile', + params: { + placementId: '12345', + siteId: 'site123', + publisherId: 'pub456' + }, + mediaTypes: { + banner: { + sizes: [[300, 250], [728, 90]] + } + }, + adUnitCode: 'test-ad-unit', + bidId: 'bid123', + ortb2Imp: { + ext: { + gpid: '/test/ad/unit' + } + } + }]; + + bidderRequest = { + bidderCode: 'mile', + bidderRequestId: 'bidderReq123', + auctionId: 'auction123', + timeout: 3000, + refererInfo: { + page: 'https://example.com/page', + domain: 'example.com', + ref: 'https://google.com' + }, + ortb2: { + source: { + tid: 'transaction123' + } + } + }; + }); + + it('should return a valid server request object', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + + expect(request).to.be.an('object'); + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://pbs.atmtd.com/mile/v1/request'); + expect(request.data).to.be.an('object'); + }); + + it('should build OpenRTB 2.5 compliant request', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = request.data; + + expect(data.id).to.equal('bidderReq123'); + expect(data.imp).to.be.an('array').with.lengthOf(1); + expect(data.tmax).to.equal(3000); + expect(data.cur).to.deep.equal(['USD']); + expect(data.site).to.be.an('object'); + expect(data.device).to.be.an('object'); + expect(data.source).to.be.an('object'); + }); + + it('should include imp object with correct structure', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const imp = request.data.imp[0]; + + expect(imp.id).to.equal('bid123'); + expect(imp.tagid).to.equal('12345'); + expect(imp.secure).to.equal(1); + expect(imp.banner).to.be.an('object'); + expect(imp.banner.format).to.be.an('array').with.lengthOf(2); + expect(imp.banner.format[0]).to.deep.equal({ w: 300, h: 250 }); + expect(imp.banner.format[1]).to.deep.equal({ w: 728, h: 90 }); + }); + + it('should include ext fields in imp object', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const imp = request.data.imp[0]; + + expect(imp.ext.adUnitCode).to.equal('test-ad-unit'); + expect(imp.ext.placementId).to.equal('12345'); + expect(imp.ext.gpid).to.equal('/test/ad/unit'); + }); + + it('should include site object with publisher info', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const site = request.data.site; + + expect(site.id).to.equal('site123'); + expect(site.page).to.equal('https://example.com/page'); + expect(site.domain).to.equal('example.com'); + expect(site.ref).to.equal('https://google.com'); + expect(site.publisher.id).to.equal('pub456'); + }); + + it('should include device object', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const device = request.data.device; + + expect(device.ua).to.be.a('string'); + expect(device.language).to.be.a('string'); + expect(device.dnt).to.be.a('number'); + }); + + it('should include source object with tid', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const source = request.data.source; + + expect(source.tid).to.equal('transaction123'); + }); + + it('should include bidfloor when floor price is available', function () { + validBidRequests[0].getFloor = function() { + return { floor: 0.5, currency: 'USD' }; + }; + + const request = spec.buildRequests(validBidRequests, bidderRequest); + const imp = request.data.imp[0]; + + expect(imp.bidfloor).to.equal(0.5); + expect(imp.bidfloorcur).to.equal('USD'); + }); + + it('should include GDPR consent when present', function () { + bidderRequest.gdprConsent = { + gdprApplies: true, + consentString: 'consent-string-123' + }; + + const request = spec.buildRequests(validBidRequests, bidderRequest); + + expect(request.data.regs.ext.gdpr).to.equal(1); + expect(request.data.user.ext.consent).to.equal('consent-string-123'); + }); + + it('should include US Privacy consent when present', function () { + bidderRequest.uspConsent = '1YNN'; + + const request = spec.buildRequests(validBidRequests, bidderRequest); + + expect(request.data.regs.ext.us_privacy).to.equal('1YNN'); + }); + + it('should include GPP consent when present', function () { + bidderRequest.gppConsent = { + gppString: 'gpp-string-123', + applicableSections: [1, 2, 3] + }; + + const request = spec.buildRequests(validBidRequests, bidderRequest); + + expect(request.data.regs.gpp).to.equal('gpp-string-123'); + expect(request.data.regs.gpp_sid).to.deep.equal([1, 2, 3]); + }); + + it('should include user EIDs when present', function () { + validBidRequests[0].userIdAsEids = [ + { + source: 'pubcid.org', + uids: [{ id: 'user-id-123' }] + } + ]; + + const request = spec.buildRequests(validBidRequests, bidderRequest); + + expect(request.data.user.ext.eids).to.be.an('array').with.lengthOf(1); + expect(request.data.user.ext.eids[0].source).to.equal('pubcid.org'); + }); + + it('should include supply chain when present', function () { + validBidRequests[0].ortb2 = { + source: { + ext: { + schain: { + ver: '1.0', + complete: 1, + nodes: [] + } + } + } + }; + + const request = spec.buildRequests(validBidRequests, bidderRequest); + + expect(request.data.source.ext.schain).to.be.an('object'); + expect(request.data.source.ext.schain.ver).to.equal('1.0'); + }); + + it('should handle multiple bid requests with same siteId and publisherId', function () { + const secondBid = { + ...validBidRequests[0], + bidId: 'bid456', + params: { + placementId: '67890', + siteId: 'site123', + publisherId: 'pub456' + } + }; + + const request = spec.buildRequests([validBidRequests[0], secondBid], bidderRequest); + + expect(request.data.imp).to.be.an('array').with.lengthOf(2); + expect(request.data.imp[0].id).to.equal('bid123'); + expect(request.data.imp[1].id).to.equal('bid456'); + }); + + it('should reject bids with different siteId', function () { + const firstBid = { + bidder: 'mile', + params: { + placementId: '12345', + siteId: 'site123', + publisherId: 'pub456' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + }; + + const secondBid = { + bidder: 'mile', + params: { + placementId: '67890', + siteId: 'differentSite', // Different siteId + publisherId: 'pub456' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + }; + + // First bid should be valid + expect(spec.isBidRequestValid(firstBid)).to.be.true; + + // Second bid should be rejected due to siteId mismatch + expect(spec.isBidRequestValid(secondBid)).to.be.false; + }); + + it('should reject bids with different publisherId', function () { + const firstBid = { + bidder: 'mile', + params: { + placementId: '12345', + siteId: 'site123', + publisherId: 'pub456' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + }; + + const secondBid = { + bidder: 'mile', + params: { + placementId: '67890', + siteId: 'site123', + publisherId: 'differentPub' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + }; + + // First bid should be valid + expect(spec.isBidRequestValid(firstBid)).to.be.true; + + // Second bid should be rejected due to publisherId mismatch + expect(spec.isBidRequestValid(secondBid)).to.be.false; + }); + }); + + describe('interpretResponse', function () { + let serverResponse; + + beforeEach(function () { + serverResponse = { + body: { + site: { + id: 'site123', + domain: 'example.com', + publisher: { + id: 'pub456' + }, + page: 'https://example.com/page', + }, + cur: 'USD', + bids: [ + { + requestId: 'bid123', + cpm: 1.5, + width: 300, + height: 250, + ad: '
test ad
', + creativeId: 'creative123', + ttl: 300, + nurl: 'https://example.com/win?price=${AUCTION_PRICE}', + adomain: ['advertiser.com'], + upstreamBidder: 'upstreamBidder' + } + ] + } + }; + }); + + it('should return an array of bid responses', function () { + const bids = spec.interpretResponse(serverResponse); + + expect(bids).to.be.an('array').with.lengthOf(1); + }); + + it('should parse bid response correctly', function () { + const bids = spec.interpretResponse(serverResponse); + const bid = bids[0]; + + expect(bid.requestId).to.equal('bid123'); + expect(bid.cpm).to.equal(1.5); + expect(bid.width).to.equal(300); + expect(bid.height).to.equal(250); + expect(bid.ad).to.equal('
test ad
'); + expect(bid.creativeId).to.equal('creative123'); + expect(bid.currency).to.equal('USD'); + expect(bid.ttl).to.equal(300); + expect(bid.netRevenue).to.be.true; + expect(bid.mediaType).to.equal(BANNER); + expect(bid.meta.upstreamBidder).to.equal('upstreamBidder'); + expect(bid.meta.siteUID).to.equal('site123'); + expect(bid.meta.publisherID).to.equal('pub456'); + expect(bid.meta.page).to.equal('https://example.com/page'); + expect(bid.meta.domain).to.equal('example.com'); + }); + + it('should include nurl in bid response', function () { + const bids = spec.interpretResponse(serverResponse); + const bid = bids[0]; + + expect(bid.nurl).to.equal('https://example.com/win?price=${AUCTION_PRICE}'); + }); + + it('should include meta.advertiserDomains', function () { + const bids = spec.interpretResponse(serverResponse); + const bid = bids[0]; + + expect(bid.meta.advertiserDomains).to.deep.equal(['advertiser.com']); + }); + + it('should handle empty response', function () { + const bids = spec.interpretResponse({ body: null }); + + expect(bids).to.be.an('array').with.lengthOf(0); + }); + + it('should handle response with no bids', function () { + serverResponse.body.bids = []; + const bids = spec.interpretResponse(serverResponse); + + expect(bids).to.be.an('array').with.lengthOf(0); + }); + + it('should handle alternative field names (w/h instead of width/height)', function () { + serverResponse.body.bids[0] = { + requestId: 'bid123', + cpm: 1.5, + w: 728, + h: 90, + ad: '
test ad
' + }; + + const bids = spec.interpretResponse(serverResponse); + const bid = bids[0]; + + expect(bid.width).to.equal(728); + expect(bid.height).to.equal(90); + }); + + it('should use default currency if not specified', function () { + delete serverResponse.body.cur; + const bids = spec.interpretResponse(serverResponse); + + expect(bids[0].currency).to.equal('USD'); + }); + + it('should handle response with no site or publisher', function () { + delete serverResponse.body.site; + delete serverResponse.body.publisher; + const bids = spec.interpretResponse(serverResponse); + + expect(bids[0].meta.siteUID).to.be.empty; + expect(bids[0].meta.publisherID).to.be.empty; + expect(bids[0].meta.page).to.be.empty; + expect(bids[0].meta.domain).to.be.empty; + }); + }); + + describe('getUserSyncs', function () { + let syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent; + + beforeEach(function () { + syncOptions = { + iframeEnabled: true, + pixelEnabled: true + }; + serverResponses = []; + }); + + it('should return iframe sync when enabled', function () { + const syncs = spec.getUserSyncs(syncOptions, serverResponses); + + expect(syncs).to.be.an('array').with.lengthOf(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.include('https://scripts.atmtd.com/user-sync/load-cookie.html'); + }); + + it('should not return syncs when iframe is disabled', function () { + syncOptions.iframeEnabled = false; + const syncs = spec.getUserSyncs(syncOptions, serverResponses); + + expect(syncs).to.be.an('array').with.lengthOf(0); + }); + + it('should include GDPR consent params', function () { + gdprConsent = { + gdprApplies: true, + consentString: 'consent-string-123' + }; + + const syncs = spec.getUserSyncs(syncOptions, serverResponses, gdprConsent); + + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include('gdpr_consent=consent-string-123'); + }); + + it('should include US Privacy consent param', function () { + uspConsent = '1YNN'; + + const syncs = spec.getUserSyncs(syncOptions, serverResponses, null, uspConsent); + + expect(syncs[0].url).to.include('us_privacy=1YNN'); + }); + + it('should include GPP consent params', function () { + gppConsent = { + gppString: 'gpp-string-123', + applicableSections: [1, 2, 3] + }; + + const syncs = spec.getUserSyncs(syncOptions, serverResponses, null, null, gppConsent); + + expect(syncs[0].url).to.include('gpp=gpp-string-123'); + expect(syncs[0].url).to.include('gpp_sid=1%2C2%2C3'); + }); + + it('should include all consent params when present', function () { + gdprConsent = { gdprApplies: true, consentString: 'gdpr-consent' }; + uspConsent = '1YNN'; + gppConsent = { gppString: 'gpp-string', applicableSections: [1] }; + + const syncs = spec.getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent); + + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include('us_privacy=1YNN'); + expect(syncs[0].url).to.include('gpp=gpp-string'); + }); + }); + + describe('onBidWon', function () { + let bid, ajaxStub; + + beforeEach(function () { + bid = { + bidder: 'mile', + adUnitCode: 'test-ad-unit', + requestId: 'bid123', + cpm: 1.5, + width: 300, + height: 250, + nurl: 'https://example.com/win', + meta: { + upstreamBidder: 'upstreamBidder', + siteUID: 'mRUDIL', + publisherID: 'pub456', + page: 'https://example.com/page', + domain: 'example.com' + } + }; + + ajaxStub = sinon.stub(ajax, 'ajax'); + }); + + afterEach(function () { + ajaxStub.restore(); + }); + + it('should call ajax with win notification endpoint', function () { + spec.onBidWon(bid); + + expect(ajaxStub.calledTwice).to.be.true; + + // First call to notification endpoint + const firstCall = ajaxStub.getCall(0); + expect(firstCall.args[0]).to.equal('https://e01.atmtd.com/bidanalytics-event/json'); + + // Second call to nurl + const secondCall = ajaxStub.getCall(1); + expect(secondCall.args[0]).to.equal('https://example.com/win'); + }); + + it('should send correct win notification data', function () { + spec.onBidWon(bid); + + const firstCall = ajaxStub.getCall(0); + const notificationData = JSON.parse(firstCall.args[2])[0]; + + expect(notificationData.adUnitCode).to.equal('test-ad-unit'); + expect(notificationData.metaData.impressionID[0]).to.equal('bid123'); + expect(notificationData.winningBidder).to.equal('upstreamBidder'); + expect(notificationData.cpm).to.equal(1.5); + expect(notificationData.winningSize).to.equal('300x250'); + expect(notificationData.eventType).to.equal('mile-bidder-win-notify'); + expect(notificationData.timestamp).to.be.a('number'); + expect(notificationData.siteUID).to.equal('mRUDIL'); + expect(notificationData.yetiPublisherID).to.equal('pub456'); + expect(notificationData.page).to.equal('https://example.com/page'); + expect(notificationData.site).to.equal('example.com'); + }); + + it('should call nurl with GET request', function () { + spec.onBidWon(bid); + + const secondCall = ajaxStub.getCall(1); + const options = secondCall.args[3]; + + expect(options.method).to.equal('GET'); + }); + }); + + describe('onTimeout', function () { + let timeoutData, ajaxStub; + + beforeEach(function () { + timeoutData = [ + { + bidder: 'mile', + bidId: 'bid123', + adUnitCode: 'test-ad-unit-1', + timeout: 3000, + params: { + placementId: '12345', + siteId: 'site123', + publisherId: 'pub456' + } + }, + { + bidder: 'mile', + bidId: 'bid456', + adUnitCode: 'test-ad-unit-2', + timeout: 3000, + params: { + placementId: '67890', + siteId: 'site123', + publisherId: 'pub456' + } + } + ]; + + ajaxStub = sinon.stub(ajax, 'ajax'); + }); + + afterEach(function () { + ajaxStub.restore(); + }); + + it('should call ajax for each timed out bid', function () { + spec.onTimeout(timeoutData); + + expect(ajaxStub.callCount).to.equal(1); + }); + + it('should send correct timeout notification data', function () { + spec.onTimeout(timeoutData); + + const firstCall = ajaxStub.getCall(0); + expect(firstCall.args[0]).to.equal('https://e01.atmtd.com/bidanalytics-event/json'); + + const notificationData = JSON.parse(firstCall.args[2])[0]; + expect(notificationData.adUnitCode).to.equal('test-ad-unit-1'); + expect(notificationData.metaData.impressionID[0]).to.equal('bid123'); + expect(notificationData.metaData.configuredTimeout[0]).to.equal('3000'); + expect(notificationData.eventType).to.equal('mile-bidder-timeout'); + expect(notificationData.timestamp).to.be.a('number'); + }); + + it('should handle single timeout', function () { + spec.onTimeout([timeoutData[0]]); + + expect(ajaxStub.calledOnce).to.be.true; + }); + + it('should handle empty timeout array', function () { + spec.onTimeout([]); + + expect(ajaxStub.called).to.be.false; + }); + }); + + describe('adapter specification', function () { + it('should have correct bidder code', function () { + expect(spec.code).to.equal('mile'); + }); + + it('should support BANNER media type', function () { + expect(spec.supportedMediaTypes).to.be.an('array'); + expect(spec.supportedMediaTypes).to.include(BANNER); + }); + + it('should have required adapter functions', function () { + expect(spec.isBidRequestValid).to.be.a('function'); + expect(spec.buildRequests).to.be.a('function'); + expect(spec.interpretResponse).to.be.a('function'); + expect(spec.getUserSyncs).to.be.a('function'); + expect(spec.onBidWon).to.be.a('function'); + expect(spec.onTimeout).to.be.a('function'); + }); + }); +}); diff --git a/test/spec/modules/missenaBidAdapter_spec.js b/test/spec/modules/missenaBidAdapter_spec.js index f4e09a981fe..c52f738ca55 100644 --- a/test/spec/modules/missenaBidAdapter_spec.js +++ b/test/spec/modules/missenaBidAdapter_spec.js @@ -367,5 +367,69 @@ describe('Missena Adapter', function () { expect(userSync[0].type).to.be.equal('iframe'); expect(userSync[0].url).to.be.equal(expectedUrl); }); + + it('sync frame url should contain gpp data when present', function () { + const gppConsent = { + gppString: 'DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', + applicableSections: [7, 8], + }; + const userSync = spec.getUserSyncs( + iframeEnabledOptions, + [], + {}, + undefined, + gppConsent, + ); + expect(userSync.length).to.be.equal(1); + expect(userSync[0].type).to.be.equal('iframe'); + const syncUrl = new URL(userSync[0].url); + expect(syncUrl.searchParams.get('gpp')).to.equal(gppConsent.gppString); + expect(syncUrl.searchParams.get('gpp_sid')).to.equal('7,8'); + }); + + it('sync frame url should not contain gpp data when gppConsent is undefined', function () { + const userSync = spec.getUserSyncs( + iframeEnabledOptions, + [], + {}, + undefined, + undefined, + ); + expect(userSync.length).to.be.equal(1); + expect(userSync[0].url).to.not.contain('gpp'); + }); + + it('sync frame url should not contain gpp data when gppString is empty', function () { + const userSync = spec.getUserSyncs( + iframeEnabledOptions, + [], + {}, + undefined, + { gppString: '', applicableSections: [7] }, + ); + expect(userSync.length).to.be.equal(1); + expect(userSync[0].url).to.not.contain('gpp'); + }); + + it('sync frame url should contain all consent params together', function () { + const gppConsent = { + gppString: 'DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', + applicableSections: [7], + }; + const userSync = spec.getUserSyncs( + iframeEnabledOptions, + [], + { gdprApplies: true, consentString }, + '1YNN', + gppConsent, + ); + expect(userSync.length).to.be.equal(1); + const syncUrl = new URL(userSync[0].url); + expect(syncUrl.searchParams.get('gdpr')).to.equal('1'); + expect(syncUrl.searchParams.get('gdpr_consent')).to.equal(consentString); + expect(syncUrl.searchParams.get('us_privacy')).to.equal('1YNN'); + expect(syncUrl.searchParams.get('gpp')).to.equal(gppConsent.gppString); + expect(syncUrl.searchParams.get('gpp_sid')).to.equal('7'); + }); }); }); diff --git a/test/spec/modules/mycodemediaBidAdapter_spec.js b/test/spec/modules/mycodemediaBidAdapter_spec.js index 7cc9b412ea0..fbf40f4b403 100644 --- a/test/spec/modules/mycodemediaBidAdapter_spec.js +++ b/test/spec/modules/mycodemediaBidAdapter_spec.js @@ -478,7 +478,7 @@ describe('MyCodeMediaBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -487,9 +487,7 @@ describe('MyCodeMediaBidAdapter', function () { expect(syncData[0].url).to.equal('https://usersync.mycodemedia.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -498,7 +496,7 @@ describe('MyCodeMediaBidAdapter', function () { expect(syncData[0].url).to.equal('https://usersync.mycodemedia.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/neuwoRtdProvider_spec.js b/test/spec/modules/neuwoRtdProvider_spec.js index 4b2951afe33..bb64b283535 100644 --- a/test/spec/modules/neuwoRtdProvider_spec.js +++ b/test/spec/modules/neuwoRtdProvider_spec.js @@ -1,10 +1,12 @@ import * as neuwo from "modules/neuwoRtdProvider"; +import * as refererDetection from "src/refererDetection.js"; import { server } from "test/mocks/xhr.js"; -const NEUWO_API_URL = "https://api.url.neuwo.ai/edge/GetAiTopics"; +const NEUWO_API_URL = "https://edge.neuwo.ai/api/aitopics/edge/v1/iab"; const NEUWO_API_TOKEN = "token"; -const IAB_CONTENT_TAXONOMY_VERSION = "3.0"; +const IAB_CONTENT_TAXONOMY_VERSION = "2.2"; +// API config const config = () => ({ params: { neuwoApiUrl: NEUWO_API_URL, @@ -13,7 +15,115 @@ const config = () => ({ }, }); +/** + * API Response Mock + * Returns new format with segtax-based structure + * Field names: id, name, relevance (lowercase) + * Structure: { "6": { "1": [...], "2": [...] }, "4": { "3": [...] } } + */ function getNeuwoApiResponse() { + return { + 7: { + 1: [ + { + id: "80DV8O", + }, + { + id: "52", + }, + { + id: "432", + }, + ], + 2: [ + { + id: "90", + }, + ], + 3: [ + { + id: "106", + }, + ], + }, + 1: { + 1: [ + { + id: "IAB12", + }, + ], + }, + 6: { + 1: [ + { + id: "52", + }, + ], + 2: [ + { + id: "90", + }, + { + id: "434", + }, + ], + 3: [ + { + id: "106", + }, + ], + }, + 4: { + 3: [ + { + id: "49", + }, + { + id: "780", + }, + ], + 4: [ + { + id: "431", + }, + { + id: "196", + }, + { + id: "197", + }, + ], + 5: [ + { + id: "98", + }, + ], + }, + }; +} + +// ============================================================================ +// V1 API Constants and Mocks +// ============================================================================ + +const NEUWO_API_URL_V1 = "https://api.url.neuwo.ai/edge/GetAiTopics"; +const IAB_CONTENT_TAXONOMY_VERSION_V1 = "3.0"; + +// Legacy V1 API config (for backward compatibility tests) +const configV1 = () => ({ + params: { + neuwoApiUrl: NEUWO_API_URL_V1, + neuwoApiToken: NEUWO_API_TOKEN, + iabContentTaxonomyVersion: IAB_CONTENT_TAXONOMY_VERSION_V1, + }, +}); + +/** + * V1 API Response Mock + * Returns legacy format with marketing_categories structure + * Field names: ID, label, relevance (capital letters) + */ +function getNeuwoApiResponseV1() { return { brand_safety: { BS_score: "1.0", @@ -65,8 +175,6 @@ function getNeuwoApiResponse() { ], }; } -const CONTENT_TIERS = ["iab_tier_1", "iab_tier_2", "iab_tier_3"]; -const AUDIENCE_TIERS = ["iab_audience_tier_3", "iab_audience_tier_4", "iab_audience_tier_5"]; /** * Object generator, like above, written using alternative techniques @@ -126,38 +234,20 @@ describe("neuwoRtdModule", function () { }); describe("buildIabData", function () { - it("should return an empty segment array when no matching tiers are found", function () { - const marketingCategories = getNeuwoApiResponse().marketing_categories; - const tiers = ["non_existent_tier"]; - const segtax = 0; - const result = neuwo.buildIabData(marketingCategories, tiers, segtax); - const expected = { - name: neuwo.DATA_PROVIDER, - segment: [], - ext: { - segtax, - }, - }; - expect(result, "should produce a valid object with an empty segment array").to.deep.equal( - expected - ); - }); - it("should correctly build the data object for content tiers", function () { - const marketingCategories = getNeuwoApiResponse().marketing_categories; + // format with tier keys "1", "2", "3" + const tierData = { + "1": [{ id: "274", name: "Home & Garden", relevance: "0.47" }], + "2": [{ id: "216", name: "Cooking", relevance: "0.41" }], + "3": [], + }; const segtax = 0; - const result = neuwo.buildIabData(marketingCategories, CONTENT_TIERS, segtax); + const result = neuwo.buildIabData(tierData, segtax); const expected = { name: neuwo.DATA_PROVIDER, segment: [ - { - id: "274", - name: "Home & Garden", - }, - { - id: "216", - name: "Cooking", - }, + { id: "274" }, + { id: "216" }, ], ext: { segtax, @@ -169,24 +259,20 @@ describe("neuwoRtdModule", function () { }); it("should correctly build the data object for audience tiers", function () { - const marketingCategories = getNeuwoApiResponse().marketing_categories; + // format with tier keys "3", "4", "5" for audience + const tierData = { + "3": [{ id: "49", name: "Demographic | Gender | Female |", relevance: "0.9923" }], + "4": [{ id: "127", name: "Demographic | Household Data | 1 Child |", relevance: "0.9673" }], + "5": [{ id: "98", name: "Demographic | Household Data | Parents with Children |", relevance: "0.9066" }], + }; const segtax = 0; - const result = neuwo.buildIabData(marketingCategories, AUDIENCE_TIERS, segtax); + const result = neuwo.buildIabData(tierData, segtax); const expected = { name: neuwo.DATA_PROVIDER, segment: [ - { - id: "49", - name: "Demographic | Gender | Female |", - }, - { - id: "127", - name: "Demographic | Household Data | 1 Child |", - }, - { - id: "98", - name: "Demographic | Household Data | Parents with Children |", - }, + { id: "49" }, + { id: "127" }, + { id: "98" }, ], ext: { segtax, @@ -197,7 +283,7 @@ describe("neuwoRtdModule", function () { ); }); - it("should return an empty segment array when marketingCategories is null or undefined", function () { + it("should return an empty segment array when tierData is null or undefined", function () { const segtax = 4; const expected = { name: neuwo.DATA_PROVIDER, @@ -207,19 +293,19 @@ describe("neuwoRtdModule", function () { }, }; expect( - neuwo.buildIabData(null, CONTENT_TIERS, segtax), - "should handle null marketingCategories gracefully" + neuwo.buildIabData(null, segtax), + "should handle null tierData gracefully" ).to.deep.equal(expected); expect( - neuwo.buildIabData(undefined, CONTENT_TIERS, segtax), - "should handle undefined marketingCategories gracefully" + neuwo.buildIabData(undefined, segtax), + "should handle undefined tierData gracefully" ).to.deep.equal(expected); }); - it("should return an empty segment array when marketingCategories is empty", function () { - const marketingCategories = {}; + it("should return an empty segment array when tierData is empty", function () { + const tierData = {}; const segtax = 4; - const result = neuwo.buildIabData(marketingCategories, CONTENT_TIERS, segtax); + const result = neuwo.buildIabData(tierData, segtax); const expected = { name: neuwo.DATA_PROVIDER, segment: [], @@ -227,23 +313,23 @@ describe("neuwoRtdModule", function () { segtax, }, }; - expect(result, "should handle an empty marketingCategories object").to.deep.equal(expected); + expect(result, "should handle an empty tierData object").to.deep.equal(expected); }); - it("should gracefully handle if a marketing_categories key contains a non-array value", function () { - const marketingCategories = getNeuwoApiResponse().marketing_categories; - // Overwrite iab_tier_1 to be an object instead of an array - marketingCategories.iab_tier_1 = { ID: "274", label: "Home & Garden" }; + it("should gracefully handle if a tier key contains a non-array value", function () { + const tierData = { + "1": { id: "274", name: "Home & Garden" }, // Not an array + "2": [{ id: "216", name: "Cooking", relevance: "0.41" }], + }; const segtax = 4; - const result = neuwo.buildIabData(marketingCategories, CONTENT_TIERS, segtax); + const result = neuwo.buildIabData(tierData, segtax); const expected = { name: neuwo.DATA_PROVIDER, - // The segment should only contain data from the valid iab_tier_2 + // The segment should only contain data from the valid tier "2" segment: [ { id: "216", - name: "Cooking", }, ], ext: { @@ -257,30 +343,29 @@ describe("neuwoRtdModule", function () { }); it("should ignore malformed objects within a tier array", function () { - const marketingCategories = getNeuwoApiResponse().marketing_categories; - // Overwrite iab_tier_1 with various malformed objects - marketingCategories.iab_tier_1 = [ - { ID: "274", label: "Valid Object" }, - { ID: "999" }, // Missing 'label' property - { label: "Another Label" }, // Missing 'ID' property - null, // A null value - "just-a-string", // A string primitive - {}, // An empty object - ]; + // Tier "1" with various malformed objects + const tierData = { + "1": [ + { id: "274", name: "Valid Object" }, + { name: "Another Label" }, // Missing 'id' property + null, // A null value + "just-a-string", // A string primitive + {}, // An empty object + ], + "2": [{ id: "216", name: "Cooking", relevance: "0.41" }], + }; const segtax = 4; - const result = neuwo.buildIabData(marketingCategories, CONTENT_TIERS, segtax); + const result = neuwo.buildIabData(tierData, segtax); const expected = { name: neuwo.DATA_PROVIDER, - // The segment should contain the one valid object from iab_tier_1 and the data from iab_tier_2 + // The segment should contain the one valid object from tier "1" and the data from tier "2" segment: [ { id: "274", - name: "Valid Object", }, { id: "216", - name: "Cooking", }, ], ext: { @@ -293,7 +378,7 @@ describe("neuwoRtdModule", function () { ); }); - it("should return an empty segment array if the entire marketingCategories object is not a valid object", function () { + it("should return an empty segment array if the entire tierData is not a valid object", function () { const segtax = 4; const expected = { name: neuwo.DATA_PROVIDER, @@ -301,1000 +386,3636 @@ describe("neuwoRtdModule", function () { ext: { segtax }, }; // Test with a string - const resultString = neuwo.buildIabData("incorrect format", CONTENT_TIERS, segtax); - expect(resultString, "should handle non-object marketingCategories input").to.deep.equal( + const resultString = neuwo.buildIabData("incorrect format", segtax); + expect(resultString, "should handle non-object tierData input").to.deep.equal( expected ); }); }); - describe("injectOrtbData", function () { - it("should correctly mutate the request bids config object with new data", function () { - const reqBidsConfigObj = { ortb2Fragments: { global: {} } }; - neuwo.injectOrtbData(reqBidsConfigObj, "c.d.e.f", { g: "h" }); - expect( - reqBidsConfigObj.ortb2Fragments.global.c.d.e.f.g, - "should deeply merge the new data into the target object" - ).to.equal("h"); + describe("buildFilterQueryParams", function () { + it("should return empty array when no filters provided", function () { + const result = neuwo.buildFilterQueryParams(null, 6); + expect(result, "should return empty array for null filters").to.deep.equal([]); }); - }); - - describe("getBidRequestData", function () { - describe("when using IAB Content Taxonomy 3.0", function () { - it("should correctly structure the bids object after a successful API response", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig = bidsConfiglike(); - const conf = config(); - // control xhr api request target for testing - conf.params.websiteToAnalyseUrl = - "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + it("should return empty array when filters parameter is undefined", function () { + const result = neuwo.buildFilterQueryParams(undefined, 6); + expect(result, "should return empty array for undefined filters").to.deep.equal([]); + }); - expect(request.url, "The request URL should be a string").to.be.a("string"); - expect(request.url, "The request URL should include the public API token").to.include( - conf.params.neuwoApiToken - ); - expect(request.url, "The request URL should include the encoded website URL").to.include( - encodeURIComponent(conf.params.websiteToAnalyseUrl) - ); + it("should return empty array when filters is empty object", function () { + const result = neuwo.buildFilterQueryParams({}, 6); + expect(result, "should return empty array for empty filters").to.deep.equal([]); + }); - request.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); + it("should convert ContentTier1 filter correctly", function () { + const filters = { + ContentTier1: { limit: 3, threshold: 0.5 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); - const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; - expect(contentData.name, "The data provider name should be correctly set").to.equal( - neuwo.DATA_PROVIDER - ); - expect( - contentData.ext.segtax, - "The segtax value should correspond to IAB Content Taxonomy 3.0" - ).to.equal(7); - expect( - contentData.segment[0].id, - "The first segment ID should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_tier_1[0].ID); - expect( - contentData.segment[1].name, - "The second segment name should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_tier_2[0].label); - }); + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_1_threshold=0.5"); + expect(result).to.have.lengthOf(2); }); - describe("when using IAB Content Taxonomy 2.2", function () { - it("should correctly structure the bids object after a successful API response", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.iabContentTaxonomyVersion = "2.2"; - // control xhr api request target for testing - conf.params.websiteToAnalyseUrl = - "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + it("should convert ContentTier2 filter correctly", function () { + const filters = { + ContentTier2: { limit: 5, threshold: 0.6 } + }; + const contentSegtax = 7; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + expect(result).to.include("filter_7_2_limit=5"); + expect(result).to.include("filter_7_2_threshold=0.6"); + expect(result).to.have.lengthOf(2); + }); - expect(request.url, "The request URL should be a string").to.be.a("string"); - expect(request.url, "The request URL should include the public API token").to.include( - conf.params.neuwoApiToken - ); - expect(request.url, "The request URL should include the encoded website URL").to.include( - encodeURIComponent(conf.params.websiteToAnalyseUrl) - ); + it("should convert ContentTier3 filter correctly", function () { + const filters = { + ContentTier3: { limit: 4, threshold: 0.8 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); - request.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); - const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; - expect(contentData.name, "The data provider name should be correctly set").to.equal( - neuwo.DATA_PROVIDER - ); - expect( - contentData.ext.segtax, - "The segtax value should correspond to IAB Content Taxonomy 2.2" - ).to.equal(6); - expect( - contentData.segment[0].id, - "The first segment ID should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_tier_1[0].ID); - expect( - contentData.segment[1].name, - "The second segment name should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_tier_2[0].label); - }); + expect(result).to.include("filter_6_3_limit=4"); + expect(result).to.include("filter_6_3_threshold=0.8"); + expect(result).to.have.lengthOf(2); }); - describe("when using the default IAB Content Taxonomy", function () { - it("should correctly structure the bids object after a successful API response", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.iabContentTaxonomyVersion = undefined; - // control xhr api request target for testing - conf.params.websiteToAnalyseUrl = - "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + it("should convert AudienceTier3 filter correctly", function () { + const filters = { + AudienceTier3: { limit: 2, threshold: 0.9 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + expect(result).to.include("filter_4_3_limit=2"); + expect(result).to.include("filter_4_3_threshold=0.9"); + expect(result).to.have.lengthOf(2); + }); - expect(request.url, "The request URL should be a string").to.be.a("string"); - expect(request.url, "The request URL should include the public API token").to.include( - conf.params.neuwoApiToken - ); - expect(request.url, "The request URL should include the encoded website URL").to.include( - encodeURIComponent(conf.params.websiteToAnalyseUrl) - ); + it("should convert AudienceTier4 filter correctly", function () { + const filters = { + AudienceTier4: { limit: 10, threshold: 0.85 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); - request.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); - const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; - expect(contentData.name, "The data provider name should be correctly set").to.equal( - neuwo.DATA_PROVIDER - ); - expect( - contentData.ext.segtax, - "The segtax value should default to IAB Content Taxonomy 3.0" - ).to.equal(7); - expect( - contentData.segment[0].id, - "The first segment ID should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_tier_1[0].ID); - expect( - contentData.segment[1].name, - "The second segment name should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_tier_2[0].label); - }); + expect(result).to.include("filter_4_4_limit=10"); + expect(result).to.include("filter_4_4_threshold=0.85"); + expect(result).to.have.lengthOf(2); }); - describe("when using IAB Audience Taxonomy 1.1", function () { - it("should correctly structure the bids object after a successful API response", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig = bidsConfiglike(); - const conf = config(); - // control xhr api request target for testing - conf.params.websiteToAnalyseUrl = - "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + it("should convert AudienceTier5 filter correctly", function () { + const filters = { + AudienceTier5: { limit: 7, threshold: 0.95 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + expect(result).to.include("filter_4_5_limit=7"); + expect(result).to.include("filter_4_5_threshold=0.95"); + expect(result).to.have.lengthOf(2); + }); - expect(request.url, "The request URL should be a string").to.be.a("string"); - expect(request.url, "The request URL should include the public API token").to.include( - conf.params.neuwoApiToken - ); - expect(request.url, "The request URL should include the encoded website URL").to.include( - encodeURIComponent(conf.params.websiteToAnalyseUrl) - ); + it("should handle multiple content tiers with same segtax", function () { + const filters = { + ContentTier1: { limit: 3 }, + ContentTier2: { limit: 5 }, + ContentTier3: { threshold: 0.7 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); - request.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); - const userData = bidsConfig.ortb2Fragments.global.user.data[0]; - expect(userData.name, "The data provider name should be correctly set").to.equal( - neuwo.DATA_PROVIDER - ); - expect( - userData.ext.segtax, - "The segtax value should correspond to IAB Audience Taxonomy 1.1" - ).to.equal(4); - expect( - userData.segment[0].id, - "The first segment ID should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_audience_tier_3[0].ID); - expect( - userData.segment[1].name, - "The second segment name should match the API response" - ).to.equal(apiResponse.marketing_categories.iab_audience_tier_4[0].label); - }); + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_2_limit=5"); + expect(result).to.include("filter_6_3_threshold=0.7"); + expect(result).to.have.lengthOf(3); }); - it("should not change the bids object structure after an unsuccessful API response", function () { - const bidsConfig = bidsConfiglike(); - const bidsConfigCopy = bidsConfiglike(); - const conf = config(); + it("should handle multiple audience tiers", function () { + const filters = { + AudienceTier3: { limit: 2 }, + AudienceTier4: { limit: 4 }, + AudienceTier5: { threshold: 0.85 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; - request.respond( - 404, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify({ detail: "test error" }) - ); - expect( - bidsConfig, - "The bids config object should remain unmodified after a failed API call" - ).to.deep.equal(bidsConfigCopy); + expect(result).to.include("filter_4_3_limit=2"); + expect(result).to.include("filter_4_4_limit=4"); + expect(result).to.include("filter_4_5_threshold=0.85"); + expect(result).to.have.lengthOf(3); }); - }); - - describe("cleanUrl", function () { - describe("when no stripping options are provided", function () { - it("should return the URL unchanged", function () { - const url = "https://example.com/page?foo=bar&baz=qux"; - const result = neuwo.cleanUrl(url, {}); - expect(result, "should return the original URL with all query params intact").to.equal(url); - }); - it("should return the URL unchanged when options object is empty", function () { - const url = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url); - expect(result, "should handle missing options parameter").to.equal(url); - }); + it("should handle both content and audience tiers together", function () { + const filters = { + ContentTier1: { limit: 3, threshold: 0.5 }, + ContentTier2: { limit: 5 }, + AudienceTier3: { limit: 2, threshold: 0.9 }, + AudienceTier4: { threshold: 0.8 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); + + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_1_threshold=0.5"); + expect(result).to.include("filter_6_2_limit=5"); + expect(result).to.include("filter_4_3_limit=2"); + expect(result).to.include("filter_4_3_threshold=0.9"); + expect(result).to.include("filter_4_4_threshold=0.8"); + expect(result).to.have.lengthOf(6); }); - describe("with query parameters edge cases", function () { - it("should strip all query parameters from the URL for `stripAllQueryParams` (edge cases)", function () { - const stripAll = (url) => neuwo.cleanUrl(url, { stripAllQueryParams: true }); - const expected = "https://example.com/page"; - const expectedWithFragment = "https://example.com/page#anchor"; - - // Basic formats - expect(stripAll("https://example.com/page?key=value"), "should remove basic key=value params").to.equal(expected); - expect(stripAll("https://example.com/page?key="), "should remove params with empty value").to.equal(expected); - expect(stripAll("https://example.com/page?key"), "should remove params without equals sign").to.equal(expected); - expect(stripAll("https://example.com/page?=value"), "should remove params with empty key").to.equal(expected); - - // Multiple parameters - expect(stripAll("https://example.com/page?key1=value1&key2=value2"), "should remove multiple different params").to.equal(expected); - expect(stripAll("https://example.com/page?key=value1&key=value2"), "should remove multiple params with same key").to.equal(expected); + it("should use different content segtax values correctly", function () { + const filters = { + ContentTier1: { limit: 3 } + }; - // Special characters and encoding - expect(stripAll("https://example.com/page?key=value%20with%20spaces"), "should remove URL encoded spaces").to.equal(expected); - expect(stripAll("https://example.com/page?key=value+with+plus"), "should remove plus as space").to.equal(expected); - expect(stripAll("https://example.com/page?key=value%3D%26%3F"), "should remove encoded special chars").to.equal(expected); - expect(stripAll("https://example.com/page?key=%"), "should remove incomplete encoding").to.equal(expected); - expect(stripAll("https://example.com/page?key=value%2"), "should remove malformed encoding").to.equal(expected); + // Test with segtax 6 (IAB 2.2) + const result6 = neuwo.buildFilterQueryParams(filters, 6, false); + expect(result6).to.include("filter_6_1_limit=3"); + expect(result6).to.not.include("filter_7_1_limit=3"); - // Delimiters and syntax edge cases - expect(stripAll("https://example.com/page?&key=value"), "should remove params with leading ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value&"), "should remove params with trailing ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value&&key2=value2"), "should remove params with double ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value?key2=value2"), "should remove params with question mark delimiter").to.equal(expected); - expect(stripAll("https://example.com/page?key=value;key2=value2"), "should remove params with semicolon delimiter").to.equal(expected); + // Test with segtax 7 (IAB 3.0) + const result7 = neuwo.buildFilterQueryParams(filters, 7, false); + expect(result7).to.include("filter_7_1_limit=3"); + expect(result7).to.not.include("filter_6_1_limit=3"); + }); - // Empty and missing cases - expect(stripAll("https://example.com/page?"), "should remove question mark alone").to.equal(expected); - expect(stripAll("https://example.com/page??"), "should remove double question mark").to.equal(expected); - expect(stripAll("https://example.com/page"), "should handle URL without query string").to.equal(expected); + it("should ignore unknown tier names", function () { + const filters = { + ContentTier1: { limit: 3 }, + UnknownTier: { limit: 10 }, + InvalidTier99: { threshold: 0.5 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - // Unicode and special values - expect(stripAll("https://example.com/page?key=值"), "should remove unicode characters").to.equal(expected); - expect(stripAll("https://example.com/page?key=null"), "should remove string 'null'").to.equal(expected); - expect(stripAll("https://example.com/page?key=undefined"), "should remove string 'undefined'").to.equal(expected); + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.have.lengthOf(1); + }); - // Fragment positioning (fragments are preserved by default) - expect(stripAll("https://example.com/page?key=value#anchor"), "should remove query params and preserve fragment").to.equal(expectedWithFragment); - expect(stripAll("https://example.com/page#anchor?key=value"), "should preserve fragment before params").to.equal("https://example.com/page#anchor?key=value"); - }); + it("should handle filters with only limit property", function () { + const filters = { + ContentTier1: { limit: 5 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - it("should strip all query parameters from the URL for `stripQueryParamsForDomains` (edge cases)", function () { - const stripAll = (url) => neuwo.cleanUrl(url, { stripQueryParamsForDomains: ["example.com"] }); - const expected = "https://example.com/page"; - const expectedWithFragment = "https://example.com/page#anchor"; + expect(result).to.include("filter_6_1_limit=5"); + expect(result).to.not.include.match(/filter_6_1_threshold/); + expect(result).to.have.lengthOf(1); + }); - // Basic formats - expect(stripAll("https://example.com/page?key=value"), "should remove basic key=value params").to.equal(expected); - expect(stripAll("https://example.com/page?key="), "should remove params with empty value").to.equal(expected); - expect(stripAll("https://example.com/page?key"), "should remove params without equals sign").to.equal(expected); - expect(stripAll("https://example.com/page?=value"), "should remove params with empty key").to.equal(expected); + it("should handle filters with only threshold property", function () { + const filters = { + AudienceTier3: { threshold: 0.75 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - // Multiple parameters - expect(stripAll("https://example.com/page?key1=value1&key2=value2"), "should remove multiple different params").to.equal(expected); - expect(stripAll("https://example.com/page?key=value1&key=value2"), "should remove multiple params with same key").to.equal(expected); + expect(result).to.include("filter_4_3_threshold=0.75"); + expect(result).to.not.include.match(/filter_4_3_limit/); + expect(result).to.have.lengthOf(1); + }); - // Special characters and encoding - expect(stripAll("https://example.com/page?key=value%20with%20spaces"), "should remove URL encoded spaces").to.equal(expected); - expect(stripAll("https://example.com/page?key=value+with+plus"), "should remove plus as space").to.equal(expected); - expect(stripAll("https://example.com/page?key=value%3D%26%3F"), "should remove encoded special chars").to.equal(expected); - expect(stripAll("https://example.com/page?key=%"), "should remove incomplete encoding").to.equal(expected); - expect(stripAll("https://example.com/page?key=value%2"), "should remove malformed encoding").to.equal(expected); + it("should handle filters with additional custom properties", function () { + const filters = { + ContentTier1: { limit: 3, threshold: 0.5, customProp: "value" } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - // Delimiters and syntax edge cases - expect(stripAll("https://example.com/page?&key=value"), "should remove params with leading ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value&"), "should remove params with trailing ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value&&key2=value2"), "should remove params with double ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value?key2=value2"), "should remove params with question mark delimiter").to.equal(expected); - expect(stripAll("https://example.com/page?key=value;key2=value2"), "should remove params with semicolon delimiter").to.equal(expected); + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_1_threshold=0.5"); + expect(result).to.include("filter_6_1_customProp=value"); + expect(result).to.have.lengthOf(3); + }); - // Empty and missing cases - expect(stripAll("https://example.com/page?"), "should remove question mark alone").to.equal(expected); - expect(stripAll("https://example.com/page??"), "should remove double question mark").to.equal(expected); - expect(stripAll("https://example.com/page"), "should handle URL without query string").to.equal(expected); + it("should handle empty filter objects for tiers", function () { + const filters = { + ContentTier1: {}, + AudienceTier3: {} + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - // Unicode and special values - expect(stripAll("https://example.com/page?key=值"), "should remove unicode characters").to.equal(expected); - expect(stripAll("https://example.com/page?key=null"), "should remove string 'null'").to.equal(expected); - expect(stripAll("https://example.com/page?key=undefined"), "should remove string 'undefined'").to.equal(expected); + expect(result).to.have.lengthOf(0); + }); - // Fragment positioning (fragments are preserved by default) - expect(stripAll("https://example.com/page?key=value#anchor"), "should remove query params and preserve fragment").to.equal(expectedWithFragment); - expect(stripAll("https://example.com/page#anchor?key=value"), "should preserve fragment before params").to.equal("https://example.com/page#anchor?key=value"); - }); + it("should not include null limit value", function () { + const filters = { + ContentTier1: { limit: null, threshold: 0.5 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - it("should strip all query parameters from the URL for `stripQueryParams` (edge cases)", function () { - const stripAll = (url) => neuwo.cleanUrl(url, { stripQueryParams: ["key", "key1", "key2", "", "?"] }); - const expected = "https://example.com/page"; - const expectedWithFragment = "https://example.com/page#anchor"; + expect(result).to.include("filter_6_1_threshold=0.5"); + expect(result).to.have.lengthOf(1); + }); - // Basic formats - expect(stripAll("https://example.com/page?key=value"), "should remove basic key=value params").to.equal(expected); - expect(stripAll("https://example.com/page?key="), "should remove params with empty value").to.equal(expected); - expect(stripAll("https://example.com/page?key"), "should remove params without equals sign").to.equal(expected); - expect(stripAll("https://example.com/page?=value"), "should remove params with empty key").to.equal(expected); + it("should not include undefined limit value", function () { + const filters = { + ContentTier1: { limit: undefined, threshold: 0.5 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - // Multiple parameters - expect(stripAll("https://example.com/page?key1=value1&key2=value2"), "should remove multiple different params").to.equal(expected); - expect(stripAll("https://example.com/page?key=value1&key=value2"), "should remove multiple params with same key").to.equal(expected); + expect(result).to.include("filter_6_1_threshold=0.5"); + expect(result).to.have.lengthOf(1); + }); - // Special characters and encoding - expect(stripAll("https://example.com/page?key=value%20with%20spaces"), "should remove URL encoded spaces").to.equal(expected); - expect(stripAll("https://example.com/page?key=value+with+plus"), "should remove plus as space").to.equal(expected); - expect(stripAll("https://example.com/page?key=value%3D%26%3F"), "should remove encoded special chars").to.equal(expected); - expect(stripAll("https://example.com/page?key=%"), "should remove incomplete encoding").to.equal(expected); - expect(stripAll("https://example.com/page?key=value%2"), "should remove malformed encoding").to.equal(expected); + it("should not include null threshold value", function () { + const filters = { + AudienceTier3: { limit: 5, threshold: null } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - // Delimiters and syntax edge cases - expect(stripAll("https://example.com/page?&key=value"), "should remove params with leading ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value&"), "should remove params with trailing ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value&&key2=value2"), "should remove params with double ampersand").to.equal(expected); - expect(stripAll("https://example.com/page?key=value?key2=value2"), "should remove params with question mark delimiter").to.equal(expected); - expect(stripAll("https://example.com/page?key=value;key2=value2"), "should remove params with semicolon delimiter").to.equal(expected); + expect(result).to.include("filter_4_3_limit=5"); + expect(result).to.have.lengthOf(1); + }); - // Empty and missing cases - expect(stripAll("https://example.com/page?"), "should remove question mark alone").to.equal(expected); - expect(stripAll("https://example.com/page"), "should handle URL without query string").to.equal(expected); + it("should not include undefined threshold value", function () { + const filters = { + AudienceTier3: { limit: 5, threshold: undefined } + }; + const result = neuwo.buildFilterQueryParams(filters, 6, false); - // Unicode and special values - expect(stripAll("https://example.com/page?key=值"), "should remove unicode characters").to.equal(expected); - expect(stripAll("https://example.com/page?key=null"), "should remove string 'null'").to.equal(expected); - expect(stripAll("https://example.com/page?key=undefined"), "should remove string 'undefined'").to.equal(expected); + expect(result).to.include("filter_4_3_limit=5"); + expect(result).to.have.lengthOf(1); + }); - // Fragment positioning (fragments are preserved by default) - expect(stripAll("https://example.com/page?key=value#anchor"), "should remove query params and preserve fragment").to.equal(expectedWithFragment); - expect(stripAll("https://example.com/page#anchor?key=value"), "should preserve fragment before params").to.equal("https://example.com/page#anchor?key=value"); + // OpenRTB 2.5 Feature Tests + describe("with enableOrtb25Fields enabled (default)", function () { + it("should add IAB 1.0 filter params when ContentTier1 filter is provided", function () { + const filters = { + ContentTier1: { limit: 3, threshold: 0.5 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax); + + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_1_threshold=0.5"); + expect(result).to.include("filter_1_1_limit=3"); + expect(result).to.include("filter_1_1_threshold=0.5"); + expect(result).to.have.lengthOf(4); }); - }); - describe("when stripAllQueryParams is true", function () { - it("should strip all query parameters from the URL", function () { - const url = "https://example.com/page?foo=bar&baz=qux&test=123"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); - expect(result, "should remove all query parameters").to.equal(expected); + it("should add IAB 1.0 filter params when ContentTier2 filter is provided", function () { + const filters = { + ContentTier2: { limit: 5, threshold: 0.6 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax); + + expect(result).to.include("filter_6_2_limit=5"); + expect(result).to.include("filter_6_2_threshold=0.6"); + expect(result).to.include("filter_1_2_limit=5"); + expect(result).to.include("filter_1_2_threshold=0.6"); + expect(result).to.have.lengthOf(4); }); - it("should return the URL unchanged if there are no query parameters", function () { - const url = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); - expect(result, "should handle URLs without query params").to.equal(url); + it("should add IAB 1.0 filter params for both ContentTier1 and ContentTier2", function () { + const filters = { + ContentTier1: { limit: 3 }, + ContentTier2: { limit: 5 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax); + + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_2_limit=5"); + expect(result).to.include("filter_1_1_limit=3"); + expect(result).to.include("filter_1_2_limit=5"); + expect(result).to.have.lengthOf(4); }); - it("should preserve the hash fragment when stripping query params without stripFragments", function () { - const url = "https://example.com/page?foo=bar#section"; - const expected = "https://example.com/page#section"; - const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); - expect(result, "should preserve hash fragments by default").to.equal(expected); + it("should NOT add ContentTier3 filter to IAB 1.0 (only tiers 1-2 exist)", function () { + const filters = { + ContentTier1: { limit: 3 }, + ContentTier2: { limit: 5 }, + ContentTier3: { threshold: 0.7 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax); + + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_2_limit=5"); + expect(result).to.include("filter_6_3_threshold=0.7"); + expect(result).to.include("filter_1_1_limit=3"); + expect(result).to.include("filter_1_2_limit=5"); + expect(result).to.not.include.match(/filter_1_3/); + expect(result).to.have.lengthOf(5); }); - it("should strip hash fragment when stripFragments is enabled", function () { - const url = "https://example.com/page?foo=bar#section"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { stripAllQueryParams: true, stripFragments: true }); - expect(result, "should strip both query params and fragments").to.equal(expected); + it("should NOT add audience tier filters to IAB 1.0", function () { + const filters = { + AudienceTier3: { limit: 2 }, + AudienceTier4: { limit: 4 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6); + + expect(result).to.include("filter_4_3_limit=2"); + expect(result).to.include("filter_4_4_limit=4"); + expect(result).to.not.include.match(/filter_1/); + expect(result).to.have.lengthOf(2); }); - it("should strip query params but preserve path and protocol", function () { - const url = "https://subdomain.example.com:8080/path/to/page?param=value"; - const expected = "https://subdomain.example.com:8080/path/to/page"; - const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); - expect(result, "should preserve protocol, domain, port, and path").to.equal(expected); + it("should add IAB 1.0 filter params alongside audience filters", function () { + const filters = { + ContentTier1: { limit: 3 }, + AudienceTier3: { limit: 2 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax); + + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_1_1_limit=3"); + expect(result).to.include("filter_4_3_limit=2"); + expect(result).to.have.lengthOf(3); }); - }); - describe("when stripQueryParamsForDomains is provided", function () { - it("should strip all query params for exact domain match", function () { - const url = "https://example.com/page?foo=bar&baz=qux"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"] - }); - expect(result, "should strip params for exact domain match").to.equal(expected); + it("should not add segtax 1 params when no content filters are provided", function () { + const filters = { + AudienceTier3: { limit: 2 } + }; + const result = neuwo.buildFilterQueryParams(filters, 6); + + expect(result).to.include("filter_4_3_limit=2"); + expect(result).to.not.include.match(/filter_1/); + expect(result).to.have.lengthOf(1); }); - it("should strip all query params for subdomain match", function () { - const url = "https://sub.example.com/page?foo=bar"; - const expected = "https://sub.example.com/page"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"] - }); - expect(result, "should strip params for subdomains").to.equal(expected); + it("should not add segtax 1 params when filters is empty", function () { + const result = neuwo.buildFilterQueryParams({}, 6); + + expect(result).to.deep.equal([]); }); - it("should not strip query params if domain does not match", function () { - const url = "https://other.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"] - }); - expect(result, "should preserve params for non-matching domains").to.equal(url); + it("should not add segtax 1 params when filters is null", function () { + const result = neuwo.buildFilterQueryParams(null, 6); + + expect(result).to.deep.equal([]); }); - it("should not strip query params if subdomain is provided for domain", function () { - const url = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["sub.example.com"] - }); - expect(result, "should preserve params for domain when subdomain is provided").to.equal(url); + it("should work with different content segtax values", function () { + const filters = { + ContentTier1: { limit: 3 } + }; + + // Test with segtax 7 (IAB 3.0) + const result7 = neuwo.buildFilterQueryParams(filters, 7); + expect(result7).to.include("filter_7_1_limit=3"); + expect(result7).to.include("filter_1_1_limit=3"); + expect(result7).to.have.lengthOf(2); }); - it("should handle multiple domains in the list", function () { - const url1 = "https://example.com/page?foo=bar"; - const url2 = "https://test.com/page?foo=bar"; - const url3 = "https://other.com/page?foo=bar"; - const domains = ["example.com", "test.com"]; + it("should not produce duplicate query params when contentSegtax is 1", function () { + const filters = { + ContentTier1: { limit: 5, threshold: 0.8 }, + ContentTier2: { limit: 3 } + }; - const result1 = neuwo.cleanUrl(url1, { stripQueryParamsForDomains: domains }); - const result2 = neuwo.cleanUrl(url2, { stripQueryParamsForDomains: domains }); - const result3 = neuwo.cleanUrl(url3, { stripQueryParamsForDomains: domains }); + const result = neuwo.buildFilterQueryParams(filters, 1); - expect(result1, "should strip params for first domain").to.equal("https://example.com/page"); - expect(result2, "should strip params for second domain").to.equal("https://test.com/page"); - expect(result3, "should preserve params for non-listed domain").to.equal(url3); + // Count occurrences of each param to verify no duplicates + const countOccurrences = (arr, val) => arr.filter(p => p === val).length; + expect(countOccurrences(result, "filter_1_1_limit=5"), "filter_1_1_limit should appear once").to.equal(1); + expect(countOccurrences(result, "filter_1_1_threshold=0.8"), "filter_1_1_threshold should appear once").to.equal(1); + expect(countOccurrences(result, "filter_1_2_limit=3"), "filter_1_2_limit should appear once").to.equal(1); + expect(result, "should have exactly 3 params total").to.have.lengthOf(3); }); + }); - it("should handle deep subdomains correctly", function () { - const url = "https://deep.sub.example.com/page?foo=bar"; - const expected = "https://deep.sub.example.com/page"; - const result1 = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"] - }); - const result2 = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["sub.example.com"] - }); - expect(result1, "should strip params for deep subdomains with domain matching").to.equal(expected); - expect(result2, "should strip params for deep subdomains with subdomain matching").to.equal(expected); + describe("with enableOrtb25Fields disabled", function () { + it("should not add IAB 1.0 filter params when disabled", function () { + const filters = { + ContentTier1: { limit: 3, threshold: 0.5 }, + ContentTier2: { limit: 5 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); + + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_1_threshold=0.5"); + expect(result).to.include("filter_6_2_limit=5"); + expect(result).to.not.include.match(/filter_1/); + expect(result).to.have.lengthOf(3); }); - it("should not match partial domain names", function () { - const url = "https://notexample.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"] - }); - expect(result, "should not match partial domain strings").to.equal(url); + it("should work correctly with all tier types when disabled", function () { + const filters = { + ContentTier1: { limit: 3 }, + ContentTier2: { limit: 5 }, + ContentTier3: { threshold: 0.7 }, + AudienceTier3: { limit: 2 } + }; + const contentSegtax = 6; + const result = neuwo.buildFilterQueryParams(filters, contentSegtax, false); + + expect(result).to.include("filter_6_1_limit=3"); + expect(result).to.include("filter_6_2_limit=5"); + expect(result).to.include("filter_6_3_threshold=0.7"); + expect(result).to.include("filter_4_3_limit=2"); + expect(result).to.not.include.match(/filter_1/); + expect(result).to.have.lengthOf(4); }); + }); + }); - it("should handle empty domain list", function () { - const url = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { stripQueryParamsForDomains: [] }); - expect(result, "should not strip params with empty domain list").to.equal(url); - }); + describe("extractCategoryIds", function () { + it("should extract IDs from single tier", function () { + const tierData = { + "1": [ + { id: "IAB12" }, + { id: "IAB12-3" } + ] + }; + const result = neuwo.extractCategoryIds(tierData); + expect(result, "should extract all IDs from tier 1").to.deep.equal(["IAB12", "IAB12-3"]); }); - describe("when stripQueryParams is provided", function () { - it("should strip only specified query parameters", function () { - const url = "https://example.com/page?foo=bar&baz=qux&keep=this"; - const expected = "https://example.com/page?keep=this"; - const result = neuwo.cleanUrl(url, { - stripQueryParams: ["foo", "baz"] + it("should extract IDs from multiple tiers", function () { + const tierData = { + "1": [ + { id: "IAB12" }, + { id: "IAB12-3" } + ], + "2": [ + { id: "IAB12-5" } + ] + }; + const result = neuwo.extractCategoryIds(tierData); + expect(result, "should extract all IDs from all tiers").to.deep.equal(["IAB12", "IAB12-3", "IAB12-5"]); + }); + + it("should handle empty tier arrays", function () { + const tierData = { + "1": [], + "2": [ + { id: "IAB12" } + ] + }; + const result = neuwo.extractCategoryIds(tierData); + expect(result, "should only extract from non-empty tiers").to.deep.equal(["IAB12"]); + }); + + it("should skip items without id property", function () { + const tierData = { + "1": [ + { id: "IAB12" }, + { name: "No ID" }, + { id: "IAB12-3" } + ] + }; + const result = neuwo.extractCategoryIds(tierData); + expect(result, "should skip items without id").to.deep.equal(["IAB12", "IAB12-3"]); + }); + + it("should return empty array for null tierData", function () { + const result = neuwo.extractCategoryIds(null); + expect(result, "should return empty array for null").to.deep.equal([]); + }); + + it("should return empty array for undefined tierData", function () { + const result = neuwo.extractCategoryIds(undefined); + expect(result, "should return empty array for undefined").to.deep.equal([]); + }); + + it("should return empty array for empty object", function () { + const result = neuwo.extractCategoryIds({}); + expect(result, "should return empty array for empty object").to.deep.equal([]); + }); + + it("should handle non-array tier values", function () { + const tierData = { + "1": { id: "IAB12" }, // Not an array + "2": [ + { id: "IAB13" } + ] + }; + const result = neuwo.extractCategoryIds(tierData); + expect(result, "should skip non-array values").to.deep.equal(["IAB13"]); + }); + + it("should handle null items in tier arrays", function () { + const tierData = { + "1": [ + { id: "IAB12" }, + null, + { id: "IAB12-3" } + ] + }; + const result = neuwo.extractCategoryIds(tierData); + expect(result, "should skip null items").to.deep.equal(["IAB12", "IAB12-3"]); + }); + + it("should handle non-object tierData", function () { + expect(neuwo.extractCategoryIds("string"), "should handle string").to.deep.equal([]); + expect(neuwo.extractCategoryIds(123), "should handle number").to.deep.equal([]); + expect(neuwo.extractCategoryIds([]), "should handle array").to.deep.equal([]); + }); + + it("should extract from all tier numbers", function () { + const tierData = { + "1": [{ id: "IAB1" }], + "2": [{ id: "IAB2" }], + "3": [{ id: "IAB3" }], + "4": [{ id: "IAB4" }], + "5": [{ id: "IAB5" }] + }; + const result = neuwo.extractCategoryIds(tierData); + expect(result, "should extract from all tiers").to.deep.equal(["IAB1", "IAB2", "IAB3", "IAB4", "IAB5"]); + }); + }); + + describe("injectOrtbData", function () { + it("should correctly mutate the request bids config object with new data", function () { + const reqBidsConfigObj = { ortb2Fragments: { global: {} } }; + neuwo.injectOrtbData(reqBidsConfigObj, "c.d.e.f", { g: "h" }); + expect( + reqBidsConfigObj.ortb2Fragments.global.c.d.e.f.g, + "should deeply merge the new data into the target object" + ).to.equal("h"); + }); + }); + + describe("injectIabCategories", function () { + it("should not inject data when responseParsed is null or undefined", function () { + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfigCopy = JSON.parse(JSON.stringify(bidsConfig1)); + + neuwo.injectIabCategories(null, bidsConfig1, "2.2"); + neuwo.injectIabCategories(undefined, bidsConfig2, "2.2"); + + expect( + bidsConfig1.ortb2Fragments.global, + "should not modify ortb2Fragments when response is null" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + expect( + bidsConfig2.ortb2Fragments.global, + "should not modify ortb2Fragments when response is undefined" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + }); + + it("should not inject data when responseParsed is not an object", function () { + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + const bidsConfigCopy = JSON.parse(JSON.stringify(bidsConfig1)); + + neuwo.injectIabCategories("invalid string", bidsConfig1, "2.2"); + neuwo.injectIabCategories(123, bidsConfig2, "2.2"); + neuwo.injectIabCategories([1, 2, 3], bidsConfig3, "2.2"); + + expect( + bidsConfig1.ortb2Fragments.global, + "should not modify ortb2Fragments when response is a string" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + expect( + bidsConfig2.ortb2Fragments.global, + "should not modify ortb2Fragments when response is a number" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + expect( + bidsConfig3.ortb2Fragments.global, + "should not modify ortb2Fragments when response is an array" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + }); + + it("should handle empty object response", function () { + const bidsConfig = bidsConfiglike(); + const bidsConfigCopy = JSON.parse(JSON.stringify(bidsConfig)); + + neuwo.injectIabCategories({}, bidsConfig, "2.2"); + + expect( + bidsConfig.ortb2Fragments.global, + "should not inject data when response object is empty" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + }); + + it("should inject content data when valid content segments exist", function () { + const response = { + "6": { + "1": [{ id: "52", name: "Food & Drink" }], + "2": [{ id: "90", name: "Cooking" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData, "should have content data").to.exist; + expect(contentData.ext.segtax, "should have correct segtax").to.equal(6); + expect(contentData.segment, "should have segments").to.have.lengthOf(2); + expect(contentData.segment[0].id, "first segment should match").to.equal("52"); + }); + + it("should inject audience data when valid audience segments exist", function () { + const response = { + "4": { + "3": [{ id: "49", name: "Female" }], + "4": [{ id: "431", name: "Age 25-34" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const userData = bidsConfig.ortb2Fragments.global?.user?.data?.[0]; + expect(userData, "should have user data").to.exist; + expect(userData.ext.segtax, "should have correct segtax").to.equal(4); + expect(userData.segment, "should have segments").to.have.lengthOf(2); + expect(userData.segment[0].id, "first segment should match").to.equal("49"); + }); + + it("should inject both content and audience data when both exist", function () { + const response = { + "6": { + "1": [{ id: "52", name: "Food & Drink" }] + }, + "4": { + "3": [{ id: "49", name: "Female" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + const userData = bidsConfig.ortb2Fragments.global?.user?.data?.[0]; + + expect(contentData, "should have content data").to.exist; + expect(userData, "should have user data").to.exist; + expect(contentData.ext.segtax, "content should have segtax 6").to.equal(6); + expect(userData.ext.segtax, "audience should have segtax 4").to.equal(4); + }); + + it("should not inject empty audience data when only content segments exist", function () { + const response = { + "6": { + "1": [{ id: "52", name: "Food & Drink" }] + }, + "4": {} + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + const userData = bidsConfig.ortb2Fragments.global?.user?.data; + expect(contentData, "should have content data").to.exist; + expect(userData, "should not inject empty audience data").to.be.undefined; + }); + + it("should not inject empty content data when only audience segments exist", function () { + const response = { + "6": {}, + "4": { + "3": [{ id: "49", name: "Female" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data; + const userData = bidsConfig.ortb2Fragments.global?.user?.data?.[0]; + expect(contentData, "should not inject empty content data").to.be.undefined; + expect(userData, "should have audience data").to.exist; + }); + + it("should handle different IAB Content Taxonomy versions", function () { + const response = { + "7": { + "1": [{ id: "80DV8O", name: "Automotive" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "3.0"); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData, "should have content data").to.exist; + expect(contentData.ext.segtax, "should use segtax 7 for IAB 3.0").to.equal(7); + }); + + it("should not inject data when segtax has no segments", function () { + const response1 = { "6": {} }; + const response2 = { "4": {} }; + const response3 = { "6": { "1": [] }, "4": { "3": [] } }; + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + + neuwo.injectIabCategories(response1, bidsConfig1, "2.2"); + neuwo.injectIabCategories(response2, bidsConfig2, "2.2"); + neuwo.injectIabCategories(response3, bidsConfig3, "2.2"); + + expect(bidsConfig1.ortb2Fragments.global?.site?.content?.data, "should not inject empty content data").to.be.undefined; + expect(bidsConfig2.ortb2Fragments.global?.user?.data, "should not inject empty audience data").to.be.undefined; + expect(bidsConfig3.ortb2Fragments.global?.site?.content?.data, "should not inject content data with empty segments").to.be.undefined; + expect(bidsConfig3.ortb2Fragments.global?.user?.data, "should not inject audience data with empty segments").to.be.undefined; + }); + + it("should use default taxonomy version when invalid version provided", function () { + const response = { + "6": { + "1": [{ id: "52", name: "Food & Drink" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "invalid-version"); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData, "should have content data").to.exist; + expect(contentData.ext.segtax, "should default to segtax 6 (IAB 2.2)").to.equal(6); + }); + + // OpenRTB 2.5 Category Fields Tests + describe("OpenRTB 2.5 category fields", function () { + describe("with enableOrtb25Fields enabled (default)", function () { + it("should inject category fields when IAB 1.0 data exists", function () { + const response = { + "1": { + "1": [{ id: "IAB12" }], + "2": [{ id: "IAB12-3" }, { id: "IAB12-5" }] + }, + "6": { + "1": [{ id: "52" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + const siteSectioncat = bidsConfig.ortb2Fragments.global?.site?.sectioncat; + const sitePagecat = bidsConfig.ortb2Fragments.global?.site?.pagecat; + const contentCat = bidsConfig.ortb2Fragments.global?.site?.content?.cat; + + expect(siteCat, "should have site.cat").to.deep.equal(["IAB12", "IAB12-3", "IAB12-5"]); + expect(siteSectioncat, "should have site.sectioncat").to.deep.equal(["IAB12", "IAB12-3", "IAB12-5"]); + expect(sitePagecat, "should have site.pagecat").to.deep.equal(["IAB12", "IAB12-3", "IAB12-5"]); + expect(contentCat, "should have site.content.cat").to.deep.equal(["IAB12", "IAB12-3", "IAB12-5"]); }); - expect(result, "should remove only specified params").to.equal(expected); - }); - it("should handle single parameter stripping", function () { - const url = "https://example.com/page?remove=this&keep=that"; - const expected = "https://example.com/page?keep=that"; - const result = neuwo.cleanUrl(url, { - stripQueryParams: ["remove"] + it("should inject category fields with single IAB 1.0 segment", function () { + const response = { + "1": { + "1": [{ id: "IAB12" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + expect(siteCat, "should have single category").to.deep.equal(["IAB12"]); }); - expect(result, "should remove single specified param").to.equal(expected); - }); - it("should return URL without query string if all params are stripped", function () { - const url = "https://example.com/page?foo=bar&baz=qux"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { - stripQueryParams: ["foo", "baz"] + it("should not inject category fields when IAB 1.0 data is missing", function () { + const response = { + "6": { + "1": [{ id: "52" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + const siteSectioncat = bidsConfig.ortb2Fragments.global?.site?.sectioncat; + + expect(siteCat, "should not have site.cat").to.be.undefined; + expect(siteSectioncat, "should not have site.sectioncat").to.be.undefined; + }); + + it("should not inject category fields when IAB 1.0 data is empty", function () { + const response = { + "1": {}, + "6": { + "1": [{ id: "52" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + expect(siteCat, "should not have site.cat with empty IAB 1.0").to.be.undefined; + }); + + it("should not inject category fields when IAB 1.0 tiers are empty arrays", function () { + const response = { + "1": { + "1": [], + "2": [] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + expect(siteCat, "should not have site.cat with empty tier arrays").to.be.undefined; + }); + + it("should handle IAB 1.0 data with malformed items", function () { + const response = { + "1": { + "1": [ + { id: "IAB12" }, + { name: "No ID" }, + null, + { id: "IAB12-3" } + ] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + expect(siteCat, "should skip malformed items").to.deep.equal(["IAB12", "IAB12-3"]); + }); + + it("should inject both content data and category fields", function () { + const response = { + "1": { + "1": [{ id: "IAB12" }] + }, + "6": { + "1": [{ id: "52", name: "Food & Drink" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + + expect(contentData, "should have content data").to.exist; + expect(contentData.ext.segtax, "should have segtax 6").to.equal(6); + expect(siteCat, "should have category fields").to.deep.equal(["IAB12"]); + }); + + it("should merge category fields with existing data", function () { + const response = { + "1": { + "1": [{ id: "IAB12" }] + } + }; + const bidsConfig = bidsConfiglike(); + // Pre-populate with existing category data + bidsConfig.ortb2Fragments.global = { + site: { + cat: ["EXISTING1"] + } + }; + + neuwo.injectIabCategories(response, bidsConfig, "2.2"); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + // mergeDeep should deduplicate and merge arrays + expect(siteCat, "should merge with existing data").to.include("IAB12"); + expect(siteCat, "should preserve existing data").to.include("EXISTING1"); }); - expect(result, "should remove query string when all params stripped").to.equal(expected); }); - it("should handle case where specified params do not exist", function () { - const url = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { - stripQueryParams: ["nonexistent", "alsonothere"] + describe("with enableOrtb25Fields disabled", function () { + it("should not inject category fields when disabled", function () { + const response = { + "1": { + "1": [{ id: "IAB12" }], + "2": [{ id: "IAB12-3" }] + }, + "6": { + "1": [{ id: "52" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2", false); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + const siteSectioncat = bidsConfig.ortb2Fragments.global?.site?.sectioncat; + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + + expect(siteCat, "should not have site.cat").to.be.undefined; + expect(siteSectioncat, "should not have site.sectioncat").to.be.undefined; + expect(contentData, "should still have content data (segtax 6)").to.exist; }); - expect(result, "should handle non-existent params gracefully").to.equal(url); + + it("should inject content data but not category fields when disabled", function () { + const response = { + "1": { + "1": [{ id: "IAB12" }] + }, + "6": { + "1": [{ id: "52", name: "Food & Drink" }] + } + }; + const bidsConfig = bidsConfiglike(); + + neuwo.injectIabCategories(response, bidsConfig, "2.2", false); + + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + + expect(contentData, "should have content data").to.exist; + expect(contentData.ext.segtax).to.equal(6); + expect(siteCat, "should not have category fields").to.be.undefined; + }); + }); + }); + }); + + describe("getBidRequestData", function () { + it("should call callback and not make API request when no URL is available", function () { + const getRefererInfoStub = sinon.stub(refererDetection, "getRefererInfo").returns({ page: "" }); + const bidsConfig = bidsConfiglike(); + const conf = config(); + let callbackCalled = false; + + neuwo.getBidRequestData(bidsConfig, () => { callbackCalled = true; }, conf, "consent data"); + + expect(callbackCalled, "callback should be called for empty URL").to.be.true; + expect(server.requests.length, "should not make API request for empty URL").to.equal(0); + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data; + expect(contentData, "should not inject any data").to.be.undefined; + + getRefererInfoStub.restore(); + }); + + it("should call callback when response processing throws an error", function (done) { + const bidsConfig = { ortb2Fragments: null }; + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/processing-error"; + + neuwo.getBidRequestData(bidsConfig, () => { + const contentData = bidsConfig.ortb2Fragments?.global?.site?.content?.data; + expect(contentData, "should not inject data after processing error").to.be.undefined; + done(); + }, conf, "consent data"); + + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(getNeuwoApiResponse()) + ); + }); + + describe("when using IAB Content Taxonomy 2.2 (API default)", function () { + it("should correctly structure the bids object after a successful API response", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig = bidsConfiglike(); + const conf = config(); + // control xhr api request target for testing + conf.params.websiteToAnalyseUrl = + "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should be a string").to.be.a("string"); + expect(request.url, "The request URL should include the public API token").to.include( + conf.params.neuwoApiToken + ); + expect(request.url, "The request URL should include the encoded website URL").to.include( + encodeURIComponent(conf.params.websiteToAnalyseUrl) + ); + expect(request.url, "The request URL should include the product identifier").to.include( + "_neuwo_prod=PrebidModule" + ); + expect(request.url, "API should include iabVersions parameter for segtax 6").to.include( + "iabVersions=6" + ); + expect(request.url, "API should include iabVersions parameter for segtax 4").to.include( + "iabVersions=4" + ); + expect(request.method, "API should use GET method").to.equal("GET"); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; + expect(contentData.name, "The data provider name should be correctly set").to.equal( + neuwo.DATA_PROVIDER + ); + expect( + contentData.ext.segtax, + "The segtax value should correspond to IAB Content Taxonomy 2.2" + ).to.equal(6); + expect( + contentData.segment[0].id, + "The first segment ID should match the API response" + ).to.equal(apiResponse["6"]["1"][0].id); + expect( + contentData.segment[1].id, + "The second segment ID should match the API response" + ).to.equal(apiResponse["6"]["2"][0].id); + }); + }); + + describe("when using IAB Content Taxonomy 1.0", function () { + it("should correctly structure the bids object after a successful API response", function () { + const apiResponse = { + 1: { 1: [{ id: "IAB1" }], 2: [{ id: "IAB1-1" }], 3: [{ id: "IAB1-1-1" }] }, + 4: { 3: [{ id: "49" }, { id: "780" }], 4: [{ id: "431" }], 5: [{ id: "98" }] }, + }; + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.iabContentTaxonomyVersion = "1.0"; + conf.params.websiteToAnalyseUrl = + "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should be a string").to.be.a("string"); + expect(request.url, "The request URL should include the public API token").to.include( + conf.params.neuwoApiToken + ); + expect(request.url, "The request URL should include the encoded website URL").to.include( + encodeURIComponent(conf.params.websiteToAnalyseUrl) + ); + expect(request.url, "The request URL should include the product identifier").to.include( + "_neuwo_prod=PrebidModule" + ); + expect(request.url, "API should include iabVersions parameter for segtax 1").to.include( + "iabVersions=1" + ); + expect(request.url, "API should include iabVersions parameter for segtax 4").to.include( + "iabVersions=4" + ); + expect(request.method, "API should use GET method").to.equal("GET"); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; + expect(contentData.name, "The data provider name should be correctly set").to.equal( + neuwo.DATA_PROVIDER + ); + expect( + contentData.ext.segtax, + "The segtax value should correspond to IAB Content Taxonomy 1.0" + ).to.equal(1); + expect( + contentData.segment[0].id, + "The first segment ID should match the API response" + ).to.equal(apiResponse["1"]["1"][0].id); + expect( + contentData.segment[1].id, + "The second segment ID should match the API response" + ).to.equal(apiResponse["1"]["2"][0].id); + }); + }); + + describe("when using IAB Content Taxonomy 3.0", function () { + it("should correctly structure the bids object after a successful API response", function () { + const apiResponse = { + 7: { 1: [{ id: "80DV8O" }], 2: [{ id: "90" }], 3: [{ id: "106" }] }, + 4: { 3: [{ id: "49" }, { id: "780" }], 4: [{ id: "431" }], 5: [{ id: "98" }] }, + }; + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.iabContentTaxonomyVersion = "3.0"; + conf.params.websiteToAnalyseUrl = + "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should be a string").to.be.a("string"); + expect(request.url, "The request URL should include the public API token").to.include( + conf.params.neuwoApiToken + ); + expect(request.url, "The request URL should include the encoded website URL").to.include( + encodeURIComponent(conf.params.websiteToAnalyseUrl) + ); + expect(request.url, "The request URL should include the product identifier").to.include( + "_neuwo_prod=PrebidModule" + ); + expect(request.url, "API should include iabVersions parameter for segtax 7").to.include( + "iabVersions=7" + ); + expect(request.url, "API should include iabVersions parameter for segtax 4").to.include( + "iabVersions=4" + ); + expect(request.method, "API should use GET method").to.equal("GET"); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; + expect(contentData.name, "The data provider name should be correctly set").to.equal( + neuwo.DATA_PROVIDER + ); + expect( + contentData.ext.segtax, + "The segtax value should correspond to IAB Content Taxonomy 3.0" + ).to.equal(7); + expect( + contentData.segment[0].id, + "The first segment ID should match the API response" + ).to.equal(apiResponse["7"]["1"][0].id); + expect( + contentData.segment[1].id, + "The second segment ID should match the API response" + ).to.equal(apiResponse["7"]["2"][0].id); + }); + }); + + describe("when using IAB Audience Taxonomy 1.1", function () { + it("should correctly structure the bids object after a successful API response", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig = bidsConfiglike(); + const conf = config(); + // control xhr api request target for testing + conf.params.websiteToAnalyseUrl = + "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should be a string").to.be.a("string"); + expect(request.url, "The request URL should include the public API token").to.include( + conf.params.neuwoApiToken + ); + expect(request.url, "The request URL should include the encoded website URL").to.include( + encodeURIComponent(conf.params.websiteToAnalyseUrl) + ); + expect(request.method, "API should use GET method").to.equal("GET"); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + const userData = bidsConfig.ortb2Fragments.global.user.data[0]; + expect(userData.name, "The data provider name should be correctly set").to.equal( + neuwo.DATA_PROVIDER + ); + expect( + userData.ext.segtax, + "The segtax value should correspond to IAB Audience Taxonomy 1.1" + ).to.equal(4); + expect( + userData.segment[0].id, + "The first segment ID should match the API response (tier 3, first item)" + ).to.equal(apiResponse["4"]["3"][0].id); + expect( + userData.segment[1].id, + "The second segment ID should match the API response (tier 3, second item)" + ).to.equal(apiResponse["4"]["3"][1].id); + }); + }); + + it("should not change the bids object structure after an unsuccessful API response", function () { + const bidsConfig = bidsConfiglike(); + const bidsConfigCopy = bidsConfiglike(); + const conf = config(); + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + request.respond( + 404, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify({ detail: "test error" }) + ); + expect( + bidsConfig, + "The bids config object should remain unmodified after a failed API call" + ).to.deep.equal(bidsConfigCopy); + }); + + // OpenRTB 2.5 Feature Tests + describe("OpenRTB 2.5 category fields", function () { + describe("with enableOrtb25Fields enabled (default)", function () { + it("should include iabVersions=1 parameter in API request", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "should include iabVersions=1").to.include("iabVersions=1"); + expect(request.url, "should include iabVersions=6").to.include("iabVersions=6"); + expect(request.url, "should include iabVersions=4").to.include("iabVersions=4"); + }); + + it("should not duplicate iabVersions=1 when iabContentTaxonomyVersion is 1.0", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + conf.params.iabContentTaxonomyVersion = "1.0"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + const matches = request.url.match(/iabVersions=1(?!\d)/g) || []; + expect(matches.length, "iabVersions=1 should appear exactly once").to.equal(1); + expect(request.url, "should still include iabVersions=4").to.include("iabVersions=4"); + }); + + it("should inject category fields when API returns IAB 1.0 data", function () { + const apiResponse = { + "1": { + "1": [{ id: "IAB12" }], + "2": [{ id: "IAB12-3" }] + }, + "6": { + "1": [{ id: "52" }] + }, + "4": { + "3": [{ id: "49" }] + } + }; + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + + expect(siteCat, "should have site.cat").to.deep.equal(["IAB12", "IAB12-3"]); + expect(contentData, "should have content data").to.exist; + }); + + it("should send IAB 1.0 filter configuration in URL parameters", function () { + const apiResponse = { + "1": { + "1": [{ id: "IAB12" }], + "2": [{ id: "IAB12-3" }] + }, + "6": { + "1": [{ id: "52" }] + } + }; + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + conf.params.iabTaxonomyFilters = { + ContentTier1: { limit: 2 } + }; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + + const request = server.requests[0]; + expect(request.url, "should have filter for segtax 1 tier 1").to.include("filter_1_1_limit=2"); + expect(request.url, "should have filter for segtax 6 tier 1").to.include("filter_6_1_limit=2"); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + expect(siteCat, "should inject category fields").to.deep.equal(["IAB12", "IAB12-3"]); + }); + }); + + describe("with enableOrtb25Fields disabled", function () { + it("should not include iabVersions=1 parameter in API request", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + conf.params.enableOrtb25Fields = false; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "should not include iabVersions=1").to.not.include("iabVersions=1"); + expect(request.url, "should still include iabVersions=6").to.include("iabVersions=6"); + }); + + it("should not inject category fields even if API returns IAB 1.0 data", function () { + const apiResponse = { + "1": { + "1": [{ id: "IAB12" }] + }, + "6": { + "1": [{ id: "52" }] + } + }; + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + conf.params.enableOrtb25Fields = false; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + + expect(siteCat, "should not have site.cat").to.be.undefined; + expect(contentData, "should still have content data").to.exist; + }); + + it("should not send IAB 1.0 filters in URL parameters", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + conf.params.enableOrtb25Fields = false; + conf.params.iabTaxonomyFilters = { + ContentTier1: { limit: 3 }, + ContentTier2: { limit: 5 } + }; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "should not have segtax 1 filters").to.not.match(/filter_1_/); + expect(request.url, "should have segtax 6 filters").to.include("filter_6_1_limit=3"); + expect(request.url, "should have segtax 6 tier 2 filters").to.include("filter_6_2_limit=5"); + }); + }); + }); + }); + + describe("cleanUrl", function () { + describe("when no stripping options are provided", function () { + it("should return the URL unchanged", function () { + const url = "https://example.com/page?foo=bar&baz=qux"; + const result = neuwo.cleanUrl(url, {}); + expect(result, "should return the original URL with all query params intact").to.equal(url); + }); + + it("should return the URL unchanged when options object is empty", function () { + const url = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url); + expect(result, "should handle missing options parameter").to.equal(url); + }); + }); + + describe("with query parameters edge cases", function () { + it("should strip all query parameters from the URL for `stripAllQueryParams` (edge cases)", function () { + const stripAll = (url) => neuwo.cleanUrl(url, { stripAllQueryParams: true }); + const expected = "https://example.com/page"; + const expectedWithFragment = "https://example.com/page#anchor"; + + // Basic formats + expect(stripAll("https://example.com/page?key=value"), "should remove basic key=value params").to.equal(expected); + expect(stripAll("https://example.com/page?key="), "should remove params with empty value").to.equal(expected); + expect(stripAll("https://example.com/page?key"), "should remove params without equals sign").to.equal(expected); + expect(stripAll("https://example.com/page?=value"), "should remove params with empty key").to.equal(expected); + + // Multiple parameters + expect(stripAll("https://example.com/page?key1=value1&key2=value2"), "should remove multiple different params").to.equal(expected); + expect(stripAll("https://example.com/page?key=value1&key=value2"), "should remove multiple params with same key").to.equal(expected); + + // Special characters and encoding + expect(stripAll("https://example.com/page?key=value%20with%20spaces"), "should remove URL encoded spaces").to.equal(expected); + expect(stripAll("https://example.com/page?key=value+with+plus"), "should remove plus as space").to.equal(expected); + expect(stripAll("https://example.com/page?key=value%3D%26%3F"), "should remove encoded special chars").to.equal(expected); + expect(stripAll("https://example.com/page?key=%"), "should remove incomplete encoding").to.equal(expected); + expect(stripAll("https://example.com/page?key=value%2"), "should remove malformed encoding").to.equal(expected); + + // Delimiters and syntax edge cases + expect(stripAll("https://example.com/page?&key=value"), "should remove params with leading ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value&"), "should remove params with trailing ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value&&key2=value2"), "should remove params with double ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value?key2=value2"), "should remove params with question mark delimiter").to.equal(expected); + expect(stripAll("https://example.com/page?key=value;key2=value2"), "should remove params with semicolon delimiter").to.equal(expected); + + // Empty and missing cases + expect(stripAll("https://example.com/page?"), "should remove question mark alone").to.equal(expected); + expect(stripAll("https://example.com/page??"), "should remove double question mark").to.equal(expected); + expect(stripAll("https://example.com/page"), "should handle URL without query string").to.equal(expected); + + // Unicode and special values + expect(stripAll("https://example.com/page?key=值"), "should remove unicode characters").to.equal(expected); + expect(stripAll("https://example.com/page?key=null"), "should remove string 'null'").to.equal(expected); + expect(stripAll("https://example.com/page?key=undefined"), "should remove string 'undefined'").to.equal(expected); + + // Fragment positioning (fragments are preserved by default) + expect(stripAll("https://example.com/page?key=value#anchor"), "should remove query params and preserve fragment").to.equal(expectedWithFragment); + expect(stripAll("https://example.com/page#anchor?key=value"), "should preserve fragment before params").to.equal("https://example.com/page#anchor?key=value"); + }); + + it("should strip all query parameters from the URL for `stripQueryParamsForDomains` (edge cases)", function () { + const stripAll = (url) => neuwo.cleanUrl(url, { stripQueryParamsForDomains: ["example.com"] }); + const expected = "https://example.com/page"; + const expectedWithFragment = "https://example.com/page#anchor"; + + // Basic formats + expect(stripAll("https://example.com/page?key=value"), "should remove basic key=value params").to.equal(expected); + expect(stripAll("https://example.com/page?key="), "should remove params with empty value").to.equal(expected); + expect(stripAll("https://example.com/page?key"), "should remove params without equals sign").to.equal(expected); + expect(stripAll("https://example.com/page?=value"), "should remove params with empty key").to.equal(expected); + + // Multiple parameters + expect(stripAll("https://example.com/page?key1=value1&key2=value2"), "should remove multiple different params").to.equal(expected); + expect(stripAll("https://example.com/page?key=value1&key=value2"), "should remove multiple params with same key").to.equal(expected); + + // Special characters and encoding + expect(stripAll("https://example.com/page?key=value%20with%20spaces"), "should remove URL encoded spaces").to.equal(expected); + expect(stripAll("https://example.com/page?key=value+with+plus"), "should remove plus as space").to.equal(expected); + expect(stripAll("https://example.com/page?key=value%3D%26%3F"), "should remove encoded special chars").to.equal(expected); + expect(stripAll("https://example.com/page?key=%"), "should remove incomplete encoding").to.equal(expected); + expect(stripAll("https://example.com/page?key=value%2"), "should remove malformed encoding").to.equal(expected); + + // Delimiters and syntax edge cases + expect(stripAll("https://example.com/page?&key=value"), "should remove params with leading ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value&"), "should remove params with trailing ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value&&key2=value2"), "should remove params with double ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value?key2=value2"), "should remove params with question mark delimiter").to.equal(expected); + expect(stripAll("https://example.com/page?key=value;key2=value2"), "should remove params with semicolon delimiter").to.equal(expected); + + // Empty and missing cases + expect(stripAll("https://example.com/page?"), "should remove question mark alone").to.equal(expected); + expect(stripAll("https://example.com/page??"), "should remove double question mark").to.equal(expected); + expect(stripAll("https://example.com/page"), "should handle URL without query string").to.equal(expected); + + // Unicode and special values + expect(stripAll("https://example.com/page?key=值"), "should remove unicode characters").to.equal(expected); + expect(stripAll("https://example.com/page?key=null"), "should remove string 'null'").to.equal(expected); + expect(stripAll("https://example.com/page?key=undefined"), "should remove string 'undefined'").to.equal(expected); + + // Fragment positioning (fragments are preserved by default) + expect(stripAll("https://example.com/page?key=value#anchor"), "should remove query params and preserve fragment").to.equal(expectedWithFragment); + expect(stripAll("https://example.com/page#anchor?key=value"), "should preserve fragment before params").to.equal("https://example.com/page#anchor?key=value"); + }); + + it("should strip all query parameters from the URL for `stripQueryParams` (edge cases)", function () { + const stripAll = (url) => neuwo.cleanUrl(url, { stripQueryParams: ["key", "key1", "key2", "", "?"] }); + const expected = "https://example.com/page"; + const expectedWithFragment = "https://example.com/page#anchor"; + + // Basic formats + expect(stripAll("https://example.com/page?key=value"), "should remove basic key=value params").to.equal(expected); + expect(stripAll("https://example.com/page?key="), "should remove params with empty value").to.equal(expected); + expect(stripAll("https://example.com/page?key"), "should remove params without equals sign").to.equal(expected); + expect(stripAll("https://example.com/page?=value"), "should remove params with empty key").to.equal(expected); + + // Multiple parameters + expect(stripAll("https://example.com/page?key1=value1&key2=value2"), "should remove multiple different params").to.equal(expected); + expect(stripAll("https://example.com/page?key=value1&key=value2"), "should remove multiple params with same key").to.equal(expected); + + // Special characters and encoding + expect(stripAll("https://example.com/page?key=value%20with%20spaces"), "should remove URL encoded spaces").to.equal(expected); + expect(stripAll("https://example.com/page?key=value+with+plus"), "should remove plus as space").to.equal(expected); + expect(stripAll("https://example.com/page?key=value%3D%26%3F"), "should remove encoded special chars").to.equal(expected); + expect(stripAll("https://example.com/page?key=%"), "should remove incomplete encoding").to.equal(expected); + expect(stripAll("https://example.com/page?key=value%2"), "should remove malformed encoding").to.equal(expected); + + // Delimiters and syntax edge cases + expect(stripAll("https://example.com/page?&key=value"), "should remove params with leading ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value&"), "should remove params with trailing ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value&&key2=value2"), "should remove params with double ampersand").to.equal(expected); + expect(stripAll("https://example.com/page?key=value?key2=value2"), "should remove params with question mark delimiter").to.equal(expected); + expect(stripAll("https://example.com/page?key=value;key2=value2"), "should remove params with semicolon delimiter").to.equal(expected); + + // Empty and missing cases + expect(stripAll("https://example.com/page?"), "should remove question mark alone").to.equal(expected); + expect(stripAll("https://example.com/page"), "should handle URL without query string").to.equal(expected); + + // Unicode and special values + expect(stripAll("https://example.com/page?key=值"), "should remove unicode characters").to.equal(expected); + expect(stripAll("https://example.com/page?key=null"), "should remove string 'null'").to.equal(expected); + expect(stripAll("https://example.com/page?key=undefined"), "should remove string 'undefined'").to.equal(expected); + + // Fragment positioning (fragments are preserved by default) + expect(stripAll("https://example.com/page?key=value#anchor"), "should remove query params and preserve fragment").to.equal(expectedWithFragment); + expect(stripAll("https://example.com/page#anchor?key=value"), "should preserve fragment before params").to.equal("https://example.com/page#anchor?key=value"); + }); + }); + + describe("when stripAllQueryParams is true", function () { + it("should strip all query parameters from the URL", function () { + const url = "https://example.com/page?foo=bar&baz=qux&test=123"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); + expect(result, "should remove all query parameters").to.equal(expected); + }); + + it("should return the URL unchanged if there are no query parameters", function () { + const url = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); + expect(result, "should handle URLs without query params").to.equal(url); + }); + + it("should preserve the hash fragment when stripping query params without stripFragments", function () { + const url = "https://example.com/page?foo=bar#section"; + const expected = "https://example.com/page#section"; + const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); + expect(result, "should preserve hash fragments by default").to.equal(expected); + }); + + it("should strip hash fragment when stripFragments is enabled", function () { + const url = "https://example.com/page?foo=bar#section"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { stripAllQueryParams: true, stripFragments: true }); + expect(result, "should strip both query params and fragments").to.equal(expected); + }); + + it("should strip query params but preserve path and protocol", function () { + const url = "https://subdomain.example.com:8080/path/to/page?param=value"; + const expected = "https://subdomain.example.com:8080/path/to/page"; + const result = neuwo.cleanUrl(url, { stripAllQueryParams: true }); + expect(result, "should preserve protocol, domain, port, and path").to.equal(expected); + }); + }); + + describe("when stripQueryParamsForDomains is provided", function () { + it("should strip all query params for exact domain match", function () { + const url = "https://example.com/page?foo=bar&baz=qux"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"] + }); + expect(result, "should strip params for exact domain match").to.equal(expected); + }); + + it("should strip all query params for subdomain match", function () { + const url = "https://sub.example.com/page?foo=bar"; + const expected = "https://sub.example.com/page"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"] + }); + expect(result, "should strip params for subdomains").to.equal(expected); + }); + + it("should not strip query params if domain does not match", function () { + const url = "https://other.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"] + }); + expect(result, "should preserve params for non-matching domains").to.equal(url); + }); + + it("should not strip query params if subdomain is provided for domain", function () { + const url = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["sub.example.com"] + }); + expect(result, "should preserve params for domain when subdomain is provided").to.equal(url); + }); + + it("should handle multiple domains in the list", function () { + const url1 = "https://example.com/page?foo=bar"; + const url2 = "https://test.com/page?foo=bar"; + const url3 = "https://other.com/page?foo=bar"; + const domains = ["example.com", "test.com"]; + + const result1 = neuwo.cleanUrl(url1, { stripQueryParamsForDomains: domains }); + const result2 = neuwo.cleanUrl(url2, { stripQueryParamsForDomains: domains }); + const result3 = neuwo.cleanUrl(url3, { stripQueryParamsForDomains: domains }); + + expect(result1, "should strip params for first domain").to.equal("https://example.com/page"); + expect(result2, "should strip params for second domain").to.equal("https://test.com/page"); + expect(result3, "should preserve params for non-listed domain").to.equal(url3); + }); + + it("should handle deep subdomains correctly", function () { + const url = "https://deep.sub.example.com/page?foo=bar"; + const expected = "https://deep.sub.example.com/page"; + const result1 = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"] + }); + const result2 = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["sub.example.com"] + }); + expect(result1, "should strip params for deep subdomains with domain matching").to.equal(expected); + expect(result2, "should strip params for deep subdomains with subdomain matching").to.equal(expected); + }); + + it("should not match partial domain names", function () { + const url = "https://notexample.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"] + }); + expect(result, "should not match partial domain strings").to.equal(url); + }); + + it("should handle empty domain list", function () { + const url = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { stripQueryParamsForDomains: [] }); + expect(result, "should not strip params with empty domain list").to.equal(url); + }); + }); + + describe("when stripQueryParams is provided", function () { + it("should strip only specified query parameters", function () { + const url = "https://example.com/page?foo=bar&baz=qux&keep=this"; + const expected = "https://example.com/page?keep=this"; + const result = neuwo.cleanUrl(url, { + stripQueryParams: ["foo", "baz"] + }); + expect(result, "should remove only specified params").to.equal(expected); + }); + + it("should handle single parameter stripping", function () { + const url = "https://example.com/page?remove=this&keep=that"; + const expected = "https://example.com/page?keep=that"; + const result = neuwo.cleanUrl(url, { + stripQueryParams: ["remove"] + }); + expect(result, "should remove single specified param").to.equal(expected); + }); + + it("should return URL without query string if all params are stripped", function () { + const url = "https://example.com/page?foo=bar&baz=qux"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { + stripQueryParams: ["foo", "baz"] + }); + expect(result, "should remove query string when all params stripped").to.equal(expected); + }); + + it("should handle case where specified params do not exist", function () { + const url = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { + stripQueryParams: ["nonexistent", "alsonothere"] + }); + expect(result, "should handle non-existent params gracefully").to.equal(url); + }); + + it("should handle empty param list", function () { + const url = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { stripQueryParams: [] }); + expect(result, "should not strip params with empty list").to.equal(url); + }); + + it("should preserve param order for remaining params", function () { + const url = "https://example.com/page?a=1&b=2&c=3&d=4"; + const result = neuwo.cleanUrl(url, { + stripQueryParams: ["b", "d"] + }); + expect(result, "should preserve order of remaining params").to.include("a=1"); + expect(result, "should preserve order of remaining params").to.include("c=3"); + expect(result, "should not include stripped param b").to.not.include("b=2"); + expect(result, "should not include stripped param d").to.not.include("d=4"); + }); + }); + + describe("error handling", function () { + it("should return null or undefined input unchanged", function () { + expect(neuwo.cleanUrl(null, {}), "should handle null input").to.equal(null); + expect(neuwo.cleanUrl(undefined, {}), "should handle undefined input").to.equal(undefined); + expect(neuwo.cleanUrl("", {}), "should handle empty string").to.equal(""); + }); + + it("should return invalid URL unchanged and log error", function () { + const invalidUrl = "not-a-valid-url"; + const result = neuwo.cleanUrl(invalidUrl, { stripAllQueryParams: true }); + expect(result, "should return invalid URL unchanged").to.equal(invalidUrl); + }); + + it("should handle malformed URLs gracefully", function () { + const malformedUrl = "http://"; + const result = neuwo.cleanUrl(malformedUrl, { stripAllQueryParams: true }); + expect(result, "should return malformed URL unchanged").to.equal(malformedUrl); + }); + }); + + describe("when stripFragments is enabled", function () { + it("should strip URL fragments from URLs without query params", function () { + const url = "https://example.com/page#section"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { stripFragments: true }); + expect(result, "should remove hash fragment").to.equal(expected); + }); + + it("should strip URL fragments from URLs with query params", function () { + const url = "https://example.com/page?foo=bar#section"; + const expected = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { stripFragments: true }); + expect(result, "should remove hash fragment and preserve query params").to.equal(expected); + }); + + it("should strip fragments when combined with stripAllQueryParams", function () { + const url = "https://example.com/page?foo=bar#section"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { stripAllQueryParams: true, stripFragments: true }); + expect(result, "should remove both query params and fragment").to.equal(expected); + }); + + it("should strip fragments when combined with stripQueryParamsForDomains", function () { + const url = "https://example.com/page?foo=bar#section"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"], + stripFragments: true + }); + expect(result, "should remove both query params and fragment for matching domain").to.equal(expected); + }); + + it("should strip fragments when combined with stripQueryParams", function () { + const url = "https://example.com/page?foo=bar&keep=this#section"; + const expected = "https://example.com/page?keep=this"; + const result = neuwo.cleanUrl(url, { + stripQueryParams: ["foo"], + stripFragments: true + }); + expect(result, "should remove specified query params and fragment").to.equal(expected); + }); + + it("should handle URLs without fragments gracefully", function () { + const url = "https://example.com/page?foo=bar"; + const expected = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { stripFragments: true }); + expect(result, "should handle URLs without fragments").to.equal(expected); + }); + + it("should handle empty fragments", function () { + const url = "https://example.com/page#"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { stripFragments: true }); + expect(result, "should remove empty fragment").to.equal(expected); + }); + + it("should handle complex fragments with special characters", function () { + const url = "https://example.com/page?foo=bar#section-1/subsection?query"; + const expected = "https://example.com/page?foo=bar"; + const result = neuwo.cleanUrl(url, { stripFragments: true }); + expect(result, "should remove complex fragments").to.equal(expected); + }); + }); + + describe("option priority", function () { + it("should apply stripAllQueryParams first when multiple options are set", function () { + const url = "https://example.com/page?foo=bar&baz=qux"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { + stripAllQueryParams: true, + stripQueryParams: ["foo"] + }); + expect(result, "stripAllQueryParams should take precedence").to.equal(expected); + }); + + it("should apply stripQueryParamsForDomains before stripQueryParams", function () { + const url = "https://example.com/page?foo=bar&baz=qux"; + const expected = "https://example.com/page"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"], + stripQueryParams: ["foo"] + }); + expect(result, "domain-specific stripping should take precedence").to.equal(expected); + }); + + it("should not strip for non-matching domain even with stripQueryParams set", function () { + const url = "https://other.com/page?foo=bar&baz=qux"; + const expected = "https://other.com/page?baz=qux"; + const result = neuwo.cleanUrl(url, { + stripQueryParamsForDomains: ["example.com"], + stripQueryParams: ["foo"] + }); + expect(result, "should fall through to stripQueryParams for non-matching domain").to.equal(expected); + }); + }); + }); + + // Integration Tests + describe("injectIabCategories edge cases and merging", function () { + it("should not inject data if response contains no segments", function () { + const apiResponse = { "6": {}, "4": {} }; // Empty response (no segments) + const bidsConfig = bidsConfiglike(); + const bidsConfigCopy = bidsConfiglike(); + const conf = config(); + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // After a successful response with no segments, the global ortb2 fragments should remain empty + // as the data injection logic only injects when segments exist + expect( + bidsConfig.ortb2Fragments.global, + "The global ORTB fragments should remain empty" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + }); + + it("should append content and user data to existing ORTB fragments", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig = bidsConfiglike(); + // Simulate existing first-party data from another source/module + const existingContentData = { name: "other_content_provider", segment: [{ id: "1" }] }; + const existingUserData = { name: "other_user_provider", segment: [{ id: "2" }] }; + + bidsConfig.ortb2Fragments.global = { + site: { + content: { + data: [existingContentData], + }, + }, + user: { + data: [existingUserData], + }, + }; + const conf = config(); + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const siteData = bidsConfig.ortb2Fragments.global.site.content.data; + const userData = bidsConfig.ortb2Fragments.global.user.data; + + // Check that the existing data is still there (index 0) + expect(siteData[0], "Existing site.content.data should be preserved").to.deep.equal( + existingContentData + ); + expect(userData[0], "Existing user.data should be preserved").to.deep.equal(existingUserData); + + // Check that the new Neuwo data is appended (index 1) + expect(siteData.length, "site.content.data array should have 2 entries").to.equal(2); + expect(userData.length, "user.data array should have 2 entries").to.equal(2); + expect(siteData[1].name, "The appended content data should be from Neuwo").to.equal( + neuwo.DATA_PROVIDER + ); + expect(userData[1].name, "The appended user data should be from Neuwo").to.equal( + neuwo.DATA_PROVIDER + ); + }); + + it("should correctly construct API URL when neuwoApiUrl already contains query parameters", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig = bidsConfiglike(); + const conf = config(); + // Set API URL that already has query parameters + conf.params.neuwoApiUrl = "https://edge.neuwo.ai/api/aitopics/edge/v1/iab?environment=production"; + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + // Should use & as joiner instead of ? + expect(request.url, "URL should contain environment param from base URL").to.include("environment=production"); + expect(request.url, "URL should contain token param joined with &").to.include("&token="); + expect(request.url, "URL should contain url param").to.include("&url="); + expect(request.url, "URL should contain product identifier").to.include("&_neuwo_prod=PrebidModule"); + expect(request.url, "URL should include iabVersions parameter").to.include("iabVersions="); + // Should not have ?? in the URL + expect(request.url, "URL should not contain double question marks").to.not.include("??"); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; + expect(contentData.name, "Should successfully process response").to.equal(neuwo.DATA_PROVIDER); + }); + + it("should treat a legacy URL with /v1/iab in query params as a legacy endpoint", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + // Proxy URL where /v1/iab appears in query params, not the path + conf.params.neuwoApiUrl = "https://proxy.example.com/api?redirect=/v1/iab"; + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + // Legacy endpoints should NOT include iabVersions params + expect(request.url, "should not include iabVersions for legacy endpoint").to.not.include("iabVersions="); + // Should still include token and url params + expect(request.url, "should include token param").to.include("token="); + expect(request.url, "should include url param").to.include("url="); + }); + + it("should detect /v1/iab endpoint from a malformed URL using fallback parsing", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + // Malformed URL that causes new URL() to throw, but has /v1/iab in path + conf.params.neuwoApiUrl = "/v1/iab"; + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + // Fallback parser should detect /v1/iab in path portion and treat as IAB endpoint + expect(request.url, "should include iabVersions for IAB endpoint").to.include("iabVersions="); + }); + }); + + describe("getBidRequestData with caching", function () { + describe("when enableCache is true (default)", function () { + it("should cache the API response and reuse it on subsequent calls", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=1"; + + // First call should make an API request + neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); + + const request1 = server.requests[0]; + request1.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // Second call should use cached response (no new API request) + neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); + expect(server.requests.length, "Second call should not make a new API request").to.equal(1); + + // Both configs should have identical data (second served from cache) + const contentData1 = bidsConfig1.ortb2Fragments.global.site.content.data[0]; + const contentData2 = bidsConfig2.ortb2Fragments.global.site.content.data[0]; + expect(contentData1, "First config should have Neuwo data").to.exist; + expect(contentData2, "Second config should have Neuwo data from cache").to.exist; + expect(contentData1.name, "First config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + expect(contentData2.name, "Second config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + expect(contentData1.segment, "Cached data should have same segments as original").to.deep.equal(contentData2.segment); + }); + + it("should cache when enableCache is explicitly set to true", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=2"; + conf.params.enableCache = true; + + // First call + neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); + + const request1 = server.requests[0]; + request1.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // Second call should use cache + neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); + expect(server.requests.length, "Second call should use cached response").to.equal(1); + }); + + it("should handle concurrent requests by sharing a pending request promise", function (done) { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=concurrent"; + conf.params.enableCache = true; + + let callbackCount = 0; + const callback = () => { + callbackCount++; + if (callbackCount === 3) { + // All callbacks have been called, now verify the data + try { + const contentData1 = bidsConfig1.ortb2Fragments.global.site.content.data[0]; + const contentData2 = bidsConfig2.ortb2Fragments.global.site.content.data[0]; + const contentData3 = bidsConfig3.ortb2Fragments.global.site.content.data[0]; + + expect(contentData1, "First config should have Neuwo data").to.exist; + expect(contentData2, "Second config should have Neuwo data from pending request").to.exist; + expect(contentData3, "Third config should have Neuwo data from pending request").to.exist; + expect(contentData1.name, "First config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + expect(contentData2.name, "Second config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + expect(contentData3.name, "Third config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + done(); + } catch (e) { + done(e); + } + } + }; + + // Make three concurrent calls before responding to the first request + neuwo.getBidRequestData(bidsConfig1, callback, conf, "consent data"); + neuwo.getBidRequestData(bidsConfig2, callback, conf, "consent data"); + neuwo.getBidRequestData(bidsConfig3, callback, conf, "consent data"); + + // Only one API request should be made + expect(server.requests.length, "Only one API request should be made for concurrent calls").to.equal(1); + + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + }); + + it("should transition through all three cache states: pending request, then cached response", function (done) { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=three-stage"; + conf.params.enableCache = true; + + let callback1and2Count = 0; + + const callback1and2 = () => { + callback1and2Count++; + if (callback1and2Count === 2) { + // Both first and second callbacks have been called + // Stage 3: Third request should use cached response (not pending request) + neuwo.getBidRequestData(bidsConfig3, () => { + try { + expect(server.requests.length, "Third call should use cache and not make a new API request").to.equal(1); + + // All three configs should have the same data + const contentData1 = bidsConfig1.ortb2Fragments.global.site.content.data[0]; + const contentData2 = bidsConfig2.ortb2Fragments.global.site.content.data[0]; + const contentData3 = bidsConfig3.ortb2Fragments.global.site.content.data[0]; + + expect(contentData1, "First config should have Neuwo data").to.exist; + expect(contentData2, "Second config should have Neuwo data from pending request").to.exist; + expect(contentData3, "Third config should have Neuwo data from cache").to.exist; + expect(contentData1.name, "First config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + expect(contentData2.name, "Second config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + expect(contentData3.name, "Third config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + done(); + } catch (e) { + done(e); + } + }, conf, "consent data"); + } + }; + + // Stage 1: First request initiates API call (creates pending request) + neuwo.getBidRequestData(bidsConfig1, callback1and2, conf, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); + + // Stage 2: Second request should attach to pending request before response + neuwo.getBidRequestData(bidsConfig2, callback1and2, conf, "consent data"); + expect(server.requests.length, "Second call should not make a new API request").to.equal(1); + + // Respond to the API request, which populates the cache + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + }); + + it("should not share cache between requests with different parameters", function (done) { + const apiResponse1 = getNeuwoApiResponse(); + const apiResponse2 = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf1 = config(); + conf1.params.websiteToAnalyseUrl = "https://publisher.works/page-a"; + conf1.params.enableCache = true; + const conf2 = config(); + conf2.params.websiteToAnalyseUrl = "https://publisher.works/page-b"; + conf2.params.enableCache = true; + + let callbackCount = 0; + const callback = () => { + callbackCount++; + if (callbackCount === 2) { + try { + // Both should have made separate API requests + expect(server.requests.length, "Should make two separate API requests for different URLs").to.equal(2); + expect(server.requests[0].url).to.contain("page-a"); + expect(server.requests[1].url).to.contain("page-b"); + done(); + } catch (e) { + done(e); + } + } + }; + + // Two concurrent calls with different URLs + neuwo.getBidRequestData(bidsConfig1, callback, conf1, "consent data"); + neuwo.getBidRequestData(bidsConfig2, callback, conf2, "consent data"); + + expect(server.requests.length, "Should make two separate API requests").to.equal(2); + + server.requests[0].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse1) + ); + server.requests[1].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse2) + ); + }); + + it("should use cache for same URL but make new request after config change", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/page-a"; + conf.params.enableCache = true; + + // First call + neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); + expect(server.requests.length).to.equal(1); + server.requests[0].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // Second call with same URL - should use cache + neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); + expect(server.requests.length, "Same URL should use cache").to.equal(1); + + // Third call with different URL (simulating config change) - should make new request + conf.params.websiteToAnalyseUrl = "https://publisher.works/page-b"; + neuwo.getBidRequestData(bidsConfig3, () => {}, conf, "consent data"); + expect(server.requests.length, "Different URL should make new request").to.equal(2); + expect(server.requests[1].url).to.contain("page-b"); + }); + + it("should not share cache when iabContentTaxonomyVersion changes", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf.params.enableCache = true; + conf.params.iabContentTaxonomyVersion = "2.2"; + + // First call with taxonomy 2.2 + neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); + expect(server.requests.length).to.equal(1); + expect(server.requests[0].url).to.contain("iabVersions=6"); // segtax 6 = taxonomy 2.2 + server.requests[0].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // Second call with same taxonomy - should use cache + neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); + expect(server.requests.length, "Same taxonomy version should use cache").to.equal(1); + + // Third call with different taxonomy version - should make new request + conf.params.iabContentTaxonomyVersion = "3.0"; + neuwo.getBidRequestData(bidsConfig3, () => {}, conf, "consent data"); + expect(server.requests.length, "Different taxonomy version should make new request").to.equal(2); + expect(server.requests[1].url).to.contain("iabVersions=7"); // segtax 7 = taxonomy 3.0 + }); + + it("should evict the oldest cache entry when MAX_CACHE_ENTRIES is exceeded", async function () { + const apiResponse = getNeuwoApiResponse(); + const conf = config(); + conf.params.enableCache = true; + + // Fill cache with 10 entries (MAX_CACHE_ENTRIES) + for (let i = 0; i < 10; i++) { + const bidsConfig = bidsConfiglike(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/page-" + i; + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + server.requests[i].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + } + // Flush microtasks so pendingRequests are cleaned up via .finally() handlers + await Promise.resolve(); + + expect(server.requests.length, "Should have made 10 API requests").to.equal(10); + + // Verify page-0 is cached + const bidsConfigCached = bidsConfiglike(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/page-0"; + neuwo.getBidRequestData(bidsConfigCached, () => {}, conf, "consent data"); + expect(server.requests.length, "page-0 should be served from cache").to.equal(10); + + // Add 11th entry to trigger eviction of the oldest (page-0) + const bidsConfig11 = bidsConfiglike(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/page-10"; + neuwo.getBidRequestData(bidsConfig11, () => {}, conf, "consent data"); + expect(server.requests.length, "page-10 should trigger new request").to.equal(11); + server.requests[10].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + await Promise.resolve(); + + // page-0 should have been evicted and require a new request + const bidsConfigEvicted = bidsConfiglike(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/page-0"; + neuwo.getBidRequestData(bidsConfigEvicted, () => {}, conf, "consent data"); + expect(server.requests.length, "page-0 should be evicted and trigger new request").to.equal(12); + + // page-1 should still be cached + const bidsConfigStillCached = bidsConfiglike(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/page-1"; + neuwo.getBidRequestData(bidsConfigStillCached, () => {}, conf, "consent data"); + expect(server.requests.length, "page-1 should still be in cache").to.equal(12); + }); + }); + + describe("when enableCache is false", function () { + it("should not cache the API response and make a new request each time", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=3"; + conf.params.enableCache = false; + + // First call should make an API request + neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); + + const request1 = server.requests[0]; + request1.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // Second call should make a new API request (not use cache) + neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); + expect(server.requests.length, "Second call should make a new API request").to.equal(2); + + const request2 = server.requests[1]; + request2.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // Both configs should have the same data structure + const contentData1 = bidsConfig1.ortb2Fragments.global.site.content.data[0]; + const contentData2 = bidsConfig2.ortb2Fragments.global.site.content.data[0]; + expect(contentData1, "First config should have Neuwo data").to.exist; + expect(contentData2, "Second config should have Neuwo data from new request").to.exist; + expect(contentData1.name, "First config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + expect(contentData2.name, "Second config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + }); + + it("should bypass existing cache when enableCache is false", function () { + const apiResponse = getNeuwoApiResponse(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=4"; + + // First call with caching enabled (default) + neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); + + const request1 = server.requests[0]; + request1.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // Second call with caching enabled should use cache + neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); + expect(server.requests.length, "Second call should use cache").to.equal(1); + + // Third call with caching disabled should bypass cache + conf.params.enableCache = false; + neuwo.getBidRequestData(bidsConfig3, () => {}, conf, "consent data"); + expect(server.requests.length, "Third call should bypass cache and make new request").to.equal(2); + + const request2 = server.requests[1]; + request2.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); }); - it("should handle empty param list", function () { - const url = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { stripQueryParams: [] }); - expect(result, "should not strip params with empty list").to.equal(url); - }); + it("should clear pending request after error response and retry on next call", function (done) { + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=error-test"; + conf.params.enableCache = true; - it("should preserve param order for remaining params", function () { - const url = "https://example.com/page?a=1&b=2&c=3&d=4"; - const result = neuwo.cleanUrl(url, { - stripQueryParams: ["b", "d"] - }); - expect(result, "should preserve order of remaining params").to.include("a=1"); - expect(result, "should preserve order of remaining params").to.include("c=3"); - expect(result, "should not include stripped param b").to.not.include("b=2"); - expect(result, "should not include stripped param d").to.not.include("d=4"); - }); - }); + // First call - will get 404 error + neuwo.getBidRequestData(bidsConfig1, () => { + // After error, data should not be injected + const contentData = bidsConfig1.ortb2Fragments.global?.site?.content?.data; + expect(contentData, "No data should be injected after error").to.be.undefined; + + // Second call - should retry API (pending should be cleared) + neuwo.getBidRequestData(bidsConfig2, () => { + try { + expect(server.requests.length, "Second call should retry after error").to.equal(2); + const contentData2 = bidsConfig2.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData2, "Second call should have Neuwo data after retry").to.exist; + expect(contentData2.name, "Second call should have correct provider").to.equal(neuwo.DATA_PROVIDER); + done(); + } catch (e) { + done(e); + } + }, conf, "consent data"); + + // Respond with success to second request + const request2 = server.requests[1]; + request2.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(getNeuwoApiResponse()) + ); + }, conf, "consent data"); - describe("error handling", function () { - it("should return null or undefined input unchanged", function () { - expect(neuwo.cleanUrl(null, {}), "should handle null input").to.equal(null); - expect(neuwo.cleanUrl(undefined, {}), "should handle undefined input").to.equal(undefined); - expect(neuwo.cleanUrl("", {}), "should handle empty string").to.equal(""); - }); + expect(server.requests.length, "First call should make an API request").to.equal(1); - it("should return invalid URL unchanged and log error", function () { - const invalidUrl = "not-a-valid-url"; - const result = neuwo.cleanUrl(invalidUrl, { stripAllQueryParams: true }); - expect(result, "should return invalid URL unchanged").to.equal(invalidUrl); + // Respond with error to first request + const request1 = server.requests[0]; + request1.respond( + 404, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify({ error: "Not found" }) + ); }); - it("should handle malformed URLs gracefully", function () { - const malformedUrl = "http://"; - const result = neuwo.cleanUrl(malformedUrl, { stripAllQueryParams: true }); - expect(result, "should return malformed URL unchanged").to.equal(malformedUrl); + it("should handle concurrent requests when API returns error", function (done) { + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const bidsConfig3 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=concurrent-error"; + conf.params.enableCache = true; + + let callbackCount = 0; + const callback = () => { + callbackCount++; + if (callbackCount === 3) { + try { + // None of the configs should have data after error + const contentData1 = bidsConfig1.ortb2Fragments.global?.site?.content?.data; + const contentData2 = bidsConfig2.ortb2Fragments.global?.site?.content?.data; + const contentData3 = bidsConfig3.ortb2Fragments.global?.site?.content?.data; + + expect(contentData1, "First config should not have data after error").to.be.undefined; + expect(contentData2, "Second config should not have data after error").to.be.undefined; + expect(contentData3, "Third config should not have data after error").to.be.undefined; + done(); + } catch (e) { + done(e); + } + } + }; + + // Make three concurrent calls + neuwo.getBidRequestData(bidsConfig1, callback, conf, "consent data"); + neuwo.getBidRequestData(bidsConfig2, callback, conf, "consent data"); + neuwo.getBidRequestData(bidsConfig3, callback, conf, "consent data"); + + expect(server.requests.length, "Only one API request should be made").to.equal(1); + + // Respond with error + const request = server.requests[0]; + request.respond( + 500, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify({ error: "Internal server error" }) + ); }); - }); - describe("when stripFragments is enabled", function () { - it("should strip URL fragments from URLs without query params", function () { - const url = "https://example.com/page#section"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { stripFragments: true }); - expect(result, "should remove hash fragment").to.equal(expected); + it("should handle JSON parsing error in success callback", function (done) { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=parse-error"; + conf.params.enableCache = true; + + neuwo.getBidRequestData(bidsConfig, () => { + // Callback should still be called + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data; + expect(contentData, "No data should be injected after parsing error").to.be.undefined; + done(); + }, conf, "consent data"); + + expect(server.requests.length, "Should make an API request").to.equal(1); + + // Respond with invalid JSON + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + "{ invalid json content }" + ); }); - it("should strip URL fragments from URLs with query params", function () { - const url = "https://example.com/page?foo=bar#section"; - const expected = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { stripFragments: true }); - expect(result, "should remove hash fragment and preserve query params").to.equal(expected); + it("should not cache response after JSON parsing error and allow retry", function (done) { + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=parse-error-retry"; + conf.params.enableCache = true; + + neuwo.getBidRequestData(bidsConfig1, () => { + // First call with parsing error + const contentData1 = bidsConfig1.ortb2Fragments.global?.site?.content?.data; + expect(contentData1, "No data after parsing error").to.be.undefined; + + // Second call should retry (not use cached error) + neuwo.getBidRequestData(bidsConfig2, () => { + try { + expect(server.requests.length, "Should retry after parsing error").to.equal(2); + const contentData2 = bidsConfig2.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData2, "Second call should have valid data").to.exist; + expect(contentData2.name, "Should have correct provider").to.equal(neuwo.DATA_PROVIDER); + done(); + } catch (e) { + done(e); + } + }, conf, "consent data"); + + // Second request gets valid response + const request2 = server.requests[1]; + request2.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(getNeuwoApiResponse()) + ); + }, conf, "consent data"); + + // First request gets invalid JSON + const request1 = server.requests[0]; + request1.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + "{ this is not valid JSON }" + ); }); - it("should strip fragments when combined with stripAllQueryParams", function () { - const url = "https://example.com/page?foo=bar#section"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { stripAllQueryParams: true, stripFragments: true }); - expect(result, "should remove both query params and fragment").to.equal(expected); + it("should handle response with empty segments", function (done) { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=no-categories"; + + neuwo.getBidRequestData(bidsConfig, () => { + // Callback should still be called even with empty response + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data; + expect(contentData, "No data should be injected without segments").to.be.undefined; + done(); + }, conf, "consent data"); + + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify({ "6": {}, "4": {} }) // Empty segments + ); }); + }); - it("should strip fragments when combined with stripQueryParamsForDomains", function () { - const url = "https://example.com/page?foo=bar#section"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"], - stripFragments: true + describe("with URL query param stripping", function () { + describe("when stripAllQueryParams is enabled", function () { + it("should strip all query parameters from the analyzed URL", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?utm_source=test&utm_campaign=example&id=5"; + conf.params.stripAllQueryParams = true; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should not contain encoded query params").to.include( + encodeURIComponent("https://publisher.works/article.php") + ); + expect(request.url, "The request URL should not contain utm_source").to.not.include( + encodeURIComponent("utm_source") + ); }); - expect(result, "should remove both query params and fragment for matching domain").to.equal(expected); }); - it("should strip fragments when combined with stripQueryParams", function () { - const url = "https://example.com/page?foo=bar&keep=this#section"; - const expected = "https://example.com/page?keep=this"; - const result = neuwo.cleanUrl(url, { - stripQueryParams: ["foo"], - stripFragments: true + describe("when stripQueryParamsForDomains is enabled", function () { + it("should strip query params only for matching domains", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?foo=bar&id=5"; + conf.params.stripQueryParamsForDomains = ["publisher.works"]; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should contain the URL without query params").to.include( + encodeURIComponent("https://publisher.works/article.php") + ); + expect(request.url, "The request URL should not contain the id param").to.not.include( + encodeURIComponent("id=5") + ); }); - expect(result, "should remove specified query params and fragment").to.equal(expected); - }); - it("should handle URLs without fragments gracefully", function () { - const url = "https://example.com/page?foo=bar"; - const expected = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { stripFragments: true }); - expect(result, "should handle URLs without fragments").to.equal(expected); - }); + it("should not strip query params for non-matching domains", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://other-domain.com/page?foo=bar&id=5"; + conf.params.stripQueryParamsForDomains = ["publisher.works"]; - it("should handle empty fragments", function () { - const url = "https://example.com/page#"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { stripFragments: true }); - expect(result, "should remove empty fragment").to.equal(expected); - }); + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; - it("should handle complex fragments with special characters", function () { - const url = "https://example.com/page?foo=bar#section-1/subsection?query"; - const expected = "https://example.com/page?foo=bar"; - const result = neuwo.cleanUrl(url, { stripFragments: true }); - expect(result, "should remove complex fragments").to.equal(expected); - }); - }); + expect(request.url, "The request URL should contain the full URL with query params").to.include( + encodeURIComponent("https://other-domain.com/page?foo=bar&id=5") + ); + }); - describe("option priority", function () { - it("should apply stripAllQueryParams first when multiple options are set", function () { - const url = "https://example.com/page?foo=bar&baz=qux"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { - stripAllQueryParams: true, - stripQueryParams: ["foo"] + it("should handle subdomain matching correctly", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://sub.publisher.works/page?tracking=123"; + conf.params.stripQueryParamsForDomains = ["publisher.works"]; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should strip params for subdomain").to.include( + encodeURIComponent("https://sub.publisher.works/page") + ); + expect(request.url, "The request URL should not contain tracking param").to.not.include( + encodeURIComponent("tracking=123") + ); }); - expect(result, "stripAllQueryParams should take precedence").to.equal(expected); }); - it("should apply stripQueryParamsForDomains before stripQueryParams", function () { - const url = "https://example.com/page?foo=bar&baz=qux"; - const expected = "https://example.com/page"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"], - stripQueryParams: ["foo"] + describe("when stripQueryParams is enabled", function () { + it("should strip only specified query parameters", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?utm_source=test&utm_campaign=example&id=5"; + conf.params.stripQueryParams = ["utm_source", "utm_campaign"]; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should contain the id param").to.include( + encodeURIComponent("id=5") + ); + expect(request.url, "The request URL should not contain utm_source").to.not.include( + encodeURIComponent("utm_source") + ); + expect(request.url, "The request URL should not contain utm_campaign").to.not.include( + encodeURIComponent("utm_campaign") + ); }); - expect(result, "domain-specific stripping should take precedence").to.equal(expected); - }); - it("should not strip for non-matching domain even with stripQueryParams set", function () { - const url = "https://other.com/page?foo=bar&baz=qux"; - const expected = "https://other.com/page?baz=qux"; - const result = neuwo.cleanUrl(url, { - stripQueryParamsForDomains: ["example.com"], - stripQueryParams: ["foo"] + it("should handle stripping params that result in no query string", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?utm_source=test"; + conf.params.stripQueryParams = ["utm_source"]; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should not contain a query string").to.include( + encodeURIComponent("https://publisher.works/article.php") + ); + expect(request.url, "The request URL should not contain utm_source").to.not.include( + encodeURIComponent("utm_source") + ); }); - expect(result, "should fall through to stripQueryParams for non-matching domain").to.equal(expected); - }); - }); - }); - // Integration Tests - describe("injectIabCategories edge cases and merging", function () { - it("should not inject data if 'marketing_categories' is missing from the successful API response", function () { - const apiResponse = { brand_safety: { BS_score: "1.0" } }; // Missing marketing_categories - const bidsConfig = bidsConfiglike(); - const bidsConfigCopy = bidsConfiglike(); - const conf = config(); + it("should leave URL unchanged if specified params do not exist", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + const originalUrl = "https://publisher.works/article.php?id=5"; + conf.params.websiteToAnalyseUrl = originalUrl; + conf.params.stripQueryParams = ["utm_source", "nonexistent"]; - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; - request.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; - // After a successful response with missing data, the global ortb2 fragments should remain empty - // as the data injection logic checks for marketingCategories. - expect( - bidsConfig.ortb2Fragments.global, - "The global ORTB fragments should remain empty" - ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); - }); + expect(request.url, "The request URL should contain the original URL").to.include( + encodeURIComponent(originalUrl) + ); + }); + }); - it("should append content and user data to existing ORTB fragments", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig = bidsConfiglike(); - // Simulate existing first-party data from another source/module - const existingContentData = { name: "other_content_provider", segment: [{ id: "1" }] }; - const existingUserData = { name: "other_user_provider", segment: [{ id: "2" }] }; + describe("when no stripping options are provided", function () { + it("should send the URL with all query parameters intact", function () { + const bidsConfig = bidsConfiglike(); + const conf = config(); + const originalUrl = "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + conf.params.websiteToAnalyseUrl = originalUrl; - bidsConfig.ortb2Fragments.global = { - site: { - content: { - data: [existingContentData], - }, - }, - user: { - data: [existingUserData], - }, - }; - const conf = config(); + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; - request.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); + expect(request.url, "The request URL should contain the full original URL").to.include( + encodeURIComponent(originalUrl) + ); + }); + }); + }); + }); - const siteData = bidsConfig.ortb2Fragments.global.site.content.data; - const userData = bidsConfig.ortb2Fragments.global.user.data; + // V1 API Format Tests + // These tests use the legacy V1 API response format with marketing_categories structure. + // V1 API uses GET requests and returns: { marketing_categories: { iab_tier_1: [], iab_tier_2: [], etc. } } + // Field names in V1: ID, label, relevance (capital letters) + describe("V1 API", function () { + describe("getBidRequestData", function () { + describe("when using IAB Content Taxonomy 3.0", function () { + it("should correctly structure the bids object after a successful API response", function () { + const apiResponse = getNeuwoApiResponseV1(); + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + // control xhr api request target for testing + conf.params.websiteToAnalyseUrl = + "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should be a string").to.be.a("string"); + expect(request.url, "The request URL should include the public API token").to.include( + conf.params.neuwoApiToken + ); + expect(request.url, "The request URL should include the encoded website URL").to.include( + encodeURIComponent(conf.params.websiteToAnalyseUrl) + ); + expect(request.url, "The request URL should include the product identifier").to.include( + "_neuwo_prod=PrebidModule" + ); + expect(request.url, "V1 API should NOT include iabVersions parameter").to.not.include( + "iabVersions" + ); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + const contentData = bidsConfig.ortb2Fragments.global.site.content.data[0]; + expect(contentData.name, "The data provider name should be correctly set").to.equal( + neuwo.DATA_PROVIDER + ); + expect( + contentData.ext.segtax, + "The segtax value should correspond to IAB Content Taxonomy 3.0" + ).to.equal(7); + expect( + contentData.segment[0].id, + "The first segment ID should match the API response (transformed from V1)" + ).to.equal(apiResponse.marketing_categories.iab_tier_1[0].ID); + expect( + contentData.segment[1].id, + "The second segment ID should match the API response (transformed from V1)" + ).to.equal(apiResponse.marketing_categories.iab_tier_2[0].ID); + }); + }); - // Check that the existing data is still there (index 0) - expect(siteData[0], "Existing site.content.data should be preserved").to.deep.equal( - existingContentData - ); - expect(userData[0], "Existing user.data should be preserved").to.deep.equal(existingUserData); + describe("when using IAB Audience Taxonomy 1.1", function () { + it("should correctly structure the bids object after a successful API response", function () { + const apiResponse = getNeuwoApiResponseV1(); + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + // control xhr api request target for testing + conf.params.websiteToAnalyseUrl = + "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + + expect(request.url, "The request URL should be a string").to.be.a("string"); + expect(request.url, "The request URL should include the public API token").to.include( + conf.params.neuwoApiToken + ); + expect(request.url, "The request URL should include the encoded website URL").to.include( + encodeURIComponent(conf.params.websiteToAnalyseUrl) + ); + + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + const userData = bidsConfig.ortb2Fragments.global.user.data[0]; + expect(userData.name, "The data provider name should be correctly set").to.equal( + neuwo.DATA_PROVIDER + ); + expect( + userData.ext.segtax, + "The segtax value should correspond to IAB Audience Taxonomy 1.1" + ).to.equal(4); + expect( + userData.segment[0].id, + "The first segment ID should match the API response (transformed from V1)" + ).to.equal(apiResponse.marketing_categories.iab_audience_tier_3[0].ID); + expect( + userData.segment[1].id, + "The second segment ID should match the API response (transformed from V1)" + ).to.equal(apiResponse.marketing_categories.iab_audience_tier_4[0].ID); + }); + }); - // Check that the new Neuwo data is appended (index 1) - expect(siteData.length, "site.content.data array should have 2 entries").to.equal(2); - expect(userData.length, "user.data array should have 2 entries").to.equal(2); - expect(siteData[1].name, "The appended content data should be from Neuwo").to.equal( - neuwo.DATA_PROVIDER - ); - expect(userData[1].name, "The appended user data should be from Neuwo").to.equal( - neuwo.DATA_PROVIDER - ); + describe("edge cases", function () { + it("should not inject data if 'marketing_categories' is missing from the successful API response", function () { + const apiResponse = { brand_safety: { BS_score: "1.0" } }; // Missing marketing_categories + const bidsConfig = bidsConfiglike(); + const bidsConfigCopy = bidsConfiglike(); + const conf = configV1(); + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + + // After a successful response with missing data, the global ortb2 fragments should remain empty + // as the data injection logic checks for marketing_categories in V1 format + expect( + bidsConfig.ortb2Fragments.global, + "The global ORTB fragments should remain empty" + ).to.deep.equal(bidsConfigCopy.ortb2Fragments.global); + }); + + it("should handle response with missing marketing_categories", function (done) { + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=no-categories"; + + neuwo.getBidRequestData(bidsConfig, () => { + // Callback should still be called even without marketing_categories + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data; + expect(contentData, "No data should be injected without marketing_categories").to.be.undefined; + done(); + }, conf, "consent data"); + + const request = server.requests[0]; + request.respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify({ brand_safety: { BS_score: "1.0" } }) // Missing marketing_categories + ); + }); + }); }); - }); - describe("getBidRequestData with caching", function () { - describe("when enableCache is true (default)", function () { - it("should cache the API response and reuse it on subsequent calls", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig1 = bidsConfiglike(); - const bidsConfig2 = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=1"; + describe("with iabTaxonomyFilters (client-side filtering)", function () { + it("should work without filtering when no iabTaxonomyFilters provided", function (done) { + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + + neuwo.getBidRequestData( + bidsConfig, + () => { + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + const userData = bidsConfig.ortb2Fragments.global?.user?.data?.[0]; + + expect(contentData, "should have content data").to.exist; + expect(contentData.segment, "should have unfiltered content segments").to.have.lengthOf(2); + expect(userData, "should have user data").to.exist; + expect(userData.segment, "should have unfiltered audience segments").to.have.lengthOf(3); + done(); + }, + conf, + "consent data" + ); - // First call should make an API request - neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); - expect(server.requests.length, "First call should make an API request").to.equal(1); + const request = server.requests[0]; + request.respond(200, {}, JSON.stringify(getNeuwoApiResponseV1())); + }); - const request1 = server.requests[0]; - request1.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) + it("should apply client-side filtering when iabTaxonomyFilters are provided", function (done) { + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + conf.params.iabTaxonomyFilters = { + ContentTier1: { limit: 1, threshold: 0.4 }, + AudienceTier3: { limit: 1, threshold: 0.9 } + }; + + neuwo.getBidRequestData( + bidsConfig, + () => { + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data?.[0]; + const userData = bidsConfig.ortb2Fragments.global?.user?.data?.[0]; + + expect(contentData, "should have content data").to.exist; + expect(contentData.segment, "should have filtered content segments").to.have.lengthOf(2); + + // Check that tier 1 was limited to 1 + const tier1Items = contentData.segment.filter(s => s.id === "274"); + expect(tier1Items, "should have only 1 tier 1 item").to.have.lengthOf(1); + + expect(userData, "should have user data").to.exist; + // Audience tier 3 should be filtered to 1, but tiers 4 and 5 should remain + expect(userData.segment, "should have filtered audience segments").to.have.lengthOf(3); + + done(); + }, + conf, + "consent data" ); - // Second call should use cached response (no new API request) - neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); - expect(server.requests.length, "Second call should not make a new API request").to.equal(1); + const request = server.requests[0]; + request.respond(200, {}, JSON.stringify(getNeuwoApiResponseV1())); + }); + + it("should apply strict client-side filtering that removes all low-relevance items", function (done) { + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + conf.params.iabTaxonomyFilters = { + ContentTier1: { threshold: 0.9 }, // Only keep items with 90%+ relevance + ContentTier2: { threshold: 0.9 }, + AudienceTier4: { threshold: 0.99 }, + AudienceTier5: { threshold: 0.99 } + }; + + neuwo.getBidRequestData( + bidsConfig, + () => { + // Tier 1 has 0.47, Tier 2 has 0.41 - both below 0.9 threshold + // All content segments are filtered out, so content data should not be injected + const contentData = bidsConfig.ortb2Fragments.global?.site?.content?.data; + expect(contentData, "should not inject content data when all segments filtered out").to.be.undefined; + + const userData = bidsConfig.ortb2Fragments.global?.user?.data?.[0]; + expect(userData, "should have user data").to.exist; + // Tier 3 has 0.9923 (passes), Tier 4 has 0.9673 (fails), Tier 5 has 0.9066 (fails) + expect(userData.segment, "should have only 1 audience segment").to.have.lengthOf(1); + + done(); + }, + conf, + "consent data" + ); - // Both configs should have the same data - const contentData1 = bidsConfig1.ortb2Fragments.global.site.content.data[0]; - const contentData2 = bidsConfig2.ortb2Fragments.global.site.content.data[0]; - expect(contentData1, "First config should have Neuwo data").to.exist; - expect(contentData2, "Second config should have Neuwo data from cache").to.exist; - expect(contentData1.name, "First config should have correct provider").to.equal(neuwo.DATA_PROVIDER); - expect(contentData2.name, "Second config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + const request = server.requests[0]; + request.respond(200, {}, JSON.stringify(getNeuwoApiResponseV1())); }); - it("should cache when enableCache is explicitly set to true", function () { - const apiResponse = getNeuwoApiResponse(); + it("should handle client-side filtering with cached response", function (done) { const bidsConfig1 = bidsConfiglike(); const bidsConfig2 = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=2"; - conf.params.enableCache = true; + const conf = configV1(); + conf.params.iabTaxonomyFilters = { + ContentTier1: { limit: 1 } + }; + + // First request + neuwo.getBidRequestData( + bidsConfig1, + () => { + // Second request (should use cache) + neuwo.getBidRequestData( + bidsConfig2, + () => { + const contentData = bidsConfig2.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData, "should have content data from cache").to.exist; + expect(contentData.segment, "should apply filtering to cached response").to.have.lengthOf(2); + done(); + }, + conf, + "consent data" + ); + }, + conf, + "consent data" + ); - // First call - neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); - expect(server.requests.length, "First call should make an API request").to.equal(1); + const request = server.requests[0]; + expect(server.requests, "should only make one API request").to.have.lengthOf(1); + request.respond(200, {}, JSON.stringify(getNeuwoApiResponseV1())); + }); + }); - const request1 = server.requests[0]; - request1.respond( + describe("OpenRTB 2.5 category fields (V1 API compatibility)", function () { + it("should not include iabVersions parameter with legacy API", function () { + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + conf.params.enableOrtb25Fields = true; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + + const request = server.requests[0]; + expect(request.url, "should not include iabVersions parameter").to.not.include("iabVersions"); + }); + + it("should not inject category fields with legacy API", function () { + const bidsConfig = bidsConfiglike(); + const conf = configV1(); + conf.params.enableOrtb25Fields = true; + + neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); + const request = server.requests[0]; + request.respond( 200, { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) + JSON.stringify(getNeuwoApiResponseV1()) ); - // Second call should use cache - neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); - expect(server.requests.length, "Second call should use cached response").to.equal(1); + const siteCat = bidsConfig.ortb2Fragments.global?.site?.cat; + expect(siteCat, "should not have site.cat with legacy API").to.be.undefined; }); }); - describe("when enableCache is false", function () { - it("should not cache the API response and make a new request each time", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig1 = bidsConfiglike(); - const bidsConfig2 = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=3"; - conf.params.enableCache = false; + describe("filterIabTaxonomyTier", function () { + it("should return original array when no filter is provided", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" }, + { ID: "3", label: "Category 3", relevance: "0.3" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, {}); + expect(result, "should return all items when no filter is provided").to.have.lengthOf(3); + }); - // First call should make an API request - neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); - expect(server.requests.length, "First call should make an API request").to.equal(1); + it("should return original array when filter is empty", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies); + expect(result, "should return all items when no filter parameter").to.have.lengthOf(2); + }); - const request1 = server.requests[0]; - request1.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); + it("should filter by threshold only", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" }, + { ID: "3", label: "Category 3", relevance: "0.3" }, + { ID: "4", label: "Category 4", relevance: "0.1" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { threshold: 0.4 }); + expect(result, "should filter out items below threshold").to.have.lengthOf(2); + expect(result[0].ID, "should keep highest relevance item").to.equal("1"); + expect(result[1].ID, "should keep second highest relevance item").to.equal("2"); + }); - // Second call should make a new API request (not use cache) - neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); - expect(server.requests.length, "Second call should make a new API request").to.equal(2); + it("should limit by count only", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" }, + { ID: "3", label: "Category 3", relevance: "0.3" }, + { ID: "4", label: "Category 4", relevance: "0.1" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { limit: 2 }); + expect(result, "should limit to specified count").to.have.lengthOf(2); + expect(result[0].ID, "should keep highest relevance item").to.equal("1"); + expect(result[1].ID, "should keep second highest relevance item").to.equal("2"); + }); - const request2 = server.requests[1]; - request2.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); + it("should return empty array when limit is 0", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { limit: 0 }); + expect(result, "limit 0 should suppress the tier entirely").to.be.an("array").that.is.empty; + }); - // Both configs should have the same data structure - const contentData1 = bidsConfig1.ortb2Fragments.global.site.content.data[0]; - const contentData2 = bidsConfig2.ortb2Fragments.global.site.content.data[0]; - expect(contentData1, "First config should have Neuwo data").to.exist; - expect(contentData2, "Second config should have Neuwo data from new request").to.exist; - expect(contentData1.name, "First config should have correct provider").to.equal(neuwo.DATA_PROVIDER); - expect(contentData2.name, "Second config should have correct provider").to.equal(neuwo.DATA_PROVIDER); + it("should apply both threshold and limit", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.9" }, + { ID: "2", label: "Category 2", relevance: "0.7" }, + { ID: "3", label: "Category 3", relevance: "0.6" }, + { ID: "4", label: "Category 4", relevance: "0.5" }, + { ID: "5", label: "Category 5", relevance: "0.2" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { threshold: 0.5, limit: 2 }); + expect(result, "should apply both threshold and limit").to.have.lengthOf(2); + expect(result[0].ID, "should keep highest relevance item").to.equal("1"); + expect(result[1].ID, "should keep second highest relevance item").to.equal("2"); }); - it("should bypass existing cache when enableCache is false", function () { - const apiResponse = getNeuwoApiResponse(); - const bidsConfig1 = bidsConfiglike(); - const bidsConfig2 = bidsConfiglike(); - const bidsConfig3 = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?id=4"; + it("should preserve original order when filter is empty", function () { + const taxonomies = [ + { ID: "3", label: "Category 3", relevance: "0.3" }, + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, {}); + expect(result[0].ID, "first item should keep original position").to.equal("3"); + expect(result[1].ID, "second item should keep original position").to.equal("1"); + expect(result[2].ID, "third item should keep original position").to.equal("2"); + }); - // First call with caching enabled (default) - neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); - expect(server.requests.length, "First call should make an API request").to.equal(1); + it("should sort by relevance descending only when limit is set", function () { + const taxonomies = [ + { ID: "3", label: "Category 3", relevance: "0.3" }, + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { limit: 2 }); + expect(result).to.have.lengthOf(2); + expect(result[0].ID, "first item should have highest relevance").to.equal("1"); + expect(result[1].ID, "second item should have second highest relevance").to.equal("2"); + }); - const request1 = server.requests[0]; - request1.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); + it("should not sort when only threshold is set", function () { + const taxonomies = [ + { ID: "3", label: "Category 3", relevance: "0.6" }, + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.2" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { threshold: 0.5 }); + expect(result).to.have.lengthOf(2); + expect(result[0].ID, "should preserve original order after threshold filter").to.equal("3"); + expect(result[1].ID, "should preserve original order after threshold filter").to.equal("1"); + }); - // Second call with caching enabled should use cache - neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); - expect(server.requests.length, "Second call should use cache").to.equal(1); + it("should handle empty array", function () { + const result = neuwo.filterIabTaxonomyTier([], { threshold: 0.5, limit: 2 }); + expect(result, "should return empty array for empty input").to.be.an("array").that.is.empty; + }); - // Third call with caching disabled should bypass cache - conf.params.enableCache = false; - neuwo.getBidRequestData(bidsConfig3, () => {}, conf, "consent data"); - expect(server.requests.length, "Third call should bypass cache and make new request").to.equal(2); + it("should handle null input", function () { + const result = neuwo.filterIabTaxonomyTier(null, { threshold: 0.5 }); + expect(result, "should return empty array for null input").to.be.an("array").that.is.empty; + }); - const request2 = server.requests[1]; - request2.respond( - 200, - { "Content-Type": "application/json; encoding=UTF-8" }, - JSON.stringify(apiResponse) - ); + it("should handle undefined input", function () { + const result = neuwo.filterIabTaxonomyTier(undefined, { threshold: 0.5 }); + expect(result, "should return empty array for undefined input").to.be.an("array").that.is.empty; + }); + + it("should handle items with missing relevance", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2" }, + { ID: "3", label: "Category 3", relevance: "0.5" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { threshold: 0.3 }); + expect(result, "should handle missing relevance").to.have.lengthOf(2); + }); + + it("should not mutate original array", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2", relevance: "0.5" }, + { ID: "3", label: "Category 3", relevance: "0.3" } + ]; + const original = [...taxonomies]; + neuwo.filterIabTaxonomyTier(taxonomies, { limit: 1 }); + expect(taxonomies, "should not mutate original array").to.deep.equal(original); + }); + + it("should sort items with undefined/null relevance to the end", function () { + const taxonomies = [ + { ID: "1", label: "Category 1", relevance: "0.8" }, + { ID: "2", label: "Category 2" }, // missing relevance + { ID: "3", label: "Category 3", relevance: null }, + { ID: "4", label: "Category 4", relevance: undefined }, + { ID: "5", label: "Category 5", relevance: "0.5" } + ]; + const result = neuwo.filterIabTaxonomyTier(taxonomies, { limit: 5 }); + expect(result, "should return all items").to.have.lengthOf(5); + expect(result[0].ID, "should have highest relevance first").to.equal("1"); + expect(result[1].ID, "should have second highest relevance").to.equal("5"); + // Items with missing/null/undefined relevance should be sorted to the end + const lastThreeIds = [result[2].ID, result[3].ID, result[4].ID].sort(); + expect(lastThreeIds, "items with no relevance should be at the end").to.deep.equal(["2", "3", "4"]); }); }); - }); - describe("getBidRequestData with URL query param stripping", function () { - describe("when stripAllQueryParams is enabled", function () { - it("should strip all query parameters from the analyzed URL", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?utm_source=test&utm_campaign=example&id=5"; - conf.params.stripAllQueryParams = true; + describe("filterIabTaxonomies", function () { + function getTestMarketingCategories() { + return { + iab_tier_1: [ + { ID: "1", label: "Cat 1", relevance: "0.9" }, + { ID: "2", label: "Cat 2", relevance: "0.7" }, + { ID: "3", label: "Cat 3", relevance: "0.5" } + ], + iab_tier_2: [ + { ID: "4", label: "Cat 4", relevance: "0.8" }, + { ID: "5", label: "Cat 5", relevance: "0.6" } + ], + iab_audience_tier_3: [ + { ID: "6", label: "Aud 1", relevance: "0.95" }, + { ID: "7", label: "Aud 2", relevance: "0.85" }, + { ID: "8", label: "Aud 3", relevance: "0.75" } + ] + }; + } + + it("should return original data when no filters provided", function () { + const marketingCategories = getTestMarketingCategories(); + const result = neuwo.filterIabTaxonomies(marketingCategories, {}); + expect(result.iab_tier_1, "should return all tier 1 items").to.have.lengthOf(3); + expect(result.iab_tier_2, "should return all tier 2 items").to.have.lengthOf(2); + expect(result.iab_audience_tier_3, "should return all audience tier 3 items").to.have.lengthOf(3); + }); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + it("should return original data when filters parameter is undefined", function () { + const marketingCategories = getTestMarketingCategories(); + const result = neuwo.filterIabTaxonomies(marketingCategories); + expect(result.iab_tier_1, "should return all tier 1 items").to.have.lengthOf(3); + }); - expect(request.url, "The request URL should not contain encoded query params").to.include( - encodeURIComponent("https://publisher.works/article.php") - ); - expect(request.url, "The request URL should not contain utm_source").to.not.include( - encodeURIComponent("utm_source") - ); + it("should filter ContentTier1 correctly", function () { + const marketingCategories = getTestMarketingCategories(); + const filters = { + ContentTier1: { limit: 1, threshold: 0.8 } + }; + const result = neuwo.filterIabTaxonomies(marketingCategories, filters); + expect(result.iab_tier_1, "should filter tier 1").to.have.lengthOf(1); + expect(result.iab_tier_1[0].ID, "should keep highest relevance item").to.equal("1"); + expect(result.iab_tier_2, "should not filter tier 2").to.have.lengthOf(2); + }); + + it("should filter multiple tiers independently", function () { + const marketingCategories = getTestMarketingCategories(); + const filters = { + ContentTier1: { limit: 2, threshold: 0.6 }, + ContentTier2: { limit: 1, threshold: 0.7 }, + AudienceTier3: { limit: 2, threshold: 0.8 } + }; + const result = neuwo.filterIabTaxonomies(marketingCategories, filters); + expect(result.iab_tier_1, "should filter tier 1 to 2 items").to.have.lengthOf(2); + expect(result.iab_tier_2, "should filter tier 2 to 1 item").to.have.lengthOf(1); + expect(result.iab_tier_2[0].ID, "tier 2 should keep highest item").to.equal("4"); + expect(result.iab_audience_tier_3, "should filter audience tier 3 to 2 items").to.have.lengthOf(2); + }); + + it("should handle tier with no matching config", function () { + const marketingCategories = getTestMarketingCategories(); + const filters = { + ContentTier1: { limit: 1 } + }; + const result = neuwo.filterIabTaxonomies(marketingCategories, filters); + expect(result.iab_tier_1, "should filter configured tier").to.have.lengthOf(1); + expect(result.iab_tier_2, "should keep all items in non-configured tier").to.have.lengthOf(2); + }); + + it("should preserve non-array tier data", function () { + const marketingCategories = { + iab_tier_1: [{ ID: "1", label: "Cat 1", relevance: "0.9" }], + some_other_field: "string value", + another_field: 123 + }; + const filters = { + ContentTier1: { limit: 1 } + }; + const result = neuwo.filterIabTaxonomies(marketingCategories, filters); + expect(result.some_other_field, "should preserve string field").to.equal("string value"); + expect(result.another_field, "should preserve number field").to.equal(123); + }); + + it("should handle null marketingCategories", function () { + const result = neuwo.filterIabTaxonomies(null, { ContentTier1: { limit: 1 } }); + expect(result, "should return null for null input").to.be.null; + }); + + it("should handle undefined marketingCategories", function () { + const result = neuwo.filterIabTaxonomies(undefined, { ContentTier1: { limit: 1 } }); + expect(result, "should return undefined for undefined input").to.be.undefined; + }); + + it("should handle all tier configurations", function () { + const marketingCategories = { + iab_tier_1: [ + { ID: "1", label: "C1", relevance: "0.9" }, + { ID: "2", label: "C2", relevance: "0.5" } + ], + iab_tier_2: [ + { ID: "3", label: "C3", relevance: "0.8" }, + { ID: "4", label: "C4", relevance: "0.4" } + ], + iab_tier_3: [ + { ID: "5", label: "C5", relevance: "0.7" }, + { ID: "6", label: "C6", relevance: "0.3" } + ], + iab_audience_tier_3: [ + { ID: "7", label: "A1", relevance: "0.95" }, + { ID: "8", label: "A2", relevance: "0.45" } + ], + iab_audience_tier_4: [ + { ID: "9", label: "A3", relevance: "0.85" }, + { ID: "10", label: "A4", relevance: "0.35" } + ], + iab_audience_tier_5: [ + { ID: "11", label: "A5", relevance: "0.75" }, + { ID: "12", label: "A6", relevance: "0.25" } + ] + }; + const filters = { + ContentTier1: { limit: 1, threshold: 0.8 }, + ContentTier2: { limit: 1, threshold: 0.7 }, + ContentTier3: { limit: 1, threshold: 0.6 }, + AudienceTier3: { limit: 1, threshold: 0.9 }, + AudienceTier4: { limit: 1, threshold: 0.8 }, + AudienceTier5: { limit: 1, threshold: 0.7 } + }; + const result = neuwo.filterIabTaxonomies(marketingCategories, filters); + expect(result.iab_tier_1, "ContentTier1 filtered").to.have.lengthOf(1); + expect(result.iab_tier_2, "ContentTier2 filtered").to.have.lengthOf(1); + expect(result.iab_tier_3, "ContentTier3 filtered").to.have.lengthOf(1); + expect(result.iab_audience_tier_3, "AudienceTier3 filtered").to.have.lengthOf(1); + expect(result.iab_audience_tier_4, "AudienceTier4 filtered").to.have.lengthOf(1); + expect(result.iab_audience_tier_5, "AudienceTier5 filtered").to.have.lengthOf(1); }); }); - describe("when stripQueryParamsForDomains is enabled", function () { - it("should strip query params only for matching domains", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?foo=bar&id=5"; - conf.params.stripQueryParamsForDomains = ["publisher.works"]; + describe("transformSegmentsV1ToV2", function () { + it("should transform V1 segment format to V2 format", function () { + const v1Segments = [ + { ID: "274", label: "Home & Garden", relevance: "0.47" }, + { ID: "216", label: "Cooking", relevance: "0.41" } + ]; + const result = neuwo.transformSegmentsV1ToV2(v1Segments); + + expect(result, "should return array with same length").to.have.lengthOf(2); + expect(result[0], "first segment should have id property").to.have.property("id", "274"); + expect(result[0], "first segment should have name property").to.have.property("name", "Home & Garden"); + expect(result[0], "first segment should have relevance property").to.have.property("relevance", "0.47"); + expect(result[1], "second segment should have id property").to.have.property("id", "216"); + expect(result[1], "second segment should have name property").to.have.property("name", "Cooking"); + expect(result[1], "second segment should have relevance property").to.have.property("relevance", "0.41"); + }); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + it("should handle empty array", function () { + const result = neuwo.transformSegmentsV1ToV2([]); + expect(result, "should return empty array").to.be.an("array").that.is.empty; + }); - expect(request.url, "The request URL should contain the URL without query params").to.include( - encodeURIComponent("https://publisher.works/article.php") - ); - expect(request.url, "The request URL should not contain the id param").to.not.include( - encodeURIComponent("id=5") - ); + it("should handle null input", function () { + const result = neuwo.transformSegmentsV1ToV2(null); + expect(result, "should return empty array for null").to.be.an("array").that.is.empty; }); - it("should not strip query params for non-matching domains", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://other-domain.com/page?foo=bar&id=5"; - conf.params.stripQueryParamsForDomains = ["publisher.works"]; + it("should handle undefined input", function () { + const result = neuwo.transformSegmentsV1ToV2(undefined); + expect(result, "should return empty array for undefined").to.be.an("array").that.is.empty; + }); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + it("should handle non-array input", function () { + const result = neuwo.transformSegmentsV1ToV2("not an array"); + expect(result, "should return empty array for non-array").to.be.an("array").that.is.empty; + }); - expect(request.url, "The request URL should contain the full URL with query params").to.include( - encodeURIComponent("https://other-domain.com/page?foo=bar&id=5") - ); + it("should handle segments with missing properties", function () { + const v1Segments = [ + { ID: "274", label: "Home & Garden" }, // missing relevance + { ID: "216", relevance: "0.41" }, // missing label + { label: "Test", relevance: "0.5" } // missing ID + ]; + const result = neuwo.transformSegmentsV1ToV2(v1Segments); + + expect(result, "should return array with same length").to.have.lengthOf(3); + expect(result[0].id, "should handle missing relevance").to.equal("274"); + expect(result[0].relevance, "should set undefined for missing relevance").to.be.undefined; + expect(result[1].name, "should set undefined for missing label").to.be.undefined; + expect(result[2].id, "should set undefined for missing ID").to.be.undefined; }); - it("should handle subdomain matching correctly", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://sub.publisher.works/page?tracking=123"; - conf.params.stripQueryParamsForDomains = ["publisher.works"]; + it("should preserve all properties from V1 format", function () { + const v1Segments = [ + { ID: "49", label: "Female", relevance: "0.9923" } + ]; + const result = neuwo.transformSegmentsV1ToV2(v1Segments); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + expect(result[0], "should only have id, name, and relevance properties").to.have.all.keys("id", "name", "relevance"); + }); + }); - expect(request.url, "The request URL should strip params for subdomain").to.include( - encodeURIComponent("https://sub.publisher.works/page") - ); - expect(request.url, "The request URL should not contain tracking param").to.not.include( - encodeURIComponent("tracking=123") - ); + describe("transformV1ResponseToV2", function () { + it("should transform complete V1 response to V2 format", function () { + const v1Response = { + marketing_categories: { + iab_tier_1: [{ ID: "274", label: "Home & Garden", relevance: "0.47" }], + iab_tier_2: [{ ID: "216", label: "Cooking", relevance: "0.41" }], + iab_tier_3: [{ ID: "388", label: "Recipes", relevance: "0.35" }], + iab_audience_tier_3: [{ ID: "49", label: "Female", relevance: "0.9923" }], + iab_audience_tier_4: [{ ID: "431", label: "Age 25-34", relevance: "0.9673" }], + iab_audience_tier_5: [{ ID: "98", label: "Interest: Cooking", relevance: "0.9066" }] + } + }; + const contentSegtax = 7; // IAB Content Taxonomy 3.0 + const result = neuwo.transformV1ResponseToV2(v1Response, contentSegtax); + + expect(result, "should have content segtax key").to.have.property("7"); + expect(result, "should have audience segtax key").to.have.property("4"); + expect(result["7"], "content segtax should have all tiers").to.have.all.keys("1", "2", "3"); + expect(result["4"], "audience segtax should have all tiers").to.have.all.keys("3", "4", "5"); + expect(result["7"]["1"][0], "content tier 1 should be transformed").to.deep.equal({ + id: "274", + name: "Home & Garden", + relevance: "0.47" + }); + expect(result["4"]["3"][0], "audience tier 3 should be transformed").to.deep.equal({ + id: "49", + name: "Female", + relevance: "0.9923" + }); + }); + + it("should handle different content segtax values", function () { + const v1Response = { + marketing_categories: { + iab_tier_1: [{ ID: "274", label: "Home & Garden", relevance: "0.47" }] + } + }; + + // Test with segtax 6 (IAB 2.2) + const result6 = neuwo.transformV1ResponseToV2(v1Response, 6); + expect(result6, "should use segtax 6 for content").to.have.property("6"); + expect(result6["6"], "should have tier 1").to.have.property("1"); + + // Test with segtax 1 (IAB 1.0) + const result1 = neuwo.transformV1ResponseToV2(v1Response, 1); + expect(result1, "should use segtax 1 for content").to.have.property("1"); + expect(result1["1"], "should have tier 1").to.have.property("1"); + }); + + it("should handle missing marketing_categories", function () { + const v1Response = {}; + const result = neuwo.transformV1ResponseToV2(v1Response, 6); + + expect(result, "should have content segtax key").to.have.property("6"); + expect(result, "should have audience segtax key").to.have.property("4"); + expect(result["6"], "content segtax should be empty object").to.deep.equal({}); + expect(result["4"], "audience segtax should be empty object").to.deep.equal({}); + }); + + it("should handle null v1Response", function () { + const result = neuwo.transformV1ResponseToV2(null, 6); + + expect(result, "should have content segtax key").to.have.property("6"); + expect(result, "should have audience segtax key").to.have.property("4"); + expect(result["6"], "content segtax should be empty object").to.deep.equal({}); + expect(result["4"], "audience segtax should be empty object").to.deep.equal({}); + }); + + it("should handle undefined v1Response", function () { + const result = neuwo.transformV1ResponseToV2(undefined, 6); + + expect(result, "should have content segtax key").to.have.property("6"); + expect(result, "should have audience segtax key").to.have.property("4"); + expect(result["6"], "content segtax should be empty object").to.deep.equal({}); + expect(result["4"], "audience segtax should be empty object").to.deep.equal({}); + }); + + it("should handle partial V1 response with only content tiers", function () { + const v1Response = { + marketing_categories: { + iab_tier_1: [{ ID: "274", label: "Home & Garden", relevance: "0.47" }], + iab_tier_2: [{ ID: "216", label: "Cooking", relevance: "0.41" }] + // No tier 3, no audience tiers + } + }; + const result = neuwo.transformV1ResponseToV2(v1Response, 6); + + expect(result["6"], "should have only tier 1 and 2").to.have.all.keys("1", "2"); + expect(result["6"], "should not have tier 3").to.not.have.property("3"); + expect(result["4"], "audience segtax should be empty").to.deep.equal({}); + }); + + it("should handle partial V1 response with only audience tiers", function () { + const v1Response = { + marketing_categories: { + iab_audience_tier_3: [{ ID: "49", label: "Female", relevance: "0.9923" }], + iab_audience_tier_4: [{ ID: "431", label: "Age 25-34", relevance: "0.9673" }] + // No content tiers + } + }; + const result = neuwo.transformV1ResponseToV2(v1Response, 6); + + expect(result["6"], "content segtax should be empty").to.deep.equal({}); + expect(result["4"], "should have tier 3 and 4").to.have.all.keys("3", "4"); + expect(result["4"], "should not have tier 5").to.not.have.property("5"); + }); + + it("should handle empty arrays in V1 response", function () { + const v1Response = { + marketing_categories: { + iab_tier_1: [], + iab_tier_2: [], + iab_audience_tier_3: [] + } + }; + const result = neuwo.transformV1ResponseToV2(v1Response, 6); + + expect(result["6"]["1"], "content tier 1 should be empty array").to.be.an("array").that.is.empty; + expect(result["6"]["2"], "content tier 2 should be empty array").to.be.an("array").that.is.empty; + expect(result["4"]["3"], "audience tier 3 should be empty array").to.be.an("array").that.is.empty; + }); + + it("should convert segtax to string keys", function () { + const v1Response = { + marketing_categories: { + iab_tier_1: [{ ID: "274", label: "Home & Garden", relevance: "0.47" }] + } + }; + const result = neuwo.transformV1ResponseToV2(v1Response, 6); + + expect(Object.keys(result), "segtax keys should be strings").to.include.members(["6", "4"]); + expect(typeof Object.keys(result)[0], "key type should be string").to.equal("string"); + }); + + it("should preserve brand_safety and other non-marketing_categories fields", function () { + const v1Response = { + brand_safety: { BS_score: "1.0" }, + marketing_categories: { + iab_tier_1: [{ ID: "274", label: "Home & Garden", relevance: "0.47" }] + }, + custom_field: "value" + }; + const result = neuwo.transformV1ResponseToV2(v1Response, 6); + + expect(result, "should not have brand_safety").to.not.have.property("brand_safety"); + expect(result, "should not have custom_field").to.not.have.property("custom_field"); + expect(result, "should only have segtax keys").to.have.all.keys("6", "4"); }); }); - describe("when stripQueryParams is enabled", function () { - it("should strip only specified query parameters", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?utm_source=test&utm_campaign=example&id=5"; - conf.params.stripQueryParams = ["utm_source", "utm_campaign"]; + describe("getBidRequestData with caching (legacy cache key isolation)", function () { + beforeEach(function () { + neuwo.clearCache(); + }); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + it("should not share cache between different iabContentTaxonomyVersion values", function () { + const apiResponse = getNeuwoApiResponseV1(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf1 = configV1(); + conf1.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf1.params.enableCache = true; + conf1.params.iabContentTaxonomyVersion = "3.0"; + + const conf2 = configV1(); + conf2.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf2.params.enableCache = true; + conf2.params.iabContentTaxonomyVersion = "2.2"; + + // First call with taxonomy 3.0 + neuwo.getBidRequestData(bidsConfig1, () => {}, conf1, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); - expect(request.url, "The request URL should contain the id param").to.include( - encodeURIComponent("id=5") - ); - expect(request.url, "The request URL should not contain utm_source").to.not.include( - encodeURIComponent("utm_source") - ); - expect(request.url, "The request URL should not contain utm_campaign").to.not.include( - encodeURIComponent("utm_campaign") + server.requests[0].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) ); - }); - it("should handle stripping params that result in no query string", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - conf.params.websiteToAnalyseUrl = "https://publisher.works/article.php?utm_source=test"; - conf.params.stripQueryParams = ["utm_source"]; + // Verify first call has content under segtax 7 (taxonomy 3.0) + const contentData1 = bidsConfig1.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData1, "First call should have content data").to.exist; + expect(contentData1.ext.segtax, "First call should use segtax 7").to.equal(7); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + // Second call with taxonomy 2.2 - should NOT use cache from taxonomy 3.0 + neuwo.getBidRequestData(bidsConfig2, () => {}, conf2, "consent data"); + expect(server.requests.length, "Second call should make a new API request for different taxonomy").to.equal(2); - expect(request.url, "The request URL should not contain a query string").to.include( - encodeURIComponent("https://publisher.works/article.php") + server.requests[1].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) ); - expect(request.url, "The request URL should not contain utm_source").to.not.include( - encodeURIComponent("utm_source") + + // Verify second call has content under segtax 6 (taxonomy 2.2) + const contentData2 = bidsConfig2.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData2, "Second call should have content data").to.exist; + expect(contentData2.ext.segtax, "Second call should use segtax 6").to.equal(6); + }); + + it("should not share cache between different iabTaxonomyFilters values", function () { + const apiResponse = getNeuwoApiResponseV1(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf1 = configV1(); + conf1.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf1.params.enableCache = true; + conf1.params.iabTaxonomyFilters = { ContentTier1: { limit: 1 } }; + + const conf2 = configV1(); + conf2.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf2.params.enableCache = true; + // No filters + + // First call with filters + neuwo.getBidRequestData(bidsConfig1, () => {}, conf1, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); + + server.requests[0].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) ); + + // Second call without filters - should NOT reuse filtered cache + neuwo.getBidRequestData(bidsConfig2, () => {}, conf2, "consent data"); + expect(server.requests.length, "Second call should make a new API request for different filters").to.equal(2); }); - it("should leave URL unchanged if specified params do not exist", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - const originalUrl = "https://publisher.works/article.php?id=5"; - conf.params.websiteToAnalyseUrl = originalUrl; - conf.params.stripQueryParams = ["utm_source", "nonexistent"]; + it("should share cache when legacy config is identical", function () { + const apiResponse = getNeuwoApiResponseV1(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf = configV1(); + conf.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf.params.enableCache = true; - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + // First call + neuwo.getBidRequestData(bidsConfig1, () => {}, conf, "consent data"); + expect(server.requests.length, "First call should make an API request").to.equal(1); - expect(request.url, "The request URL should contain the original URL").to.include( - encodeURIComponent(originalUrl) + server.requests[0].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) ); - }); - }); - describe("when no stripping options are provided", function () { - it("should send the URL with all query parameters intact", function () { - const bidsConfig = bidsConfiglike(); - const conf = config(); - const originalUrl = "https://publisher.works/article.php?get=horrible_url_for_testing&id=5"; - conf.params.websiteToAnalyseUrl = originalUrl; + // Second call with same config - should use cache + neuwo.getBidRequestData(bidsConfig2, () => {}, conf, "consent data"); + expect(server.requests.length, "Second call should use cache for identical config").to.equal(1); - neuwo.getBidRequestData(bidsConfig, () => {}, conf, "consent data"); - const request = server.requests[0]; + const contentData1 = bidsConfig1.ortb2Fragments.global?.site?.content?.data?.[0]; + const contentData2 = bidsConfig2.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData1, "First call should have content data").to.exist; + expect(contentData2, "Second call should have content data from cache").to.exist; + expect(contentData1.segment, "Cached data should match original").to.deep.equal(contentData2.segment); + }); - expect(request.url, "The request URL should contain the full original URL").to.include( - encodeURIComponent(originalUrl) + it("should not share pending requests between different taxonomy versions", function (done) { + const apiResponse = getNeuwoApiResponseV1(); + const bidsConfig1 = bidsConfiglike(); + const bidsConfig2 = bidsConfiglike(); + const conf1 = configV1(); + conf1.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf1.params.enableCache = true; + conf1.params.iabContentTaxonomyVersion = "3.0"; + + const conf2 = configV1(); + conf2.params.websiteToAnalyseUrl = "https://publisher.works/same-page"; + conf2.params.enableCache = true; + conf2.params.iabContentTaxonomyVersion = "2.2"; + + let callbackCount = 0; + const callback = () => { + callbackCount++; + if (callbackCount === 2) { + try { + // Both should have made separate API requests + expect(server.requests.length, "Should make two API requests for different taxonomies").to.equal(2); + + const contentData1 = bidsConfig1.ortb2Fragments.global?.site?.content?.data?.[0]; + const contentData2 = bidsConfig2.ortb2Fragments.global?.site?.content?.data?.[0]; + expect(contentData1, "First call should have content data").to.exist; + expect(contentData2, "Second call should have content data").to.exist; + expect(contentData1.ext.segtax, "First call should use segtax 7").to.equal(7); + expect(contentData2.ext.segtax, "Second call should use segtax 6").to.equal(6); + done(); + } catch (e) { + done(e); + } + } + }; + + // Two concurrent calls with different taxonomy versions + neuwo.getBidRequestData(bidsConfig1, callback, conf1, "consent data"); + neuwo.getBidRequestData(bidsConfig2, callback, conf2, "consent data"); + + // Should be two separate requests, not shared pending promise + expect(server.requests.length, "Should make two separate API requests").to.equal(2); + + server.requests[0].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) + ); + server.requests[1].respond( + 200, + { "Content-Type": "application/json; encoding=UTF-8" }, + JSON.stringify(apiResponse) ); }); }); diff --git a/test/spec/modules/omsBidAdapter_spec.js b/test/spec/modules/omsBidAdapter_spec.js index f1f7df46843..da9c21e7ae3 100644 --- a/test/spec/modules/omsBidAdapter_spec.js +++ b/test/spec/modules/omsBidAdapter_spec.js @@ -34,7 +34,11 @@ describe('omsBidAdapter', function () { }; win = { document: { - visibilityState: 'visible' + visibilityState: 'visible', + documentElement: { + clientWidth: 800, + clientHeight: 600, + } }, location: { href: "http:/location" @@ -80,6 +84,7 @@ describe('omsBidAdapter', function () { sandbox = sinon.createSandbox(); sandbox.stub(document, 'getElementById').withArgs('adunit-code').returns(element); + sandbox.stub(winDimensions, 'getWinDimensions').returns(win); sandbox.stub(utils, 'getWindowTop').returns(win); sandbox.stub(utils, 'getWindowSelf').returns(win); }); @@ -262,6 +267,16 @@ describe('omsBidAdapter', function () { expect(data.regs.coppa).to.equal(1); }); + it('sends instl property when ortb2Imp.instl = 1', function () { + const data = JSON.parse(spec.buildRequests([{ ...bidRequests[0], ortb2Imp: { instl: 1 }}]).data); + expect(data.imp[0].instl).to.equal(1); + }); + + it('ignores instl property when ortb2Imp.instl is falsy', function () { + const data = JSON.parse(spec.buildRequests(bidRequests).data); + expect(data.imp[0].instl).to.be.undefined; + }); + it('sends schain', function () { const data = JSON.parse(spec.buildRequests(bidRequests).data); expect(data).to.not.be.undefined; @@ -347,8 +362,6 @@ describe('omsBidAdapter', function () { context('when element is partially in view', function () { it('returns percentage', function () { - const getWinDimensionsStub = sandbox.stub(winDimensions, 'getWinDimensions') - getWinDimensionsStub.returns({ innerHeight: win.innerHeight, innerWidth: win.innerWidth }); Object.assign(element, {width: 800, height: 800}); const request = spec.buildRequests(bidRequests); const payload = JSON.parse(request.data); @@ -358,8 +371,6 @@ describe('omsBidAdapter', function () { context('when width or height of the element is zero', function () { it('try to use alternative values', function () { - const getWinDimensionsStub = sandbox.stub(winDimensions, 'getWinDimensions') - getWinDimensionsStub.returns({ innerHeight: win.innerHeight, innerWidth: win.innerWidth }); Object.assign(element, {width: 0, height: 0}); bidRequests[0].mediaTypes.banner.sizes = [[800, 2400]]; const request = spec.buildRequests(bidRequests); diff --git a/test/spec/modules/onomagicBidAdapter_spec.js b/test/spec/modules/onomagicBidAdapter_spec.js index 6b4d44f49ed..d043363b34d 100644 --- a/test/spec/modules/onomagicBidAdapter_spec.js +++ b/test/spec/modules/onomagicBidAdapter_spec.js @@ -34,9 +34,12 @@ describe('onomagicBidAdapter', function() { }; win = { document: { - visibilityState: 'visible' + visibilityState: 'visible', + documentElement: { + clientWidth: 800, + clientHeight: 600 + } }, - innerWidth: 800, innerHeight: 600 }; @@ -57,6 +60,7 @@ describe('onomagicBidAdapter', function() { }]; sandbox = sinon.createSandbox(); + sandbox.stub(winDimensions, 'getWinDimensions').returns(win); sandbox.stub(document, 'getElementById').withArgs('adunit-code').returns(element); sandbox.stub(utils, 'getWindowTop').returns(win); sandbox.stub(utils, 'getWindowSelf').returns(win); @@ -162,8 +166,6 @@ describe('onomagicBidAdapter', function() { context('when element is partially in view', function() { it('returns percentage', function() { - const getWinDimensionsStub = sandbox.stub(winDimensions, 'getWinDimensions') - getWinDimensionsStub.returns({ innerHeight: win.innerHeight, innerWidth: win.innerWidth }); Object.assign(element, { width: 800, height: 800 }); const request = spec.buildRequests(bidRequests); const payload = JSON.parse(request.data); @@ -173,8 +175,6 @@ describe('onomagicBidAdapter', function() { context('when width or height of the element is zero', function() { it('try to use alternative values', function() { - const getWinDimensionsStub = sandbox.stub(winDimensions, 'getWinDimensions') - getWinDimensionsStub.returns({ innerHeight: win.innerHeight, innerWidth: win.innerWidth }); Object.assign(element, { width: 0, height: 0 }); bidRequests[0].mediaTypes.banner.sizes = [[800, 2400]]; const request = spec.buildRequests(bidRequests); diff --git a/test/spec/modules/optidigitalBidAdapter_spec.js b/test/spec/modules/optidigitalBidAdapter_spec.js index 3b4ef61e961..bb0526d9aaa 100755 --- a/test/spec/modules/optidigitalBidAdapter_spec.js +++ b/test/spec/modules/optidigitalBidAdapter_spec.js @@ -63,8 +63,7 @@ describe('optidigitalAdapterTests', function () { 'adserver': { 'name': 'gam', 'adslot': '/19968336/header-bid-tag-0' - }, - 'pbadslot': '/19968336/header-bid-tag-0' + } }, 'gpid': '/19968336/header-bid-tag-0' } @@ -212,7 +211,8 @@ describe('optidigitalAdapterTests', function () { it('should send bid request to given endpoint', function() { const request = spec.buildRequests(validBidRequests, bidderRequest); - expect(request.url).to.equal(ENDPOINT); + const finalEndpoint = `${ENDPOINT}/s123`; + expect(request.url).to.equal(finalEndpoint); }); it('should be bidRequest data', function () { @@ -282,6 +282,49 @@ describe('optidigitalAdapterTests', function () { expect(payload.imp[0].adContainerHeight).to.exist; }); + it('should read container size from DOM when divId exists', function () { + const containerId = 'od-test-container'; + const el = document.createElement('div'); + el.id = containerId; + el.style.width = '321px'; + el.style.height = '111px'; + el.style.position = 'absolute'; + el.style.left = '0'; + el.style.top = '0'; + document.body.appendChild(el); + + const validBidRequestsWithDivId = [ + { + 'bidder': 'optidigital', + 'bidId': '51ef8751f9aead', + 'params': { + 'publisherId': 's123', + 'placementId': 'Billboard_Top', + 'divId': containerId + }, + 'adUnitCode': containerId, + 'transactionId': 'd7b773de-ceaa-484d-89ca-d9f51b8d61ec', + 'mediaTypes': { + 'banner': { + 'sizes': [[300, 50], [300, 250], [300, 600]] + } + }, + 'sizes': [[320, 50], [300, 250], [300, 600]], + 'bidderRequestId': '418b37f85e772c', + 'auctionId': '18fd8b8b0bd757' + } + ]; + + const request = spec.buildRequests(validBidRequestsWithDivId, bidderRequest); + const payload = JSON.parse(request.data); + try { + expect(payload.imp[0].adContainerWidth).to.equal(el.offsetWidth); + expect(payload.imp[0].adContainerHeight).to.equal(el.offsetHeight); + } finally { + document.body.removeChild(el); + } + }); + it('should add pageTemplate to payload if pageTemplate exsists in parameter', function () { const validBidRequestsWithDivId = [ { @@ -325,7 +368,8 @@ describe('optidigitalAdapterTests', function () { 'domain': 'example.com', 'publisher': { 'domain': 'example.com' - } + }, + 'keywords': 'key1,key2' }, 'device': { 'w': 1507, @@ -386,6 +430,12 @@ describe('optidigitalAdapterTests', function () { expect(payload.bapp).to.deep.equal(validBidRequests[0].params.bapp); }); + it('should add keywords to payload when site keywords present', function() { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const payload = JSON.parse(request.data); + expect(payload.site.keywords).to.deep.equal('key1,key2'); + }); + it('should send empty GDPR consent and required set to false', function() { const request = spec.buildRequests(validBidRequests, bidderRequest); const payload = JSON.parse(request.data); @@ -401,6 +451,7 @@ describe('optidigitalAdapterTests', function () { 'vendorData': { 'hasGlobalConsent': false }, + 'addtlConsent': '1~7.12.35.62.66.70.89.93.108', 'apiVersion': 1 } const request = spec.buildRequests(validBidRequests, bidderRequest); @@ -408,6 +459,7 @@ describe('optidigitalAdapterTests', function () { expect(payload.gdpr).to.exist; expect(payload.gdpr.consent).to.equal(consentString); expect(payload.gdpr.required).to.exist.and.to.be.true; + expect(payload.gdpr.addtlConsent).to.exist; }); it('should send empty GDPR consent to endpoint', function() { @@ -425,6 +477,22 @@ describe('optidigitalAdapterTests', function () { expect(payload.gdpr.consent).to.equal(''); }); + it('should send GDPR consent and required set to false when gdprApplies is not boolean', function() { + let consentString = 'DFR8KRePoQNsRREZCADBG+A=='; + bidderRequest.gdprConsent = { + 'consentString': consentString, + 'gdprApplies': "", + 'vendorData': { + 'hasGlobalConsent': false + }, + 'apiVersion': 1 + } + const request = spec.buildRequests(validBidRequests, bidderRequest); + const payload = JSON.parse(request.data); + expect(payload.gdpr.consent).to.equal(consentString); + expect(payload.gdpr.required).to.exist.and.to.be.false; + }); + it('should send uspConsent to given endpoint', function() { bidderRequest.uspConsent = '1YYY'; const request = spec.buildRequests(validBidRequests, bidderRequest); @@ -457,6 +525,21 @@ describe('optidigitalAdapterTests', function () { expect(payload.gpp).to.exist; }); + it('should set testMode when optidigitalTestMode flag present in location', function() { + const originalUrl = window.location.href; + const newUrl = originalUrl.includes('optidigitalTestMode=true') + ? originalUrl + : `${window.location.pathname}${window.location.search}${window.location.search && window.location.search.length ? '&' : '?'}optidigitalTestMode=true${window.location.hash || ''}`; + try { + window.history.pushState({}, '', newUrl); + const request = spec.buildRequests(validBidRequests, bidderRequest); + const payload = JSON.parse(request.data); + expect(payload.testMode).to.equal(true); + } finally { + window.history.replaceState({}, '', originalUrl); + } + }); + it('should use appropriate mediaTypes banner sizes', function() { const mediaTypesBannerSize = { 'mediaTypes': { @@ -532,6 +615,23 @@ describe('optidigitalAdapterTests', function () { expect(payload.user).to.deep.equal(undefined); }); + it('should add gpid to payload when gpid', function() { + validBidRequests[0].ortb2Imp = { + 'ext': { + 'data': { + 'adserver': { + 'name': 'gam', + 'adslot': '/19968336/header-bid-tag-0' + } + }, + 'gpid': '/19968336/header-bid-tag-0' + } + }; + const request = spec.buildRequests(validBidRequests, bidderRequest); + const payload = JSON.parse(request.data); + expect(payload.imp[0].gpid).to.deep.equal('/19968336/header-bid-tag-0'); + }); + function returnBannerSizes(mediaTypes, expectedSizes) { const bidRequest = Object.assign(validBidRequests[0], mediaTypes); const request = spec.buildRequests([bidRequest], bidderRequest); diff --git a/test/spec/modules/orakiBidAdapter_spec.js b/test/spec/modules/orakiBidAdapter_spec.js index 4a6b8fa7d36..f3f31be3e30 100644 --- a/test/spec/modules/orakiBidAdapter_spec.js +++ b/test/spec/modules/orakiBidAdapter_spec.js @@ -478,7 +478,7 @@ describe('OrakiBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -487,9 +487,7 @@ describe('OrakiBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.oraki.io/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -498,7 +496,7 @@ describe('OrakiBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.oraki.io/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/panxoRtdProvider_spec.js b/test/spec/modules/panxoRtdProvider_spec.js new file mode 100644 index 00000000000..d3e0cbe12bf --- /dev/null +++ b/test/spec/modules/panxoRtdProvider_spec.js @@ -0,0 +1,311 @@ +import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js'; + +import * as utils from '../../../src/utils.js'; +import * as hook from '../../../src/hook.js'; +import * as refererDetection from '../../../src/refererDetection.js'; + +import { __TEST__ } from '../../../modules/panxoRtdProvider.js'; + +const { + SUBMODULE_NAME, + SCRIPT_URL, + main, + load, + onImplLoaded, + onImplMessage, + onGetBidRequestData, + flushPendingCallbacks +} = __TEST__; + +describe('panxo RTD module', function () { + let sandbox; + + const stubUuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const panxoBridgeId = `panxo_${stubUuid}`; + const stubWindow = { [panxoBridgeId]: undefined }; + + const validSiteId = 'a1b2c3d4e5f67890'; + + beforeEach(function() { + sandbox = sinon.createSandbox(); + sandbox.stub(utils, 'getWindowSelf').returns(stubWindow); + sandbox.stub(utils, 'generateUUID').returns(stubUuid); + sandbox.stub(refererDetection, 'getRefererInfo').returns({ domain: 'example.com' }); + }); + afterEach(function() { + sandbox.restore(); + }); + + describe('Initialization step', function () { + let sandbox2; + let connectSpy; + beforeEach(function() { + sandbox2 = sinon.createSandbox(); + connectSpy = sandbox.spy(); + // Simulate: once the impl script is loaded, it registers the bridge API + sandbox2.stub(stubWindow, panxoBridgeId).value({ connect: connectSpy }); + }); + afterEach(function () { + sandbox2.restore(); + }); + + it('should accept valid configuration with siteId', function () { + expect(() => load({ params: { siteId: validSiteId } })).to.not.throw(); + }); + + it('should throw an Error when siteId is missing', function () { + expect(() => load({})).to.throw(); + expect(() => load({ params: {} })).to.throw(); + expect(() => load({ params: { siteId: '' } })).to.throw(); + }); + + it('should throw an Error when siteId is not a valid hex string', function () { + expect(() => load({ params: { siteId: 'abc' } })).to.throw(); + expect(() => load({ params: { siteId: 123 } })).to.throw(); + expect(() => load({ params: { siteId: 'zzzzzzzzzzzzzzzz' } })).to.throw(); + expect(() => load({ params: { siteId: 'a1b2c3d4e5f6789' } })).to.throw(); // 15 chars + expect(() => load({ params: { siteId: 'a1b2c3d4e5f678901' } })).to.throw(); // 17 chars + }); + + it('should insert implementation script with correct URL', () => { + load({ params: { siteId: validSiteId } }); + + expect(loadExternalScriptStub.calledOnce).to.be.true; + + const args = loadExternalScriptStub.getCall(0).args; + expect(args[0]).to.be.equal( + `${SCRIPT_URL}?siteId=${validSiteId}&session=${stubUuid}&r=example.com` + ); + expect(args[2]).to.be.equal(SUBMODULE_NAME); + expect(args[3]).to.be.equal(onImplLoaded); + }); + + it('should connect to the implementation script once it loads', function () { + load({ params: { siteId: validSiteId } }); + + expect(loadExternalScriptStub.calledOnce).to.be.true; + expect(connectSpy.calledOnce).to.be.true; + + const args = connectSpy.getCall(0).args; + expect(args[0]).to.haveOwnProperty('cmd'); // pbjs global + expect(args[0]).to.haveOwnProperty('que'); + expect(args[1]).to.be.equal(onImplMessage); + }); + + it('should flush pending callbacks when bridge is unavailable', function () { + sandbox2.restore(); + // Bridge is not registered on the window -- onImplLoaded should fail open + sandbox2 = sinon.createSandbox(); + sandbox2.stub(stubWindow, panxoBridgeId).value(undefined); + + load({ params: { siteId: validSiteId } }); + + const callbackSpy = sandbox2.spy(); + const reqBidsConfig = { ortb2Fragments: { bidder: {}, global: {} } }; + + // Queue a callback before bridge fails + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + // onImplLoaded already ran (bridge undefined) and flushed + expect(callbackSpy.calledOnce).to.be.true; + }); + + it('should not throw when bridge message is null', function () { + load({ params: { siteId: validSiteId } }); + expect(() => onImplMessage(null)).to.not.throw(); + expect(() => onImplMessage(undefined)).to.not.throw(); + }); + }); + + describe('Bid enrichment step', function () { + const signalData = { + device: { v1: 'session-token-123' }, + site: { ai: true, src: 'chatgpt', conf: 0.95, seg: 'technology', co: 'US' } + }; + + let sandbox2; + let callbackSpy; + let reqBidsConfig; + beforeEach(function() { + sandbox2 = sinon.createSandbox(); + callbackSpy = sandbox2.spy(); + reqBidsConfig = { ortb2Fragments: { bidder: {}, global: {} } }; + // Prevent onImplLoaded from firing automatically so tests can + // control module readiness via onImplMessage directly. + loadExternalScriptStub.callsFake(() => {}); + }); + afterEach(function () { + loadExternalScriptStub.reset(); + sandbox2.restore(); + }); + + it('should defer callback when implementation has not sent a signal yet', () => { + load({ params: { siteId: validSiteId } }); + + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + + // Callback is deferred until the implementation script sends its first signal + expect(callbackSpy.notCalled).to.be.true; + }); + + it('should flush deferred callback once a signal arrives', () => { + load({ params: { siteId: validSiteId } }); + + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + expect(callbackSpy.notCalled).to.be.true; + + // First signal arrives -- deferred callback should now fire + onImplMessage({ type: 'signal', data: { device: { v1: 'tok' }, site: {} } }); + expect(callbackSpy.calledOnce).to.be.true; + }); + + it('should flush all deferred callbacks when the first signal arrives', () => { + load({ params: { siteId: validSiteId } }); + + const spy1 = sandbox2.spy(); + const spy2 = sandbox2.spy(); + const cfg1 = { ortb2Fragments: { bidder: {}, global: {} } }; + const cfg2 = { ortb2Fragments: { bidder: {}, global: {} } }; + + onGetBidRequestData(cfg1, spy1, { params: {} }, {}); + onGetBidRequestData(cfg2, spy2, { params: {} }, {}); + expect(spy1.notCalled).to.be.true; + expect(spy2.notCalled).to.be.true; + + onImplMessage({ type: 'signal', data: { device: { v1: 'tok' }, site: {} } }); + expect(spy1.calledOnce).to.be.true; + expect(spy2.calledOnce).to.be.true; + }); + + it('should call callback immediately once implementation is ready', () => { + load({ params: { siteId: validSiteId } }); + + // Mark implementation as ready via a signal + onImplMessage({ type: 'signal', data: { device: { v1: 'tok' }, site: {} } }); + + // Subsequent calls should resolve immediately + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + expect(callbackSpy.calledOnce).to.be.true; + }); + + it('should call callback when implementation reports an error', () => { + load({ params: { siteId: validSiteId } }); + + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + expect(callbackSpy.notCalled).to.be.true; + + // An error still unblocks the auction + onImplMessage({ type: 'error', data: 'some error' }); + expect(callbackSpy.calledOnce).to.be.true; + // No device or site should be added since panxoData is empty + }); + + it('should add device.ext.panxo with session token when signal is received', () => { + load({ params: { siteId: validSiteId } }); + + onImplMessage({ type: 'signal', data: signalData }); + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + + expect(callbackSpy.calledOnce).to.be.true; + expect(reqBidsConfig.ortb2Fragments.global).to.have.own.property('device'); + expect(reqBidsConfig.ortb2Fragments.global.device).to.have.own.property('ext'); + expect(reqBidsConfig.ortb2Fragments.global.device.ext).to.have.own.property('panxo') + .which.is.an('object') + .that.deep.equals(signalData.device); + }); + + it('should add site.ext.data.panxo with AI classification data', () => { + load({ params: { siteId: validSiteId } }); + + onImplMessage({ type: 'signal', data: signalData }); + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + + expect(callbackSpy.calledOnce).to.be.true; + expect(reqBidsConfig.ortb2Fragments.global).to.have.own.property('site'); + expect(reqBidsConfig.ortb2Fragments.global.site).to.have.own.property('ext'); + expect(reqBidsConfig.ortb2Fragments.global.site.ext).to.have.own.property('data'); + expect(reqBidsConfig.ortb2Fragments.global.site.ext.data).to.have.own.property('panxo') + .which.is.an('object') + .that.deep.equals(signalData.site); + }); + + it('should update panxo data when new signal is received', () => { + load({ params: { siteId: validSiteId } }); + + const updatedData = { + device: { v1: 'updated-token' }, + site: { ai: true, src: 'perplexity', conf: 0.88, seg: 'finance', co: 'UK' } + }; + + onImplMessage({ type: 'signal', data: { device: { v1: 'old-token' }, site: {} } }); + onImplMessage({ type: 'signal', data: updatedData }); + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + + expect(callbackSpy.calledOnce).to.be.true; + expect(reqBidsConfig.ortb2Fragments.global.device.ext.panxo) + .to.deep.equal(updatedData.device); + expect(reqBidsConfig.ortb2Fragments.global.site.ext.data.panxo) + .to.deep.equal(updatedData.site); + }); + + it('should not add site data when site object is empty', () => { + load({ params: { siteId: validSiteId } }); + + onImplMessage({ type: 'signal', data: { device: { v1: 'token' }, site: {} } }); + onGetBidRequestData(reqBidsConfig, callbackSpy, { params: {} }, {}); + + expect(callbackSpy.calledOnce).to.be.true; + expect(reqBidsConfig.ortb2Fragments.global.device.ext.panxo) + .to.deep.equal({ v1: 'token' }); + // site should not have panxo data since it was empty + expect(reqBidsConfig.ortb2Fragments.global).to.not.have.own.property('site'); + }); + }); + + describe('Submodule execution', function() { + let sandbox2; + let submoduleStub; + beforeEach(function() { + sandbox2 = sinon.createSandbox(); + submoduleStub = sandbox2.stub(hook, 'submodule'); + }); + afterEach(function () { + sandbox2.restore(); + }); + + function getModule() { + main(); + + expect(submoduleStub.calledOnceWith('realTimeData')).to.equal(true); + + const submoduleDef = submoduleStub.getCall(0).args[1]; + expect(submoduleDef).to.be.an('object'); + expect(submoduleDef).to.have.own.property('name', SUBMODULE_NAME); + expect(submoduleDef).to.have.own.property('init').that.is.a('function'); + expect(submoduleDef).to.have.own.property('getBidRequestData').that.is.a('function'); + + return submoduleDef; + } + + it('should register panxo RTD submodule provider', function () { + getModule(); + }); + + it('should refuse initialization when siteId is missing', function () { + const { init } = getModule(); + expect(init({ params: {} })).to.equal(false); + expect(loadExternalScriptStub.notCalled).to.be.true; + }); + + it('should refuse initialization when siteId is invalid', function () { + const { init } = getModule(); + expect(init({ params: { siteId: 'invalid' } })).to.equal(false); + expect(loadExternalScriptStub.notCalled).to.be.true; + }); + + it('should commence initialization with valid siteId', function () { + const { init } = getModule(); + expect(init({ params: { siteId: validSiteId } })).to.equal(true); + expect(loadExternalScriptStub.calledOnce).to.be.true; + }); + }); +}); diff --git a/test/spec/modules/pgamsspBidAdapter_spec.js b/test/spec/modules/pgamsspBidAdapter_spec.js index 6eea9bec92a..b881be50402 100644 --- a/test/spec/modules/pgamsspBidAdapter_spec.js +++ b/test/spec/modules/pgamsspBidAdapter_spec.js @@ -481,7 +481,7 @@ describe('PGAMBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -490,9 +490,7 @@ describe('PGAMBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.pgammedia.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -501,7 +499,7 @@ describe('PGAMBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.pgammedia.com/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/priceFloors_spec.js b/test/spec/modules/priceFloors_spec.js index 761e5256674..6795326246f 100644 --- a/test/spec/modules/priceFloors_spec.js +++ b/test/spec/modules/priceFloors_spec.js @@ -82,6 +82,7 @@ describe('the price floors module', function () { endpoint: {}, enforcement: { enforceJS: true, + enforceBidders: ['*'], enforcePBS: false, floorDeals: false, bidAdjustment: true @@ -95,6 +96,7 @@ describe('the price floors module', function () { endpoint: {}, enforcement: { enforceJS: true, + enforceBidders: ['*'], enforcePBS: false, floorDeals: false, bidAdjustment: true @@ -108,6 +110,7 @@ describe('the price floors module', function () { endpoint: {}, enforcement: { enforceJS: true, + enforceBidders: ['*'], enforcePBS: false, floorDeals: false, bidAdjustment: true @@ -2286,6 +2289,51 @@ describe('the price floors module', function () { expect(reject.calledOnce).to.be.true; expect(returnedBidResponse).to.not.exist; }); + it('enforces floors for all bidders by default', function () { + _floorDataForAuction[AUCTION_ID] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[AUCTION_ID].data.values = { 'banner': 1.0 }; + returnedBidResponse = null; + runBidResponse({ + ...basicBidResponse, + bidderCode: 'rubicon' + }); + expect(reject.calledOnce).to.be.true; + expect(returnedBidResponse).to.equal(null); + }); + it('enforces floors only for configured enforceBidders when provided', function () { + _floorDataForAuction[AUCTION_ID] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[AUCTION_ID].enforcement.enforceBidders = ['rubicon']; + _floorDataForAuction[AUCTION_ID].data.values = { 'banner': 1.0 }; + + runBidResponse({ + ...basicBidResponse, + bidderCode: 'appnexus' + }); + expect(reject.called).to.be.false; + expect(returnedBidResponse).to.haveOwnProperty('floorData'); + + returnedBidResponse = null; + runBidResponse({ + ...basicBidResponse, + bidderCode: 'rubicon' + }); + expect(reject.calledOnce).to.be.true; + expect(returnedBidResponse).to.equal(null); + }); + it('uses adapterCode when checking enforceBidders', function () { + _floorDataForAuction[AUCTION_ID] = utils.deepClone(basicFloorConfig); + _floorDataForAuction[AUCTION_ID].enforcement.enforceBidders = ['rubicon']; + _floorDataForAuction[AUCTION_ID].data.values = { 'banner': 1.0 }; + + runBidResponse({ + ...basicBidResponse, + bidderCode: 'alternateBidder', + adapterCode: 'rubicon' + }); + + expect(reject.calledOnce).to.be.true; + expect(returnedBidResponse).to.equal(null); + }); it('if it finds a rule and does not floor should update the bid accordingly', function () { _floorDataForAuction[AUCTION_ID] = utils.deepClone(basicFloorConfig); _floorDataForAuction[AUCTION_ID].data.values = { 'banner': 0.3 }; @@ -2299,6 +2347,7 @@ describe('the price floors module', function () { cpmAfterAdjustments: 0.5, enforcements: { bidAdjustment: true, + enforceBidders: ['*'], enforceJS: true, enforcePBS: false, floorDeals: false @@ -2336,6 +2385,7 @@ describe('the price floors module', function () { cpmAfterAdjustments: 0.5, enforcements: { bidAdjustment: true, + enforceBidders: ['*'], enforceJS: true, enforcePBS: false, floorDeals: false @@ -2364,6 +2414,7 @@ describe('the price floors module', function () { cpmAfterAdjustments: 7.5, enforcements: { bidAdjustment: true, + enforceBidders: ['*'], enforceJS: true, enforcePBS: false, floorDeals: false diff --git a/test/spec/modules/proxistoreBidAdapter_spec.js b/test/spec/modules/proxistoreBidAdapter_spec.js index 767ef93cf81..a03fb9a8e57 100644 --- a/test/spec/modules/proxistoreBidAdapter_spec.js +++ b/test/spec/modules/proxistoreBidAdapter_spec.js @@ -1,167 +1,493 @@ -import { expect } from 'chai'; -import { spec } from 'modules/proxistoreBidAdapter.js'; -import { newBidder } from 'src/adapters/bidderFactory.js'; -import { config } from '../../../src/config.js'; +import {expect} from 'chai'; +import {spec} from 'modules/proxistoreBidAdapter.js'; +import {BANNER} from 'src/mediaTypes.js'; const BIDDER_CODE = 'proxistore'; +const COOKIE_BASE_URL = 'https://abs.proxistore.com/v3/rtb/openrtb'; +const COOKIE_LESS_URL = 'https://abs.cookieless-proxistore.com/v3/rtb/openrtb'; + describe('ProxistoreBidAdapter', function () { const consentString = 'BOJ8RZsOJ8RZsABAB8AAAAAZ+A=='; - const bidderRequest = { + + const baseBidderRequest = { bidderCode: BIDDER_CODE, auctionId: '1025ba77-5463-4877-b0eb-14b205cb9304', bidderRequestId: '10edf38ec1a719', - gdprConsent: { - apiVersion: 2, - gdprApplies: true, - consentString: consentString, - vendorData: { - vendor: { - consents: { - 418: true, - }, + timeout: 1000, + }; + + const gdprConsentWithVendor = { + apiVersion: 2, + gdprApplies: true, + consentString: consentString, + vendorData: { + vendor: { + consents: { + 418: true, }, }, }, }; - const bid = { - sizes: [[300, 600]], + + const gdprConsentWithoutVendor = { + apiVersion: 2, + gdprApplies: true, + consentString: consentString, + vendorData: { + vendor: { + consents: { + 418: false, + }, + }, + }, + }; + + const gdprConsentNoVendorData = { + apiVersion: 2, + gdprApplies: true, + consentString: consentString, + vendorData: null, + }; + + const baseBid = { + bidder: BIDDER_CODE, params: { website: 'example.fr', language: 'fr', }, - ortb2: { - user: { ext: { data: { segments: [], contextual_categories: {} } } }, + mediaTypes: { + banner: { + sizes: [[300, 600], [300, 250]], + }, }, - auctionId: 442133079, - bidId: 464646969, - transactionId: 511916005, + adUnitCode: 'div-gpt-ad-123', + transactionId: '511916005', + bidId: '464646969', + auctionId: '1025ba77-5463-4877-b0eb-14b205cb9304', }; + + describe('spec properties', function () { + it('should have correct bidder code', function () { + expect(spec.code).to.equal(BIDDER_CODE); + }); + + it('should have correct GVLID', function () { + expect(spec.gvlid).to.equal(418); + }); + + it('should support banner media type', function () { + expect(spec.supportedMediaTypes).to.deep.equal([BANNER]); + }); + + it('should have browsingTopics enabled', function () { + expect(spec.browsingTopics).to.be.true; + }); + + it('should have getUserSyncs function', function () { + expect(spec.getUserSyncs).to.be.a('function'); + }); + }); + describe('isBidRequestValid', function () { - it('it should be true if required params are presents and there is no info in the local storage', function () { - expect(spec.isBidRequestValid(bid)).to.equal(true); + it('should return true when website and language params are present', function () { + expect(spec.isBidRequestValid(baseBid)).to.equal(true); + }); + + it('should return false when website param is missing', function () { + const bid = {...baseBid, params: {language: 'fr'}}; + expect(spec.isBidRequestValid(bid)).to.equal(false); }); - it('it should be false if the value in the localstorage is less than 5minutes of the actual time', function () { - const date = new Date(); - date.setMinutes(date.getMinutes() - 1); - localStorage.setItem(`PX_NoAds_${bid.params.website}`, date); - expect(spec.isBidRequestValid(bid)).to.equal(true); + + it('should return false when language param is missing', function () { + const bid = {...baseBid, params: {website: 'example.fr'}}; + expect(spec.isBidRequestValid(bid)).to.equal(false); }); - it('it should be true if the value in the localstorage is more than 5minutes of the actual time', function () { - const date = new Date(); - date.setMinutes(date.getMinutes() - 10); - localStorage.setItem(`PX_NoAds_${bid.params.website}`, date); - expect(spec.isBidRequestValid(bid)).to.equal(true); + + it('should return false when params object is empty', function () { + const bid = {...baseBid, params: {}}; + expect(spec.isBidRequestValid(bid)).to.equal(false); }); }); + describe('buildRequests', function () { - const url = { - cookieBase: 'https://abs.proxistore.com/v3/rtb/prebid/multi', - cookieLess: - 'https://abs.cookieless-proxistore.com/v3/rtb/prebid/multi', - }; - - let request = spec.buildRequests([bid], bidderRequest); - it('should return a valid object', function () { - expect(request).to.be.an('object'); - expect(request.method).to.exist; - expect(request.url).to.exist; - expect(request.data).to.exist; - }); - it('request method should be POST', function () { - expect(request.method).to.equal('POST'); - }); - it('should have the value consentGiven to true bc we have 418 in the vendor list', function () { - const data = JSON.parse(request.data); - expect(data.gdpr.consentString).equal( - bidderRequest.gdprConsent.consentString - ); - expect(data.gdpr.applies).to.be.true; - expect(data.gdpr.consentGiven).to.be.true; - }); - it('should contain a valid url', function () { - // has gdpr consent - expect(request.url).equal(url.cookieBase); - // doens't have gdpr consent - bidderRequest.gdprConsent.vendorData = null; - - request = spec.buildRequests([bid], bidderRequest); - expect(request.url).equal(url.cookieLess); - - // api v2 - bidderRequest.gdprConsent = { + describe('request structure', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + + it('should return a valid object', function () { + expect(request).to.be.an('object'); + expect(request.method).to.exist; + expect(request.url).to.exist; + expect(request.data).to.exist; + expect(request.options).to.exist; + }); + + it('should use POST method', function () { + expect(request.method).to.equal('POST'); + }); + + it('should have correct options', function () { + expect(request.options.contentType).to.equal('application/json'); + expect(request.options.customHeaders).to.deep.equal({version: '2.0.0'}); + }); + }); + + describe('OpenRTB data format', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + const data = request.data; + + it('should have valid OpenRTB structure', function () { + expect(data).to.be.an('object'); + expect(data.id).to.be.a('string'); + expect(data.imp).to.be.an('array'); + }); + + it('should have imp array with correct length', function () { + expect(data.imp.length).to.equal(1); + }); + + it('should have imp with banner object', function () { + expect(data.imp[0].banner).to.be.an('object'); + expect(data.imp[0].banner.format).to.be.an('array'); + }); + + it('should include banner formats from bid sizes', function () { + const formats = data.imp[0].banner.format; + expect(formats).to.deep.include({w: 300, h: 600}); + expect(formats).to.deep.include({w: 300, h: 250}); + }); + + it('should set imp.id to bidId', function () { + expect(data.imp[0].id).to.equal(baseBid.bidId); + }); + + it('should include tmax from bidderRequest timeout', function () { + expect(data.tmax).to.equal(1000); + }); + + it('should include website and language in ext.proxistore', function () { + expect(data.ext).to.be.an('object'); + expect(data.ext.proxistore).to.be.an('object'); + expect(data.ext.proxistore.website).to.equal('example.fr'); + expect(data.ext.proxistore.language).to.equal('fr'); + }); + }); + + describe('endpoint URL selection', function () { + it('should use cookie URL when GDPR consent is given for vendor 418', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.url).to.equal(COOKIE_BASE_URL); + }); + + it('should use cookieless URL when GDPR applies but consent not given', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithoutVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.url).to.equal(COOKIE_LESS_URL); + }); + + it('should use cookieless URL when vendorData is null', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentNoVendorData}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.url).to.equal(COOKIE_LESS_URL); + }); + + it('should use cookie URL when GDPR does not apply', function () { + const bidderRequest = { + ...baseBidderRequest, + gdprConsent: { + gdprApplies: false, + consentString: consentString, + }, + }; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.url).to.equal(COOKIE_BASE_URL); + }); + + it('should use cookie URL when no gdprConsent object', function () { + const bidderRequest = {...baseBidderRequest}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.url).to.equal(COOKIE_BASE_URL); + }); + }); + + describe('withCredentials option', function () { + it('should set withCredentials to true when consent given', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.options.withCredentials).to.be.true; + }); + + it('should set withCredentials to false when consent not given', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithoutVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.options.withCredentials).to.be.false; + }); + + it('should set withCredentials to false when no vendorData', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentNoVendorData}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.options.withCredentials).to.be.false; + }); + + it('should set withCredentials to false when no gdprConsent', function () { + const bidderRequest = {...baseBidderRequest}; + const request = spec.buildRequests([baseBid], bidderRequest); + expect(request.options.withCredentials).to.be.false; + }); + }); + + describe('multiple bids', function () { + it('should create imp for each bid request', function () { + const secondBid = { + ...baseBid, + bidId: '789789789', + adUnitCode: 'div-gpt-ad-456', + }; + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid, secondBid], bidderRequest); + const data = request.data; + + expect(data.imp.length).to.equal(2); + expect(data.imp[0].id).to.equal(baseBid.bidId); + expect(data.imp[1].id).to.equal(secondBid.bidId); + }); + }); + }); + + describe('interpretResponse', function () { + it('should return empty array for empty response', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + const emptyResponse = {body: null}; + + const bids = spec.interpretResponse(emptyResponse, request); + expect(bids).to.be.an('array'); + expect(bids.length).to.equal(0); + }); + + it('should return empty array for response with no seatbid', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + const response = {body: {id: '123', seatbid: []}}; + + const bids = spec.interpretResponse(response, request); + expect(bids).to.be.an('array'); + expect(bids.length).to.equal(0); + }); + + it('should correctly parse OpenRTB bid response', function () { + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid], bidderRequest); + const requestData = request.data; + + const serverResponse = { + body: { + id: requestData.id, + seatbid: [{ + seat: 'proxistore', + bid: [{ + id: 'bid-id-1', + impid: baseBid.bidId, + price: 6.25, + adm: '
Ad markup
', + w: 300, + h: 600, + crid: '22c3290b-8cd5-4cd6-8e8c-28a2de180ccd', + dealid: '2021-03_deal123', + adomain: ['advertiser.com'], + }], + }], + cur: 'EUR', + }, + }; + + const bids = spec.interpretResponse(serverResponse, request); + + expect(bids).to.be.an('array'); + expect(bids.length).to.equal(1); + + const bid = bids[0]; + expect(bid.requestId).to.equal(baseBid.bidId); + expect(bid.cpm).to.equal(6.25); + expect(bid.width).to.equal(300); + expect(bid.height).to.equal(600); + expect(bid.ad).to.equal('
Ad markup
'); + expect(bid.creativeId).to.equal('22c3290b-8cd5-4cd6-8e8c-28a2de180ccd'); + expect(bid.dealId).to.equal('2021-03_deal123'); + expect(bid.currency).to.equal('EUR'); + expect(bid.netRevenue).to.be.true; + expect(bid.ttl).to.equal(30); + expect(bid.meta.advertiserDomains).to.deep.equal(['advertiser.com']); + }); + + it('should handle multiple bids in response', function () { + const secondBid = { + ...baseBid, + bidId: '789789789', + adUnitCode: 'div-gpt-ad-456', + }; + const bidderRequest = {...baseBidderRequest, gdprConsent: gdprConsentWithVendor}; + const request = spec.buildRequests([baseBid, secondBid], bidderRequest); + const requestData = request.data; + + const serverResponse = { + body: { + id: requestData.id, + seatbid: [{ + seat: 'proxistore', + bid: [ + { + id: 'bid-id-1', + impid: baseBid.bidId, + price: 6.25, + adm: '
Ad 1
', + w: 300, + h: 600, + crid: 'creative-1', + }, + { + id: 'bid-id-2', + impid: secondBid.bidId, + price: 4.50, + adm: '
Ad 2
', + w: 300, + h: 250, + crid: 'creative-2', + }, + ], + }], + cur: 'EUR', + }, + }; + + const bids = spec.interpretResponse(serverResponse, request); + + expect(bids).to.be.an('array'); + expect(bids.length).to.equal(2); + expect(bids[0].requestId).to.equal(baseBid.bidId); + expect(bids[0].cpm).to.equal(6.25); + expect(bids[1].requestId).to.equal(secondBid.bidId); + expect(bids[1].cpm).to.equal(4.50); + }); + }); + + describe('getUserSyncs', function () { + const SYNC_BASE_URL = 'https://abs.proxistore.com/v3/rtb/sync'; + + it('should return empty array when GDPR applies and consent not given', function () { + const syncOptions = {pixelEnabled: true, iframeEnabled: true}; + const gdprConsent = { gdprApplies: true, - allowAuctionWithoutConsent: true, consentString: consentString, vendorData: { vendor: { - consents: { - 418: true, - }, + consents: {418: false}, }, }, - apiVersion: 2, }; - // has gdpr consent - request = spec.buildRequests([bid], bidderRequest); - expect(request.url).equal(url.cookieBase); - - bidderRequest.gdprConsent.vendorData.vendor = {}; - request = spec.buildRequests([bid], bidderRequest); - expect(request.url).equal(url.cookieLess); - }); - it('should have a property a length of bids equal to one if there is only one bid', function () { - const data = JSON.parse(request.data); - expect(data.hasOwnProperty('bids')).to.be.true; - expect(data.bids).to.be.an('array'); - expect(data.bids.length).equal(1); - expect(data.bids[0].hasOwnProperty('id')).to.be.true; - expect(data.bids[0].sizes).to.be.an('array'); - }); - it('should correctly set bidfloor on imp when getfloor in scope', function () { - let data = JSON.parse(request.data); - expect(data.bids[0].floor).to.be.null; - - bid.params['bidFloor'] = 1; - let req = spec.buildRequests([bid], bidderRequest); - data = JSON.parse(req.data); - expect(data.bids[0].floor).equal(1); - bid.getFloor = function () { - return { currency: 'USD', floor: 1.0 }; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.be.an('array'); + expect(syncs.length).to.equal(0); + }); + + it('should return pixel sync when pixelEnabled and consent given', function () { + const syncOptions = {pixelEnabled: true, iframeEnabled: false}; + const gdprConsent = { + gdprApplies: true, + consentString: consentString, + vendorData: { + vendor: { + consents: {418: true}, + }, + }, }; - req = spec.buildRequests([bid], bidderRequest); - data = JSON.parse(req.data); - expect(data.bids[0].floor).to.be.null; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.be.an('array'); + expect(syncs.length).to.equal(1); + expect(syncs[0].type).to.equal('image'); + expect(syncs[0].url).to.include(`${SYNC_BASE_URL}/image`); + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include(`gdpr_consent=${encodeURIComponent(consentString)}`); }); - }); - describe('interpretResponse', function () { - const emptyResponseParam = { body: [] }; - const fakeResponseParam = { - body: [ - { - ad: '', - cpm: 6.25, - creativeId: '22c3290b-8cd5-4cd6-8e8c-28a2de180ccd', - currency: 'EUR', - dealId: '2021-03_a63ec55e-b9bb-4ca4-b2c9-f456be67e656', - height: 600, - netRevenue: true, - requestId: '3543724f2a033c9', - segments: [], - ttl: 10, - vastUrl: null, - vastXml: null, - width: 300, + + it('should return iframe sync when iframeEnabled and consent given', function () { + const syncOptions = {pixelEnabled: false, iframeEnabled: true}; + const gdprConsent = { + gdprApplies: true, + consentString: consentString, + vendorData: { + vendor: { + consents: {418: true}, + }, }, - ], - }; - - it('should always return an array', function () { - let response = spec.interpretResponse(emptyResponseParam, bid); - expect(response).to.be.an('array'); - expect(response.length).equal(0); - response = spec.interpretResponse(fakeResponseParam, bid); - expect(response).to.be.an('array'); - expect(response.length).equal(1); + }; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.be.an('array'); + expect(syncs.length).to.equal(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.include(`${SYNC_BASE_URL}/iframe`); + }); + + it('should return both syncs when both enabled and consent given', function () { + const syncOptions = {pixelEnabled: true, iframeEnabled: true}; + const gdprConsent = { + gdprApplies: true, + consentString: consentString, + vendorData: { + vendor: { + consents: {418: true}, + }, + }, + }; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.be.an('array'); + expect(syncs.length).to.equal(2); + expect(syncs[0].type).to.equal('image'); + expect(syncs[1].type).to.equal('iframe'); + }); + + it('should return syncs when GDPR does not apply', function () { + const syncOptions = {pixelEnabled: true, iframeEnabled: true}; + const gdprConsent = { + gdprApplies: false, + consentString: consentString, + }; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.be.an('array'); + expect(syncs.length).to.equal(2); + expect(syncs[0].url).to.include('gdpr=0'); + }); + + it('should return syncs when no gdprConsent provided', function () { + const syncOptions = {pixelEnabled: true, iframeEnabled: true}; + + const syncs = spec.getUserSyncs(syncOptions, [], undefined); + expect(syncs).to.be.an('array'); + expect(syncs.length).to.equal(2); + }); + + it('should return empty array when no sync options enabled', function () { + const syncOptions = {pixelEnabled: false, iframeEnabled: false}; + const gdprConsent = { + gdprApplies: true, + consentString: consentString, + vendorData: { + vendor: { + consents: {418: true}, + }, + }, + }; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs).to.be.an('array'); + expect(syncs.length).to.equal(0); }); }); }); diff --git a/test/spec/modules/pubCircleBidAdapter_spec.js b/test/spec/modules/pubCircleBidAdapter_spec.js index 97953192a6e..ebf53063c52 100644 --- a/test/spec/modules/pubCircleBidAdapter_spec.js +++ b/test/spec/modules/pubCircleBidAdapter_spec.js @@ -432,7 +432,7 @@ describe('PubCircleBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -441,9 +441,7 @@ describe('PubCircleBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.pubcircle.ai/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -452,7 +450,7 @@ describe('PubCircleBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.pubcircle.ai/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/pubriseBidAdapter_spec.js b/test/spec/modules/pubriseBidAdapter_spec.js index 786f6a98b5c..37aaa964602 100644 --- a/test/spec/modules/pubriseBidAdapter_spec.js +++ b/test/spec/modules/pubriseBidAdapter_spec.js @@ -482,7 +482,7 @@ describe('PubriseBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -491,9 +491,7 @@ describe('PubriseBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.pubrise.ai/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -502,7 +500,7 @@ describe('PubriseBidAdapter', function () { expect(syncData[0].url).to.equal('https://sync.pubrise.ai/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/pubstackBidAdapter_spec.js b/test/spec/modules/pubstackBidAdapter_spec.js new file mode 100644 index 00000000000..1f8143e47fa --- /dev/null +++ b/test/spec/modules/pubstackBidAdapter_spec.js @@ -0,0 +1,348 @@ +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.placement.code')).to.equal('adunit-code'); + expect(utils.deepAccess(request, 'data.imp.0.ext.prebid.placement.viewability')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.imp.0.ext.prebid.placement.viewportDistance')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.imp.0.ext.prebid.placement.height')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.ext.prebid.version')).to.be.a('string'); + expect(utils.deepAccess(request, 'data.ext.prebid.request.count')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.ext.prebid.request.timeoutCount')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.ext.prebid.page.tabActive')).to.be.a('boolean'); + expect(utils.deepAccess(request, 'data.ext.prebid.page.height')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.ext.prebid.page.viewportHeight')).to.be.a('number'); + expect(utils.deepAccess(request, 'data.ext.prebid.page.timeFromNavigation')).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 counter 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.request.count')) + .to.equal(utils.deepAccess(firstRequest, 'data.ext.prebid.request.count') + 1); + }); + + it('updates timeout count after onTimeout callback', function () { + const bidRequest = createBidRequest({ bidId: 'bid-timeout-rate-1' }); + const firstRequest = spec.buildRequests([bidRequest], createBidderRequest(bidRequest)); + expect(utils.deepAccess(firstRequest, 'data.ext.prebid.request.timeoutCount')).to.equal(0); + + spec.onTimeout([]); + + const secondBidRequest = createBidRequest({ bidId: 'bid-timeout-rate-2' }); + const secondRequest = spec.buildRequests([secondBidRequest], createBidderRequest(secondBidRequest)); + expect(utils.deepAccess(secondRequest, 'data.ext.prebid.request.timeoutCount')).to.equal(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 }); + }); + }); +}); diff --git a/test/spec/modules/qtBidAdapter_spec.js b/test/spec/modules/qtBidAdapter_spec.js index 279962d0d3c..b2b7511cb18 100644 --- a/test/spec/modules/qtBidAdapter_spec.js +++ b/test/spec/modules/qtBidAdapter_spec.js @@ -481,7 +481,7 @@ describe('QTBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -490,9 +490,7 @@ describe('QTBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.qt.io/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0') }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -501,7 +499,7 @@ describe('QTBidAdapter', function () { expect(syncData[0].url).to.equal('https://cs.qt.io/image?pbjs=1&ccpa_consent=1---&coppa=0') }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/rakutenBidAdapter_spec.js b/test/spec/modules/rakutenBidAdapter_spec.js index e6cdb12e31d..9b3dd70f1a6 100644 --- a/test/spec/modules/rakutenBidAdapter_spec.js +++ b/test/spec/modules/rakutenBidAdapter_spec.js @@ -161,7 +161,7 @@ describe('rakutenBidAdapter', function() { pixelEnabled: true } }); - it('sucess usersync url', function () { + it('success usersync url', function () { const result = []; result.push({type: 'image', url: 'https://rdn1.test/sync?uid=9876543210'}); result.push({type: 'image', url: 'https://rdn2.test/sync?uid=9876543210'}); diff --git a/test/spec/modules/revantageBidAdapter_spec.js b/test/spec/modules/revantageBidAdapter_spec.js new file mode 100644 index 00000000000..b560c218bfd --- /dev/null +++ b/test/spec/modules/revantageBidAdapter_spec.js @@ -0,0 +1,994 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { spec } from '../../../modules/revantageBidAdapter.js'; +import { newBidder } from '../../../src/adapters/bidderFactory.js'; +import { deepClone } from '../../../src/utils.js'; +import { BANNER, VIDEO } from '../../../src/mediaTypes.js'; +import * as utils from '../../../src/utils.js'; + +const ENDPOINT_URL = 'https://bid.revantage.io/bid'; +const SYNC_URL = 'https://sync.revantage.io/sync'; + +describe('RevantageBidAdapter', function () { + const adapter = newBidder(spec); + + describe('inherited functions', function () { + it('exists and is a function', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + }); + + describe('isBidRequestValid', function () { + const validBid = { + bidder: 'revantage', + params: { + feedId: 'test-feed-123' + }, + adUnitCode: 'adunit-code', + sizes: [[300, 250], [300, 600]], + bidId: '30b31c1838de1e', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475' + }; + + it('should return true when required params found', function () { + expect(spec.isBidRequestValid(validBid)).to.equal(true); + }); + + it('should return false when bid is undefined', function () { + expect(spec.isBidRequestValid(undefined)).to.equal(false); + }); + + it('should return false when bid is null', function () { + expect(spec.isBidRequestValid(null)).to.equal(false); + }); + + it('should return false when params is missing', function () { + const invalidBid = deepClone(validBid); + delete invalidBid.params; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false when feedId is missing', function () { + const invalidBid = deepClone(validBid); + invalidBid.params = {}; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false when feedId is empty string', function () { + const invalidBid = deepClone(validBid); + invalidBid.params = { feedId: '' }; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return true with optional params', function () { + const bidWithOptional = deepClone(validBid); + bidWithOptional.params.placementId = 'test-placement'; + bidWithOptional.params.publisherId = 'test-publisher'; + expect(spec.isBidRequestValid(bidWithOptional)).to.equal(true); + }); + }); + + describe('buildRequests', function () { + const validBidRequests = [{ + bidder: 'revantage', + params: { + feedId: 'test-feed-123', + placementId: 'test-placement', + publisherId: 'test-publisher' + }, + adUnitCode: 'adunit-code', + sizes: [[300, 250], [300, 600]], + bidId: '30b31c1838de1e', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475', + mediaTypes: { + banner: { + sizes: [[300, 250], [300, 600]] + } + }, + getFloor: function(params) { + return { + currency: 'USD', + floor: 0.5 + }; + } + }]; + + const bidderRequest = { + auctionId: '1d1a030790a475', + bidderRequestId: '22edbae2733bf6', + timeout: 3000, + gdprConsent: { + consentString: 'BOJ/P2HOJ/P2HABABMAAAAAZ+A==', + gdprApplies: true + }, + uspConsent: '1---', + gppConsent: { + gppString: 'DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', + applicableSections: [7, 8] + }, + ortb2: { + site: { + domain: 'example.com', + page: 'https://example.com/test' + }, + device: { + ua: 'Mozilla/5.0...', + language: 'en' + } + } + }; + + it('should return valid request object', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + + expect(request).to.be.an('object'); + expect(request.method).to.equal('POST'); + expect(request.url).to.include(ENDPOINT_URL); + expect(request.url).to.include('feed=test-feed-123'); + expect(request.options.contentType).to.equal('text/plain'); + expect(request.options.withCredentials).to.equal(false); + expect(request.bidRequests).to.equal(validBidRequests); + }); + + it('should include all required OpenRTB fields', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.id).to.equal('1d1a030790a475'); + expect(data.imp).to.be.an('array').with.lengthOf(1); + expect(data.site).to.be.an('object'); + expect(data.device).to.be.an('object'); + expect(data.user).to.be.an('object'); + expect(data.regs).to.be.an('object'); + expect(data.cur).to.deep.equal(['USD']); + expect(data.tmax).to.equal(3000); + }); + + it('should build correct impression object', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = JSON.parse(request.data); + const imp = data.imp[0]; + + expect(imp.id).to.equal('30b31c1838de1e'); + expect(imp.tagid).to.equal('adunit-code'); + expect(imp.bidfloor).to.equal(0.5); + expect(imp.banner).to.be.an('object'); + expect(imp.banner.w).to.equal(300); + expect(imp.banner.h).to.equal(250); + expect(imp.banner.format).to.deep.equal([ + { w: 300, h: 250 }, + { w: 300, h: 600 } + ]); + }); + + it('should include bidder-specific ext parameters', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = JSON.parse(request.data); + const imp = data.imp[0]; + + expect(imp.ext.feedId).to.equal('test-feed-123'); + expect(imp.ext.bidder.placementId).to.equal('test-placement'); + expect(imp.ext.bidder.publisherId).to.equal('test-publisher'); + }); + + it('should include GDPR consent data', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.regs.ext.gdpr).to.equal(1); + expect(data.user.ext.consent).to.equal('BOJ/P2HOJ/P2HABABMAAAAAZ+A=='); + }); + + it('should include CCPA/USP consent', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.regs.ext.us_privacy).to.equal('1---'); + }); + + it('should include GPP consent with sections as array', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.regs.ext.gpp).to.equal('DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA'); + expect(data.regs.ext.gpp_sid).to.deep.equal([7, 8]); + }); + + it('should handle GDPR not applies', function () { + const bidderRequestNoGdpr = deepClone(bidderRequest); + bidderRequestNoGdpr.gdprConsent.gdprApplies = false; + + const request = spec.buildRequests(validBidRequests, bidderRequestNoGdpr); + const data = JSON.parse(request.data); + + expect(data.regs.ext.gdpr).to.equal(0); + }); + + it('should handle missing getFloor function', function () { + const bidRequestsWithoutFloor = deepClone(validBidRequests); + delete bidRequestsWithoutFloor[0].getFloor; + + const request = spec.buildRequests(bidRequestsWithoutFloor, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.imp[0].bidfloor).to.equal(0); + }); + + it('should handle getFloor returning non-USD currency', function () { + const bidRequestsEurFloor = deepClone(validBidRequests); + bidRequestsEurFloor[0].getFloor = function() { + return { currency: 'EUR', floor: 0.5 }; + }; + + const request = spec.buildRequests(bidRequestsEurFloor, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.imp[0].bidfloor).to.equal(0); + }); + + it('should handle missing ortb2 data', function () { + const bidderRequestNoOrtb2 = deepClone(bidderRequest); + delete bidderRequestNoOrtb2.ortb2; + + const request = spec.buildRequests(validBidRequests, bidderRequestNoOrtb2); + const data = JSON.parse(request.data); + + expect(data.site).to.be.an('object'); + expect(data.site.domain).to.exist; + expect(data.device).to.be.an('object'); + }); + + it('should include supply chain when present in bidderRequest', function () { + const bidderRequestWithSchain = deepClone(bidderRequest); + bidderRequestWithSchain.schain = { + ver: '1.0', + complete: 1, + nodes: [{ + asi: 'example.com', + sid: '12345', + hp: 1 + }] + }; + + const request = spec.buildRequests(validBidRequests, bidderRequestWithSchain); + const data = JSON.parse(request.data); + + expect(data.schain).to.exist; + expect(data.schain.ver).to.equal('1.0'); + expect(data.schain.complete).to.equal(1); + expect(data.schain.nodes).to.have.lengthOf(1); + }); + + it('should include supply chain from first bid request', function () { + const bidRequestsWithSchain = deepClone(validBidRequests); + bidRequestsWithSchain[0].schain = { + ver: '1.0', + complete: 1, + nodes: [{ asi: 'bidder.com', sid: '999', hp: 1 }] + }; + + const bidderRequestNoSchain = deepClone(bidderRequest); + delete bidderRequestNoSchain.schain; + + const request = spec.buildRequests(bidRequestsWithSchain, bidderRequestNoSchain); + const data = JSON.parse(request.data); + + expect(data.schain).to.exist; + expect(data.schain.nodes[0].asi).to.equal('bidder.com'); + }); + + it('should include user EIDs when present', function () { + const bidRequestsWithEids = deepClone(validBidRequests); + bidRequestsWithEids[0].userIdAsEids = [ + { + source: 'id5-sync.com', + uids: [{ id: 'test-id5-id', atype: 1 }] + } + ]; + + const request = spec.buildRequests(bidRequestsWithEids, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.user.eids).to.be.an('array'); + expect(data.user.eids[0].source).to.equal('id5-sync.com'); + }); + + it('should return empty array when feedIds differ across bids', function () { + const mixedFeedBidRequests = [ + { + bidder: 'revantage', + params: { feedId: 'feed-1' }, + adUnitCode: 'adunit-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + sizes: [[300, 250]], + bidId: 'bid1', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475' + }, + { + bidder: 'revantage', + params: { feedId: 'feed-2' }, + adUnitCode: 'adunit-2', + mediaTypes: { banner: { sizes: [[728, 90]] } }, + sizes: [[728, 90]], + bidId: 'bid2', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475' + } + ]; + + const request = spec.buildRequests(mixedFeedBidRequests, bidderRequest); + expect(request).to.deep.equal([]); + }); + + it('should return empty array on exception', function () { + const request = spec.buildRequests(null, bidderRequest); + expect(request).to.deep.equal([]); + }); + + it('should handle video media type', function () { + const videoBidRequests = [{ + bidder: 'revantage', + params: { feedId: 'test-feed-123' }, + adUnitCode: 'video-adunit', + bidId: 'video-bid-1', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475', + mediaTypes: { + video: { + playerSize: [[640, 480]], + mimes: ['video/mp4', 'video/webm'], + protocols: [2, 3, 5, 6], + api: [1, 2], + placement: 1, + minduration: 5, + maxduration: 30, + skip: 1, + skipmin: 5, + skipafter: 5 + } + } + }]; + + const request = spec.buildRequests(videoBidRequests, bidderRequest); + const data = JSON.parse(request.data); + const imp = data.imp[0]; + + expect(imp.video).to.exist; + expect(imp.video.w).to.equal(640); + expect(imp.video.h).to.equal(480); + expect(imp.video.mimes).to.deep.equal(['video/mp4', 'video/webm']); + expect(imp.video.protocols).to.deep.equal([2, 3, 5, 6]); + expect(imp.video.minduration).to.equal(5); + expect(imp.video.maxduration).to.equal(30); + expect(imp.video.skip).to.equal(1); + expect(imp.banner).to.be.undefined; + }); + + it('should handle multi-format (banner + video) bid', function () { + const multiFormatBidRequests = [{ + bidder: 'revantage', + params: { feedId: 'test-feed-123' }, + adUnitCode: 'multi-format-adunit', + bidId: 'multi-bid-1', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475', + mediaTypes: { + banner: { + sizes: [[300, 250]] + }, + video: { + playerSize: [[640, 480]], + mimes: ['video/mp4'] + } + } + }]; + + const request = spec.buildRequests(multiFormatBidRequests, bidderRequest); + const data = JSON.parse(request.data); + const imp = data.imp[0]; + + expect(imp.banner).to.exist; + expect(imp.video).to.exist; + }); + + it('should handle multiple impressions with same feedId', function () { + const multipleBidRequests = [ + { + bidder: 'revantage', + params: { feedId: 'test-feed-123' }, + adUnitCode: 'adunit-1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + sizes: [[300, 250]], + bidId: 'bid1', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475' + }, + { + bidder: 'revantage', + params: { feedId: 'test-feed-123' }, + adUnitCode: 'adunit-2', + mediaTypes: { banner: { sizes: [[728, 90]] } }, + sizes: [[728, 90]], + bidId: 'bid2', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475' + } + ]; + + const request = spec.buildRequests(multipleBidRequests, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.imp).to.have.lengthOf(2); + expect(data.imp[0].id).to.equal('bid1'); + expect(data.imp[1].id).to.equal('bid2'); + }); + + it('should use default sizes when sizes array is empty', function () { + const bidWithEmptySizes = [{ + bidder: 'revantage', + params: { feedId: 'test-feed' }, + adUnitCode: 'adunit-code', + mediaTypes: { banner: { sizes: [] } }, + sizes: [], + bidId: '30b31c1838de1e', + bidderRequestId: '22edbae2733bf6', + auctionId: '1d1a030790a475' + }]; + + const request = spec.buildRequests(bidWithEmptySizes, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.imp[0].banner.w).to.equal(300); + expect(data.imp[0].banner.h).to.equal(250); + }); + + it('should include prebid version in ext', function () { + const request = spec.buildRequests(validBidRequests, bidderRequest); + const data = JSON.parse(request.data); + + expect(data.ext).to.exist; + expect(data.ext.prebid).to.exist; + expect(data.ext.prebid.version).to.exist; + }); + }); + + describe('interpretResponse', function () { + const serverResponse = { + body: { + id: '1d1a030790a475', + seatbid: [{ + seat: 'test-dsp', + bid: [{ + id: 'test-bid-id', + impid: '30b31c1838de1e', + price: 1.25, + crid: 'test-creative-123', + adm: '
Test Ad Markup
', + w: 300, + h: 250, + adomain: ['advertiser.com'], + dealid: 'deal-123' + }] + }], + cur: 'USD' + } + }; + + const bidRequest = { + bidRequests: [{ + bidId: '30b31c1838de1e', + adUnitCode: 'adunit-code', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + }] + }; + + it('should return valid banner bid response', function () { + const result = spec.interpretResponse(serverResponse, bidRequest); + + expect(result).to.be.an('array').with.lengthOf(1); + + const bid = result[0]; + expect(bid.requestId).to.equal('30b31c1838de1e'); + expect(bid.cpm).to.equal(1.25); + expect(bid.width).to.equal(300); + expect(bid.height).to.equal(250); + expect(bid.creativeId).to.equal('test-creative-123'); + expect(bid.currency).to.equal('USD'); + expect(bid.netRevenue).to.equal(true); + expect(bid.ttl).to.equal(300); + expect(bid.ad).to.equal('
Test Ad Markup
'); + expect(bid.mediaType).to.equal(BANNER); + expect(bid.dealId).to.equal('deal-123'); + }); + + it('should include meta data in bid response', function () { + const result = spec.interpretResponse(serverResponse, bidRequest); + const bid = result[0]; + + expect(bid.meta).to.be.an('object'); + expect(bid.meta.advertiserDomains).to.deep.equal(['advertiser.com']); + expect(bid.meta.dsp).to.equal('test-dsp'); + expect(bid.meta.networkName).to.equal('Revantage'); + }); + + it('should include burl when provided', function () { + const responseWithBurl = deepClone(serverResponse); + responseWithBurl.body.seatbid[0].bid[0].burl = 'https://bid.revantage.io/win?auction=1d1a030790a475&dsp=test-dsp&price=0.625000&impid=30b31c1838de1e&bidid=test-bid-id&adid=test-creative-123&page=&domain=&country=&feedid=test-feed&ref='; + + const result = spec.interpretResponse(responseWithBurl, bidRequest); + const bid = result[0]; + + expect(bid.burl).to.include('https://bid.revantage.io/win'); + expect(bid.burl).to.include('dsp=test-dsp'); + expect(bid.burl).to.include('impid=30b31c1838de1e'); + }); + + it('should handle video response with vastXml', function () { + const videoResponse = deepClone(serverResponse); + videoResponse.body.seatbid[0].bid[0].vastXml = '...'; + delete videoResponse.body.seatbid[0].bid[0].adm; + + const videoBidRequest = { + bidRequests: [{ + bidId: '30b31c1838de1e', + adUnitCode: 'video-adunit', + mediaTypes: { + video: { + playerSize: [[640, 480]] + } + } + }] + }; + + const result = spec.interpretResponse(videoResponse, videoBidRequest); + const bid = result[0]; + + expect(bid.mediaType).to.equal(VIDEO); + expect(bid.vastXml).to.equal('...'); + }); + + it('should handle video response with vastUrl', function () { + const videoResponse = deepClone(serverResponse); + videoResponse.body.seatbid[0].bid[0].vastUrl = 'https://vast.example.com/vast.xml'; + delete videoResponse.body.seatbid[0].bid[0].adm; + + const videoBidRequest = { + bidRequests: [{ + bidId: '30b31c1838de1e', + adUnitCode: 'video-adunit', + mediaTypes: { + video: { + playerSize: [[640, 480]] + } + } + }] + }; + + const result = spec.interpretResponse(videoResponse, videoBidRequest); + const bid = result[0]; + + expect(bid.mediaType).to.equal(VIDEO); + expect(bid.vastUrl).to.equal('https://vast.example.com/vast.xml'); + }); + + it('should detect video from ext.mediaType', function () { + const videoResponse = deepClone(serverResponse); + videoResponse.body.seatbid[0].bid[0].adm = '...'; + videoResponse.body.seatbid[0].bid[0].ext = { mediaType: 'video' }; + + const result = spec.interpretResponse(videoResponse, bidRequest); + const bid = result[0]; + + expect(bid.mediaType).to.equal(VIDEO); + expect(bid.vastXml).to.equal('...'); + }); + + it('should use default dimensions from bid request when missing in response', function () { + const responseNoDimensions = deepClone(serverResponse); + delete responseNoDimensions.body.seatbid[0].bid[0].w; + delete responseNoDimensions.body.seatbid[0].bid[0].h; + + const result = spec.interpretResponse(responseNoDimensions, bidRequest); + const bid = result[0]; + + expect(bid.width).to.equal(300); + expect(bid.height).to.equal(250); + }); + + it('should include dspPrice from ext when available', function () { + const responseWithDspPrice = deepClone(serverResponse); + responseWithDspPrice.body.seatbid[0].bid[0].ext = { dspPrice: 1.50 }; + + const result = spec.interpretResponse(responseWithDspPrice, bidRequest); + const bid = result[0]; + + expect(bid.meta.dspPrice).to.equal(1.50); + }); + + it('should return empty array for null response body', function () { + const result = spec.interpretResponse({ body: null }, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should return empty array for undefined response body', function () { + const result = spec.interpretResponse({}, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should return empty array when seatbid is not an array', function () { + const invalidResponse = { + body: { + id: '1d1a030790a475', + seatbid: 'not-an-array', + cur: 'USD' + } + }; + + const result = spec.interpretResponse(invalidResponse, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should return empty array for empty seatbid', function () { + const emptyResponse = { + body: { + id: '1d1a030790a475', + seatbid: [], + cur: 'USD' + } + }; + + const result = spec.interpretResponse(emptyResponse, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should filter out bids with zero price', function () { + const zeroPriceResponse = deepClone(serverResponse); + zeroPriceResponse.body.seatbid[0].bid[0].price = 0; + + const result = spec.interpretResponse(zeroPriceResponse, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should filter out bids with negative price', function () { + const negativePriceResponse = deepClone(serverResponse); + negativePriceResponse.body.seatbid[0].bid[0].price = -1; + + const result = spec.interpretResponse(negativePriceResponse, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should filter out bids without ad markup', function () { + const noAdmResponse = deepClone(serverResponse); + delete noAdmResponse.body.seatbid[0].bid[0].adm; + + const result = spec.interpretResponse(noAdmResponse, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should filter out bids with unknown impid', function () { + const unknownImpidResponse = deepClone(serverResponse); + unknownImpidResponse.body.seatbid[0].bid[0].impid = 'unknown-imp-id'; + + const result = spec.interpretResponse(unknownImpidResponse, bidRequest); + expect(result).to.deep.equal([]); + }); + + it('should handle missing bidRequests in request object', function () { + const result = spec.interpretResponse(serverResponse, {}); + expect(result).to.deep.equal([]); + }); + + it('should handle multiple seatbids', function () { + const multiSeatResponse = deepClone(serverResponse); + multiSeatResponse.body.seatbid.push({ + seat: 'another-dsp', + bid: [{ + id: 'another-bid-id', + impid: 'another-imp-id', + price: 2.00, + crid: 'another-creative', + adm: '
Another Ad
', + w: 728, + h: 90, + adomain: ['another-advertiser.com'] + }] + }); + + const multiBidRequest = { + bidRequests: [ + { + bidId: '30b31c1838de1e', + adUnitCode: 'adunit-code', + mediaTypes: { banner: { sizes: [[300, 250]] } } + }, + { + bidId: 'another-imp-id', + adUnitCode: 'adunit-code-2', + mediaTypes: { banner: { sizes: [[728, 90]] } } + } + ] + }; + + const result = spec.interpretResponse(multiSeatResponse, multiBidRequest); + + expect(result).to.have.lengthOf(2); + expect(result[0].meta.dsp).to.equal('test-dsp'); + expect(result[1].meta.dsp).to.equal('another-dsp'); + }); + + it('should use default currency USD when not specified', function () { + const noCurrencyResponse = deepClone(serverResponse); + delete noCurrencyResponse.body.cur; + + const result = spec.interpretResponse(noCurrencyResponse, bidRequest); + const bid = result[0]; + + expect(bid.currency).to.equal('USD'); + }); + + it('should generate creativeId when crid is missing', function () { + const noCridResponse = deepClone(serverResponse); + delete noCridResponse.body.seatbid[0].bid[0].crid; + + const result = spec.interpretResponse(noCridResponse, bidRequest); + const bid = result[0]; + + expect(bid.creativeId).to.exist; + expect(bid.creativeId).to.satisfy(crid => + crid === 'test-bid-id' || crid.startsWith('revantage-') + ); + }); + + it('should handle empty adomain array', function () { + const noAdomainResponse = deepClone(serverResponse); + delete noAdomainResponse.body.seatbid[0].bid[0].adomain; + + const result = spec.interpretResponse(noAdomainResponse, bidRequest); + const bid = result[0]; + + expect(bid.meta.advertiserDomains).to.deep.equal([]); + }); + + it('should use "unknown" for missing seat', function () { + const noSeatResponse = deepClone(serverResponse); + delete noSeatResponse.body.seatbid[0].seat; + + const result = spec.interpretResponse(noSeatResponse, bidRequest); + const bid = result[0]; + + expect(bid.meta.dsp).to.equal('unknown'); + }); + }); + + describe('getUserSyncs', function () { + const syncOptions = { + iframeEnabled: true, + pixelEnabled: true + }; + + const gdprConsent = { + gdprApplies: true, + consentString: 'BOJ/P2HOJ/P2HABABMAAAAAZ+A==' + }; + + const uspConsent = '1---'; + + const gppConsent = { + gppString: 'DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', + applicableSections: [7, 8] + }; + + it('should return iframe sync when iframe enabled', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: true, pixelEnabled: false }, + [], + gdprConsent, + uspConsent, + gppConsent + ); + + expect(syncs).to.be.an('array').with.lengthOf(1); + expect(syncs[0].type).to.equal('iframe'); + expect(syncs[0].url).to.include(SYNC_URL); + }); + + it('should return pixel sync when pixel enabled', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: false, pixelEnabled: true }, + [], + gdprConsent, + uspConsent, + gppConsent + ); + + expect(syncs).to.be.an('array').with.lengthOf(1); + expect(syncs[0].type).to.equal('image'); + expect(syncs[0].url).to.include(SYNC_URL); + expect(syncs[0].url).to.include('tag=img'); + }); + + it('should return both syncs when both enabled', function () { + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, gppConsent); + + expect(syncs).to.have.lengthOf(2); + expect(syncs.map(s => s.type)).to.include('iframe'); + expect(syncs.map(s => s.type)).to.include('image'); + }); + + it('should include cache buster parameter', function () { + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, gppConsent); + + expect(syncs[0].url).to.include('cb='); + }); + + it('should include GDPR parameters when consent applies', function () { + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, gppConsent); + + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include('gdpr_consent=BOJ%2FP2HOJ%2FP2HABABMAAAAAZ%2BA%3D%3D'); + }); + + it('should set gdpr=0 when GDPR does not apply', function () { + const gdprNotApplies = { + gdprApplies: false, + consentString: 'BOJ/P2HOJ/P2HABABMAAAAAZ+A==' + }; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprNotApplies, uspConsent, gppConsent); + + expect(syncs[0].url).to.include('gdpr=0'); + }); + + it('should include USP consent parameter', function () { + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, gppConsent); + + expect(syncs[0].url).to.include('us_privacy=1---'); + }); + + it('should include GPP parameters', function () { + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, gppConsent); + + expect(syncs[0].url).to.include('gpp='); + expect(syncs[0].url).to.include('gpp_sid=7%2C8'); + }); + + it('should handle missing GDPR consent', function () { + const syncs = spec.getUserSyncs(syncOptions, [], null, uspConsent, gppConsent); + + expect(syncs[0].url).to.not.include('gdpr='); + expect(syncs[0].url).to.not.include('gdpr_consent='); + }); + + it('should handle missing USP consent', function () { + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, null, gppConsent); + + expect(syncs[0].url).to.not.include('us_privacy='); + }); + + it('should handle missing GPP consent', function () { + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, null); + + expect(syncs[0].url).to.not.include('gpp='); + expect(syncs[0].url).to.not.include('gpp_sid='); + }); + + it('should handle undefined GPP string', function () { + const partialGppConsent = { + applicableSections: [7, 8] + }; + + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, partialGppConsent); + + expect(syncs[0].url).to.not.include('gpp='); + expect(syncs[0].url).to.include('gpp_sid=7%2C8'); + }); + + it('should return empty array when no sync options enabled', function () { + const syncs = spec.getUserSyncs( + { iframeEnabled: false, pixelEnabled: false }, + [], + gdprConsent, + uspConsent, + gppConsent + ); + + expect(syncs).to.be.an('array').that.is.empty; + }); + + it('should return empty array when syncOptions is empty object', function () { + const syncs = spec.getUserSyncs({}, [], gdprConsent, uspConsent, gppConsent); + + expect(syncs).to.be.an('array').that.is.empty; + }); + }); + + describe('onBidWon', function () { + let triggerPixelStub; + + beforeEach(function () { + triggerPixelStub = sinon.stub(utils, 'triggerPixel'); + }); + + afterEach(function () { + triggerPixelStub.restore(); + }); + + it('should call triggerPixel with correct burl', function () { + const bid = { + bidId: '30b31c1838de1e', + cpm: 1.25, + adUnitCode: 'adunit-code', + burl: 'https://bid.revantage.io/win?auction=1d1a030790a475&dsp=test-dsp&price=0.625000&impid=30b31c1838de1e&bidid=test-bid-id&adid=test-ad-123&page=https%3A%2F%2Fexample.com&domain=example.com&country=US&feedid=test-feed&ref=' + }; + + spec.onBidWon(bid); + + expect(triggerPixelStub.calledOnce).to.be.true; + expect(triggerPixelStub.firstCall.args[0]).to.include('https://bid.revantage.io/win'); + expect(triggerPixelStub.firstCall.args[0]).to.include('dsp=test-dsp'); + expect(triggerPixelStub.firstCall.args[0]).to.include('impid=30b31c1838de1e'); + expect(triggerPixelStub.firstCall.args[0]).to.include('feedid=test-feed'); + }); + + it('should not throw error when burl is missing', function () { + const bid = { + bidId: '30b31c1838de1e', + cpm: 1.25, + adUnitCode: 'adunit-code' + }; + + expect(() => spec.onBidWon(bid)).to.not.throw(); + expect(triggerPixelStub.called).to.be.false; + }); + + it('should handle burl with all query parameters', function () { + // This is the actual format generated by your RTB server + const burl = 'https://bid.revantage.io/win?' + + 'auction=auction_123456789' + + '&dsp=Improve_Digital' + + '&price=0.750000' + + '&impid=imp_001%7Cfeed123' + // URL encoded pipe for feedId in impid + '&bidid=bid_abc' + + '&adid=creative_xyz' + + '&page=https%3A%2F%2Fexample.com%2Fpage' + + '&domain=example.com' + + '&country=US' + + '&feedid=feed123' + + '&ref=https%3A%2F%2Fgoogle.com'; + + const bid = { + bidId: 'imp_001', + cpm: 1.50, + burl: burl + }; + + spec.onBidWon(bid); + + expect(triggerPixelStub.calledOnce).to.be.true; + const calledUrl = triggerPixelStub.firstCall.args[0]; + expect(calledUrl).to.include('auction=auction_123456789'); + expect(calledUrl).to.include('dsp=Improve_Digital'); + expect(calledUrl).to.include('price=0.750000'); + expect(calledUrl).to.include('domain=example.com'); + expect(calledUrl).to.include('country=US'); + expect(calledUrl).to.include('feedid=feed123'); + }); + }); + + describe('spec properties', function () { + it('should have correct bidder code', function () { + expect(spec.code).to.equal('revantage'); + }); + + it('should support banner and video media types', function () { + expect(spec.supportedMediaTypes).to.deep.equal([BANNER, VIDEO]); + }); + }); +}); diff --git a/test/spec/modules/rocketlabBidAdapter_spec.js b/test/spec/modules/rocketlabBidAdapter_spec.js index fc162c67959..ffe48e4c2d9 100644 --- a/test/spec/modules/rocketlabBidAdapter_spec.js +++ b/test/spec/modules/rocketlabBidAdapter_spec.js @@ -544,7 +544,7 @@ describe("RocketLabBidAdapter", function () { consentString: "ALL", gdprApplies: true, }, - {} + undefined ); expect(syncData).to.be.an("array").which.is.not.empty; expect(syncData[0]).to.be.an("object"); @@ -560,9 +560,7 @@ describe("RocketLabBidAdapter", function () { {}, {}, {}, - { - consentString: "1---", - } + "1---" ); expect(syncData).to.be.an("array").which.is.not.empty; expect(syncData[0]).to.be.an("object"); @@ -578,7 +576,7 @@ describe("RocketLabBidAdapter", function () { {}, {}, {}, - {}, + undefined, { gppString: "abc123", applicableSections: [8], diff --git a/test/spec/modules/rubiconBidAdapter_spec.js b/test/spec/modules/rubiconBidAdapter_spec.js index bd9990f75de..831f349cef5 100644 --- a/test/spec/modules/rubiconBidAdapter_spec.js +++ b/test/spec/modules/rubiconBidAdapter_spec.js @@ -1815,7 +1815,7 @@ describe('the rubicon adapter', function () { }) }) - it('should send gpid and pbadslot since it is prefered over dfp code', function () { + it('should send gpid and pbadslot since it is preferred over dfp code', function () { bidderRequest.bids[0].ortb2Imp = { ext: { gpid: '/1233/sports&div1', diff --git a/test/spec/modules/rules_spec.js b/test/spec/modules/rules_spec.js new file mode 100644 index 00000000000..12353de1a2e --- /dev/null +++ b/test/spec/modules/rules_spec.js @@ -0,0 +1,856 @@ +import { expect } from 'chai'; +import * as rulesModule from 'modules/rules/index.ts'; +import * as utils from 'src/utils.js'; +import * as storageManager from 'src/storageManager.js'; +import * as analyticsAdapter from 'libraries/analyticsAdapter/AnalyticsAdapter.ts'; +import { isActivityAllowed } from 'src/activities/rules.js'; +import { activityParams } from 'src/activities/activityParams.js'; +import { ACTIVITY_FETCH_BIDS, ACTIVITY_ADD_BID_RESPONSE } from 'src/activities/activities.js'; +import { MODULE_TYPE_BIDDER } from 'src/activities/modules.ts'; +import { config } from 'src/config.js'; + +describe('Rules Module', function() { + let sandbox; + let logWarnStub; + let logInfoStub; + let newStorageManagerStub; + + beforeEach(function() { + sandbox = sinon.createSandbox(); + + logWarnStub = sandbox.stub(utils, 'logWarn'); + logInfoStub = sandbox.stub(utils, 'logInfo'); + + const mockStorageManager = { + localStorageIsEnabled: sandbox.stub().returns(true), + getDataFromLocalStorage: sandbox.stub().returns(null), + setDataInLocalStorage: sandbox.stub() + }; + newStorageManagerStub = sandbox.stub(storageManager, 'newStorageManager').returns(mockStorageManager); + }); + + afterEach(function() { + sandbox.restore(); + config.resetConfig(); + rulesModule.reset(); + }); + + describe('getAssignedModelGroups', function() { + it('should select model group based on weights', function() { + const rulesets = [{ + name: 'testRuleSet', + stage: 'processed-auction-request', + modelGroups: [{ + weight: 50, + selected: false, + analyticsKey: 'testKey1', + schema: [], + rules: [] + }, { + weight: 50, + selected: false, + analyticsKey: 'testKey2', + schema: [], + rules: [] + }] + }]; + + // Mock Math.random to return 0.15 (15 < 50, so first group should be selected) + // randomValue = 0.15 * 100 = 15, 15 < 50 so first group selected + sandbox.stub(Math, 'random').returns(0.15); + + const result = rulesModule.getAssignedModelGroups(rulesets); + + // Verify that first model group was selected in the returned result + expect(result[0].modelGroups[0].selected).to.be.true; + expect(result[0].modelGroups[1].selected).to.be.false; + // Verify original was not mutated + expect(rulesets[0].modelGroups[0].selected).to.be.false; + expect(rulesets[0].modelGroups[1].selected).to.be.false; + }); + + it('should use default weight of 100 when weight is not specified', function() { + const rulesets = [{ + name: 'testRuleSet', + stage: 'processed-auction-request', + modelGroups: [{ + // weight not specified, should default to 100 + selected: false, + analyticsKey: 'testKey1', + schema: [], + rules: [] + }, { + // weight not specified, should default to 100 + selected: false, + analyticsKey: 'testKey2', + schema: [], + rules: [] + }, { + // weight not specified, should default to 100 + selected: false, + analyticsKey: 'testKey3', + schema: [], + rules: [] + }] + }]; + + // Mock Math.random to return value that selects last group + // randomValue = 0.85 * 300 = 255 + // Cumulative weights: [100, 200, 300] + // First group: 255 < 100? No + // Second group: 255 < 200? No + // Third group: 255 < 300? Yes, selected! + sandbox.stub(Math, 'random').returns(0.85); + + const result = rulesModule.getAssignedModelGroups(rulesets); + + expect(result[0].modelGroups[0].selected).to.be.false; + expect(result[0].modelGroups[1].selected).to.be.false; + expect(result[0].modelGroups[2].selected).to.be.true; + // Verify original was not mutated + expect(rulesets[0].modelGroups[0].selected).to.be.false; + expect(rulesets[0].modelGroups[1].selected).to.be.false; + expect(rulesets[0].modelGroups[2].selected).to.be.false; + }); + + it('should select correctly regardless of weight order (descending)', function() { + const rulesets = [{ + name: 'testRuleSet', + stage: 'processed-auction-request', + modelGroups: [{ + weight: 100, // largest first + selected: false, + analyticsKey: 'testKey1', + schema: [], + rules: [] + }, { + weight: 50, // medium + selected: false, + analyticsKey: 'testKey2', + schema: [], + rules: [] + }, { + weight: 25, // smallest last + selected: false, + analyticsKey: 'testKey3', + schema: [], + rules: [] + }] + }]; + + // randomValue = 0.3 * 175 = 52.5 + // Cumulative weights: [100, 150, 175] + // First group: 52.5 < 100? Yes, selected! + sandbox.stub(Math, 'random').returns(0.3); + + const result = rulesModule.getAssignedModelGroups(rulesets); + + expect(result[0].modelGroups[0].selected).to.be.true; + expect(result[0].modelGroups[1].selected).to.be.false; + expect(result[0].modelGroups[2].selected).to.be.false; + }); + + it('should select correctly regardless of weight order (mixed)', function() { + const rulesets = [{ + name: 'testRuleSet', + stage: 'processed-auction-request', + modelGroups: [{ + weight: 30, // medium + selected: false, + analyticsKey: 'testKey1', + schema: [], + rules: [] + }, { + weight: 100, // largest in middle + selected: false, + analyticsKey: 'testKey2', + schema: [], + rules: [] + }, { + weight: 20, // smallest last + selected: false, + analyticsKey: 'testKey3', + schema: [], + rules: [] + }] + }]; + + // randomValue = 0.6 * 150 = 90 + // Cumulative weights: [30, 130, 150] + // First group: 90 < 30? No + // Second group: 90 < 130? Yes, selected! + sandbox.stub(Math, 'random').returns(0.6); + + const result = rulesModule.getAssignedModelGroups(rulesets); + + expect(result[0].modelGroups[0].selected).to.be.false; + expect(result[0].modelGroups[1].selected).to.be.true; + expect(result[0].modelGroups[2].selected).to.be.false; + }); + + it('should select last group when randomValue is in last range', function() { + const rulesets = [{ + name: 'testRuleSet', + stage: 'processed-auction-request', + modelGroups: [{ + weight: 10, + selected: false, + analyticsKey: 'testKey1', + schema: [], + rules: [] + }, { + weight: 20, + selected: false, + analyticsKey: 'testKey2', + schema: [], + rules: [] + }, { + weight: 70, // largest weight + selected: false, + analyticsKey: 'testKey3', + schema: [], + rules: [] + }] + }]; + + // randomValue = 0.8 * 100 = 80 + // Cumulative weights: [10, 30, 100] + // First group: 80 < 10? No + // Second group: 80 < 30? No + // Third group: 80 < 100? Yes, selected! + sandbox.stub(Math, 'random').returns(0.8); + + const result = rulesModule.getAssignedModelGroups(rulesets); + + expect(result[0].modelGroups[0].selected).to.be.false; + expect(result[0].modelGroups[1].selected).to.be.false; + expect(result[0].modelGroups[2].selected).to.be.true; + }); + + it('should select first group when randomValue is very small', function() { + const rulesets = [{ + name: 'testRuleSet', + stage: 'processed-auction-request', + modelGroups: [{ + weight: 10, // smallest + selected: false, + analyticsKey: 'testKey1', + schema: [], + rules: [] + }, { + weight: 50, + selected: false, + analyticsKey: 'testKey2', + schema: [], + rules: [] + }, { + weight: 40, + selected: false, + analyticsKey: 'testKey3', + schema: [], + rules: [] + }] + }]; + + // randomValue = 0.01 * 100 = 1 + // Cumulative weights: [10, 60, 100] + // First group: 1 < 10? Yes, selected! + sandbox.stub(Math, 'random').returns(0.01); + + const result = rulesModule.getAssignedModelGroups(rulesets); + + expect(result[0].modelGroups[0].selected).to.be.true; + expect(result[0].modelGroups[1].selected).to.be.false; + expect(result[0].modelGroups[2].selected).to.be.false; + }); + }); + + describe('evaluateConfig', function() { + beforeEach(function() { + rulesModule.registerActivities(); + }); + + [ + ['processed-auction-request', ACTIVITY_FETCH_BIDS], + ['processed-auction', ACTIVITY_ADD_BID_RESPONSE] + ].forEach(([stage, activity]) => { + it(`should exclude bidder when it matches bidders list for ${stage} stage`, function() { + const rulesJson = { + enabled: true, + timestamp: '1234567890', + ruleSets: [{ + name: 'testRuleSet', + stage: stage, + version: '1.0', + modelGroups: [{ + weight: 100, + selected: true, + analyticsKey: 'testAnalyticsKey', + schema: [{ function: 'adUnitCode', args: [] }], + rules: [{ + conditions: ['adUnit-0000'], + results: [{ + function: 'excludeBidders', + args: [{ + bidders: ['bidder1'], + analyticsValue: 'excluded' + }] + }] + }] + }] + }] + }; + + sandbox.stub(Math, 'random').returns(0.5); + + const bidder1Params = activityParams(MODULE_TYPE_BIDDER, 'bidder1', { + adUnit: { code: 'adUnit-0000' }, + auctionId: 'test-auction-id' + }); + + const bidder2Params = activityParams(MODULE_TYPE_BIDDER, 'bidder2', { + adUnit: { code: 'adUnit-0000' }, + auctionId: 'test-auction-id' + }); + + expect(isActivityAllowed(activity, bidder1Params)).to.be.true; + expect(isActivityAllowed(activity, bidder2Params)).to.be.true; + + rulesModule.evaluateConfig(rulesJson, 'test-auction-id'); + + expect(isActivityAllowed(activity, bidder1Params)).to.be.false; + expect(isActivityAllowed(activity, bidder2Params)).to.be.true; + }); + + it(`should include only bidder when it matches bidders list for ${stage} stage`, function() { + const rulesJson = { + enabled: true, + timestamp: '1234567890', + ruleSets: [{ + name: 'testRuleSet', + stage: stage, + version: '1.0', + modelGroups: [{ + weight: 100, + selected: true, + analyticsKey: 'testAnalyticsKey', + schema: [{ function: 'adUnitCode', args: [] }], + rules: [{ + conditions: ['adUnit-0000'], + results: [{ + function: 'includeBidders', + args: [{ + bidders: ['bidder1'], + analyticsValue: 'included' + }] + }] + }] + }] + }] + }; + + sandbox.stub(Math, 'random').returns(0.5); + + const bidder1Params = activityParams(MODULE_TYPE_BIDDER, 'bidder1', { + adUnit: { code: 'adUnit-0000' }, + auctionId: 'test-auction-id' + }); + + const bidder2Params = activityParams(MODULE_TYPE_BIDDER, 'bidder2', { + adUnit: { code: 'adUnit-0000' }, + auctionId: 'test-auction-id' + }); + + expect(isActivityAllowed(activity, bidder1Params)).to.be.true; + expect(isActivityAllowed(activity, bidder2Params)).to.be.true; + + rulesModule.evaluateConfig(rulesJson, 'test-auction-id'); + + expect(isActivityAllowed(activity, bidder1Params)).to.be.true; + expect(isActivityAllowed(activity, bidder2Params)).to.be.false; + }); + }); + + it('should execute default rules when provided and no rules match', function() { + const setLabelsStub = sandbox.stub(analyticsAdapter, 'setLabels'); + const rulesJson = { + enabled: true, + timestamp: '1234567890', + ruleSets: [{ + name: 'testRuleSet', + stage: 'processed-auction-request', + version: '1.0', + modelGroups: [{ + weight: 100, + selected: true, + analyticsKey: 'testAnalyticsKey', + schema: [{ + function: 'percent', + args: [5] + }], + default: [{ + function: 'logAtag', + args: { analyticsValue: 'default-allow' } + }], + rules: [{ + conditions: ['true'], + results: [{ + function: 'excludeBidders', + args: [{ + bidders: ['bidder1'], + analyticsValue: 'excluded' + }] + }] + }] + }] + }] + }; + + sandbox.stub(Math, 'random').returns(0.5); + const auctionId = 'test-auction-id'; + rulesModule.evaluateConfig(rulesJson, auctionId); + + const bidder1Params = activityParams(MODULE_TYPE_BIDDER, 'bidder1', { + auctionId + }); + + expect(isActivityAllowed(ACTIVITY_FETCH_BIDS, bidder1Params)).to.be.true; + + expect(setLabelsStub.calledWith({ [auctionId + '-testAnalyticsKey']: 'default-allow' })).to.be.true; + + setLabelsStub.resetHistory(); + }); + }); + + describe('getGlobalRandom', function() { + it('should return the same value for the same auctionId and call Math.random only once', function() { + const auctionId = 'test-auction-id'; + const otherAuctionId = 'other-auction-id'; + const mathRandomStub = sandbox.stub(Math, 'random').returns(0.42); + const auction1 = {auctionId: auctionId}; + const auction2 = {auctionId: otherAuctionId}; + const auctions = { + [auctionId]: auction1, + [otherAuctionId]: auction2 + } + + const index = { + getAuction: ({auctionId}) => auctions[auctionId] + } + + const result1 = rulesModule.dep.getGlobalRandom(auctionId, index); + const result2 = rulesModule.dep.getGlobalRandom(auctionId, index); + const result3 = rulesModule.dep.getGlobalRandom(auctionId, index); + + expect(result1).to.equal(0.42); + expect(result2).to.equal(0.42); + expect(result3).to.equal(0.42); + expect(mathRandomStub.calledOnce).to.equal(true); + + mathRandomStub.returns(0.99); + const result4 = rulesModule.dep.getGlobalRandom(otherAuctionId, index); + + expect(result4).to.equal(0.99); + expect(mathRandomStub.calledTwice).to.equal(true); + }); + }); + + describe('evaluateSchema', function() { + it('should evaluate percent condition', function() { + sandbox.stub(rulesModule.dep, 'getGlobalRandom').returns(0.3); + const func = rulesModule.evaluateSchema('percent', [50], {}); + const result = func(); + // 30 < 50, so should return true + expect(result).to.be.true; + }); + + it('should evaluate adUnitCode condition', function() { + const context = { + adUnit: { + code: 'div-1' + } + }; + const func = rulesModule.evaluateSchema('adUnitCode', [], context); + expect(func()).to.equal('div-1'); + + const func2 = rulesModule.evaluateSchema('adUnitCode', [], context); + expect(func2()).to.equal('div-1'); + }); + + it('should evaluate adUnitCodeIn condition', function() { + const context = { + adUnit: { + code: 'div-1' + } + }; + const func = rulesModule.evaluateSchema('adUnitCodeIn', [['div-1', 'div-2']], context); + expect(func()).to.be.true; + + const func2 = rulesModule.evaluateSchema('adUnitCodeIn', [['div-3', 'div-4']], context); + expect(func2()).to.be.false; + }); + + it('should evaluate deviceCountry condition', function() { + const context = { + ortb2: { + device: { + geo: { + country: 'US' + } + } + } + }; + const func = rulesModule.evaluateSchema('deviceCountry', [], context); + expect(func()).to.equal('US'); + + const func2 = rulesModule.evaluateSchema('deviceCountry', [], context); + expect(func2()).to.equal('US'); + }); + + it('should evaluate deviceCountryIn condition', function() { + const context = { + ortb2: { + device: { + geo: { + country: 'US' + } + } + } + }; + const func = rulesModule.evaluateSchema('deviceCountryIn', [['US', 'UK']], context); + expect(func()).to.be.true; + + const func2 = rulesModule.evaluateSchema('deviceCountryIn', [['DE', 'FR']], context); + expect(func2()).to.be.false; + }); + + it('should evaluate channel condition', function() { + const context1 = { + ortb2: { + ext: { + prebid: { + channel: 'pbjs' + } + } + } + }; + const func1 = rulesModule.evaluateSchema('channel', [], context1); + expect(func1()).to.equal('web'); + }); + + it('should evaluate eidAvailable condition', function() { + const context1 = { + ortb2: { + user: { + eids: [{ source: 'test', id: '123' }] + } + } + }; + const func1 = rulesModule.evaluateSchema('eidAvailable', [], context1); + expect(func1()).to.be.true; + + const context2 = { + ortb2: { + user: { + eids: [] + } + } + }; + const func2 = rulesModule.evaluateSchema('eidAvailable', [], context2); + expect(func2()).to.be.false; + }); + + it('should evaluate userFpdAvailable condition', function() { + const context1 = { + ortb2: { + user: { + data: [{ name: 'test', segment: [] }] + } + } + }; + const func1 = rulesModule.evaluateSchema('userFpdAvailable', [], context1); + expect(func1()).to.be.true; + + const context2 = { + ortb2: { + user: { + ext: { + data: [{ name: 'test', segment: [] }] + } + } + } + }; + const func2 = rulesModule.evaluateSchema('userFpdAvailable', [], context2); + expect(func2()).to.be.true; + + const context3 = { + ortb2: { + user: {} + } + }; + const func3 = rulesModule.evaluateSchema('userFpdAvailable', [], context3); + expect(func3()).to.be.false; + }); + + it('should evaluate fpdAvailable condition', function() { + const context1 = { + ortb2: { + user: { + data: [{ name: 'test' }] + } + } + }; + const func1 = rulesModule.evaluateSchema('fpdAvailable', [], context1); + expect(func1()).to.be.true; + + const context2 = { + ortb2: { + site: { + content: { + data: [{ name: 'test' }] + } + } + } + }; + const func2 = rulesModule.evaluateSchema('fpdAvailable', [], context2); + expect(func2()).to.be.true; + + const context3 = { + ortb2: {} + }; + const func3 = rulesModule.evaluateSchema('fpdAvailable', [], context3); + expect(func3()).to.be.false; + }); + + it('should evaluate gppSidIn condition', function() { + const context1 = { + ortb2: { + regs: { + gpp_sid: [1, 2, 3] + } + } + }; + const func1 = rulesModule.evaluateSchema('gppSidIn', [[2]], context1); + expect(func1()).to.be.true; + + const func2 = rulesModule.evaluateSchema('gppSidIn', [[4]], context1); + expect(func2()).to.be.false; + }); + + it('should evaluate tcfInScope condition', function() { + const context1 = { + ortb2: { + regs: { + ext: { + gdpr: 1 + } + } + } + }; + const func1 = rulesModule.evaluateSchema('tcfInScope', [], context1); + expect(func1()).to.be.true; + + const context2 = { + regs: { + ext: { + gdpr: 0 + } + } + }; + const func2 = rulesModule.evaluateSchema('tcfInScope', [], context2); + expect(func2()).to.be.false; + }); + + it('should evaluate domain condition', function() { + const context1 = { + ortb2: { + site: { + domain: 'example.com' + } + } + }; + const func1 = rulesModule.evaluateSchema('domain', [], context1); + expect(func1()).to.equal('example.com'); + + const context2 = { + ortb2: { + app: { + domain: 'app.example.com' + } + } + }; + const func2 = rulesModule.evaluateSchema('domain', [], context2); + expect(func2()).to.equal('app.example.com'); + + const context3 = { + ortb2: {} + }; + const func3 = rulesModule.evaluateSchema('domain', [], context3); + expect(func3()).to.equal(''); + }); + + it('should evaluate domainIn condition', function() { + const context1 = { + ortb2: { + site: { + domain: 'example.com' + } + } + }; + const func1 = rulesModule.evaluateSchema('domainIn', [['example.com', 'test.com']], context1); + expect(func1()).to.be.true; + + const context2 = { + ortb2: { + app: { + domain: 'app.example.com' + } + } + }; + const func2 = rulesModule.evaluateSchema('domainIn', [['app.example.com']], context2); + expect(func2()).to.be.true; + + const func3 = rulesModule.evaluateSchema('domainIn', [['other.com']], context1); + expect(func3()).to.be.false; + }); + + it('should evaluate bundle condition', function() { + const context1 = { + ortb2: { + app: { + bundle: 'com.example.app' + } + } + }; + const func1 = rulesModule.evaluateSchema('bundle', [], context1); + expect(func1()).to.equal('com.example.app'); + + const context2 = { + ortb2: {} + }; + const func2 = rulesModule.evaluateSchema('bundle', [], context2); + expect(func2()).to.equal(''); + }); + + it('should evaluate bundleIn condition', function() { + const context1 = { + ortb2: { + app: { + bundle: 'com.example.app' + } + } + }; + const func1 = rulesModule.evaluateSchema('bundleIn', ['com.example.app'], context1); + expect(func1()).to.be.true; + + const func2 = rulesModule.evaluateSchema('bundleIn', [['com.other.app']], context1); + expect(func2()).to.be.false; + }); + + it('should evaluate mediaTypeIn condition', function() { + const context1 = { + adUnit: { + mediaTypes: { + banner: {}, + video: {} + } + } + }; + const func1 = rulesModule.evaluateSchema('mediaTypeIn', [['banner']], context1); + expect(func1()).to.be.true; + + const func2 = rulesModule.evaluateSchema('mediaTypeIn', [['native']], context1); + expect(func2()).to.be.false; + }); + + it('should evaluate deviceTypeIn condition', function() { + const context1 = { + ortb2: { + device: { + devicetype: 2 + } + } + }; + const func1 = rulesModule.evaluateSchema('deviceTypeIn', [[2, 3]], context1); + expect(func1()).to.be.true; + + const func2 = rulesModule.evaluateSchema('deviceTypeIn', [[4, 5]], context1); + expect(func2()).to.be.false; + }); + + it('should evaluate bidPrice condition', function() { + const context1 = { + bid: { + cpm: 5.50, + currency: 'USD' + } + }; + const func1 = rulesModule.evaluateSchema('bidPrice', ['gt', 'USD', 5.0], context1); + expect(func1()).to.be.true; + + const func2 = rulesModule.evaluateSchema('bidPrice', ['gt', 'USD', 6.0], context1); + expect(func2()).to.be.false; + + const func3 = rulesModule.evaluateSchema('bidPrice', ['lte', 'USD', 6.0], context1); + expect(func3()).to.be.true; + + const context3 = { + bid: { + cpm: 0, + currency: 'USD' + } + }; + const func4 = rulesModule.evaluateSchema('bidPrice', ['gt', 'USD', 1.0], context3); + expect(func4()).to.be.false; + }); + + it('should return null function for unknown schema function', function() { + const func = rulesModule.evaluateSchema('unknownFunction', [], {}); + expect(func()).to.be.null; + }); + + describe('extraSchemaEvaluators', function() { + it('should use custom browser evaluator from extraSchemaEvaluators', function() { + const browserEvaluator = (args, context) => { + return () => { + const userAgent = context.ortb2?.device?.ua || navigator.userAgent; + if (userAgent.includes('Chrome')) return 'Chrome'; + if (userAgent.includes('Firefox')) return 'Firefox'; + if (userAgent.includes('Safari')) return 'Safari'; + if (userAgent.includes('Edge')) return 'Edge'; + return 'Unknown'; + }; + }; + + config.setConfig({ + shapingRules: { + extraSchemaEvaluators: { + browser: browserEvaluator + } + } + }); + + const context1 = { + ortb2: { + device: { + ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0' + } + } + }; + + const func1 = rulesModule.evaluateSchema('browser', [], context1); + expect(func1()).to.equal('Chrome'); + + const context2 = { + ortb2: { + device: { + ua: 'Mozilla/5.0 Firefox/121.0.0' + } + } + }; + const func2 = rulesModule.evaluateSchema('browser', [], context2); + expect(func2()).to.equal('Firefox'); + }); + }); + }); +}); diff --git a/test/spec/modules/sevioBidAdapter_spec.js b/test/spec/modules/sevioBidAdapter_spec.js index 63b36465dad..ce03c1baf12 100644 --- a/test/spec/modules/sevioBidAdapter_spec.js +++ b/test/spec/modules/sevioBidAdapter_spec.js @@ -521,4 +521,108 @@ describe('sevioBidAdapter', function () { expect(requests[0].data.keywords.tokens).to.deep.equal(['play', 'games', 'fun']); }); }); + + describe('native parsing', function () { + it('parses native assets: title, data->desc (type 2), image (asset.img), clickUrl and trackers', function () { + const serverResponseNative = { + body: { + bids: [ + { + requestId: 'native-1', + cpm: 1.0, + currency: 'EUR', + width: 1, + height: 1, + creativeId: 'native-creative-1', + ad: JSON.stringify({ + ver: '1.2', + assets: [ + { id: 2, title: { text: 'Native Title' } }, + { id: 4, data: { type: 2, value: 'Native body text' } }, + { id: 5, img: { type: 3, url: 'https://img.example/x.png', w: 120, h: 60 } } + ], + eventtrackers: [ + { event: 1, method: 1, url: 'https://impr.example/1' }, + { event: 2, method: 1, url: 'https://view.example/1' } + ], + link: { url: 'https://click.example', clicktrackers: ['https://clickt.example/1'] } + }), + ttl: 300, + netRevenue: true, + mediaType: 'NATIVE', + meta: { advertiserDomains: ['adv.example'] }, + bidder: 'sevio' + } + ] + } + }; + + const result = spec.interpretResponse(serverResponseNative); + expect(result).to.be.an('array').with.lengthOf(1); + + const out = result[0]; + expect(out).to.have.property('native'); + + const native = out.native; + expect(native.title).to.equal('Native Title'); + expect(native.image).to.equal('https://img.example/x.png'); + expect(native.image_width).to.equal(120); + expect(native.image_height).to.equal(60); + expect(native.clickUrl).to.equal('https://click.example'); + + expect(native.impressionTrackers).to.be.an('array').that.includes('https://impr.example/1'); + expect(native.viewableTrackers).to.be.an('array').that.includes('https://view.example/1'); + expect(native.clickTrackers).to.be.an('array').that.includes('https://clickt.example/1'); + + // meta preserved + expect(out.meta).to.have.property('advertiserDomains').that.deep.equals(['adv.example']); + }); + + it('maps legacy asset.id -> image types (13 -> icon, 14 -> image) and sets icon fields', function () { + const serverResponseIcon = { + body: { + bids: [ + { + requestId: 'native-icon', + cpm: 1.0, + currency: 'EUR', + width: 1, + height: 1, + creativeId: 'native-creative-icon', + ad: JSON.stringify({ + ver: '1.2', + assets: [ + // legacy asset id 13 should map to icon (img type 1) + { id: 13, img: { url: 'https://img.example/icon.png', w: 50, h: 50 } }, + // legacy asset id 14 should map to image (img type 3) + { id: 14, img: { url: 'https://img.example/img.png', w: 200, h: 100 } }, + { id: 2, title: { text: 'Legacy Mapping Test' } } + ], + link: { url: 'https://click.example/leg' } + }), + ttl: 300, + netRevenue: true, + mediaType: 'NATIVE', + meta: { advertiserDomains: ['legacy.example'] }, + bidder: 'sevio' + } + ] + } + }; + + const result = spec.interpretResponse(serverResponseIcon); + expect(result).to.be.an('array').with.lengthOf(1); + const native = result[0].native; + + // icon mapped from id 13 + expect(native.icon).to.equal('https://img.example/icon.png'); + expect(native.icon_width).to.equal(50); + expect(native.icon_height).to.equal(50); + + // image mapped from id 14 + expect(native.image).to.equal('https://img.example/img.png'); + expect(native.image_width).to.equal(200); + expect(native.image_height).to.equal(100); + }); + }); }); diff --git a/test/spec/modules/smarthubBidAdapter_spec.js b/test/spec/modules/smarthubBidAdapter_spec.js index 29607365c68..19848ffd03f 100644 --- a/test/spec/modules/smarthubBidAdapter_spec.js +++ b/test/spec/modules/smarthubBidAdapter_spec.js @@ -446,7 +446,7 @@ describe('SmartHubBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -455,9 +455,7 @@ describe('SmartHubBidAdapter', function () { expect(syncData[0].url).to.equal('https://us4.shb-sync.com/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0&pid=360') }); it('Should return array of objects with CCPA values', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -466,7 +464,7 @@ describe('SmartHubBidAdapter', function () { expect(syncData[0].url).to.equal('https://us4.shb-sync.com/image?pbjs=1&ccpa_consent=1---&coppa=0&pid=360') }); it('Should return array of objects with GPP values', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'ab12345', applicableSections: [8] }); @@ -478,7 +476,7 @@ describe('SmartHubBidAdapter', function () { expect(syncData[0].url).to.equal('https://us4.shb-sync.com/image?pbjs=1&gpp=ab12345&gpp_sid=8&coppa=0&pid=360') }); it('Should return iframe type if iframeEnabled is true', function() { - const syncData = spec.getUserSyncs({iframeEnabled: true}, {}, {}, {}, {}); + const syncData = spec.getUserSyncs({iframeEnabled: true}, {}, {}, undefined, {}); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') diff --git a/test/spec/modules/smootBidAdapter_spec.js b/test/spec/modules/smootBidAdapter_spec.js index d9c5580e60d..7ab8100bbe1 100644 --- a/test/spec/modules/smootBidAdapter_spec.js +++ b/test/spec/modules/smootBidAdapter_spec.js @@ -544,7 +544,7 @@ describe('SmootBidAdapter', function () { consentString: 'ALL', gdprApplies: true, }, - {} + undefined ); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object'); @@ -560,9 +560,7 @@ describe('SmootBidAdapter', function () { {}, {}, {}, - { - consentString: '1---', - } + '1---' ); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object'); @@ -578,7 +576,7 @@ describe('SmootBidAdapter', function () { {}, {}, {}, - {}, + undefined, { gppString: 'abc123', applicableSections: [8], diff --git a/test/spec/modules/taboolaBidAdapter_spec.js b/test/spec/modules/taboolaBidAdapter_spec.js index 8c23be4bcd0..817757c9f1d 100644 --- a/test/spec/modules/taboolaBidAdapter_spec.js +++ b/test/spec/modules/taboolaBidAdapter_spec.js @@ -1,5 +1,5 @@ import {expect} from 'chai'; -import {spec, internal, END_POINT_URL, userData, EVENT_ENDPOINT, detectBot, getPageVisibility} from 'modules/taboolaBidAdapter.js'; +import {spec, internal, BANNER_ENDPOINT_URL, NATIVE_ENDPOINT_URL, userData, EVENT_ENDPOINT, detectBot, getPageVisibility} from 'modules/taboolaBidAdapter.js'; import {config} from '../../../src/config.js' import * as utils from '../../../src/utils.js' import {server} from '../../mocks/xhr.js' @@ -33,7 +33,11 @@ describe('Taboola Adapter', function () { }) const displayBidRequestParams = { - sizes: [[300, 250], [300, 600]] + mediaTypes: { + banner: { + sizes: [[300, 250], [300, 600]] + } + } } const createBidRequest = () => ({ @@ -266,22 +270,23 @@ describe('Taboola Adapter', function () { } it('should build display request', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); const expectedData = { 'imp': [{ 'id': res.data.imp[0].id, - 'secure': 1, 'banner': { + topframe: 0, format: [{ - w: displayBidRequestParams.sizes[0][0], - h: displayBidRequestParams.sizes[0][1] + w: displayBidRequestParams.mediaTypes.banner.sizes[0][0], + h: displayBidRequestParams.mediaTypes.banner.sizes[0][1] }, { - w: displayBidRequestParams.sizes[1][0], - h: displayBidRequestParams.sizes[1][1] + w: displayBidRequestParams.mediaTypes.banner.sizes[1][0], + h: displayBidRequestParams.mediaTypes.banner.sizes[1][1] } ] }, + 'secure': 1, 'tagid': commonBidRequest.params.tagId, 'bidfloor': null, 'bidfloorcur': 'USD', @@ -315,7 +320,7 @@ describe('Taboola Adapter', function () { 'ext': res.data.ext }; - expect(res.url).to.equal(`${END_POINT_URL}?publisher=${commonBidRequest.params.publisherId}`); + expect(res.url).to.equal(`${BANNER_ENDPOINT_URL}?publisher=${commonBidRequest.params.publisherId}`); expect(JSON.stringify(res.data)).to.deep.equal(JSON.stringify(expectedData)); expect(res.data.ext.prebid.version).to.equal('$prebid.version$'); }); @@ -331,7 +336,7 @@ describe('Taboola Adapter', function () { params: {...commonBidRequest.params, ...optionalParams} }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].bidfloor).to.deep.equal(0.25); expect(res.data.imp[0].bidfloorcur).to.deep.equal('EUR'); }); @@ -347,7 +352,7 @@ describe('Taboola Adapter', function () { } } }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].bidfloor).to.deep.equal(2.7); expect(res.data.imp[0].bidfloorcur).to.deep.equal('USD'); }); @@ -368,7 +373,7 @@ describe('Taboola Adapter', function () { } } }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].bidfloor).to.deep.equal(2.7); expect(res.data.imp[0].bidfloorcur).to.deep.equal('USD'); }); @@ -383,7 +388,7 @@ describe('Taboola Adapter', function () { params: {...commonBidRequest.params, ...optionalParams} }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].banner.pos).to.deep.equal(2); }); @@ -399,7 +404,7 @@ describe('Taboola Adapter', function () { params: {...commonBidRequest.params} }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].ext.gpid).to.deep.equal('/homepage/#1'); }); @@ -415,7 +420,7 @@ describe('Taboola Adapter', function () { params: {...commonBidRequest.params} }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].ext.example).to.deep.equal('example'); }); @@ -424,7 +429,7 @@ describe('Taboola Adapter', function () { ...commonBidderRequest, timeout: 500 } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.tmax).to.equal(500); }); @@ -433,7 +438,7 @@ describe('Taboola Adapter', function () { ...commonBidderRequest, timeout: '500' } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.tmax).to.equal(500); }); @@ -442,7 +447,7 @@ describe('Taboola Adapter', function () { ...commonBidderRequest, timeout: null } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.tmax).to.equal(undefined); }); @@ -471,7 +476,7 @@ describe('Taboola Adapter', function () { } } } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.bcat).to.deep.equal(bidderRequest.ortb2.bcat) expect(res.data.badv).to.deep.equal(bidderRequest.ortb2.badv) expect(res.data.wlang).to.deep.equal(bidderRequest.ortb2.wlang) @@ -495,7 +500,7 @@ describe('Taboola Adapter', function () { } } } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.id).to.deep.equal(bidderRequest.ortb2.user.id) expect(res.data.user.buyeruid).to.deep.equal(bidderRequest.ortb2.user.buyeruid) expect(res.data.user.yob).to.deep.equal(bidderRequest.ortb2.user.yob) @@ -512,7 +517,7 @@ describe('Taboola Adapter', function () { } } } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.ext.pageType).to.deep.equal(bidderRequest.ortb2.ext.data.pageType); }); @@ -525,7 +530,7 @@ describe('Taboola Adapter', function () { } } } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.ext.example).to.deep.equal(bidderRequest.ortb2.ext.example); }); @@ -549,7 +554,7 @@ describe('Taboola Adapter', function () { } } } - const res = spec.buildRequests([defaultBidRequest], {...ortb2}) + const [res] = spec.buildRequests([defaultBidRequest], {...ortb2}) expect(res.data.user.data).to.deep.equal(ortb2.ortb2.user.data); }); }); @@ -566,7 +571,7 @@ describe('Taboola Adapter', function () { } }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest) + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest) expect(res.data.user.ext.consent).to.equal('consentString') expect(res.data.regs.ext.gdpr).to.equal(1) }); @@ -579,7 +584,7 @@ describe('Taboola Adapter', function () { } } - const res = spec.buildRequests([defaultBidRequest], {...commonBidderRequest, ortb2}) + const [res] = spec.buildRequests([defaultBidRequest], {...commonBidderRequest, ortb2}) expect(res.data.regs.ext.gpp).to.equal('testGpp') expect(res.data.regs.ext.gpp_sid).to.deep.equal([1, 2, 3]) }); @@ -591,14 +596,14 @@ describe('Taboola Adapter', function () { }, uspConsent: 'consentString' } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.regs.ext.us_privacy).to.equal('consentString'); }); it('should pass coppa consent', function () { config.setConfig({coppa: true}) - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest) + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest) expect(res.data.regs.coppa).to.equal(1) config.resetConfig() @@ -615,7 +620,7 @@ describe('Taboola Adapter', function () { ...commonBidderRequest, timeout: 500 } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal(51525152); }); @@ -629,7 +634,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal('12121212'); }); @@ -650,7 +655,7 @@ describe('Taboola Adapter', function () { } } }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.id).to.deep.equal('userid') expect(res.data.user.buyeruid).to.equal('12121212'); }); @@ -672,7 +677,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal('user:12121212'); }); @@ -690,7 +695,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal('user:12121212'); }); @@ -708,7 +713,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal('user:tbla:12121212'); }); @@ -735,7 +740,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal('cookie:1'); }); @@ -749,7 +754,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest }; - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal('d966c5be-c49f-4f73-8cd1-37b6b5790653-tuct9f7bf10'); }); @@ -766,7 +771,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal(window.TRC.user_id); delete window.TRC; @@ -779,7 +784,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal(0); }); @@ -787,7 +792,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest } - const res = spec.buildRequests([defaultBidRequest], bidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequest); expect(res.data.user.buyeruid).to.equal(0); }); }); @@ -809,7 +814,7 @@ describe('Taboola Adapter', function () { const bidderRequest = { ...commonBidderRequest }; - const request = spec.buildRequests([defaultBidRequest], bidderRequest); + const [request] = spec.buildRequests([defaultBidRequest], bidderRequest); const serverResponse = { body: { @@ -1096,7 +1101,7 @@ describe('Taboola Adapter', function () { }); it('should interpret multi impression request', function () { - const multiRequest = spec.buildRequests([defaultBidRequest, defaultBidRequest], bidderRequest); + const [multiRequest] = spec.buildRequests([defaultBidRequest, defaultBidRequest], bidderRequest); const multiServerResponse = { body: { @@ -1438,7 +1443,7 @@ describe('Taboola Adapter', function () { }); it('should replace AUCTION_PRICE macro in adm', function () { - const multiRequest = spec.buildRequests([defaultBidRequest, defaultBidRequest], bidderRequest); + const [multiRequest] = spec.buildRequests([defaultBidRequest, defaultBidRequest], bidderRequest); const multiServerResponseWithMacro = { body: { 'id': '49ffg4d58ef9a163a69fhgfghd4fad03621b9e036f24f7_15', @@ -1661,7 +1666,11 @@ describe('Taboola Adapter', function () { }, bidId: 'test-bid-id', auctionId: 'test-auction-id', - sizes: [[300, 250]] + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } }; const commonBidderRequest = { @@ -1674,13 +1683,13 @@ describe('Taboola Adapter', function () { }; it('should include bot detection in device.ext', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.device.ext.bot).to.exist; expect(res.data.device.ext.bot).to.have.property('detected'); }); it('should include visibility in device.ext', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.device.ext.visibility).to.exist; expect(res.data.device.ext.visibility).to.have.property('hidden'); expect(res.data.device.ext.visibility).to.have.property('state'); @@ -1688,7 +1697,7 @@ describe('Taboola Adapter', function () { }); it('should include scroll position in device.ext', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.device.ext.scroll).to.exist; expect(res.data.device.ext.scroll).to.have.property('top'); expect(res.data.device.ext.scroll).to.have.property('left'); @@ -1710,7 +1719,7 @@ describe('Taboola Adapter', function () { }; try { - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); // Viewability should be a number between 0-100 when element exists expect(res.data.imp[0].ext.viewability).to.be.a('number'); expect(res.data.imp[0].ext.viewability).to.be.at.least(0); @@ -1725,7 +1734,7 @@ describe('Taboola Adapter', function () { ...defaultBidRequest, adUnitCode: 'non-existent-element-id' }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].ext.viewability).to.be.undefined; }); @@ -1746,7 +1755,7 @@ describe('Taboola Adapter', function () { }; try { - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].ext.placement).to.exist; expect(res.data.imp[0].ext.placement).to.have.property('top'); expect(res.data.imp[0].ext.placement).to.have.property('left'); @@ -1771,7 +1780,7 @@ describe('Taboola Adapter', function () { }; try { - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].ext.fold).to.exist; expect(res.data.imp[0].ext.fold).to.be.oneOf(['above', 'below']); } finally { @@ -1784,7 +1793,7 @@ describe('Taboola Adapter', function () { ...defaultBidRequest, adUnitCode: 'non-existent-placement-element' }; - const res = spec.buildRequests([bidRequest], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest], commonBidderRequest); expect(res.data.imp[0].ext.placement).to.be.undefined; expect(res.data.imp[0].ext.fold).to.be.undefined; }); @@ -1801,19 +1810,19 @@ describe('Taboola Adapter', function () { } } }; - const res = spec.buildRequests([defaultBidRequest], bidderRequestWithDeviceExt); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequestWithDeviceExt); expect(res.data.device.ext.existingProp).to.equal('existingValue'); expect(res.data.device.ext.bot).to.exist; expect(res.data.device.ext.visibility).to.exist; }); it('should include device.js = 1', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.device.js).to.equal(1); }); it('should include connectiontype when available', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); // connectiontype is optional - depends on navigator.connection availability if (res.data.device.connectiontype !== undefined) { expect(res.data.device.connectiontype).to.be.a('number'); @@ -1832,7 +1841,7 @@ describe('Taboola Adapter', function () { } } }; - const res = spec.buildRequests([defaultBidRequest], bidderRequestWithDevice); + const [res] = spec.buildRequests([defaultBidRequest], bidderRequestWithDevice); expect(res.data.device.ua).to.equal('custom-ua'); expect(res.data.device.w).to.equal(1920); expect(res.data.device.h).to.equal(1080); @@ -1850,7 +1859,11 @@ describe('Taboola Adapter', function () { bidId: 'test-bid-id-123', auctionId: 'test-auction-id-456', adUnitCode: 'test-ad-unit-code', - sizes: [[300, 250]] + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } }; const commonBidderRequest = { @@ -1864,17 +1877,17 @@ describe('Taboola Adapter', function () { }; it('should include auctionId in ext.prebid', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.ext.prebid.auctionId).to.equal('auction-id-789'); }); it('should include bidId in imp.ext.prebid', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.imp[0].ext.prebid.bidId).to.equal('test-bid-id-123'); }); it('should include adUnitCode in imp.ext.prebid', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.imp[0].ext.prebid.adUnitCode).to.equal('test-ad-unit-code'); }); @@ -1883,12 +1896,12 @@ describe('Taboola Adapter', function () { ...defaultBidRequest, adUnitId: 'test-ad-unit-id' }; - const res = spec.buildRequests([bidRequestWithAdUnitId], commonBidderRequest); + const [res] = spec.buildRequests([bidRequestWithAdUnitId], commonBidderRequest); expect(res.data.imp[0].ext.prebid.adUnitId).to.equal('test-ad-unit-id'); }); it('should not include adUnitId when not available', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.imp[0].ext.prebid.adUnitId).to.be.undefined; }); @@ -1903,7 +1916,7 @@ describe('Taboola Adapter', function () { bidId: 'bid-id-2', adUnitCode: 'ad-unit-2' }; - const res = spec.buildRequests([bidRequest1, bidRequest2], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest1, bidRequest2], commonBidderRequest); expect(res.data.imp[0].ext.prebid.bidId).to.equal('bid-id-1'); expect(res.data.imp[0].ext.prebid.adUnitCode).to.equal('ad-unit-1'); expect(res.data.imp[1].ext.prebid.bidId).to.equal('bid-id-2'); @@ -1923,7 +1936,11 @@ describe('Taboola Adapter', function () { bidRequestsCount: 3, bidderRequestsCount: 2, bidderWinsCount: 1, - sizes: [[300, 250]] + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } }; const commonBidderRequest = { @@ -1937,17 +1954,17 @@ describe('Taboola Adapter', function () { }; it('should include bidRequestsCount in imp.ext.prebid', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.imp[0].ext.prebid.bidRequestsCount).to.equal(3); }); it('should include bidderRequestsCount in imp.ext.prebid', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.imp[0].ext.prebid.bidderRequestsCount).to.equal(2); }); it('should include bidderWinsCount in imp.ext.prebid', function () { - const res = spec.buildRequests([defaultBidRequest], commonBidderRequest); + const [res] = spec.buildRequests([defaultBidRequest], commonBidderRequest); expect(res.data.imp[0].ext.prebid.bidderWinsCount).to.equal(1); }); @@ -1968,7 +1985,7 @@ describe('Taboola Adapter', function () { bidderRequestsCount: 1, bidderWinsCount: 0 }; - const res = spec.buildRequests([bidRequest1, bidRequest2], commonBidderRequest); + const [res] = spec.buildRequests([bidRequest1, bidRequest2], commonBidderRequest); expect(res.data.imp[0].ext.prebid.bidRequestsCount).to.equal(5); expect(res.data.imp[0].ext.prebid.bidderRequestsCount).to.equal(4); @@ -1980,4 +1997,389 @@ describe('Taboola Adapter', function () { }); }); }) + + describe('native', function () { + const commonBidderRequest = { + bidderRequestId: 'mock-uuid', + refererInfo: { + page: 'https://example.com/ref', + ref: 'https://ref', + domain: 'example.com', + }, + ortb2: { + device: { + ua: navigator.userAgent, + }, + } + }; + + const nativeBidRequestParams = { + mediaTypes: { + native: { + title: {required: true, len: 150}, + image: {required: true, sizes: [300, 250]}, + sponsoredBy: {required: true} + } + } + }; + + describe('isBidRequestValid', function () { + it('should return true for valid native bid without sizes', function () { + const bid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'native-placement' + }, + ...nativeBidRequestParams + }; + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false for native bid without publisherId', function () { + const bid = { + bidder: 'taboola', + params: { + tagId: 'native-placement' + }, + ...nativeBidRequestParams + }; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false for native bid without tagId', function () { + const bid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId' + }, + ...nativeBidRequestParams + }; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests', function () { + if (FEATURES.NATIVE) { + it('should build native request without banner imp', function () { + const nativeBidRequest = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'native-placement' + }, + ...nativeBidRequestParams, + nativeOrtbRequest: { + ver: '1.2', + assets: [ + {id: 1, required: 1, title: {len: 150}}, + {id: 2, required: 1, img: {type: 3, w: 300, h: 250}}, + {id: 3, required: 1, data: {type: 1}} + ] + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const [res] = spec.buildRequests([nativeBidRequest], commonBidderRequest); + + expect(res.data.imp[0]).to.not.have.property('banner'); + expect(res.data.imp[0]).to.have.property('native'); + expect(res.data.imp[0].tagid).to.equal('native-placement'); + }); + } + + it('should build banner request without native imp', function () { + const bannerBidRequest = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'banner-placement' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const [res] = spec.buildRequests([bannerBidRequest], commonBidderRequest); + + expect(res.data.imp[0]).to.have.property('banner'); + expect(res.data.imp[0]).to.not.have.property('native'); + expect(res.data.imp[0]).to.not.have.property('native'); + }); + }); + + describe('interpretResponse', function () { + if (FEATURES.NATIVE) { + it('should interpret native response correctly', function () { + const nativeBidRequest = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'native-placement' + }, + ...nativeBidRequestParams, + nativeOrtbRequest: { + ver: '1.2', + assets: [ + {id: 1, required: 1, title: {len: 150}}, + {id: 2, required: 1, img: {type: 3, w: 300, h: 250}} + ] + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const [request] = spec.buildRequests([nativeBidRequest], commonBidderRequest); + + const nativeAdm = { + ver: '1.2', + assets: [ + {id: 1, title: {text: 'Native Ad Title'}}, + {id: 2, img: {url: 'https://example.com/image.jpg', w: 300, h: 250}} + ], + link: { + url: 'https://example.com/click' + } + }; + + const serverResponse = { + body: { + id: 'response-id', + seatbid: [{ + bid: [{ + id: 'bid-id', + impid: request.data.imp[0].id, + price: 1.5, + adm: JSON.stringify(nativeAdm), + adomain: ['example.com'], + crid: 'creative-id', + exp: 300, + nurl: 'https://example.com/win' + }], + seat: 'taboola' + }], + cur: 'USD' + } + }; + + const res = spec.interpretResponse(serverResponse, request); + + expect(res).to.be.an('array').with.lengthOf(1); + expect(res[0].mediaType).to.equal('native'); + expect(res[0].native).to.exist; + expect(res[0].native.ortb).to.deep.equal(nativeAdm); + expect(res[0]).to.not.have.property('ad'); + }); + } + }); + + if (FEATURES.NATIVE) { + describe('multiformat support', function () { + it('should split multiformat bid into separate banner and native requests', function () { + const multiformatBid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'multiformat-placement' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + }, + native: { + title: {required: true, len: 150}, + image: {required: true, sizes: [300, 250]}, + sponsoredBy: {required: true} + } + }, + nativeOrtbRequest: { + ver: '1.2', + assets: [ + {id: 1, required: 1, title: {len: 150}}, + {id: 2, required: 1, img: {type: 3, w: 300, h: 250}}, + {id: 3, required: 1, data: {type: 1}} + ] + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const requests = spec.buildRequests([multiformatBid], commonBidderRequest); + + expect(requests).to.be.an('array').with.lengthOf(2); + + const bannerReq = requests.find(r => r.url.includes('display')); + const nativeReq = requests.find(r => r.url.includes('native')); + + expect(bannerReq).to.exist; + expect(nativeReq).to.exist; + + expect(bannerReq.url).to.include(BANNER_ENDPOINT_URL); + expect(nativeReq.url).to.include(NATIVE_ENDPOINT_URL); + + expect(bannerReq.data.imp[0]).to.have.property('banner'); + expect(bannerReq.data.imp[0]).to.not.have.property('native'); + + expect(nativeReq.data.imp[0]).to.have.property('native'); + expect(nativeReq.data.imp[0]).to.not.have.property('banner'); + }); + + it('should send banner-only bids to display endpoint only', function () { + const bannerBid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'banner-placement' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const requests = spec.buildRequests([bannerBid], commonBidderRequest); + + expect(requests).to.be.an('array').with.lengthOf(1); + expect(requests[0].url).to.include(BANNER_ENDPOINT_URL); + }); + + it('should send native-only bids to native endpoint only', function () { + const nativeBid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'native-placement' + }, + ...nativeBidRequestParams, + nativeOrtbRequest: { + ver: '1.2', + assets: [ + {id: 1, required: 1, title: {len: 150}}, + {id: 2, required: 1, img: {type: 3, w: 300, h: 250}} + ] + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const requests = spec.buildRequests([nativeBid], commonBidderRequest); + + expect(requests).to.be.an('array').with.lengthOf(1); + expect(requests[0].url).to.include(NATIVE_ENDPOINT_URL); + }); + + it('should group mixed banner and native bids into separate requests', function () { + const bannerBid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'banner-placement' + }, + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const nativeBid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'native-placement' + }, + ...nativeBidRequestParams, + nativeOrtbRequest: { + ver: '1.2', + assets: [ + {id: 1, required: 1, title: {len: 150}}, + {id: 2, required: 1, img: {type: 3, w: 300, h: 250}} + ] + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const requests = spec.buildRequests([bannerBid, nativeBid], commonBidderRequest); + + expect(requests).to.be.an('array').with.lengthOf(2); + + const bannerReq = requests.find(r => r.url.includes('display')); + const nativeReq = requests.find(r => r.url.includes('native')); + + expect(bannerReq.data.imp).to.have.lengthOf(1); + expect(nativeReq.data.imp).to.have.lengthOf(1); + }); + + it('should use bid.mtype to determine response media type', function () { + const nativeBid = { + bidder: 'taboola', + params: { + publisherId: 'publisherId', + tagId: 'native-placement' + }, + ...nativeBidRequestParams, + nativeOrtbRequest: { + ver: '1.2', + assets: [ + {id: 1, required: 1, title: {len: 150}}, + {id: 2, required: 1, img: {type: 3, w: 300, h: 250}} + ] + }, + bidId: utils.generateUUID(), + auctionId: utils.generateUUID(), + }; + + const [request] = spec.buildRequests([nativeBid], commonBidderRequest); + + const nativeAdm = { + ver: '1.2', + assets: [ + {id: 1, title: {text: 'Native Ad Title'}}, + {id: 2, img: {url: 'https://example.com/image.jpg', w: 300, h: 250}} + ], + link: {url: 'https://example.com/click'} + }; + + const serverResponse = { + body: { + id: 'response-id', + seatbid: [{ + bid: [{ + id: 'bid-id', + impid: request.data.imp[0].id, + price: 1.5, + adm: JSON.stringify(nativeAdm), + adomain: ['example.com'], + crid: 'creative-id', + exp: 300, + mtype: 4, + nurl: 'https://example.com/win' + }], + seat: 'taboola' + }], + cur: 'USD' + } + }; + + const res = spec.interpretResponse(serverResponse, request); + + expect(res).to.be.an('array').with.lengthOf(1); + expect(res[0].mediaType).to.equal('native'); + expect(res[0].native).to.exist; + expect(res[0]).to.not.have.property('ad'); + }); + }); + } + }); }) diff --git a/test/spec/modules/targetVideoBidAdapter_spec.js b/test/spec/modules/targetVideoBidAdapter_spec.js index 838fd50f76d..9b91e71c7d8 100644 --- a/test/spec/modules/targetVideoBidAdapter_spec.js +++ b/test/spec/modules/targetVideoBidAdapter_spec.js @@ -7,6 +7,13 @@ describe('TargetVideo Bid Adapter', function() { const params = { placementId: 12345, }; + const videoMediaTypes = { + video: { + playerSize: [[640, 360]], + context: 'instream', + playbackmethod: [1, 2, 3, 4] + } + } const defaultBidderRequest = { bidderRequestId: 'mock-uuid', @@ -25,13 +32,7 @@ describe('TargetVideo Bid Adapter', function() { const videoRequest = [{ bidder, params, - mediaTypes: { - video: { - playerSize: [[640, 360]], - context: 'instream', - playbackmethod: [1, 2, 3, 4] - } - } + mediaTypes: videoMediaTypes, }]; it('Test the bid validation function', function() { @@ -353,4 +354,43 @@ describe('TargetVideo Bid Adapter', function() { const userSyncs = spec.getUserSyncs({iframeEnabled: false}); expect(userSyncs).to.have.lengthOf(0); }); + + it('Test the VIDEO request floor param', function() { + const requests = [ + { + bidder, + params: { + ...params, + floor: 2.12, + }, + mediaTypes: videoMediaTypes, + }, + { + bidder, + params: { + ...params, + floor: "1.55", + }, + mediaTypes: videoMediaTypes, + }, + { + bidder, + params: { + ...params, + floor: "abc", + }, + mediaTypes: videoMediaTypes, + } + ] + + const bids = spec.buildRequests(requests, defaultBidderRequest) + + const payload1 = JSON.parse(bids[0].data); + const payload2 = JSON.parse(bids[1].data); + const payload3 = JSON.parse(bids[2].data); + + expect(payload1.imp[0].bidfloor).to.exist.and.equal(2.12); + expect(payload2.imp[0].bidfloor).to.exist.and.equal(1.55); + expect(payload3.imp[0].bidfloor).to.not.exist; + }); }); diff --git a/test/spec/modules/teqBlazeSalesAgentBidAdapter_spec.js b/test/spec/modules/teqBlazeSalesAgentBidAdapter_spec.js new file mode 100644 index 00000000000..f2dbe70f30d --- /dev/null +++ b/test/spec/modules/teqBlazeSalesAgentBidAdapter_spec.js @@ -0,0 +1,440 @@ +import { expect } from 'chai'; +import { spec } from '../../../modules/teqBlazeSalesAgentBidAdapter.js'; +import { BANNER, VIDEO, NATIVE } from '../../../src/mediaTypes.js'; +import { getUniqueIdentifierStr } from '../../../src/utils.js'; + +const bidder = 'teqBlazeSalesAgent'; + +describe('TeqBlazeSalesAgentBidAdapter', function () { + const userIdAsEids = [{ + source: 'test.org', + uids: [{ + id: '01**********', + atype: 1, + ext: { + third: '01***********' + } + }] + }]; + const bids = [ + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + placementId: 'testBanner', + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [VIDEO]: { + playerSize: [[300, 300]], + minduration: 5, + maxduration: 60 + } + }, + params: { + placementId: 'testVideo', + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [NATIVE]: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + } + }, + params: { + placementId: 'testNative' + }, + userIdAsEids + } + ]; + + const invalidBid = { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + + } + } + + const bidderRequest = { + uspConsent: '1---', + gdprConsent: { + consentString: 'COvFyGBOvFyGBAbAAAENAPCAAOAAAAAAAAAAAEEUACCKAAA.IFoEUQQgAIQwgIwQABAEAAAAOIAACAIAAAAQAIAgEAACEAAAAAgAQBAAAAAAAGBAAgAAAAAAAFAAECAAAgAAQARAEQAAAAAJAAIAAgAAAYQEAAAQmAgBC3ZAYzUw', + vendorData: {} + }, + refererInfo: { + referer: 'https://test.com', + page: 'https://test.com' + }, + ortb2: { + device: { + w: 1512, + h: 982, + language: 'en-UK', + }, + site: { + ext: { + data: { + scope3_aee: { + include: 'include', + exclude: 'exclude', + macro: 'macro' + } + } + } + } + }, + timeout: 500 + }; + + describe('isBidRequestValid', function () { + it('Should return true if there are bidId, params and key parameters present', function () { + expect(spec.isBidRequestValid(bids[0])).to.be.true; + }); + it('Should return false if at least one of parameters is not present', function () { + expect(spec.isBidRequestValid(invalidBid)).to.be.false; + }); + }); + + describe('buildRequests', function () { + let serverRequest = spec.buildRequests(bids, bidderRequest); + + it('Creates a ServerRequest object with method, URL and data', function () { + expect(serverRequest).to.exist; + expect(serverRequest.method).to.exist; + expect(serverRequest.url).to.exist; + expect(serverRequest.data).to.exist; + }); + + it('Returns POST method', function () { + expect(serverRequest.method).to.equal('POST'); + }); + + it('Returns general data valid', function () { + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.all.keys( + 'deviceWidth', + 'deviceHeight', + 'device', + 'language', + 'secure', + 'host', + 'page', + 'placements', + 'coppa', + 'ccpa', + 'gdpr', + 'tmax', + 'bcat', + 'badv', + 'bapp', + 'battr' + ); + expect(data.deviceWidth).to.be.a('number'); + expect(data.deviceHeight).to.be.a('number'); + expect(data.language).to.be.a('string'); + expect(data.secure).to.be.within(0, 1); + expect(data.host).to.be.a('string'); + expect(data.page).to.be.a('string'); + expect(data.coppa).to.be.a('number'); + expect(data.gdpr).to.be.a('object'); + expect(data.ccpa).to.be.a('string'); + expect(data.tmax).to.be.a('number'); + expect(data.placements).to.have.lengthOf(3); + }); + + it('Returns valid placements', function () { + const { placements } = serverRequest.data; + for (let i = 0, len = placements.length; i < len; i++) { + const placement = placements[i]; + expect(placement.placementId).to.be.oneOf(['testBanner', 'testVideo', 'testNative']); + expect(placement.adFormat).to.be.oneOf([BANNER, VIDEO, NATIVE]); + expect(placement.bidId).to.be.a('string'); + expect(placement.schain).to.be.an('object'); + expect(placement.bidfloor).to.exist.and.to.equal(0); + expect(placement.type).to.exist.and.to.equal('publisher'); + expect(placement.eids).to.exist.and.to.be.deep.equal(userIdAsEids); + expect(placement.axei).to.exist.and.to.be.equal('include'); + expect(placement.axex).to.exist.and.to.be.equal('exclude'); + expect(placement.axem).to.exist.and.to.be.equal('macro'); + + if (placement.adFormat === BANNER) { + expect(placement.sizes).to.be.an('array'); + } + switch (placement.adFormat) { + case BANNER: + expect(placement.sizes).to.be.an('array'); + break; + case VIDEO: + expect(placement.playerSize).to.be.an('array'); + expect(placement.minduration).to.be.an('number'); + expect(placement.maxduration).to.be.an('number'); + break; + case NATIVE: + expect(placement.native).to.be.an('object'); + break; + } + } + }); + + it('Returns data with gdprConsent and without uspConsent', function () { + delete bidderRequest.uspConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.gdpr).to.exist; + expect(data.gdpr).to.be.a('object'); + expect(data.gdpr).to.have.property('consentString'); + expect(data.gdpr).to.not.have.property('vendorData'); + expect(data.gdpr.consentString).to.equal(bidderRequest.gdprConsent.consentString); + expect(data.ccpa).to.not.exist; + delete bidderRequest.gdprConsent; + }); + + it('Returns data with uspConsent and without gdprConsent', function () { + bidderRequest.uspConsent = '1---'; + delete bidderRequest.gdprConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.ccpa).to.exist; + expect(data.ccpa).to.be.a('string'); + expect(data.ccpa).to.equal(bidderRequest.uspConsent); + expect(data.gdpr).to.not.exist; + }); + }); + + describe('gpp consent', function () { + it('bidderRequest.gppConsent', () => { + bidderRequest.gppConsent = { + gppString: 'abc123', + applicableSections: [8] + }; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + + delete bidderRequest.gppConsent; + }) + + it('bidderRequest.ortb2.regs.gpp', () => { + bidderRequest.ortb2 = bidderRequest.ortb2 || {}; + bidderRequest.ortb2.regs = bidderRequest.ortb2.regs || {}; + bidderRequest.ortb2.regs.gpp = 'abc123'; + bidderRequest.ortb2.regs.gpp_sid = [8]; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + }) + }); + + describe('interpretResponse', function () { + it('Should interpret banner response', function () { + const banner = { + body: [{ + mediaType: 'banner', + width: 300, + height: 250, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const bannerResponses = spec.interpretResponse(banner); + expect(bannerResponses).to.be.an('array').that.is.not.empty; + const dataItem = bannerResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'ad', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal(banner.body[0].requestId); + expect(dataItem.cpm).to.equal(banner.body[0].cpm); + expect(dataItem.width).to.equal(banner.body[0].width); + expect(dataItem.height).to.equal(banner.body[0].height); + expect(dataItem.ad).to.equal(banner.body[0].ad); + expect(dataItem.ttl).to.equal(banner.body[0].ttl); + expect(dataItem.creativeId).to.equal(banner.body[0].creativeId); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal(banner.body[0].currency); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret video response', function () { + const video = { + body: [{ + vastUrl: 'test.com', + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const videoResponses = spec.interpretResponse(video); + expect(videoResponses).to.be.an('array').that.is.not.empty; + + const dataItem = videoResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'vastUrl', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.5); + expect(dataItem.vastUrl).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret native response', function () { + const native = { + body: [{ + mediaType: 'native', + native: { + clickUrl: 'test.com', + title: 'Test', + image: 'test.com', + impressionTrackers: ['test.com'], + }, + ttl: 120, + cpm: 0.4, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const nativeResponses = spec.interpretResponse(native); + expect(nativeResponses).to.be.an('array').that.is.not.empty; + + const dataItem = nativeResponses[0]; + expect(dataItem).to.have.keys('requestId', 'cpm', 'ttl', 'creativeId', 'netRevenue', 'currency', 'mediaType', 'native', 'meta'); + expect(dataItem.native).to.have.keys('clickUrl', 'impressionTrackers', 'title', 'image') + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.4); + expect(dataItem.native.clickUrl).to.equal('test.com'); + expect(dataItem.native.title).to.equal('Test'); + expect(dataItem.native.image).to.equal('test.com'); + expect(dataItem.native.impressionTrackers).to.be.an('array').that.is.not.empty; + expect(dataItem.native.impressionTrackers[0]).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should return an empty array if invalid banner response is passed', function () { + const invBanner = { + body: [{ + width: 300, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + + const serverResponses = spec.interpretResponse(invBanner); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid video response is passed', function () { + const invVideo = { + body: [{ + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invVideo); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid native response is passed', function () { + const invNative = { + body: [{ + mediaType: 'native', + clickUrl: 'test.com', + title: 'Test', + impressionTrackers: ['test.com'], + ttl: 120, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + }] + }; + const serverResponses = spec.interpretResponse(invNative); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid response is passed', function () { + const invalid = { + body: [{ + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invalid); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + }); +}); diff --git a/test/spec/modules/theAdxBidAdapter_spec.js b/test/spec/modules/theAdxBidAdapter_spec.js index fd2a306ca05..2b531b6b10b 100644 --- a/test/spec/modules/theAdxBidAdapter_spec.js +++ b/test/spec/modules/theAdxBidAdapter_spec.js @@ -416,7 +416,7 @@ describe('TheAdxAdapter', function () { expect(result).to.eql([]); }); - it('returns a valid bid response on sucessful banner request', function () { + it('returns a valid bid response on successful banner request', function () { const incomingRequestId = 'XXtestingXX'; const responsePrice = 3.14 @@ -484,7 +484,7 @@ describe('TheAdxAdapter', function () { expect(processedBid.currency).to.equal(responseCurrency); }); - it('returns a valid deal bid response on sucessful banner request with deal', function () { + it('returns a valid deal bid response on successful banner request with deal', function () { const incomingRequestId = 'XXtestingXX'; const responsePrice = 3.14 @@ -556,7 +556,7 @@ describe('TheAdxAdapter', function () { expect(processedBid.dealId).to.equal(dealId); }); - it('returns an valid bid response on sucessful video request', function () { + it('returns an valid bid response on successful video request', function () { const incomingRequestId = 'XXtesting-275XX'; const responsePrice = 6 const vast_url = 'https://theadx.com/vast?rid=a8ae0b48-a8db-4220-ba0c-7458f452b1f5&{FOR_COVARAGE}' @@ -622,7 +622,7 @@ describe('TheAdxAdapter', function () { expect(processedBid.vastUrl).to.equal(vast_url); }); - it('returns an valid bid response on sucessful native request', function () { + it('returns an valid bid response on successful native request', function () { const incomingRequestId = 'XXtesting-275XX'; const responsePrice = 6 const nurl = 'https://app.theadx.com/ixc?rid=02aefd80-2df9-11e9-896d-d33384d77f5c&time=v-1549888312715&sp=1WzMjcRpeyk%3D'; diff --git a/test/spec/modules/unrulyBidAdapter_spec.js b/test/spec/modules/unrulyBidAdapter_spec.js index d73b9b6e8c7..38ec126bb73 100644 --- a/test/spec/modules/unrulyBidAdapter_spec.js +++ b/test/spec/modules/unrulyBidAdapter_spec.js @@ -42,20 +42,7 @@ describe('UnrulyAdapter', function () { } } - function createOutStreamExchangeAuctionConfig() { - return { - 'seller': 'https://nexxen.tech', - 'decisionLogicURL': 'https://nexxen.tech/padecisionlogic', - 'interestGroupBuyers': 'https://mydsp.com', - 'perBuyerSignals': { - 'https://mydsp.com': { - 'floor': 'bouttreefiddy' - } - } - } - }; - - function createExchangeResponse (bidList, auctionConfigs = null) { + function createExchangeResponse (bidList) { let bids = []; if (Array.isArray(bidList)) { bids = bidList; @@ -63,18 +50,9 @@ describe('UnrulyAdapter', function () { bids.push(bidList); } - if (!auctionConfigs) { - return { - 'body': {bids} - }; - } - return { - 'body': { - bids, - auctionConfigs - } - } + 'body': {bids} + }; }; const inStreamServerResponse = { @@ -692,231 +670,6 @@ describe('UnrulyAdapter', function () { const result = adapter.buildRequests(mockBidRequests.bids, mockBidRequests); expect(result[0].data).to.deep.equal(expectedResult); }); - describe('Protected Audience Support', function() { - it('should return an array with 2 items and enabled protected audience', function () { - mockBidRequests = { - 'bidderCode': 'unruly', - 'paapi': { - enabled: true - }, - 'bids': [ - { - 'bidder': 'unruly', - 'params': { - 'siteId': 233261, - }, - 'mediaTypes': { - 'video': { - 'context': 'outstream', - 'mimes': [ - 'video/mp4' - ], - 'playerSize': [ - [ - 640, - 480 - ] - ] - } - }, - 'adUnitCode': 'video2', - 'transactionId': 'a89619e3-137d-4cc5-9ed4-58a0b2a0bbc2', - 'sizes': [ - [ - 640, - 480 - ] - ], - 'bidId': '27a3ee1626a5c7', - 'bidderRequestId': '12e00d17dff07b', - 'ortb2Imp': { - 'ext': { - 'ae': 1 - } - } - }, - { - 'bidder': 'unruly', - 'params': { - 'siteId': 2234554, - }, - 'mediaTypes': { - 'video': { - 'context': 'outstream', - 'mimes': [ - 'video/mp4' - ], - 'playerSize': [ - [ - 640, - 480 - ] - ] - } - }, - 'adUnitCode': 'video2', - 'transactionId': 'a89619e3-137d-4cc5-9ed4-58a0b2a0bbc2', - 'sizes': [ - [ - 640, - 480 - ] - ], - 'bidId': '27a3ee1626a5c7', - 'bidderRequestId': '12e00d17dff07b', - 'ortb2Imp': { - 'ext': { - 'ae': 1 - } - } - } - ] - }; - - const result = adapter.buildRequests(mockBidRequests.bids, mockBidRequests); - expect(typeof result).to.equal('object'); - expect(result.length).to.equal(2); - expect(result[0].data.bidderRequest.bids.length).to.equal(1); - expect(result[1].data.bidderRequest.bids.length).to.equal(1); - expect(result[0].data.bidderRequest.bids[0].ortb2Imp.ext.ae).to.equal(1); - expect(result[1].data.bidderRequest.bids[0].ortb2Imp.ext.ae).to.equal(1); - }); - it('should return an array with 2 items and enabled protected audience on only one unit', function () { - mockBidRequests = { - 'bidderCode': 'unruly', - 'paapi': { - enabled: true - }, - 'bids': [ - { - 'bidder': 'unruly', - 'params': { - 'siteId': 233261, - }, - 'mediaTypes': { - 'video': { - 'context': 'outstream', - 'mimes': [ - 'video/mp4' - ], - 'playerSize': [ - [ - 640, - 480 - ] - ] - } - }, - 'adUnitCode': 'video2', - 'transactionId': 'a89619e3-137d-4cc5-9ed4-58a0b2a0bbc2', - 'sizes': [ - [ - 640, - 480 - ] - ], - 'bidId': '27a3ee1626a5c7', - 'bidderRequestId': '12e00d17dff07b', - 'ortb2Imp': { - 'ext': { - 'ae': 1 - } - } - }, - { - 'bidder': 'unruly', - 'params': { - 'siteId': 2234554, - }, - 'mediaTypes': { - 'video': { - 'context': 'outstream', - 'mimes': [ - 'video/mp4' - ], - 'playerSize': [ - [ - 640, - 480 - ] - ] - } - }, - 'adUnitCode': 'video2', - 'transactionId': 'a89619e3-137d-4cc5-9ed4-58a0b2a0bbc2', - 'sizes': [ - [ - 640, - 480 - ] - ], - 'bidId': '27a3ee1626a5c7', - 'bidderRequestId': '12e00d17dff07b', - 'ortb2Imp': { - 'ext': {} - } - } - ] - }; - - const result = adapter.buildRequests(mockBidRequests.bids, mockBidRequests); - expect(typeof result).to.equal('object'); - expect(result.length).to.equal(2); - expect(result[0].data.bidderRequest.bids.length).to.equal(1); - expect(result[1].data.bidderRequest.bids.length).to.equal(1); - expect(result[0].data.bidderRequest.bids[0].ortb2Imp.ext.ae).to.equal(1); - expect(result[1].data.bidderRequest.bids[0].ortb2Imp.ext.ae).to.be.undefined; - }); - it('disables configured protected audience when fledge is not availble', function () { - mockBidRequests = { - 'bidderCode': 'unruly', - 'fledgeEnabled': false, - 'bids': [ - { - 'bidder': 'unruly', - 'params': { - 'siteId': 233261, - }, - 'mediaTypes': { - 'video': { - 'context': 'outstream', - 'mimes': [ - 'video/mp4' - ], - 'playerSize': [ - [ - 640, - 480 - ] - ] - } - }, - 'adUnitCode': 'video2', - 'transactionId': 'a89619e3-137d-4cc5-9ed4-58a0b2a0bbc2', - 'sizes': [ - [ - 640, - 480 - ] - ], - 'bidId': '27a3ee1626a5c7', - 'bidderRequestId': '12e00d17dff07b', - 'ortb2Imp': { - 'ext': { - 'ae': 1 - } - } - } - ] - }; - - const result = adapter.buildRequests(mockBidRequests.bids, mockBidRequests); - expect(typeof result).to.equal('object'); - expect(result.length).to.equal(1); - expect(result[0].data.bidderRequest.bids.length).to.equal(1); - expect(result[0].data.bidderRequest.bids[0].ortb2Imp.ext.ae).to.be.undefined; - }); - }); }); describe('interpretResponse', function () { @@ -967,166 +720,6 @@ describe('UnrulyAdapter', function () { ]); }); - it('should return object with an array of bids and an array of auction configs when it receives a successful response from server', function () { - const bidId = '27a3ee1626a5c7' - const mockExchangeBid = createOutStreamExchangeBid({adUnitCode: 'video1', requestId: 'mockBidId'}); - const mockExchangeAuctionConfig = {}; - mockExchangeAuctionConfig[bidId] = createOutStreamExchangeAuctionConfig(); - const mockServerResponse = createExchangeResponse(mockExchangeBid, mockExchangeAuctionConfig); - const originalRequest = { - 'data': { - 'bidderRequest': { - 'bids': [ - { - 'bidder': 'unruly', - 'params': { - 'siteId': 233261, - }, - 'mediaTypes': { - 'banner': { - 'sizes': [ - [ - 640, - 480 - ], - [ - 640, - 480 - ], - [ - 300, - 250 - ], - [ - 300, - 250 - ] - ] - } - }, - 'adUnitCode': 'video2', - 'transactionId': 'a89619e3-137d-4cc5-9ed4-58a0b2a0bbc2', - 'bidId': bidId, - 'bidderRequestId': '12e00d17dff07b', - } - ] - } - } - }; - - expect(adapter.interpretResponse(mockServerResponse, originalRequest)).to.deep.equal({ - 'bids': [ - { - 'ext': { - 'statusCode': 1, - 'renderer': { - 'id': 'unruly_inarticle', - 'config': { - 'siteId': 123456, - 'targetingUUID': 'xxx-yyy-zzz' - }, - 'url': 'https://video.unrulymedia.com/native/prebid-loader.js' - }, - 'adUnitCode': 'video1' - }, - requestId: 'mockBidId', - bidderCode: 'unruly', - cpm: 20, - width: 323, - height: 323, - vastUrl: 'https://targeting.unrulymedia.com/in_article?uuid=74544e00-d43b-4f3a-a799-69d22ce979ce&supported_mime_type=application/javascript&supported_mime_type=video/mp4&tj=%7B%22site%22%3A%7B%22lang%22%3A%22en-GB%22%2C%22ref%22%3A%22%22%2C%22page%22%3A%22https%3A%2F%2Fdemo.unrulymedia.com%2FinArticle%2Finarticle_nypost_upbeat%2Ftravel_magazines.html%22%2C%22domain%22%3A%22demo.unrulymedia.com%22%7D%2C%22user%22%3A%7B%22profile%22%3A%7B%22quantcast%22%3A%7B%22segments%22%3A%5B%7B%22id%22%3A%22D%22%7D%2C%7B%22id%22%3A%22T%22%7D%5D%7D%7D%7D%7D&video_width=618&video_height=347', - netRevenue: true, - creativeId: 'mockBidId', - ttl: 360, - 'meta': { - 'mediaType': 'video', - 'videoContext': 'outstream' - }, - currency: 'USD', - renderer: fakeRenderer, - mediaType: 'video' - } - ], - 'paapi': [{ - 'bidId': bidId, - 'config': { - 'seller': 'https://nexxen.tech', - 'decisionLogicURL': 'https://nexxen.tech/padecisionlogic', - 'interestGroupBuyers': 'https://mydsp.com', - 'perBuyerSignals': { - 'https://mydsp.com': { - 'floor': 'bouttreefiddy' - } - } - } - }] - }); - }); - - it('should return object with an array of auction configs when it receives a successful response from server without bids', function () { - const bidId = '27a3ee1626a5c7'; - const mockExchangeAuctionConfig = {}; - mockExchangeAuctionConfig[bidId] = createOutStreamExchangeAuctionConfig(); - const mockServerResponse = createExchangeResponse(null, mockExchangeAuctionConfig); - const originalRequest = { - 'data': { - 'bidderRequest': { - 'bids': [ - { - 'bidder': 'unruly', - 'params': { - 'siteId': 233261, - }, - 'mediaTypes': { - 'banner': { - 'sizes': [ - [ - 640, - 480 - ], - [ - 640, - 480 - ], - [ - 300, - 250 - ], - [ - 300, - 250 - ] - ] - } - }, - 'adUnitCode': 'video2', - 'transactionId': 'a89619e3-137d-4cc5-9ed4-58a0b2a0bbc2', - 'bidId': bidId, - 'bidderRequestId': '12e00d17dff07b' - } - ] - } - } - }; - - expect(adapter.interpretResponse(mockServerResponse, originalRequest)).to.deep.equal({ - 'bids': [], - 'paapi': [{ - 'bidId': bidId, - 'config': { - 'seller': 'https://nexxen.tech', - 'decisionLogicURL': 'https://nexxen.tech/padecisionlogic', - 'interestGroupBuyers': 'https://mydsp.com', - 'perBuyerSignals': { - 'https://mydsp.com': { - 'floor': 'bouttreefiddy' - } - } - } - }] - }); - }); - it('should initialize and set the renderer', function () { expect(Renderer.install.called).to.be.false; expect(fakeRenderer.setRender.called).to.be.false; diff --git a/test/spec/modules/verbenBidAdapter_spec.js b/test/spec/modules/verbenBidAdapter_spec.js new file mode 100644 index 00000000000..9864e9d2b70 --- /dev/null +++ b/test/spec/modules/verbenBidAdapter_spec.js @@ -0,0 +1,478 @@ +import { expect } from 'chai'; +import { spec } from '../../../modules/verbenBidAdapter.js'; +import { BANNER, VIDEO, NATIVE } from '../../../src/mediaTypes.js'; +import { getUniqueIdentifierStr } from '../../../src/utils.js'; + +const bidder = 'verben'; + +describe('VerbenBidAdapter', function () { + const userIdAsEids = [{ + source: 'test.org', + uids: [{ + id: '01**********', + atype: 1, + ext: { + third: '01***********' + } + }] + }]; + const bids = [ + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + placementId: 'testBanner' + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [VIDEO]: { + playerSize: [[300, 300]], + minduration: 5, + maxduration: 60 + } + }, + params: { + placementId: 'testVideo' + }, + userIdAsEids + }, + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [NATIVE]: { + native: { + title: { + required: true + }, + body: { + required: true + }, + icon: { + required: true, + size: [64, 64] + } + } + } + }, + params: { + placementId: 'testNative' + }, + userIdAsEids + } + ]; + + const invalidBid = { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + + } + } + + const bidderRequest = { + uspConsent: '1---', + gdprConsent: { + consentString: 'COvFyGBOvFyGBAbAAAENAPCAAOAAAAAAAAAAAEEUACCKAAA.IFoEUQQgAIQwgIwQABAEAAAAOIAACAIAAAAQAIAgEAACEAAAAAgAQBAAAAAAAGBAAgAAAAAAAFAAECAAAgAAQARAEQAAAAAJAAIAAgAAAYQEAAAQmAgBC3ZAYzUw', + vendorData: {} + }, + refererInfo: { + referer: 'https://test.com', + page: 'https://test.com' + }, + ortb2: { + device: { + w: 1512, + h: 982, + language: 'en-UK' + } + }, + timeout: 500 + }; + + describe('isBidRequestValid', function () { + it('Should return true if there are bidId, params and key parameters present', function () { + expect(spec.isBidRequestValid(bids[0])).to.be.true; + }); + it('Should return false if at least one of parameters is not present', function () { + expect(spec.isBidRequestValid(invalidBid)).to.be.false; + }); + }); + + describe('buildRequests', function () { + let serverRequest = spec.buildRequests(bids, bidderRequest); + + it('Creates a ServerRequest object with method, URL and data', function () { + expect(serverRequest).to.exist; + expect(serverRequest.method).to.exist; + expect(serverRequest.url).to.exist; + expect(serverRequest.data).to.exist; + }); + + it('Returns POST method', function () { + expect(serverRequest.method).to.equal('POST'); + }); + + it('Returns valid URL', function () { + expect(serverRequest.url).to.equal('https://east-node.verben.com/pbjs'); + }); + + it('Returns general data valid', function () { + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.all.keys('deviceWidth', + 'deviceHeight', + 'device', + 'language', + 'secure', + 'host', + 'page', + 'placements', + 'coppa', + 'ccpa', + 'gdpr', + 'tmax', + 'bcat', + 'badv', + 'bapp', + 'battr' + ); + expect(data.deviceWidth).to.be.a('number'); + expect(data.deviceHeight).to.be.a('number'); + expect(data.language).to.be.a('string'); + expect(data.secure).to.be.within(0, 1); + expect(data.host).to.be.a('string'); + expect(data.page).to.be.a('string'); + expect(data.coppa).to.be.a('number'); + expect(data.gdpr).to.be.a('object'); + expect(data.ccpa).to.be.a('string'); + expect(data.tmax).to.be.a('number'); + expect(data.placements).to.have.lengthOf(3); + }); + + it('Returns valid placements', function () { + const { placements } = serverRequest.data; + for (let i = 0, len = placements.length; i < len; i++) { + const placement = placements[i]; + expect(placement.placementId).to.be.oneOf(['testBanner', 'testVideo', 'testNative']); + expect(placement.adFormat).to.be.oneOf([BANNER, VIDEO, NATIVE]); + expect(placement.bidId).to.be.a('string'); + expect(placement.schain).to.be.an('object'); + expect(placement.bidfloor).to.exist.and.to.equal(0); + expect(placement.type).to.exist.and.to.equal('publisher'); + expect(placement.eids).to.exist.and.to.be.deep.equal(userIdAsEids); + + if (placement.adFormat === BANNER) { + expect(placement.sizes).to.be.an('array'); + } + switch (placement.adFormat) { + case BANNER: + expect(placement.sizes).to.be.an('array'); + break; + case VIDEO: + expect(placement.playerSize).to.be.an('array'); + expect(placement.minduration).to.be.an('number'); + expect(placement.maxduration).to.be.an('number'); + break; + case NATIVE: + expect(placement.native).to.be.an('object'); + break; + } + } + }); + + it('Returns valid endpoints', function () { + const bids = [ + { + bidId: getUniqueIdentifierStr(), + bidder: bidder, + mediaTypes: { + [BANNER]: { + sizes: [[300, 250]] + } + }, + params: { + endpointId: 'testBanner', + }, + userIdAsEids + } + ]; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + + const { placements } = serverRequest.data; + for (let i = 0, len = placements.length; i < len; i++) { + const placement = placements[i]; + expect(placement.endpointId).to.be.oneOf(['testBanner', 'testVideo', 'testNative']); + expect(placement.adFormat).to.be.oneOf([BANNER, VIDEO, NATIVE]); + expect(placement.bidId).to.be.a('string'); + expect(placement.schain).to.be.an('object'); + expect(placement.bidfloor).to.exist.and.to.equal(0); + expect(placement.type).to.exist.and.to.equal('network'); + expect(placement.eids).to.exist.and.to.be.deep.equal(userIdAsEids); + + if (placement.adFormat === BANNER) { + expect(placement.sizes).to.be.an('array'); + } + switch (placement.adFormat) { + case BANNER: + expect(placement.sizes).to.be.an('array'); + break; + case VIDEO: + expect(placement.playerSize).to.be.an('array'); + expect(placement.minduration).to.be.an('number'); + expect(placement.maxduration).to.be.an('number'); + break; + case NATIVE: + expect(placement.native).to.be.an('object'); + break; + } + } + }); + + it('Returns data with gdprConsent and without uspConsent', function () { + delete bidderRequest.uspConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.gdpr).to.exist; + expect(data.gdpr).to.be.a('object'); + expect(data.gdpr).to.have.property('consentString'); + expect(data.gdpr).to.not.have.property('vendorData'); + expect(data.gdpr.consentString).to.equal(bidderRequest.gdprConsent.consentString); + expect(data.ccpa).to.not.exist; + delete bidderRequest.gdprConsent; + }); + + it('Returns data with uspConsent and without gdprConsent', function () { + bidderRequest.uspConsent = '1---'; + delete bidderRequest.gdprConsent; + serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data.ccpa).to.exist; + expect(data.ccpa).to.be.a('string'); + expect(data.ccpa).to.equal(bidderRequest.uspConsent); + expect(data.gdpr).to.not.exist; + }); + }); + + describe('gpp consent', function () { + it('bidderRequest.gppConsent', () => { + bidderRequest.gppConsent = { + gppString: 'abc123', + applicableSections: [8] + }; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + + delete bidderRequest.gppConsent; + }) + + it('bidderRequest.ortb2.regs.gpp', () => { + bidderRequest.ortb2 = bidderRequest.ortb2 || {}; + bidderRequest.ortb2.regs = bidderRequest.ortb2.regs || {}; + bidderRequest.ortb2.regs.gpp = 'abc123'; + bidderRequest.ortb2.regs.gpp_sid = [8]; + + const serverRequest = spec.buildRequests(bids, bidderRequest); + const data = serverRequest.data; + expect(data).to.be.an('object'); + expect(data).to.have.property('gpp'); + expect(data).to.have.property('gpp_sid'); + }) + }); + + describe('interpretResponse', function () { + it('Should interpret banner response', function () { + const banner = { + body: [{ + mediaType: 'banner', + width: 300, + height: 250, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const bannerResponses = spec.interpretResponse(banner); + expect(bannerResponses).to.be.an('array').that.is.not.empty; + const dataItem = bannerResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'width', 'height', 'ad', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal(banner.body[0].requestId); + expect(dataItem.cpm).to.equal(banner.body[0].cpm); + expect(dataItem.width).to.equal(banner.body[0].width); + expect(dataItem.height).to.equal(banner.body[0].height); + expect(dataItem.ad).to.equal(banner.body[0].ad); + expect(dataItem.ttl).to.equal(banner.body[0].ttl); + expect(dataItem.creativeId).to.equal(banner.body[0].creativeId); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal(banner.body[0].currency); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret video response', function () { + const video = { + body: [{ + vastUrl: 'test.com', + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const videoResponses = spec.interpretResponse(video); + expect(videoResponses).to.be.an('array').that.is.not.empty; + + const dataItem = videoResponses[0]; + expect(dataItem).to.have.all.keys('requestId', 'cpm', 'vastUrl', 'ttl', 'creativeId', + 'netRevenue', 'currency', 'dealId', 'mediaType', 'meta'); + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.5); + expect(dataItem.vastUrl).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should interpret native response', function () { + const native = { + body: [{ + mediaType: 'native', + native: { + clickUrl: 'test.com', + title: 'Test', + image: 'test.com', + impressionTrackers: ['test.com'], + }, + ttl: 120, + cpm: 0.4, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + meta: { + advertiserDomains: ['google.com'], + advertiserId: 1234 + } + }] + }; + const nativeResponses = spec.interpretResponse(native); + expect(nativeResponses).to.be.an('array').that.is.not.empty; + + const dataItem = nativeResponses[0]; + expect(dataItem).to.have.keys('requestId', 'cpm', 'ttl', 'creativeId', 'netRevenue', 'currency', 'mediaType', 'native', 'meta'); + expect(dataItem.native).to.have.keys('clickUrl', 'impressionTrackers', 'title', 'image') + expect(dataItem.requestId).to.equal('23fhj33i987f'); + expect(dataItem.cpm).to.equal(0.4); + expect(dataItem.native.clickUrl).to.equal('test.com'); + expect(dataItem.native.title).to.equal('Test'); + expect(dataItem.native.image).to.equal('test.com'); + expect(dataItem.native.impressionTrackers).to.be.an('array').that.is.not.empty; + expect(dataItem.native.impressionTrackers[0]).to.equal('test.com'); + expect(dataItem.ttl).to.equal(120); + expect(dataItem.creativeId).to.equal('2'); + expect(dataItem.netRevenue).to.be.true; + expect(dataItem.currency).to.equal('USD'); + expect(dataItem.meta).to.be.an('object').that.has.any.key('advertiserDomains'); + }); + it('Should return an empty array if invalid banner response is passed', function () { + const invBanner = { + body: [{ + width: 300, + cpm: 0.4, + ad: 'Test', + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + + const serverResponses = spec.interpretResponse(invBanner); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid video response is passed', function () { + const invVideo = { + body: [{ + mediaType: 'video', + cpm: 0.5, + requestId: '23fhj33i987f', + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invVideo); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid native response is passed', function () { + const invNative = { + body: [{ + mediaType: 'native', + clickUrl: 'test.com', + title: 'Test', + impressionTrackers: ['test.com'], + ttl: 120, + requestId: '23fhj33i987f', + creativeId: '2', + netRevenue: true, + currency: 'USD', + }] + }; + const serverResponses = spec.interpretResponse(invNative); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + it('Should return an empty array if invalid response is passed', function () { + const invalid = { + body: [{ + ttl: 120, + creativeId: '2', + netRevenue: true, + currency: 'USD', + dealId: '1' + }] + }; + const serverResponses = spec.interpretResponse(invalid); + expect(serverResponses).to.be.an('array').that.is.empty; + }); + }); +}); diff --git a/test/spec/modules/visiblemeasuresBidAdapter_spec.js b/test/spec/modules/visiblemeasuresBidAdapter_spec.js index d17e82a1c7a..b76805e4ba0 100644 --- a/test/spec/modules/visiblemeasuresBidAdapter_spec.js +++ b/test/spec/modules/visiblemeasuresBidAdapter_spec.js @@ -483,7 +483,7 @@ describe('VisibleMeasuresBidAdapter', function () { const syncData = spec.getUserSyncs({}, {}, { consentString: 'ALL', gdprApplies: true, - }, {}); + }, undefined); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -492,9 +492,7 @@ describe('VisibleMeasuresBidAdapter', function () { expect(syncData[0].url).to.equal(`${syncUrl}/image?pbjs=1&gdpr=1&gdpr_consent=ALL&coppa=0`) }); it('Should return array of objects with proper sync config , include CCPA', function() { - const syncData = spec.getUserSyncs({}, {}, {}, { - consentString: '1---' - }); + const syncData = spec.getUserSyncs({}, {}, {}, '1---'); expect(syncData).to.be.an('array').which.is.not.empty; expect(syncData[0]).to.be.an('object') expect(syncData[0].type).to.be.a('string') @@ -503,7 +501,7 @@ describe('VisibleMeasuresBidAdapter', function () { expect(syncData[0].url).to.equal(`${syncUrl}/image?pbjs=1&ccpa_consent=1---&coppa=0`) }); it('Should return array of objects with proper sync config , include GPP', function() { - const syncData = spec.getUserSyncs({}, {}, {}, {}, { + const syncData = spec.getUserSyncs({}, {}, {}, undefined, { gppString: 'abc123', applicableSections: [8] }); diff --git a/test/spec/modules/yahooAdsBidAdapter_spec.js b/test/spec/modules/yahooAdsBidAdapter_spec.js index ad1e428aafd..093df9355f1 100644 --- a/test/spec/modules/yahooAdsBidAdapter_spec.js +++ b/test/spec/modules/yahooAdsBidAdapter_spec.js @@ -992,7 +992,6 @@ describe('Yahoo Advertising Bid Adapter:', () => { {source: 'neustar.biz', uids: [{id: 'fabrickId_FROM_USER_ID_MODULE', atype: 1}]} ]; const data = spec.buildRequests(validBidRequests, bidderRequest)[0].data; - expect(data.user.ext.eids).to.deep.equal(validBidRequests[0].userIdAsEids); }); @@ -1034,6 +1033,7 @@ describe('Yahoo Advertising Bid Adapter:', () => { }); expect(data.source).to.deep.equal({ + tid: undefined, ext: { hb: 1, adapterver: ADAPTER_VERSION, @@ -1056,6 +1056,74 @@ describe('Yahoo Advertising Bid Adapter:', () => { expect(data.cur).to.deep.equal(['USD']); }); + it('should not include source.tid when publisher does not provide it', () => { + const { validBidRequests, bidderRequest } = generateBuildRequestMock({}); + const data = spec.buildRequests(validBidRequests, bidderRequest)[0].data; + expect(data.source.tid).to.be.undefined; + }); + + it('should include source.tid from bidderRequest.ortb2.source.tid when provided', () => { + const ortb2 = { + source: { + tid: 'test-transaction-id-12345' + } + }; + const { validBidRequests, bidderRequest } = generateBuildRequestMock({ortb2}); + bidderRequest.ortb2 = ortb2; + const data = spec.buildRequests(validBidRequests, bidderRequest)[0].data; + expect(data.source.tid).to.equal('test-transaction-id-12345'); + }); + + it('should read source.tid from global ortb2 config when enableTids is true', () => { + const ortb2 = { + source: { + tid: 'global-tid-67890' + } + }; + const { validBidRequests, bidderRequest } = generateBuildRequestMock({ortb2}); + bidderRequest.ortb2 = ortb2; + const data = spec.buildRequests(validBidRequests, bidderRequest)[0].data; + expect(data.source.tid).to.equal('global-tid-67890'); + expect(data.source.ext.hb).to.equal(1); + expect(data.source.fd).to.equal(1); + }); + + it('should include source.tid alongside existing source.ext properties', () => { + const ortb2 = { + source: { + tid: 'test-tid-with-ext' + } + }; + const { validBidRequests, bidderRequest } = generateBuildRequestMock({ortb2}); + bidderRequest.ortb2 = ortb2; + const data = spec.buildRequests(validBidRequests, bidderRequest)[0].data; + + expect(data.source).to.have.property('tid'); + expect(data.source.tid).to.equal('test-tid-with-ext'); + expect(data.source.ext).to.deep.equal({ + hb: 1, + adapterver: ADAPTER_VERSION, + prebidver: PREBID_VERSION, + integration: { + name: INTEGRATION_METHOD, + ver: PREBID_VERSION + } + }); + expect(data.source.fd).to.equal(1); + }); + + it('should handle empty source.tid gracefully', () => { + const ortb2 = { + source: { + tid: '' + } + }; + const { validBidRequests, bidderRequest } = generateBuildRequestMock({ortb2}); + bidderRequest.ortb2 = ortb2; + const data = spec.buildRequests(validBidRequests, bidderRequest)[0].data; + expect(data.source.tid).to.equal(''); + }); + it('should generate a valid openRTB imp.ext object in the bid-request', () => { const { validBidRequests, bidderRequest } = generateBuildRequestMock({}); const bid = validBidRequests[0]; diff --git a/test/spec/modules/yaleoBidAdapter_spec.js b/test/spec/modules/yaleoBidAdapter_spec.js new file mode 100644 index 00000000000..5bbba97109c --- /dev/null +++ b/test/spec/modules/yaleoBidAdapter_spec.js @@ -0,0 +1,213 @@ +import { cloneDeep } from "lodash"; +import { spec } from "../../../modules/yaleoBidAdapter.ts"; + +const bannerBidRequestBase = { + adUnitCode: 'banner-ad-unit-code', + auctionId: 'banner-auction-id', + bidId: 'banner-bid-id', + bidder: 'yaleo', + bidderRequestId: 'banner-bidder-request-id', + mediaTypes: { banner: [[300, 250]] }, + params: { placementId: '12345' }, +}; + +const bannerServerResponseBase = { + body: { + id: 'banner-server-response-id', + seatbid: [ + { + bid: [ + { + id: 'banner-seatbid-bid-id', + impid: 'banner-bid-id', + price: 0.05, + adm: '
banner-ad
', + adid: 'banner-ad-id', + adomain: ['audienzz.com', 'yaleo.com'], + iurl: 'iurl', + cid: 'banner-campaign-id', + crid: 'banner-creative-id', + cat: [], + w: 300, + h: 250, + mtype: 1, + }, + ], + seat: 'yaleo', + }, + ], + cur: 'USD', + }, +}; + +describe('Yaleo bid adapter', () => { + let bannerBidRequest; + + beforeEach(() => { + bannerBidRequest = cloneDeep(bannerBidRequestBase); + }); + + describe('spec', () => { + it('checks that spec has required properties', () => { + expect(spec).to.have.property('code', 'yaleo'); + expect(spec).to.have.property('supportedMediaTypes').that.includes('banner'); + expect(spec).to.have.property('isBidRequestValid').that.is.a('function'); + expect(spec).to.have.property('buildRequests').that.is.a('function'); + expect(spec).to.have.property('interpretResponse').that.is.a('function'); + expect(spec).to.have.property('gvlid').that.equals(783); + }); + }); + + describe('isBidRequestValid', () => { + it('returns true when all params are specified', () => { + bannerBidRequest.params = { + placementId: '12345', + maxCpm: 5.00, + memberId: 12345, + }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.true; + }); + + it('returns true when required params are specified', () => { + bannerBidRequest.params = { placementId: '12345' }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.true; + }); + + it('returns false when params are empty', () => { + bannerBidRequest.params = {}; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returns false when params are not specified', () => { + bannerBidRequest.params = undefined; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returnsfalse when required params are not specified', () => { + bannerBidRequest.params = { wrongParam: '12345' }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returns false when placementId is a number', () => { + bannerBidRequest.params = { placementId: 12345 }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returns false when placementId is a boolean', () => { + bannerBidRequest.params = { placementId: true }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returns false when placementId is an object', () => { + bannerBidRequest.params = { placementId: {} }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returns false when placementId is an array', () => { + bannerBidRequest.params = { placementId: [] }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returns false when placementId is undefined', () => { + bannerBidRequest.params = { placementId: undefined }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + + it('returns false when placementId is null', () => { + bannerBidRequest.params = { placementId: null }; + expect(spec.isBidRequestValid(bannerBidRequest)).to.be.false; + }); + }); + + describe('buildRequests', () => { + it('creates a valid banner bid request', () => { + const bidderRequest = { + bids: [bannerBidRequest], + auctionId: bannerBidRequest.auctionId, + bidderRequestId: bannerBidRequest.bidderRequestId, + ortb2: { + site: { + page: 'http://example.com', + } + } + }; + + const request = spec.buildRequests([bannerBidRequest], bidderRequest); + + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://bidder.yaleo.com/prebid'); + expect(request.data.imp.length).to.equal(1); + expect(request.data.imp[0]).to.have.property('banner'); + expect(request.data.site.page).to.be.equal('http://example.com'); + }); + + it('checks that all params are passed to the request', () => { + bannerBidRequest.params = { + placementId: '12345', + maxCpm: 5.00, + memberId: 12345, + }; + const bidderRequest = { + bids: [bannerBidRequest], + auctionId: bannerBidRequest.auctionId, + bidderRequestId: bannerBidRequest.bidderRequestId, + }; + + const request = spec.buildRequests([bannerBidRequest], bidderRequest); + const yaleoParams = request.data.imp[0].ext.prebid.bidder.yaleo; + + expect(yaleoParams.placementId).to.equal('12345'); + expect(yaleoParams.maxCpm).to.equal(5.00); + expect(yaleoParams.memberId).to.equal(12345); + }); + }); + + it('checks that only specified params are passed to the request', () => { + bannerBidRequest.params = { + placementId: '12345', + }; + + const bidderRequest = { + bids: [bannerBidRequest], + auctionId: bannerBidRequest.auctionId, + bidderRequestId: bannerBidRequest.bidderRequestId, + }; + + const request = spec.buildRequests([bannerBidRequest], bidderRequest); + const yaleoParams = request.data.imp[0].ext.prebid.bidder.yaleo; + + expect(yaleoParams).to.deep.equal({ placementId: '12345' }); + }); + + describe('interpretResponse', () => { + let bannerServerResponse; + beforeEach(() => { + bannerServerResponse = cloneDeep(bannerServerResponseBase); + }); + + it('parses banner bid response correctly', () => { + const bidderRequest = { + bids: [bannerBidRequest], + auctionId: bannerBidRequest.auctionId, + bidderRequestId: bannerBidRequest.bidderRequestId, + }; + + const request = spec.buildRequests([bannerBidRequest], bidderRequest); + const response = spec.interpretResponse(bannerServerResponse, request); + + expect(response).to.have.property('bids'); + expect(response.bids.length).to.be.equal(1); + expect(response.bids[0].cpm).to.equal(0.05); + expect(response.bids[0].currency).to.equal('USD'); + expect(response.bids[0].mediaType).to.equal('banner'); + expect(response.bids[0].width).to.equal(300); + expect(response.bids[0].height).to.equal(250); + expect(response.bids[0].creativeId).to.equal('banner-creative-id'); + expect(response.bids[0].ad).to.equal('
banner-ad
'); + expect(response.bids[0].bidderCode).to.equal('yaleo'); + expect(response.bids[0].meta.advertiserDomains).to.deep.equal(['audienzz.com', 'yaleo.com']); + expect(response.bids[0].netRevenue).to.be.true; + expect(response.bids[0].ttl).to.equal(300); + }); + }); +}); diff --git a/test/spec/modules/zeta_global_sspAnalyticsAdapter_spec.js b/test/spec/modules/zeta_global_sspAnalyticsAdapter_spec.js index 6e55083338f..d3e6e01c655 100644 --- a/test/spec/modules/zeta_global_sspAnalyticsAdapter_spec.js +++ b/test/spec/modules/zeta_global_sspAnalyticsAdapter_spec.js @@ -1,8 +1,7 @@ import zetaAnalyticsAdapter from 'modules/zeta_global_sspAnalyticsAdapter.js'; -import {config} from 'src/config'; -import {EVENTS} from 'src/constants.js'; -import {server} from '../../mocks/xhr.js'; -import {logError} from '../../../src/utils.js'; +import { config } from 'src/config'; +import { EVENTS } from 'src/constants.js'; +import { server } from '../../mocks/xhr.js'; const utils = require('src/utils'); const events = require('src/events'); @@ -112,6 +111,9 @@ const SAMPLE_EVENTS = { 'device': { 'mobile': 1 } + }, + 'getFloor': function() { + return { floor: 1.5, currency: 'USD' }; } } ], @@ -178,6 +180,9 @@ const SAMPLE_EVENTS = { 'device': { 'mobile': 1 } + }, + 'getFloor': function() { + return { floor: 1.5, currency: 'USD' }; } } ], @@ -275,6 +280,7 @@ const SAMPLE_EVENTS = { 'pbDg': '2.25', 'pbCg': '', 'size': '480x320', + 'dspId': 'test-dsp-id-123', 'adserverTargeting': { 'hb_bidder': 'zeta_global_ssp', 'hb_adid': '5759bb3ef7be1e8', @@ -337,6 +343,7 @@ const SAMPLE_EVENTS = { 'adUnitCode': '/19968336/header-bid-tag-0', 'timeToRespond': 123, 'size': '480x320', + 'dspId': 'test-dsp-id-123', 'adserverTargeting': { 'hb_bidder': 'zeta_global_ssp', 'hb_adid': '5759bb3ef7be1e8', @@ -351,7 +358,12 @@ const SAMPLE_EVENTS = { { 'nonZetaParam': 'nonZetaValue' } - ] + ], + 'floorData': { + 'floorValue': 1.5, + 'floorCurrency': 'USD', + 'floorRule': 'test-rule' + } }, 'adId': '5759bb3ef7be1e8' }, @@ -435,7 +447,10 @@ const SAMPLE_EVENTS = { } } }, - 'timeout': 3 + 'timeout': 3, + 'getFloor': function() { + return { floor: 0.75, currency: 'USD' }; + } }, { 'bidder': 'zeta_global_ssp', @@ -516,7 +531,10 @@ const SAMPLE_EVENTS = { } } }, - 'timeout': 3 + 'timeout': 3, + 'getFloor': function() { + return { floor: 0.75, currency: 'USD' }; + } } ] } @@ -562,14 +580,10 @@ describe('Zeta Global SSP Analytics Adapter', function () { zetaAnalyticsAdapter.disableAnalytics(); }); - it('Handle events', function () { - this.timeout(3000); - + it('should handle AUCTION_END event', function () { events.emit(EVENTS.AUCTION_END, SAMPLE_EVENTS.AUCTION_END); - events.emit(EVENTS.AD_RENDER_SUCCEEDED, SAMPLE_EVENTS.AD_RENDER_SUCCEEDED); - events.emit(EVENTS.BID_TIMEOUT, SAMPLE_EVENTS.BID_TIMEOUT); - expect(requests.length).to.equal(3); + expect(requests.length).to.equal(1); const auctionEnd = JSON.parse(requests[0].requestBody); expect(auctionEnd).to.be.deep.equal({ zetaParams: {sid: 111, tags: {position: 'top', shortname: 'name'}}, @@ -582,11 +596,15 @@ describe('Zeta Global SSP Analytics Adapter', function () { bidId: '206be9a13236af', auctionId: '75e394d9', bidder: 'zeta_global_ssp', - mediaType: 'BANNER', - size: '300x250', + mediaType: 'banner', + sizes: [ + [300, 250], + [300, 600] + ], device: { mobile: 1 - } + }, + floor: 1.5 }] }, { bidderCode: 'appnexus', @@ -597,11 +615,15 @@ describe('Zeta Global SSP Analytics Adapter', function () { bidId: '41badc0e164c758', auctionId: '75e394d9', bidder: 'appnexus', - mediaType: 'BANNER', - size: '300x250', + mediaType: 'banner', + sizes: [ + [300, 250], + [300, 600] + ], device: { mobile: 1 - } + }, + floor: 1.5 }] }], bidsReceived: [{ @@ -614,10 +636,17 @@ describe('Zeta Global SSP Analytics Adapter', function () { size: '480x320', adomain: 'example.adomain', timeToRespond: 123, - cpm: 2.258302852806723 + cpm: 2.258302852806723, + dspId: 'test-dsp-id-123' }] }); - const auctionSucceeded = JSON.parse(requests[1].requestBody); + }); + + it('should handle AD_RENDER_SUCCEEDED event', function () { + events.emit(EVENTS.AD_RENDER_SUCCEEDED, SAMPLE_EVENTS.AD_RENDER_SUCCEEDED); + + expect(requests.length).to.equal(1); + const auctionSucceeded = JSON.parse(requests[0].requestBody); expect(auctionSucceeded.zetaParams).to.be.deep.equal({ sid: 111, tags: { @@ -634,15 +663,26 @@ describe('Zeta Global SSP Analytics Adapter', function () { auctionId: '75e394d9', creativeId: '456456456', bidder: 'zeta_global_ssp', + dspId: 'test-dsp-id-123', mediaType: 'banner', size: '480x320', adomain: 'example.adomain', timeToRespond: 123, - cpm: 2.258302852806723 + cpm: 2.258302852806723, + floorData: { + floorValue: 1.5, + floorCurrency: 'USD', + floorRule: 'test-rule' + } }); expect(auctionSucceeded.device.ua).to.not.be.empty; + }); + + it('should handle BID_TIMEOUT event', function () { + events.emit(EVENTS.BID_TIMEOUT, SAMPLE_EVENTS.BID_TIMEOUT); - const bidTimeout = JSON.parse(requests[2].requestBody); + expect(requests.length).to.equal(1); + const bidTimeout = JSON.parse(requests[0].requestBody); expect(bidTimeout.zetaParams).to.be.deep.equal({ sid: 111, tags: { @@ -656,8 +696,10 @@ describe('Zeta Global SSP Analytics Adapter', function () { 'bidId': '27c8c05823e2f', 'auctionId': 'fa9ef841-bcb9-401f-96ad-03a94ac64e63', 'bidder': 'zeta_global_ssp', - 'mediaType': 'BANNER', - 'size': '300x250', + 'mediaType': 'banner', + 'sizes': [ + [300, 250] + ], 'timeout': 3, 'device': { 'w': 807, @@ -675,13 +717,16 @@ describe('Zeta Global SSP Analytics Adapter', function () { 'mobile': 0 } }, - 'adUnitCode': 'ad-1' + 'adUnitCode': 'ad-1', + 'floor': 0.75 }, { 'bidId': '31a3b551cbf1ed', 'auctionId': 'fa9ef841-bcb9-401f-96ad-03a94ac64e63', 'bidder': 'zeta_global_ssp', - 'mediaType': 'BANNER', - 'size': '300x250', + 'mediaType': 'banner', + 'sizes': [ + [300, 250] + ], 'timeout': 3, 'device': { 'w': 807, @@ -699,7 +744,8 @@ describe('Zeta Global SSP Analytics Adapter', function () { 'mobile': 0 } }, - 'adUnitCode': 'ad-2' + 'adUnitCode': 'ad-2', + 'floor': 0.75 }]); }); }); diff --git a/test/test_deps.js b/test/test_deps.js index 7047e775d9c..5f0ab890035 100644 --- a/test/test_deps.js +++ b/test/test_deps.js @@ -41,6 +41,14 @@ sinon.createFakeServerWithClock = fakeServerWithClock.create.bind(fakeServerWith localStorage.clear(); +if (window.frameElement != null) { + // sometimes (e.g. chrome headless) the tests run in an iframe that is offset from the top window + // other times (e.g. browser debug page) they run in the top window + // this can cause inconsistencies with the percentInView libraries; if we are in a frame, + // fake the same dimensions as the top window + window.frameElement.getBoundingClientRect = () => window.top.getBoundingClientRect(); +} + require('test/helpers/global_hooks.js'); require('test/helpers/consentData.js'); require('test/helpers/prebidGlobal.js');