From 38db9cfc4d34db064671f37ceca08c3bb4f75a21 Mon Sep 17 00:00:00 2001 From: Brett Oberg Date: Tue, 9 Dec 2025 10:30:45 -0600 Subject: [PATCH] APS Bid Adapter Initial Release **Overview** ------------ APS (Amazon Publisher Services) bid adapter initial open source release. **Changes** ----------- - (feat) Banner ad support - (feat) Video ad support - (feat) iframe user sync support - (feat) Telemetry and analytics - (docs) Integration guide --- modules/apsBidAdapter.js | 367 ++++++++ modules/apsBidAdapter.md | 84 ++ test/spec/modules/apsBidAdapter_spec.js | 1059 +++++++++++++++++++++++ 3 files changed, 1510 insertions(+) create mode 100644 modules/apsBidAdapter.js create mode 100644 modules/apsBidAdapter.md create mode 100644 test/spec/modules/apsBidAdapter_spec.js diff --git a/modules/apsBidAdapter.js b/modules/apsBidAdapter.js new file mode 100644 index 0000000000..732737d32b --- /dev/null +++ b/modules/apsBidAdapter.js @@ -0,0 +1,367 @@ +import { isStr, isNumber, logWarn, logError } from '../src/utils.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { config } from '../src/config.js'; +import { BANNER, VIDEO } from '../src/mediaTypes.js'; +import { hasPurpose1Consent } from '../src/utils/gdpr.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; + +/** + * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest + * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid + * @typedef {import('../src/adapters/bidderFactory.js').ServerRequest} ServerRequest + * @typedef {import('../src/adapters/bidderFactory.js').BidderSpec} BidderSpec + */ + +const GVLID = 793; +export const ADAPTER_VERSION = '2.0.0'; +const BIDDER_CODE = 'aps'; +const AAX_ENDPOINT = 'https://web.ads.aps.amazon-adsystem.com/e/pb/bid'; +const DEFAULT_PREBID_CREATIVE_JS_URL = + 'https://client.aps.amazon-adsystem.com/prebid-creative.js'; + +/** + * Records an event by pushing a CustomEvent onto a global queue. + * Creates an account-specific store on window._aps if needed. + * Automatically prefixes eventName with 'prebidAdapter/' if not already prefixed. + * Automatically appends '/didTrigger' if there is no third part provided in the event name. + * + * @param {string} eventName - The name of the event to record + * @param {object} data - Event data object, typically containing an 'error' property + */ +function record(eventName, data) { + // Check if telemetry is enabled + if (config.readConfig('aps.telemetry') === false) { + return; + } + + // Automatically prefix eventName with 'prebidAdapter/' if not already prefixed + const prefixedEventName = eventName.startsWith('prebidAdapter/') + ? eventName + : `prebidAdapter/${eventName}`; + + // Automatically append 'didTrigger' if there is no third part provided in the event name + const parts = prefixedEventName.split('/'); + const finalEventName = + parts.length < 3 ? `${prefixedEventName}/didTrigger` : prefixedEventName; + + const accountID = config.readConfig('aps.accountID'); + if (!accountID) { + return; + } + + window._aps = window._aps || new Map(); + if (!window._aps.has(accountID)) { + window._aps.set(accountID, { + queue: [], + store: new Map(), + }); + } + + // Ensure analytics key exists unless error key is present + const detailData = { ...data }; + if (!detailData.error) { + detailData.analytics = detailData.analytics || {}; + } + + window._aps.get(accountID).queue.push( + new CustomEvent(finalEventName, { + detail: { + ...detailData, + source: 'prebid-adapter', + libraryVersion: ADAPTER_VERSION, + }, + }) + ); +} + +/** + * Record and log a new error. + * + * @param {string} eventName - The name of the event to record + * @param {Error} err - Error object + * @param {any} data - Event data object + */ +function recordAndLogError(eventName, err, data) { + record(eventName, { ...data, error: err }); + logError(err.message); +} + +/** + * Validates whether a given account ID is valid. + * + * @param {string|number} accountID - The account ID to validate + * @returns {boolean} Returns true if the account ID is valid, false otherwise + */ +function isValidAccountID(accountID) { + // null/undefined are not acceptable + if (accountID == null) { + return false; + } + + // Numbers are valid (including 0) + if (isNumber(accountID)) { + return true; + } + + // Strings must have content after trimming + if (isStr(accountID)) { + return accountID.trim().length > 0; + } + + // Other types are invalid + return false; +} + +export const converter = ortbConverter({ + context: { + netRevenue: true, + }, + + request(buildRequest, imps, bidderRequest, context) { + const request = buildRequest(imps, bidderRequest, context); + + // Remove precise geo locations for privacy. + if (request?.device?.geo) { + delete request.device.geo.lat; + delete request.device.geo.lon; + } + + if (request.user) { + // Remove sensitive user data. + delete request.user.gender; + delete request.user.yob; + // Remove both 'keywords' and alternate 'kwarry' if present. + delete request.user.keywords; + delete request.user.kwarry; + delete request.user.customdata; + delete request.user.geo; + delete request.user.data; + } + + request.ext = request.ext ?? {}; + request.ext.account = config.readConfig('aps.accountID'); + request.ext.sdk = { + version: ADAPTER_VERSION, + source: 'prebid', + }; + request.cur = request.cur ?? ['USD']; + + if (!request.imp || !Array.isArray(request.imp)) { + return request; + } + + request.imp.forEach((imp, index) => { + if (!imp) { + return; // continue to next iteration + } + + if (!imp.banner) { + return; // continue to next iteration + } + + const doesHWExist = imp.banner.w >= 0 && imp.banner.h >= 0; + const doesFormatExist = + Array.isArray(imp.banner.format) && imp.banner.format.length > 0; + + if (doesHWExist || !doesFormatExist) { + return; // continue to next iteration + } + + const { w, h } = imp.banner.format[0]; + + if (typeof w !== 'number' || typeof h !== 'number') { + return; // continue to next iteration + } + + imp.banner.w = w; + imp.banner.h = h; + }); + + return request; + }, + + bidResponse(buildBidResponse, bid, context) { + let vastUrl; + if (bid.mtype === 2) { + vastUrl = bid.adm; + // Making sure no adm value is passed down to prevent issues with some renderers + delete bid.adm; + } + + const bidResponse = buildBidResponse(bid, context); + if (bidResponse.mediaType === VIDEO) { + bidResponse.vastUrl = vastUrl; + } + + return bidResponse; + }, +}); + +/** @type {BidderSpec} */ +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER, VIDEO], + + /** + * Validates the bid request. + * Always fires 100% of requests when account ID is valid. + * @param {object} bid + * @return {boolean} + */ + isBidRequestValid: (bid) => { + record('isBidRequestValid'); + try { + const accountID = config.readConfig('aps.accountID'); + if (!isValidAccountID(accountID)) { + logWarn(`Invalid accountID: ${accountID}`); + return false; + } + return true; + } catch (err) { + err.message = `Error while validating bid request: ${err?.message}`; + recordAndLogError('isBidRequestValid/didError', err); + } + }, + + /** + * Constructs the server request for the bidder. + * @param {BidRequest[]} bidRequests + * @param {*} bidderRequest + * @return {ServerRequest} + */ + buildRequests: (bidRequests, bidderRequest) => { + record('buildRequests'); + try { + let endpoint = config.readConfig('aps.debugURL') ?? AAX_ENDPOINT; + // Append debug parameters to the URL if debug mode is enabled. + if (config.readConfig('aps.debug')) { + const debugQueryChar = endpoint.includes('?') ? '&' : '?'; + const renderMethod = config.readConfig('aps.renderMethod'); + if (renderMethod === 'fif') { + endpoint += debugQueryChar + 'amzn_debug_mode=fif&amzn_debug_mode=1'; + } else { + endpoint += debugQueryChar + 'amzn_debug_mode=1'; + } + } + return { + method: 'POST', + url: endpoint, + data: converter.toORTB({ bidRequests, bidderRequest }), + }; + } catch (err) { + err.message = `Error while building bid request: ${err?.message}`; + recordAndLogError('buildRequests/didError', err); + } + }, + + /** + * Interprets the response from the server. + * Constructs a creative script to render the ad using a prebid creative JS. + * @param {*} response + * @param {ServerRequest} request + * @return {Bid[] | {bids: Bid[]}} + */ + interpretResponse: (response, request) => { + record('interpretResponse'); + try { + const interpretedResponse = converter.fromORTB({ + response: response.body, + request: request.data, + }); + const accountID = config.readConfig('aps.accountID'); + + const creativeUrl = + config.readConfig('aps.creativeURL') || DEFAULT_PREBID_CREATIVE_JS_URL; + + interpretedResponse.bids.forEach((bid) => { + if (bid.mediaType !== VIDEO) { + delete bid.ad; + bid.ad = ` +`.trim(); + } + }); + + return interpretedResponse.bids; + } catch (err) { + err.message = `Error while interpreting bid response: ${err?.message}`; + recordAndLogError('interpretResponse/didError', err); + } + }, + + /** + * Register user syncs to be processed during the shared user ID sync activity + * + * @param {Object} syncOptions - Options for user synchronization + * @param {Array} serverResponses - Array of bid responses + * @param {Object} gdprConsent - GDPR consent information + * @param {Object} uspConsent - USP consent information + * @returns {Array} Array of user sync objects + */ + getUserSyncs: function ( + syncOptions, + serverResponses, + gdprConsent, + uspConsent + ) { + record('getUserSyncs'); + try { + if (hasPurpose1Consent(gdprConsent)) { + return serverResponses + .flatMap((res) => res?.body?.ext?.userSyncs ?? []) + .filter( + (s) => + (s.type === 'iframe' && syncOptions.iframeEnabled) || + (s.type === 'image' && syncOptions.pixelEnabled) + ); + } + } catch (err) { + err.message = `Error while getting user syncs: ${err?.message}`; + recordAndLogError('getUserSyncs/didError', err); + } + }, + + onTimeout: (timeoutData) => { + record('onTimeout', { error: timeoutData }); + }, + + onSetTargeting: (bid) => { + record('onSetTargeting'); + }, + + onAdRenderSucceeded: (bid) => { + record('onAdRenderSucceeded'); + }, + + onBidderError: (error) => { + record('onBidderError', { error }); + }, + + onBidWon: (bid) => { + record('onBidWon'); + }, + + onBidAttribute: (bid) => { + record('onBidAttribute'); + }, + + onBidBillable: (bid) => { + record('onBidBillable'); + }, +}; + +registerBidder(spec); diff --git a/modules/apsBidAdapter.md b/modules/apsBidAdapter.md new file mode 100644 index 0000000000..1b772210af --- /dev/null +++ b/modules/apsBidAdapter.md @@ -0,0 +1,84 @@ +# Overview + +``` +Module Name: APS Bidder Adapter +Module Type: Bidder Adapter +Maintainer: aps-prebid@amazon.com +``` + +# Description + +Connects to Amazon Publisher Services (APS) for bids. + +## Test Bids + +Please contact your APS Account Manager to learn more about our testing policies. + +# Usage + +## Prerequisites + +Add the account ID provided by APS to your configuration. + +``` +pbjs.setBidderConfig( + { + bidders: ['aps'], + config: { + aps: { + accountID: YOUR_APS_ACCOUNT_ID, + } + }, + }, + true // mergeConfig toggle +); +``` + +## Ad Units + +## Banner + +``` +const adUnits = [ + { + code: 'banner_div', + mediaTypes: { + banner: { + sizes: [[300, 250]], + }, + }, + bids: [{ bidder: 'aps' }], + }, +]; +``` + +## Video + +Please select your preferred video renderer. The following example uses in-renderer-js: + +``` +const adUnits = [ + { + code: 'video_div', + mediaTypes: { + video: { + playerSize: [400, 225], + context: 'outstream', + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4, 5, 6, 7, 8], + minduration: 5, + maxduration: 30, + placement: 3, + }, + }, + bids: [{ bidder: 'aps' }], + renderer: { + url: 'https://cdn.jsdelivr.net/npm/in-renderer-js@1/dist/in-renderer.umd.min.js', + render(bid) { + new window.InRenderer().render('video_div', bid); + }, + }, + }, +]; + +``` diff --git a/test/spec/modules/apsBidAdapter_spec.js b/test/spec/modules/apsBidAdapter_spec.js new file mode 100644 index 0000000000..429572b034 --- /dev/null +++ b/test/spec/modules/apsBidAdapter_spec.js @@ -0,0 +1,1059 @@ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { spec, ADAPTER_VERSION } from 'modules/apsBidAdapter'; +import { config } from 'src/config.js'; + +/** + * Update config without rewriting the entire aps scope. + * + * Every call to setConfig() overwrites supplied values at the top level. + * e.g. if ortb2 is provided as a value, any previously-supplied ortb2 + * values will disappear. + */ +const updateAPSConfig = (data) => { + const existingAPSConfig = config.readConfig('aps'); + config.setConfig({ + aps: { + ...existingAPSConfig, + ...data, + }, + }); +}; + +describe('apsBidAdapter', () => { + const accountID = 'test-account'; + + beforeEach(() => { + updateAPSConfig({ accountID }); + }); + + afterEach(() => { + config.resetConfig(); + delete window._aps; + }); + + describe('isBidRequestValid', () => { + it('should record prebidAdapter/isBidRequestValid/didTrigger event', () => { + spec.isBidRequestValid({}); + + const accountQueue = window._aps.get(accountID).queue; + expect(accountQueue).to.have.length(1); + expect(accountQueue[0].type).to.equal( + 'prebidAdapter/isBidRequestValid/didTrigger' + ); + }); + + it('when no accountID provided, should not record event', () => { + updateAPSConfig({ accountID: undefined }); + spec.isBidRequestValid({}); + + expect(window._aps).not.to.exist; + }); + + it('when telemetry is turned off, should not record event', () => { + updateAPSConfig({ telemetry: false }); + spec.isBidRequestValid({}); + + expect(window._aps).not.to.exist; + }); + + [ + { accountID: undefined }, + { accountID: null }, + { accountID: [] }, + { accountID: { key: 'value' } }, + { accountID: true }, + { accountID: false }, + ].forEach((scenario) => { + it(`when accountID is ${JSON.stringify(scenario.accountID)}, should return false`, () => { + updateAPSConfig({ accountID: scenario.accountID }); + const actual = spec.isBidRequestValid({}); + expect(actual).to.equal(false); + }); + }); + + it('when accountID is a number, should return true', () => { + updateAPSConfig({ accountID: 1234 }); + const actual = spec.isBidRequestValid({}); + expect(actual).to.equal(true); + }); + + it('when accountID is a string, should return true', () => { + updateAPSConfig({ accountID: '1234' }); + const actual = spec.isBidRequestValid({}); + expect(actual).to.equal(true); + }); + }); + + describe('buildRequests', () => { + let bidRequests, bidderRequest; + + beforeEach(() => { + bidRequests = [ + { + bidId: 'bid1', + adUnitCode: 'adunit1', + mediaTypes: { banner: { sizes: [[300, 250]] } }, + params: {}, + }, + { + bidId: 'bid2', + code: 'video_div', + mediaTypes: { + video: { + playerSize: [400, 225], + context: 'outstream', + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4, 5, 6, 7, 8], + minduration: 5, + maxduration: 30, + placement: 3, + }, + }, + bids: [{ bidder: 'aps' }], + }, + ]; + bidderRequest = { + bidderCode: 'aps', + auctionId: 'auction1', + bidderRequestId: 'request1', + }; + }); + + it('should record prebidAdapter/buildRequests/didTrigger event', () => { + spec.buildRequests(bidRequests, bidderRequest); + + const accountQueue = window._aps.get(accountID).queue; + expect(accountQueue).to.have.length(1); + expect(accountQueue[0].type).to.equal( + 'prebidAdapter/buildRequests/didTrigger' + ); + }); + + it('when no accountID provided, should not record event', () => { + updateAPSConfig({ accountID: undefined }); + spec.buildRequests(bidRequests, bidderRequest); + + expect(window._aps).not.to.exist; + }); + + it('when telemetry is turned off, should not record event', () => { + updateAPSConfig({ telemetry: false }); + spec.buildRequests(bidRequests, bidderRequest); + + expect(window._aps).not.to.exist; + }); + + it('should return server request with default endpoint', () => { + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.method).to.equal('POST'); + expect(result.url).to.equal( + 'https://web.ads.aps.amazon-adsystem.com/e/pb/bid' + ); + expect(result.data).to.exist; + }); + + it('should return server request with properly formatted impressions', () => { + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.imp.length).to.equal(2); + expect(result.data.imp[0]).to.deep.equal({ + banner: { format: [{ h: 250, w: 300 }], h: 250, topframe: 0, w: 300 }, + id: 'bid1', + secure: 1, + }); + expect(result.data.imp[1]).to.deep.equal({ + id: 'bid2', + secure: 1, + ...(FEATURES.VIDEO && { + video: { + h: 225, + maxduration: 30, + mimes: ['video/mp4'], + minduration: 5, + placement: 3, + protocols: [1, 2, 3, 4, 5, 6, 7, 8], + w: 400, + }, + }), + }); + }); + + it('when debugURL is provided, should use custom debugURL', () => { + updateAPSConfig({ debugURL: 'https://example.com' }); + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.url).to.equal('https://example.com'); + }); + + it('should convert bid requests to ORTB format with account', () => { + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data).to.be.an('object'); + expect(result.data.ext).to.exist; + expect(result.data.ext.account).to.equal(accountID); + }); + + it('should include ADAPTER_VERSION in request data', () => { + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.ext.sdk.version).to.equal(ADAPTER_VERSION); + expect(result.data.ext.sdk.source).to.equal('prebid'); + }); + + it('when accountID is not provided, should convert bid requests to ORTB format with no account', () => { + updateAPSConfig({ accountID: undefined }); + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data).to.be.an('object'); + expect(result.data.ext).to.exist; + expect(result.data.ext.account).to.equal(undefined); + }); + + it('should remove sensitive geo data from device', () => { + bidderRequest.ortb2 = { + device: { + geo: { + lat: 37.7749, + lon: -122.4194, + country: 'US', + }, + }, + }; + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.device.geo.lat).to.be.undefined; + expect(result.data.device.geo.lon).to.be.undefined; + expect(result.data.device.geo.country).to.equal('US'); + }); + + it('should remove sensitive user data', () => { + bidderRequest.ortb2 = { + user: { + gender: 'M', + yob: 1990, + keywords: 'sports,tech', + kwarry: 'alternate keywords', + customdata: 'custom user data', + geo: { lat: 37.7749, lon: -122.4194 }, + data: [{ id: 'segment1' }], + id: 'user123', + }, + }; + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.user.gender).to.be.undefined; + expect(result.data.user.yob).to.be.undefined; + expect(result.data.user.keywords).to.be.undefined; + expect(result.data.user.kwarry).to.be.undefined; + expect(result.data.user.customdata).to.be.undefined; + expect(result.data.user.geo).to.be.undefined; + expect(result.data.user.data).to.be.undefined; + expect(result.data.user.id).to.equal('user123'); + }); + + it('should set default currency to USD', () => { + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.cur).to.deep.equal(['USD']); + }); + + [ + { imp: undefined }, + { imp: null }, + { imp: 'not an array' }, + { imp: 123 }, + { imp: true }, + { imp: false }, + ].forEach((scenario) => { + it(`when imp is ${JSON.stringify(scenario.imp)}, should send data`, () => { + bidderRequest.ortb2 = { + imp: scenario.imp, + }; + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.imp).to.equal(scenario.imp); + }); + }); + + [ + { imp: [null] }, + { imp: [undefined] }, + { imp: [null, {}] }, + { imp: [{}, null] }, + { imp: [undefined, {}] }, + { imp: [{}, undefined] }, + ].forEach((scenario, scenarioIndex) => { + it(`when imp array contains null/undefined at index, should send data - scenario ${scenarioIndex}`, () => { + bidRequests = []; + bidderRequest.ortb2 = { imp: scenario.imp }; + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.imp).to.deep.equal(scenario.imp); + }); + }); + + [ + { w: 'invalid', h: 250 }, + { w: 300, h: 'invalid' }, + { w: null, h: 250 }, + { w: 300, h: undefined }, + { w: true, h: 250 }, + { w: 300, h: false }, + { w: {}, h: 250 }, + { w: 300, h: [] }, + ].forEach((scenario) => { + it(`when imp array contains banner object with invalid format (h: "${scenario.h}", w: "${scenario.w}"), should send data`, () => { + const { w, h } = scenario; + const invalidBannerObj = { + banner: { + format: [ + { w, h }, + { w: 300, h: 250 }, + ], + }, + }; + const imp = [ + { banner: { format: [{ w: 300, h: 250 }] } }, + { video: { w: 300, h: undefined } }, + invalidBannerObj, + { video: { w: undefined, h: 300 } }, + ]; + bidRequests = []; + bidderRequest.ortb2 = { imp }; + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.data.imp).to.deep.equal(imp); + }); + }); + + describe('when debug mode is enabled', () => { + beforeEach(() => { + updateAPSConfig({ debug: true }); + }); + + it('should append debug parameters', () => { + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.url).to.equal( + 'https://web.ads.aps.amazon-adsystem.com/e/pb/bid?amzn_debug_mode=1' + ); + }); + + it('when using custom endpoint, should append debug parameters', () => { + updateAPSConfig({ debugURL: 'https://example.com' }); + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.url).to.equal('https://example.com?amzn_debug_mode=1'); + }); + + it('when endpoint has existing query params, should append debug parameters with &', () => { + updateAPSConfig({ + debugURL: 'https://example.com?existing=param', + }); + + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.url).to.equal( + 'https://example.com?existing=param&amzn_debug_mode=1' + ); + }); + + describe('when renderMethod is fif', () => { + beforeEach(() => { + updateAPSConfig({ renderMethod: 'fif' }); + }); + + it('when renderMethod is fif, should append fif debug parameters', () => { + const result = spec.buildRequests(bidRequests, bidderRequest); + + expect(result.url).to.equal( + 'https://web.ads.aps.amazon-adsystem.com/e/pb/bid?amzn_debug_mode=fif&amzn_debug_mode=1' + ); + }); + }); + }); + }); + + describe('interpretResponse', () => { + const impid = '32adcfab8e54178'; + let response, request, bidRequests, bidderRequest; + + beforeEach(() => { + bidRequests = [ + { + bidder: 'aps', + params: {}, + ortb2Imp: { ext: { data: {} } }, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + adUnitCode: 'display-ad', + adUnitId: '57661158-f277-4061-bbfc-532b6f811c7b', + sizes: [[300, 250]], + bidId: impid, + bidderRequestId: '2a1ec2d1ccea318', + }, + ]; + bidderRequest = { + bidderCode: 'aps', + auctionId: null, + bidderRequestId: '2a1ec2d1ccea318', + bids: [ + { + bidder: 'aps', + params: {}, + ortb2Imp: { ext: { data: {} } }, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + adUnitCode: 'display-ad', + adUnitId: '57661158-f277-4061-bbfc-532b6f811c7b', + sizes: [[300, 250]], + bidId: impid, + bidderRequestId: '2a1ec2d1ccea318', + }, + ], + start: 1758899825329, + }; + + request = spec.buildRequests(bidRequests, bidderRequest); + + response = { + body: { + id: '53d4dda2-cf3d-455a-8554-48f051ca4ad3', + cur: 'USD', + seatbid: [ + { + bid: [ + { + mtype: 1, + id: 'jp45_n29nkvhfuttv0rhl5iaaagvz_t54weaaaxzaqbhchnfdhhux2jpzdigicbhchnfdhhux2ltcdegicdpqbra', + adid: 'eaayacognuhq9jcfs8rwkoyyhmwtke4e4jmnrjcx.ywnbprnvr0ybkk6wpu_', + price: 5.5, + impid, + crid: 'amazon-test-ad', + w: 300, + h: 250, + exp: 3600, + }, + ], + }, + ], + }, + headers: {}, + }; + }); + + it('should record prebidAdapter/interpretResponse/didTrigger event', () => { + spec.interpretResponse(response, request); + + const accountQueue = window._aps.get(accountID).queue; + expect(accountQueue).to.have.length(2); + expect(accountQueue[0].type).to.equal( + 'prebidAdapter/buildRequests/didTrigger' + ); + expect(accountQueue[1].type).to.equal( + 'prebidAdapter/interpretResponse/didTrigger' + ); + }); + + it('should return interpreted bids from ORTB response', () => { + const result = spec.interpretResponse(response, request); + + expect(result).to.be.an('array'); + expect(result.length).to.equal(1); + }); + + it('should include accountID in creative script', () => { + updateAPSConfig({ accountID: accountID }); + + const result = spec.interpretResponse(response, request); + + expect(result).to.have.length(1); + expect(result[0].ad).to.include("const accountID = 'test-account'"); + }); + + it('when creativeURL is provided, should use custom creative URL', () => { + updateAPSConfig({ + creativeURL: 'https://custom-creative.com/script.js', + }); + + const result = spec.interpretResponse(response, request); + + expect(result).to.have.length(1); + expect(result[0].ad).to.include( + 'src="https://custom-creative.com/script.js"' + ); + }); + + it('should use default creative URL when not provided', () => { + const result = spec.interpretResponse(response, request); + + expect(result).to.have.length(1); + expect(result[0].ad).to.include( + 'src="https://client.aps.amazon-adsystem.com/prebid-creative.js"' + ); + }); + + describe('when bid mediaType is VIDEO', () => { + beforeEach(() => { + response.body.seatbid[0].bid[0].mtype = 2; + }); + + it('should not inject creative script for video bids', () => { + const result = spec.interpretResponse(response, request); + + expect(result).to.have.length(1); + expect(result[0].ad).to.be.undefined; + }); + }); + + describe('when bid mediaType is not VIDEO', () => { + it('should inject creative script for non-video bids', () => { + const result = spec.interpretResponse(response, request); + + expect(result).to.have.length(1); + expect(result[0].ad).to.include('