From af13ec80b0f88e5d7f5130fda3bb589ff1a82405 Mon Sep 17 00:00:00 2001 From: Anton Babak <76536883+AntoxaAntoxic@users.noreply.github.com> Date: Wed, 13 Nov 2024 15:39:39 +0100 Subject: [PATCH] Core: Bid Adjustments Feature (#3542) --- docs/application-settings.md | 7 +- .../prebid/server/auction/BidsAdjuster.java | 140 +-- .../server/auction/model/AuctionContext.java | 12 + .../requestfactory/AuctionRequestFactory.java | 8 +- .../BidAdjustmentRulesValidator.java | 100 ++ .../BidAdjustmentsProcessor.java | 204 ++++ .../BidAdjustmentsResolver.java | 106 +++ .../BidAdjustmentsRetriever.java | 86 ++ .../model/BidAdjustmentType.java | 19 + .../bidadjustments/model/BidAdjustments.java | 52 + .../ext/request/ExtRequestBidAdjustments.java | 15 + .../request/ExtRequestBidAdjustmentsRule.java | 24 + .../openrtb/ext/request/ExtRequestPrebid.java | 5 + .../openrtb/ext/request/ImpMediaType.java | 3 + .../settings/model/AccountAuctionConfig.java | 4 + .../spring/config/ServiceConfiguration.java | 44 +- .../model/config/AccountAuctionConfig.groovy | 3 + .../request/auction/AdjustmentRule.groovy | 17 + .../request/auction/AdjustmentType.groovy | 13 + .../request/auction/BidAdjustment.groovy | 20 + .../auction/BidAdjustmentFactors.groovy | 1 - .../auction/BidAdjustmentMediaType.groovy | 5 +- .../request/auction/BidAdjustmentRule.groovy | 16 + .../model/request/auction/BidRequest.groovy | 5 + .../model/request/auction/Prebid.groovy | 1 + .../functional/tests/BidAdjustmentSpec.groovy | 886 +++++++++++++++++- .../server/auction/BidsAdjusterTest.java | 756 +-------------- .../AuctionRequestFactoryTest.java | 37 +- .../BidAdjustmentRulesValidatorTest.java | 306 ++++++ .../BidAdjustmentsProcessorTest.java | 823 ++++++++++++++++ .../BidAdjustmentsResolverTest.java | 243 +++++ .../BidAdjustmentsRetrieverTest.java | 396 ++++++++ .../model/BidAdjustmentsTest.java | 65 ++ 33 files changed, 3560 insertions(+), 862 deletions(-) create mode 100644 src/main/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidator.java create mode 100644 src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessor.java create mode 100644 src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsResolver.java create mode 100644 src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsRetriever.java create mode 100644 src/main/java/org/prebid/server/bidadjustments/model/BidAdjustmentType.java create mode 100644 src/main/java/org/prebid/server/bidadjustments/model/BidAdjustments.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustments.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustmentsRule.java create mode 100644 src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentRule.groovy create mode 100644 src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentType.groovy create mode 100644 src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustment.groovy create mode 100644 src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentRule.groovy create mode 100644 src/test/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidatorTest.java create mode 100644 src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessorTest.java create mode 100644 src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsResolverTest.java create mode 100644 src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsRetrieverTest.java create mode 100644 src/test/java/org/prebid/server/bidadjustments/model/BidAdjustmentsTest.java diff --git a/docs/application-settings.md b/docs/application-settings.md index bf0dc61bfd0..7f164caa0dc 100644 --- a/docs/application-settings.md +++ b/docs/application-settings.md @@ -19,8 +19,13 @@ There are two ways to configure application settings: database and file. This do operational warning. - "enforce": if a bidder returns a creative that's larger in height or width than any of the allowed sizes, reject the bid and log an operational warning. +- `auction.bidadjustments` - configuration JSON for default bid adjustments +- `auction.bidadjustments.mediatype.{banner, video-instream, video-outstream, audio, native, *}.{, *}.{, *}[]` - array of bid adjustment to be applied to any bid of the provided mediatype, and (`*` means ANY) +- `auction.bidadjustments.mediatype.*.*.*[].adjtype` - type of the bid adjustment (cpm, multiplier, static) +- `auction.bidadjustments.mediatype.*.*.*[].value` - value of the bid adjustment +- `auction.bidadjustments.mediatype.*.*.*[].currency` - currency of the bid adjustment - `auction.events.enabled` - enables events for account if true -- `auction.price-floors.enabeled` - enables price floors for account if true. Defaults to true. +- `auction.price-floors.enabled` - enables price floors for account if true. Defaults to true. - `auction.price-floors.fetch.enabled`- enables data fetch for price floors for account if true. Defaults to false. - `auction.price-floors.fetch.url` - url to fetch price floors data from. - `auction.price-floors.fetch.timeout-ms` - timeout for fetching price floors data. Defaults to 5000. diff --git a/src/main/java/org/prebid/server/auction/BidsAdjuster.java b/src/main/java/org/prebid/server/auction/BidsAdjuster.java index 8134662fcd9..4ae7a6e3e3e 100644 --- a/src/main/java/org/prebid/server/auction/BidsAdjuster.java +++ b/src/main/java/org/prebid/server/auction/BidsAdjuster.java @@ -1,32 +1,19 @@ package org.prebid.server.auction; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.DecimalNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.TextNode; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.response.Bid; -import org.apache.commons.lang3.StringUtils; -import org.prebid.server.auction.adjustment.BidAdjustmentFactorResolver; import org.prebid.server.auction.model.AuctionContext; import org.prebid.server.auction.model.AuctionParticipation; import org.prebid.server.auction.model.BidderResponse; +import org.prebid.server.bidadjustments.BidAdjustmentsProcessor; import org.prebid.server.bidder.model.BidderBid; import org.prebid.server.bidder.model.BidderError; import org.prebid.server.bidder.model.BidderSeatBid; -import org.prebid.server.currency.CurrencyConversionService; -import org.prebid.server.exception.PreBidException; import org.prebid.server.floors.PriceFloorEnforcer; -import org.prebid.server.json.JacksonMapper; -import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentFactors; -import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; -import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; import org.prebid.server.util.ObjectUtil; -import org.prebid.server.util.PbsUtil; import org.prebid.server.validation.ResponseBidValidator; import org.prebid.server.validation.model.ValidationResult; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -35,29 +22,20 @@ public class BidsAdjuster { - private static final String ORIGINAL_BID_CPM = "origbidcpm"; - private static final String ORIGINAL_BID_CURRENCY = "origbidcur"; - private final ResponseBidValidator responseBidValidator; - private final CurrencyConversionService currencyService; - private final BidAdjustmentFactorResolver bidAdjustmentFactorResolver; private final PriceFloorEnforcer priceFloorEnforcer; + private final BidAdjustmentsProcessor bidAdjustmentsProcessor; private final DsaEnforcer dsaEnforcer; - private final JacksonMapper mapper; public BidsAdjuster(ResponseBidValidator responseBidValidator, - CurrencyConversionService currencyService, - BidAdjustmentFactorResolver bidAdjustmentFactorResolver, PriceFloorEnforcer priceFloorEnforcer, - DsaEnforcer dsaEnforcer, - JacksonMapper mapper) { + BidAdjustmentsProcessor bidAdjustmentsProcessor, + DsaEnforcer dsaEnforcer) { this.responseBidValidator = Objects.requireNonNull(responseBidValidator); - this.currencyService = Objects.requireNonNull(currencyService); - this.bidAdjustmentFactorResolver = Objects.requireNonNull(bidAdjustmentFactorResolver); this.priceFloorEnforcer = Objects.requireNonNull(priceFloorEnforcer); + this.bidAdjustmentsProcessor = Objects.requireNonNull(bidAdjustmentsProcessor); this.dsaEnforcer = Objects.requireNonNull(dsaEnforcer); - this.mapper = Objects.requireNonNull(mapper); } public List validateAndAdjustBids(List auctionParticipations, @@ -66,12 +44,18 @@ public List validateAndAdjustBids(List validBidderResponse(auctionParticipation, auctionContext, aliases)) - .map(auctionParticipation -> applyBidPriceChanges(auctionParticipation, auctionContext.getBidRequest())) + + .map(auctionParticipation -> bidAdjustmentsProcessor.enrichWithAdjustedBids( + auctionParticipation, + auctionContext.getBidRequest(), + auctionContext.getBidAdjustments())) + .map(auctionParticipation -> priceFloorEnforcer.enforce( auctionContext.getBidRequest(), auctionParticipation, auctionContext.getAccount(), auctionContext.getBidRejectionTrackers().get(auctionParticipation.getBidder()))) + .map(auctionParticipation -> dsaEnforcer.enforce( auctionContext.getBidRequest(), auctionParticipation, @@ -137,104 +121,4 @@ private BidderError makeValidationBidderError(Bid bid, ValidationResult validati final String bidId = ObjectUtil.getIfNotNullOrDefault(bid, Bid::getId, () -> "unknown"); return BidderError.invalidBid("BidId `" + bidId + "` validation messages: " + validationErrors); } - - private AuctionParticipation applyBidPriceChanges(AuctionParticipation auctionParticipation, - BidRequest bidRequest) { - if (auctionParticipation.isRequestBlocked()) { - return auctionParticipation; - } - - final BidderResponse bidderResponse = auctionParticipation.getBidderResponse(); - final BidderSeatBid seatBid = bidderResponse.getSeatBid(); - - final List bidderBids = seatBid.getBids(); - if (bidderBids.isEmpty()) { - return auctionParticipation; - } - - final List updatedBidderBids = new ArrayList<>(bidderBids.size()); - final List errors = new ArrayList<>(seatBid.getErrors()); - final String adServerCurrency = bidRequest.getCur().getFirst(); - - for (final BidderBid bidderBid : bidderBids) { - try { - final BidderBid updatedBidderBid = - updateBidderBidWithBidPriceChanges(bidderBid, bidderResponse, bidRequest, adServerCurrency); - updatedBidderBids.add(updatedBidderBid); - } catch (PreBidException e) { - errors.add(BidderError.generic(e.getMessage())); - } - } - - final BidderResponse resultBidderResponse = bidderResponse.with(seatBid.toBuilder() - .bids(updatedBidderBids) - .errors(errors) - .build()); - return auctionParticipation.with(resultBidderResponse); - } - - private BidderBid updateBidderBidWithBidPriceChanges(BidderBid bidderBid, - BidderResponse bidderResponse, - BidRequest bidRequest, - String adServerCurrency) { - final Bid bid = bidderBid.getBid(); - final String bidCurrency = bidderBid.getBidCurrency(); - final BigDecimal price = bid.getPrice(); - - final BigDecimal priceInAdServerCurrency = currencyService.convertCurrency( - price, bidRequest, StringUtils.stripToNull(bidCurrency), adServerCurrency); - - final BigDecimal priceAdjustmentFactor = - bidAdjustmentForBidder(bidderResponse.getBidder(), bidRequest, bidderBid); - final BigDecimal adjustedPrice = adjustPrice(priceAdjustmentFactor, priceInAdServerCurrency); - - final ObjectNode bidExt = bid.getExt(); - final ObjectNode updatedBidExt = bidExt != null ? bidExt : mapper.mapper().createObjectNode(); - - updateExtWithOrigPriceValues(updatedBidExt, price, bidCurrency); - - final Bid.BidBuilder bidBuilder = bid.toBuilder(); - if (adjustedPrice.compareTo(price) != 0) { - bidBuilder.price(adjustedPrice); - } - - if (!updatedBidExt.isEmpty()) { - bidBuilder.ext(updatedBidExt); - } - - return bidderBid.toBuilder().bid(bidBuilder.build()).build(); - } - - private BigDecimal bidAdjustmentForBidder(String bidder, BidRequest bidRequest, BidderBid bidderBid) { - final ExtRequestBidAdjustmentFactors adjustmentFactors = extBidAdjustmentFactors(bidRequest); - if (adjustmentFactors == null) { - return null; - } - final ImpMediaType mediaType = ImpMediaTypeResolver.resolve( - bidderBid.getBid().getImpid(), bidRequest.getImp(), bidderBid.getType()); - - return bidAdjustmentFactorResolver.resolve(mediaType, adjustmentFactors, bidder); - } - - private static ExtRequestBidAdjustmentFactors extBidAdjustmentFactors(BidRequest bidRequest) { - final ExtRequestPrebid prebid = PbsUtil.extRequestPrebid(bidRequest); - return prebid != null ? prebid.getBidadjustmentfactors() : null; - } - - private static BigDecimal adjustPrice(BigDecimal priceAdjustmentFactor, BigDecimal price) { - return priceAdjustmentFactor != null && priceAdjustmentFactor.compareTo(BigDecimal.ONE) != 0 - ? price.multiply(priceAdjustmentFactor) - : price; - } - - private static void updateExtWithOrigPriceValues(ObjectNode updatedBidExt, BigDecimal price, String bidCurrency) { - addPropertyToNode(updatedBidExt, ORIGINAL_BID_CPM, new DecimalNode(price)); - if (StringUtils.isNotBlank(bidCurrency)) { - addPropertyToNode(updatedBidExt, ORIGINAL_BID_CURRENCY, new TextNode(bidCurrency)); - } - } - - private static void addPropertyToNode(ObjectNode node, String propertyName, JsonNode propertyValue) { - node.set(propertyName, propertyValue); - } } diff --git a/src/main/java/org/prebid/server/auction/model/AuctionContext.java b/src/main/java/org/prebid/server/auction/model/AuctionContext.java index 3ee60aab4fa..5dbe83c3ff2 100644 --- a/src/main/java/org/prebid/server/auction/model/AuctionContext.java +++ b/src/main/java/org/prebid/server/auction/model/AuctionContext.java @@ -8,6 +8,7 @@ import org.prebid.server.activity.infrastructure.ActivityInfrastructure; import org.prebid.server.auction.gpp.model.GppContext; import org.prebid.server.auction.model.debug.DebugContext; +import org.prebid.server.bidadjustments.model.BidAdjustments; import org.prebid.server.cache.model.DebugHttpCall; import org.prebid.server.cookie.UidsCookie; import org.prebid.server.geolocation.model.GeoInfo; @@ -17,6 +18,7 @@ import org.prebid.server.privacy.model.PrivacyContext; import org.prebid.server.settings.model.Account; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -71,6 +73,10 @@ public class AuctionContext { CachedDebugLog cachedDebugLog; + @JsonIgnore + @Builder.Default + BidAdjustments bidAdjustments = BidAdjustments.of(Collections.emptyMap()); + public AuctionContext with(Account account) { return this.toBuilder().account(account).build(); } @@ -124,6 +130,12 @@ public AuctionContext with(GeoInfo geoInfo) { .build(); } + public AuctionContext with(BidAdjustments bidAdjustments) { + return this.toBuilder() + .bidAdjustments(bidAdjustments) + .build(); + } + public AuctionContext withRequestRejected() { return this.toBuilder() .requestRejected(true) diff --git a/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java b/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java index bd720a25f2b..34140a26228 100644 --- a/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java +++ b/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java @@ -17,6 +17,7 @@ import org.prebid.server.auction.model.AuctionStoredResult; import org.prebid.server.auction.privacy.contextfactory.AuctionPrivacyContextFactory; import org.prebid.server.auction.versionconverter.BidRequestOrtbVersionConversionManager; +import org.prebid.server.bidadjustments.BidAdjustmentsRetriever; import org.prebid.server.cookie.CookieDeprecationService; import org.prebid.server.exception.InvalidRequestException; import org.prebid.server.json.JacksonMapper; @@ -50,6 +51,7 @@ public class AuctionRequestFactory { private final JacksonMapper mapper; private final OrtbTypesResolver ortbTypesResolver; private final GeoLocationServiceWrapper geoLocationServiceWrapper; + private final BidAdjustmentsRetriever bidAdjustmentsRetriever; private static final String ENDPOINT = Endpoint.openrtb2_auction.value(); @@ -66,7 +68,8 @@ public AuctionRequestFactory(long maxRequestSize, AuctionPrivacyContextFactory auctionPrivacyContextFactory, DebugResolver debugResolver, JacksonMapper mapper, - GeoLocationServiceWrapper geoLocationServiceWrapper) { + GeoLocationServiceWrapper geoLocationServiceWrapper, + BidAdjustmentsRetriever bidAdjustmentsRetriever) { this.maxRequestSize = maxRequestSize; this.ortb2RequestFactory = Objects.requireNonNull(ortb2RequestFactory); @@ -82,6 +85,7 @@ public AuctionRequestFactory(long maxRequestSize, this.debugResolver = Objects.requireNonNull(debugResolver); this.mapper = Objects.requireNonNull(mapper); this.geoLocationServiceWrapper = Objects.requireNonNull(geoLocationServiceWrapper); + this.bidAdjustmentsRetriever = Objects.requireNonNull(bidAdjustmentsRetriever); } /** @@ -142,6 +146,8 @@ public Future enrichAuctionContext(AuctionContext initialContext .compose(auctionContext -> ortb2RequestFactory.enrichBidRequestWithAccountAndPrivacyData(auctionContext) .map(auctionContext::with)) + .map(auctionContext -> auctionContext.with(bidAdjustmentsRetriever.retrieve(auctionContext))) + .compose(auctionContext -> ortb2RequestFactory.executeProcessedAuctionRequestHooks(auctionContext) .map(auctionContext::with)) diff --git a/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidator.java b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidator.java new file mode 100644 index 00000000000..9ddeefb6e2e --- /dev/null +++ b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidator.java @@ -0,0 +1,100 @@ +package org.prebid.server.bidadjustments; + +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.bidadjustments.model.BidAdjustmentType; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustments; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; +import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; +import org.prebid.server.validation.ValidationException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class BidAdjustmentRulesValidator { + + public static final Set SUPPORTED_MEDIA_TYPES = Set.of( + BidAdjustmentsResolver.WILDCARD, + ImpMediaType.banner.toString(), + ImpMediaType.audio.toString(), + ImpMediaType.video_instream.toString(), + ImpMediaType.video_outstream.toString(), + ImpMediaType.xNative.toString()); + + private BidAdjustmentRulesValidator() { + + } + + public static void validate(ExtRequestBidAdjustments bidAdjustments) throws ValidationException { + if (bidAdjustments == null) { + return; + } + + final Map>>> mediatypes = + bidAdjustments.getMediatype(); + + if (MapUtils.isEmpty(mediatypes)) { + return; + } + + for (String mediatype : mediatypes.keySet()) { + if (SUPPORTED_MEDIA_TYPES.contains(mediatype)) { + final Map>> bidders = mediatypes.get(mediatype); + if (MapUtils.isEmpty(bidders)) { + throw new ValidationException("no bidders found in %s".formatted(mediatype)); + } + for (String bidder : bidders.keySet()) { + final Map> deals = bidders.get(bidder); + + if (MapUtils.isEmpty(deals)) { + throw new ValidationException("no deals found in %s.%s".formatted(mediatype, bidder)); + } + + for (String dealId : deals.keySet()) { + final String path = "%s.%s.%s".formatted(mediatype, bidder, dealId); + validateRules(deals.get(dealId), path); + } + } + } + } + } + + private static void validateRules(List rules, + String path) throws ValidationException { + + if (rules == null) { + throw new ValidationException("no bid adjustment rules found in %s".formatted(path)); + } + + for (ExtRequestBidAdjustmentsRule rule : rules) { + final BidAdjustmentType type = rule.getAdjType(); + final String currency = rule.getCurrency(); + final BigDecimal value = rule.getValue(); + + final boolean isNotSpecifiedCurrency = StringUtils.isBlank(currency); + + final boolean unknownType = type == null || type == BidAdjustmentType.UNKNOWN; + + final boolean invalidCpm = type == BidAdjustmentType.CPM + && (isNotSpecifiedCurrency || isValueNotInRange(value, 0, Integer.MAX_VALUE)); + + final boolean invalidMultiplier = type == BidAdjustmentType.MULTIPLIER + && isValueNotInRange(value, 0, 100); + + final boolean invalidStatic = type == BidAdjustmentType.STATIC + && (isNotSpecifiedCurrency || isValueNotInRange(value, 0, Integer.MAX_VALUE)); + + if (unknownType || invalidCpm || invalidMultiplier || invalidStatic) { + throw new ValidationException("the found rule %s in %s is invalid".formatted(rule, path)); + } + } + } + + private static boolean isValueNotInRange(BigDecimal value, int minValue, int maxValue) { + return value == null + || value.compareTo(BigDecimal.valueOf(minValue)) < 0 + || value.compareTo(BigDecimal.valueOf(maxValue)) >= 0; + } +} diff --git a/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessor.java b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessor.java new file mode 100644 index 00000000000..8bd10535b98 --- /dev/null +++ b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessor.java @@ -0,0 +1,204 @@ +package org.prebid.server.bidadjustments; + +import com.fasterxml.jackson.databind.node.DecimalNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.response.Bid; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.auction.ImpMediaTypeResolver; +import org.prebid.server.auction.adjustment.BidAdjustmentFactorResolver; +import org.prebid.server.auction.model.AuctionParticipation; +import org.prebid.server.auction.model.BidderResponse; +import org.prebid.server.bidadjustments.model.BidAdjustments; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.BidderSeatBid; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentFactors; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; +import org.prebid.server.util.PbsUtil; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class BidAdjustmentsProcessor { + + private static final String ORIGINAL_BID_CPM = "origbidcpm"; + private static final String ORIGINAL_BID_CURRENCY = "origbidcur"; + + private final CurrencyConversionService currencyService; + private final BidAdjustmentFactorResolver bidAdjustmentFactorResolver; + private final BidAdjustmentsResolver bidAdjustmentsResolver; + private final JacksonMapper mapper; + + public BidAdjustmentsProcessor(CurrencyConversionService currencyService, + BidAdjustmentFactorResolver bidAdjustmentFactorResolver, + BidAdjustmentsResolver bidAdjustmentsResolver, + JacksonMapper mapper) { + + this.currencyService = Objects.requireNonNull(currencyService); + this.bidAdjustmentFactorResolver = Objects.requireNonNull(bidAdjustmentFactorResolver); + this.bidAdjustmentsResolver = Objects.requireNonNull(bidAdjustmentsResolver); + this.mapper = Objects.requireNonNull(mapper); + } + + public AuctionParticipation enrichWithAdjustedBids(AuctionParticipation auctionParticipation, + BidRequest bidRequest, + BidAdjustments bidAdjustments) { + + if (auctionParticipation.isRequestBlocked()) { + return auctionParticipation; + } + + final BidderResponse bidderResponse = auctionParticipation.getBidderResponse(); + final BidderSeatBid seatBid = bidderResponse.getSeatBid(); + + final List bidderBids = seatBid.getBids(); + if (bidderBids.isEmpty()) { + return auctionParticipation; + } + + final List errors = new ArrayList<>(seatBid.getErrors()); + final String bidder = auctionParticipation.getBidder(); + + final List updatedBidderBids = bidderBids.stream() + .map(bidderBid -> applyBidAdjustments(bidderBid, bidRequest, bidder, bidAdjustments, errors)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + final BidderResponse updatedBidderResponse = bidderResponse.with(seatBid.toBuilder() + .bids(updatedBidderBids) + .errors(errors) + .build()); + + return auctionParticipation.with(updatedBidderResponse); + } + + private static ExtRequestBidAdjustmentFactors extBidAdjustmentFactors(BidRequest bidRequest) { + final ExtRequestPrebid prebid = PbsUtil.extRequestPrebid(bidRequest); + return prebid != null ? prebid.getBidadjustmentfactors() : null; + } + + private static BigDecimal adjustPrice(BigDecimal priceAdjustmentFactor, BigDecimal price) { + return priceAdjustmentFactor != null && priceAdjustmentFactor.compareTo(BigDecimal.ONE) != 0 + ? price.multiply(priceAdjustmentFactor) + : price; + } + + private BidderBid applyBidAdjustments(BidderBid bidderBid, + BidRequest bidRequest, + String bidder, + BidAdjustments bidAdjustments, + List errors) { + try { + final Price originalPrice = getOriginalPrice(bidderBid); + final Price priceWithFactorsApplied = applyBidAdjustmentFactors( + originalPrice, + bidderBid, + bidder, + bidRequest); + final Price priceWithAdjustmentsApplied = applyBidAdjustmentRules( + priceWithFactorsApplied, + bidderBid, + bidder, + bidRequest, + bidAdjustments); + return updateBid(originalPrice, priceWithAdjustmentsApplied, bidderBid, bidRequest); + } catch (PreBidException e) { + errors.add(BidderError.generic(e.getMessage())); + return null; + } + } + + private BidderBid updateBid(Price originalPrice, Price adjustedPrice, BidderBid bidderBid, BidRequest bidRequest) { + final Bid bid = bidderBid.getBid(); + final ObjectNode bidExt = bid.getExt(); + final ObjectNode updatedBidExt = bidExt != null ? bidExt : mapper.mapper().createObjectNode(); + + final BigDecimal originalBidPrice = originalPrice.getValue(); + final String originalBidCurrency = originalPrice.getCurrency(); + updatedBidExt.set(ORIGINAL_BID_CPM, new DecimalNode(originalBidPrice)); + if (StringUtils.isNotBlank(originalBidCurrency)) { + updatedBidExt.set(ORIGINAL_BID_CURRENCY, new TextNode(originalBidCurrency)); + } + + final String requestCurrency = bidRequest.getCur().getFirst(); + final BigDecimal requestCurrencyPrice = currencyService.convertCurrency( + adjustedPrice.getValue(), + bidRequest, + adjustedPrice.getCurrency(), + requestCurrency); + + return bidderBid.toBuilder() + .bidCurrency(requestCurrency) + .bid(bid.toBuilder() + .ext(updatedBidExt) + .price(requestCurrencyPrice) + .build()) + .build(); + } + + private Price getOriginalPrice(BidderBid bidderBid) { + final Bid bid = bidderBid.getBid(); + final String bidCurrency = bidderBid.getBidCurrency(); + final BigDecimal price = bid.getPrice(); + + return Price.of(StringUtils.stripToNull(bidCurrency), price); + } + + private Price applyBidAdjustmentFactors(Price bidPrice, BidderBid bidderBid, String bidder, BidRequest bidRequest) { + final String bidCurrency = bidPrice.getCurrency(); + final BigDecimal price = bidPrice.getValue(); + + final BigDecimal priceAdjustmentFactor = bidAdjustmentForBidder(bidder, bidRequest, bidderBid); + final BigDecimal adjustedPrice = adjustPrice(priceAdjustmentFactor, price); + + return Price.of(bidCurrency, adjustedPrice.compareTo(price) != 0 ? adjustedPrice : price); + } + + private BigDecimal bidAdjustmentForBidder(String bidder, BidRequest bidRequest, BidderBid bidderBid) { + final ExtRequestBidAdjustmentFactors adjustmentFactors = extBidAdjustmentFactors(bidRequest); + if (adjustmentFactors == null) { + return null; + } + + final ImpMediaType mediaType = ImpMediaTypeResolver.resolve( + bidderBid.getBid().getImpid(), + bidRequest.getImp(), + bidderBid.getType()); + + return bidAdjustmentFactorResolver.resolve(mediaType, adjustmentFactors, bidder); + } + + private Price applyBidAdjustmentRules(Price bidPrice, + BidderBid bidderBid, + String bidder, + BidRequest bidRequest, + BidAdjustments bidAdjustments) { + + final Bid bid = bidderBid.getBid(); + final String bidCurrency = bidPrice.getCurrency(); + final BigDecimal price = bidPrice.getValue(); + + final ImpMediaType mediaType = ImpMediaTypeResolver.resolve( + bid.getImpid(), + bidRequest.getImp(), + bidderBid.getType()); + + return bidAdjustmentsResolver.resolve( + Price.of(bidCurrency, price), + bidRequest, + bidAdjustments, + mediaType == null || mediaType == ImpMediaType.video ? ImpMediaType.video_instream : mediaType, + bidder, + bid.getDealid()); + } +} diff --git a/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsResolver.java b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsResolver.java new file mode 100644 index 00000000000..ffac1cbc51a --- /dev/null +++ b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsResolver.java @@ -0,0 +1,106 @@ +package org.prebid.server.bidadjustments; + +import com.iab.openrtb.request.BidRequest; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.bidadjustments.model.BidAdjustmentType; +import org.prebid.server.bidadjustments.model.BidAdjustments; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; +import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.dsl.config.PrebidConfigMatchingStrategy; +import org.prebid.server.util.dsl.config.PrebidConfigParameter; +import org.prebid.server.util.dsl.config.PrebidConfigParameters; +import org.prebid.server.util.dsl.config.PrebidConfigSource; +import org.prebid.server.util.dsl.config.impl.MostAccurateCombinationStrategy; +import org.prebid.server.util.dsl.config.impl.SimpleDirectParameter; +import org.prebid.server.util.dsl.config.impl.SimpleParameters; +import org.prebid.server.util.dsl.config.impl.SimpleSource; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class BidAdjustmentsResolver { + + public static final String WILDCARD = "*"; + public static final String DELIMITER = "|"; + + private final PrebidConfigMatchingStrategy matchingStrategy; + private final CurrencyConversionService currencyService; + + public BidAdjustmentsResolver(CurrencyConversionService currencyService) { + this.currencyService = Objects.requireNonNull(currencyService); + this.matchingStrategy = new MostAccurateCombinationStrategy(); + } + + public Price resolve(Price initialPrice, + BidRequest bidRequest, + BidAdjustments bidAdjustments, + ImpMediaType targetMediaType, + String targetBidder, + String targetDealId) { + + final List adjustmentsRules = findRules( + bidAdjustments, + targetMediaType, + targetBidder, + targetDealId); + + return adjustPrice(initialPrice, adjustmentsRules, bidRequest); + } + + private List findRules(BidAdjustments bidAdjustments, + ImpMediaType targetMediaType, + String targetBidder, + String targetDealId) { + + final Map> rules = bidAdjustments.getRules(); + final PrebidConfigSource source = SimpleSource.of(WILDCARD, DELIMITER, rules.keySet()); + final PrebidConfigParameters parameters = createParameters(targetMediaType, targetBidder, targetDealId); + + final String rule = matchingStrategy.match(source, parameters); + return rule == null ? Collections.emptyList() : rules.get(rule); + } + + private PrebidConfigParameters createParameters(ImpMediaType mediaType, String bidder, String dealId) { + final List conditionsMatchers = List.of( + SimpleDirectParameter.of(mediaType.toString()), + SimpleDirectParameter.of(bidder), + StringUtils.isNotBlank(dealId) ? SimpleDirectParameter.of(dealId) : PrebidConfigParameter.wildcard()); + + return SimpleParameters.of(conditionsMatchers); + } + + private Price adjustPrice(Price price, + List bidAdjustmentRules, + BidRequest bidRequest) { + + String resolvedCurrency = price.getCurrency(); + BigDecimal resolvedPrice = price.getValue(); + + for (ExtRequestBidAdjustmentsRule rule : bidAdjustmentRules) { + final BidAdjustmentType adjustmentType = rule.getAdjType(); + final BigDecimal adjustmentValue = rule.getValue(); + final String adjustmentCurrency = rule.getCurrency(); + + switch (adjustmentType) { + case MULTIPLIER -> resolvedPrice = BidderUtil.roundFloor(resolvedPrice.multiply(adjustmentValue)); + case CPM -> { + final BigDecimal convertedAdjustmentValue = currencyService.convertCurrency( + adjustmentValue, bidRequest, adjustmentCurrency, resolvedCurrency); + resolvedPrice = BidderUtil.roundFloor(resolvedPrice.subtract(convertedAdjustmentValue)); + } + case STATIC -> { + resolvedPrice = adjustmentValue; + resolvedCurrency = adjustmentCurrency; + } + } + } + + return Price.of(resolvedCurrency, resolvedPrice); + } +} diff --git a/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsRetriever.java b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsRetriever.java new file mode 100644 index 00000000000..6a151754bb2 --- /dev/null +++ b/src/main/java/org/prebid/server/bidadjustments/BidAdjustmentsRetriever.java @@ -0,0 +1,86 @@ +package org.prebid.server.bidadjustments; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iab.openrtb.request.BidRequest; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.bidadjustments.model.BidAdjustments; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.log.ConditionalLogger; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustments; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.settings.model.Account; +import org.prebid.server.settings.model.AccountAuctionConfig; +import org.prebid.server.validation.ValidationException; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class BidAdjustmentsRetriever { + + private static final Logger logger = LoggerFactory.getLogger(BidAdjustmentsRetriever.class); + private static final ConditionalLogger conditionalLogger = new ConditionalLogger(logger); + + private final ObjectMapper mapper; + private final JsonMerger jsonMerger; + private final double samplingRate; + + public BidAdjustmentsRetriever(JacksonMapper mapper, + JsonMerger jsonMerger, + double samplingRate) { + this.mapper = Objects.requireNonNull(mapper).mapper(); + this.jsonMerger = Objects.requireNonNull(jsonMerger); + this.samplingRate = samplingRate; + } + + public BidAdjustments retrieve(AuctionContext auctionContext) { + final List debugWarnings = auctionContext.getDebugWarnings(); + final boolean debugEnabled = auctionContext.getDebugContext().isDebugEnabled(); + + final JsonNode requestBidAdjustmentsNode = Optional.ofNullable(auctionContext.getBidRequest()) + .map(BidRequest::getExt) + .map(ExtRequest::getPrebid) + .map(ExtRequestPrebid::getBidadjustments) + .orElseGet(mapper::createObjectNode); + + final JsonNode accountBidAdjustmentsNode = Optional.ofNullable(auctionContext.getAccount()) + .map(Account::getAuction) + .map(AccountAuctionConfig::getBidAdjustments) + .orElseGet(mapper::createObjectNode); + + final JsonNode mergedBidAdjustmentsNode = jsonMerger.merge( + requestBidAdjustmentsNode, + accountBidAdjustmentsNode); + + final List resolvedWarnings = debugEnabled ? debugWarnings : null; + return convertAndValidate(mergedBidAdjustmentsNode, resolvedWarnings, "request") + .or(() -> convertAndValidate(accountBidAdjustmentsNode, resolvedWarnings, "account")) + .orElse(BidAdjustments.of(Collections.emptyMap())); + } + + private Optional convertAndValidate(JsonNode bidAdjustmentsNode, + List debugWarnings, + String errorLocation) { + try { + final ExtRequestBidAdjustments accountBidAdjustments = mapper.convertValue( + bidAdjustmentsNode, + ExtRequestBidAdjustments.class); + + BidAdjustmentRulesValidator.validate(accountBidAdjustments); + return Optional.of(BidAdjustments.of(accountBidAdjustments)); + } catch (IllegalArgumentException | ValidationException e) { + final String message = "bid adjustment from " + errorLocation + " was invalid: " + e.getMessage(); + if (debugWarnings != null) { + debugWarnings.add(message); + } + conditionalLogger.error(message, samplingRate); + return Optional.empty(); + } + } +} diff --git a/src/main/java/org/prebid/server/bidadjustments/model/BidAdjustmentType.java b/src/main/java/org/prebid/server/bidadjustments/model/BidAdjustmentType.java new file mode 100644 index 00000000000..e9b790e5eab --- /dev/null +++ b/src/main/java/org/prebid/server/bidadjustments/model/BidAdjustmentType.java @@ -0,0 +1,19 @@ +package org.prebid.server.bidadjustments.model; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public enum BidAdjustmentType { + + CPM, MULTIPLIER, STATIC, UNKNOWN; + + @SuppressWarnings("unused") + @JsonCreator + public static BidAdjustmentType of(String name) { + try { + return BidAdjustmentType.valueOf(name.toUpperCase()); + } catch (IllegalArgumentException e) { + return UNKNOWN; + } + } + +} diff --git a/src/main/java/org/prebid/server/bidadjustments/model/BidAdjustments.java b/src/main/java/org/prebid/server/bidadjustments/model/BidAdjustments.java new file mode 100644 index 00000000000..385a7644811 --- /dev/null +++ b/src/main/java/org/prebid/server/bidadjustments/model/BidAdjustments.java @@ -0,0 +1,52 @@ +package org.prebid.server.bidadjustments.model; + +import lombok.Value; +import org.apache.commons.collections4.MapUtils; +import org.prebid.server.bidadjustments.BidAdjustmentRulesValidator; +import org.prebid.server.bidadjustments.BidAdjustmentsResolver; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustments; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Value(staticConstructor = "of") +public class BidAdjustments { + + private static final String RULE_SCHEME = + "%s" + BidAdjustmentsResolver.DELIMITER + "%s" + BidAdjustmentsResolver.DELIMITER + "%s"; + + Map> rules; + + public static BidAdjustments of(ExtRequestBidAdjustments bidAdjustments) { + if (bidAdjustments == null) { + return BidAdjustments.of(Collections.emptyMap()); + } + + final Map> rules = new HashMap<>(); + + final Map>>> mediatypes = + bidAdjustments.getMediatype(); + + if (MapUtils.isEmpty(mediatypes)) { + return BidAdjustments.of(Collections.emptyMap()); + } + + for (String mediatype : mediatypes.keySet()) { + if (BidAdjustmentRulesValidator.SUPPORTED_MEDIA_TYPES.contains(mediatype)) { + final Map>> bidders = mediatypes.get(mediatype); + for (String bidder : bidders.keySet()) { + final Map> deals = bidders.get(bidder); + for (String dealId : deals.keySet()) { + rules.put(RULE_SCHEME.formatted(mediatype, bidder, dealId), deals.get(dealId)); + } + } + } + } + + return BidAdjustments.of(MapUtils.unmodifiableMap(rules)); + } + +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustments.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustments.java new file mode 100644 index 00000000000..ab0565ce44e --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustments.java @@ -0,0 +1,15 @@ +package org.prebid.server.proto.openrtb.ext.request; + +import lombok.Builder; +import lombok.Value; + +import java.util.List; +import java.util.Map; + +@Builder(toBuilder = true) +@Value +public class ExtRequestBidAdjustments { + + Map>>> mediatype; + +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustmentsRule.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustmentsRule.java new file mode 100644 index 00000000000..a857575a85f --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestBidAdjustmentsRule.java @@ -0,0 +1,24 @@ +package org.prebid.server.proto.openrtb.ext.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; +import org.prebid.server.bidadjustments.model.BidAdjustmentType; + +import java.math.BigDecimal; + +@Builder(toBuilder = true) +@Value +public class ExtRequestBidAdjustmentsRule { + + @JsonProperty("adjtype") + BidAdjustmentType adjType; + + BigDecimal value; + + String currency; + + public String toString() { + return "[adjtype=%s, value=%s, currency=%s]".formatted(adjType, value, currency); + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java index 6dee1b7ba38..cb325bd088a 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java @@ -50,6 +50,11 @@ public class ExtRequestPrebid { */ ExtRequestBidAdjustmentFactors bidadjustmentfactors; + /** + * Defines the contract for bidrequest.ext.prebid.bidadjustments + */ + ObjectNode bidadjustments; + /** * Defines the contract for bidrequest.ext.prebid.currency */ diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ImpMediaType.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ImpMediaType.java index 732ddca6236..d619ed27e80 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ImpMediaType.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ImpMediaType.java @@ -9,6 +9,8 @@ public enum ImpMediaType { @JsonProperty("native") xNative, video, + @JsonProperty("video-instream") + video_instream, @JsonProperty("video-outstream") video_outstream; @@ -16,6 +18,7 @@ public enum ImpMediaType { public String toString() { return this == xNative ? "native" : this == video_outstream ? "video-outstream" + : this == video_instream ? "video-instream" : super.toString(); } } diff --git a/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java b/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java index 9708c23bb1c..9943535aa7e 100644 --- a/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java +++ b/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Builder; import lombok.Value; import org.prebid.server.spring.config.bidder.model.MediaType; @@ -33,6 +34,9 @@ public class AccountAuctionConfig { @JsonAlias("bid-validations") AccountBidValidationConfig bidValidations; + @JsonProperty("bidadjustments") + ObjectNode bidAdjustments; + AccountEventsConfig events; @JsonAlias("price-floors") diff --git a/src/main/java/org/prebid/server/spring/config/ServiceConfiguration.java b/src/main/java/org/prebid/server/spring/config/ServiceConfiguration.java index 7d657bc7b8c..c2ee2ca5dfa 100644 --- a/src/main/java/org/prebid/server/spring/config/ServiceConfiguration.java +++ b/src/main/java/org/prebid/server/spring/config/ServiceConfiguration.java @@ -66,6 +66,9 @@ import org.prebid.server.auction.requestfactory.VideoRequestFactory; import org.prebid.server.auction.versionconverter.BidRequestOrtbVersionConversionManager; import org.prebid.server.auction.versionconverter.BidRequestOrtbVersionConverterFactory; +import org.prebid.server.bidadjustments.BidAdjustmentsProcessor; +import org.prebid.server.bidadjustments.BidAdjustmentsResolver; +import org.prebid.server.bidadjustments.BidAdjustmentsRetriever; import org.prebid.server.bidder.BidderCatalog; import org.prebid.server.bidder.BidderDeps; import org.prebid.server.bidder.BidderErrorNotifier; @@ -424,7 +427,8 @@ AuctionRequestFactory auctionRequestFactory( AuctionPrivacyContextFactory auctionPrivacyContextFactory, DebugResolver debugResolver, JacksonMapper mapper, - GeoLocationServiceWrapper geoLocationServiceWrapper) { + GeoLocationServiceWrapper geoLocationServiceWrapper, + BidAdjustmentsRetriever bidAdjustmentsRetriever) { return new AuctionRequestFactory( maxRequestSize, @@ -440,7 +444,8 @@ AuctionRequestFactory auctionRequestFactory( auctionPrivacyContextFactory, debugResolver, mapper, - geoLocationServiceWrapper); + geoLocationServiceWrapper, + bidAdjustmentsRetriever); } @Bean @@ -883,19 +888,11 @@ ExchangeService exchangeService( @Bean BidsAdjuster bidsAdjuster(ResponseBidValidator responseBidValidator, - CurrencyConversionService currencyConversionService, PriceFloorEnforcer priceFloorEnforcer, DsaEnforcer dsaEnforcer, - BidAdjustmentFactorResolver bidAdjustmentFactorResolver, - JacksonMapper mapper) { + BidAdjustmentsProcessor bidAdjustmentsProcessor) { - return new BidsAdjuster( - responseBidValidator, - currencyConversionService, - bidAdjustmentFactorResolver, - priceFloorEnforcer, - dsaEnforcer, - mapper); + return new BidsAdjuster(responseBidValidator, priceFloorEnforcer, bidAdjustmentsProcessor, dsaEnforcer); } @Bean @@ -1174,6 +1171,29 @@ SkippedAuctionService skipAuctionService(StoredResponseProcessor storedResponseP return new SkippedAuctionService(storedResponseProcessor, bidResponseCreator); } + @Bean + BidAdjustmentsRetriever bidAdjustmentsRetriever(JacksonMapper mapper, JsonMerger jsonMerger) { + return new BidAdjustmentsRetriever(mapper, jsonMerger, logSamplingRate); + } + + @Bean + BidAdjustmentsResolver bidAdjustmentsResolver(CurrencyConversionService currencyService) { + return new BidAdjustmentsResolver(currencyService); + } + + @Bean + BidAdjustmentsProcessor bidAdjustmentsProcessor(CurrencyConversionService currencyService, + BidAdjustmentFactorResolver bidAdjustmentFactorResolver, + BidAdjustmentsResolver bidAdjustmentsResolver, + JacksonMapper mapper) { + + return new BidAdjustmentsProcessor( + currencyService, + bidAdjustmentFactorResolver, + bidAdjustmentsResolver, + mapper); + } + private static List splitToList(String listAsString) { return splitToCollection(listAsString, ArrayList::new); } diff --git a/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy b/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy index 2983ac3f731..4e423c03312 100644 --- a/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.databind.annotation.JsonNaming import groovy.transform.ToString import org.prebid.server.functional.model.bidder.BidderName +import org.prebid.server.functional.model.request.auction.BidAdjustment import org.prebid.server.functional.model.request.auction.Targeting import org.prebid.server.functional.model.response.auction.MediaType @@ -26,6 +27,8 @@ class AccountAuctionConfig { Map preferredMediaType @JsonProperty("privacysandbox") PrivacySandbox privacySandbox + @JsonProperty("bidadjustments") + BidAdjustment bidAdjustments @JsonProperty("price_granularity") PriceGranularityType priceGranularitySnakeCase diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentRule.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentRule.groovy new file mode 100644 index 00000000000..953f66fd988 --- /dev/null +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentRule.groovy @@ -0,0 +1,17 @@ +package org.prebid.server.functional.model.request.auction + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import groovy.transform.ToString +import org.prebid.server.functional.model.Currency + +@JsonNaming(PropertyNamingStrategies.LowerCaseStrategy) +@ToString(includeNames = true, ignoreNulls = true) +class AdjustmentRule { + + @JsonProperty('adjtype') + AdjustmentType adjustmentType + BigDecimal value + Currency currency +} diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentType.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentType.groovy new file mode 100644 index 00000000000..20574d525a1 --- /dev/null +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/AdjustmentType.groovy @@ -0,0 +1,13 @@ +package org.prebid.server.functional.model.request.auction + +import com.fasterxml.jackson.annotation.JsonValue + +enum AdjustmentType { + + MULTIPLIER, CPM, STATIC, UNKNOWN + + @JsonValue + String getValue() { + name().toLowerCase() + } +} diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustment.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustment.groovy new file mode 100644 index 00000000000..7f7250a6a75 --- /dev/null +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustment.groovy @@ -0,0 +1,20 @@ +package org.prebid.server.functional.model.request.auction + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import groovy.transform.ToString +import org.prebid.server.functional.util.PBSUtils + +@JsonNaming(PropertyNamingStrategies.LowerCaseStrategy) +@ToString(includeNames = true, ignoreNulls = true) +class BidAdjustment { + + Map mediaType + Integer version + + static getDefaultWithSingleMediaTypeRule(BidAdjustmentMediaType type, + BidAdjustmentRule rule, + Integer version = PBSUtils.randomNumber) { + new BidAdjustment(mediaType: [(type): rule], version: version) + } +} diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentFactors.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentFactors.groovy index a005d407241..9cb90edb27b 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentFactors.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentFactors.groovy @@ -15,7 +15,6 @@ class BidAdjustmentFactors { Map adjustments Map> mediaTypes - @JsonAnyGetter Map getAdjustments() { adjustments diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentMediaType.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentMediaType.groovy index a959f5b800c..26a58655215 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentMediaType.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentMediaType.groovy @@ -8,7 +8,10 @@ enum BidAdjustmentMediaType { AUDIO("audio"), NATIVE("native"), VIDEO("video"), - VIDEO_OUTSTREAM("video-outstream") + VIDEO_IN_STREAM("video-instream"), + VIDEO_OUT_STREAM("video-outstream"), + ANY('*'), + UNKNOWN('unknown') @JsonValue String value diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentRule.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentRule.groovy new file mode 100644 index 00000000000..4fcfc1125e1 --- /dev/null +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidAdjustmentRule.groovy @@ -0,0 +1,16 @@ +package org.prebid.server.functional.model.request.auction + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import groovy.transform.ToString + +@JsonNaming(PropertyNamingStrategies.LowerCaseStrategy) +@ToString(includeNames = true, ignoreNulls = true) +class BidAdjustmentRule { + + @JsonProperty('*') + Map> wildcardBidder + Map> generic + Map> alias +} diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy index f60cabcc606..aa9da45a4b6 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy @@ -10,6 +10,7 @@ import static org.prebid.server.functional.model.request.auction.DistributionCha import static org.prebid.server.functional.model.request.auction.DistributionChannel.DOOH import static org.prebid.server.functional.model.request.auction.DistributionChannel.SITE import static org.prebid.server.functional.model.response.auction.MediaType.AUDIO +import static org.prebid.server.functional.model.response.auction.MediaType.NATIVE import static org.prebid.server.functional.model.response.auction.MediaType.VIDEO @EqualsAndHashCode @@ -48,6 +49,10 @@ class BidRequest { getDefaultRequest(channel, Imp.getDefaultImpression(VIDEO)) } + static BidRequest getDefaultNativeRequest(DistributionChannel channel = SITE) { + getDefaultRequest(channel, Imp.getDefaultImpression(NATIVE)) + } + static BidRequest getDefaultAudioRequest(DistributionChannel channel = SITE) { getDefaultRequest(channel, Imp.getDefaultImpression(AUDIO)) } diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy index d99122f180d..ba89f5680fa 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy @@ -15,6 +15,7 @@ class Prebid { Map aliases Map aliasgvlids BidAdjustmentFactors bidAdjustmentFactors + BidAdjustment bidAdjustments PrebidCurrency currency Targeting targeting TraceLevel trace diff --git a/src/test/groovy/org/prebid/server/functional/tests/BidAdjustmentSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/BidAdjustmentSpec.groovy index 20536435161..90c6c764929 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/BidAdjustmentSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/BidAdjustmentSpec.groovy @@ -1,22 +1,71 @@ package org.prebid.server.functional.tests +import org.prebid.server.functional.model.Currency +import org.prebid.server.functional.model.config.AccountAuctionConfig +import org.prebid.server.functional.model.config.AccountConfig +import org.prebid.server.functional.model.db.Account +import org.prebid.server.functional.model.mock.services.currencyconversion.CurrencyConversionRatesResponse +import org.prebid.server.functional.model.request.auction.AdjustmentRule +import org.prebid.server.functional.model.request.auction.AdjustmentType +import org.prebid.server.functional.model.request.auction.BidAdjustment import org.prebid.server.functional.model.request.auction.BidAdjustmentFactors +import org.prebid.server.functional.model.request.auction.BidAdjustmentRule import org.prebid.server.functional.model.request.auction.BidRequest +import org.prebid.server.functional.model.request.auction.Imp import org.prebid.server.functional.model.response.auction.BidResponse import org.prebid.server.functional.service.PrebidServerException +import org.prebid.server.functional.service.PrebidServerService +import org.prebid.server.functional.testcontainers.scaffolding.CurrencyConversion import org.prebid.server.functional.util.PBSUtils +import java.math.RoundingMode +import java.time.Instant + import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST +import static org.prebid.server.functional.model.Currency.EUR +import static org.prebid.server.functional.model.Currency.GBP +import static org.prebid.server.functional.model.Currency.USD import static org.prebid.server.functional.model.bidder.BidderName.APPNEXUS import static org.prebid.server.functional.model.bidder.BidderName.GENERIC import static org.prebid.server.functional.model.bidder.BidderName.RUBICON +import static org.prebid.server.functional.model.request.auction.AdjustmentType.CPM +import static org.prebid.server.functional.model.request.auction.AdjustmentType.MULTIPLIER +import static org.prebid.server.functional.model.request.auction.AdjustmentType.STATIC +import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.ANY +import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.AUDIO import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.BANNER import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.NATIVE +import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.UNKNOWN import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.VIDEO +import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.VIDEO_IN_STREAM +import static org.prebid.server.functional.model.request.auction.BidAdjustmentMediaType.VIDEO_OUT_STREAM import static org.prebid.server.functional.model.request.auction.DistributionChannel.SITE +import static org.prebid.server.functional.model.request.auction.VideoPlacementSubtypes.IN_ARTICLE +import static org.prebid.server.functional.model.request.auction.VideoPlacementSubtypes.IN_STREAM +import static org.prebid.server.functional.model.response.auction.ErrorType.PREBID +import static org.prebid.server.functional.testcontainers.Dependencies.getNetworkServiceContainer +import static org.prebid.server.functional.util.PBSUtils.getRandomDecimal class BidAdjustmentSpec extends BaseSpec { + private static final String WILDCARD = '*' + private static final BigDecimal MIN_ADJUST_VALUE = 0 + private static final BigDecimal MAX_MULTIPLIER_ADJUST_VALUE = 99 + private static final BigDecimal MAX_CPM_ADJUST_VALUE = Integer.MAX_VALUE + private static final BigDecimal MAX_STATIC_ADJUST_VALUE = Integer.MAX_VALUE + private static final Currency DEFAULT_CURRENCY = USD + private static final int BID_ADJUST_PRECISION = 4 + private static final int PRICE_PRECISION = 3 + private static final Map> DEFAULT_CURRENCY_RATES = [(USD): [(EUR): 0.9124920156948626, + (GBP): 0.793776804452961], + (GBP): [(USD): 1.2597999770088517, + (EUR): 1.1495574203931487], + (EUR): [(USD): 1.3429368029739777]] + private static final CurrencyConversion currencyConversion = new CurrencyConversion(networkServiceContainer).tap { + setCurrencyConversionRatesResponse(CurrencyConversionRatesResponse.getDefaultCurrencyConversionRatesResponse(DEFAULT_CURRENCY_RATES)) + } + private static final PrebidServerService pbsService = pbsServiceFactory.getService(externalCurrencyConverterConfig) + def "PBS should adjust bid price for matching bidder when request has per-bidder bid adjustment factors"() { given: "Default bid request with bid adjustment" def bidRequest = BidRequest.getDefaultBidRequest(SITE).tap { @@ -28,10 +77,10 @@ class BidAdjustmentSpec extends BaseSpec { bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" - def response = defaultPbsService.sendAuctionRequest(bidRequest) + def response = pbsService.sendAuctionRequest(bidRequest) then: "Final bid price should be adjusted" - assert response?.seatbid?.first()?.bid?.first()?.price == bidResponse.seatbid.first().bid.first().price * + assert response?.seatbid?.first?.bid?.first?.price == bidResponse.seatbid.first.bid.first.price * bidAdjustmentFactor where: @@ -40,7 +89,7 @@ class BidAdjustmentSpec extends BaseSpec { def "PBS should prefer bid price adjustment based on media type when request has per-media-type bid adjustment factors"() { given: "Default bid request with bid adjustment" - def bidAdjustment = PBSUtils.randomDecimal + def bidAdjustment = randomDecimal def mediaTypeBidAdjustment = bidAdjustmentFactor def bidRequest = BidRequest.getDefaultBidRequest(SITE).tap { ext.prebid.bidAdjustmentFactors = new BidAdjustmentFactors().tap { @@ -54,10 +103,10 @@ class BidAdjustmentSpec extends BaseSpec { bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" - def response = defaultPbsService.sendAuctionRequest(bidRequest) + def response = pbsService.sendAuctionRequest(bidRequest) then: "Final bid price should be adjusted" - assert response?.seatbid?.first()?.bid?.first()?.price == bidResponse.seatbid.first().bid.first().price * + assert response?.seatbid?.first?.bid?.first?.price == bidResponse.seatbid.first.bid.first.price * mediaTypeBidAdjustment where: @@ -66,7 +115,7 @@ class BidAdjustmentSpec extends BaseSpec { def "PBS should adjust bid price for bidder only when request contains bid adjustment for corresponding bidder"() { given: "Default bid request with bid adjustment" - def bidAdjustment = PBSUtils.randomDecimal + def bidAdjustment = randomDecimal def bidRequest = BidRequest.getDefaultBidRequest(SITE).tap { ext.prebid.bidAdjustmentFactors = new BidAdjustmentFactors().tap { adjustments = [(adjustmentBidder): bidAdjustment] @@ -78,10 +127,10 @@ class BidAdjustmentSpec extends BaseSpec { bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" - def response = defaultPbsService.sendAuctionRequest(bidRequest) + def response = pbsService.sendAuctionRequest(bidRequest) then: "Final bid price should not be adjusted" - assert response?.seatbid?.first()?.bid?.first()?.price == bidResponse.seatbid.first().bid.first().price + assert response?.seatbid?.first?.bid?.first?.price == bidResponse.seatbid.first.bid.first.price where: adjustmentBidder << [RUBICON, APPNEXUS] @@ -102,10 +151,10 @@ class BidAdjustmentSpec extends BaseSpec { bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" - def response = defaultPbsService.sendAuctionRequest(bidRequest) + def response = pbsService.sendAuctionRequest(bidRequest) then: "Final bid price should not be adjusted" - assert response?.seatbid?.first()?.bid?.first()?.price == bidResponse.seatbid.first().bid.first().price + assert response?.seatbid?.first?.bid?.first?.price == bidResponse.seatbid.first.bid.first.price where: adjustmentMediaType << [VIDEO, NATIVE] @@ -125,7 +174,7 @@ class BidAdjustmentSpec extends BaseSpec { bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" - defaultPbsService.sendAuctionRequest(bidRequest) + pbsService.sendAuctionRequest(bidRequest) then: "PBS should fail the request" def exception = thrown(PrebidServerException) @@ -133,6 +182,819 @@ class BidAdjustmentSpec extends BaseSpec { assert exception.responseBody.contains("Invalid request format: request.ext.prebid.bidadjustmentfactors.$bidderName.value must be a positive number") where: - bidAdjustmentFactor << [0, PBSUtils.randomNegativeNumber] + bidAdjustmentFactor << [MIN_ADJUST_VALUE, PBSUtils.randomNegativeNumber] + } + + def "PBS should adjust bid price for matching bidder when request has bidAdjustments config"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: ruleValue, currency: currency)]]) + bidRequest.ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(mediaType, rule) + bidRequest.cur = [currency] + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted" + assert response.seatbid.first.bid.first.price == getAdjustedPrice(originalPrice, ruleValue as BigDecimal, adjustmentType) + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain default currency" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType | ruleValue | mediaType | bidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + } + + def "PBS should adjust bid price for matching bidder with specific dealId when request has bidAdjustments config"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def dealId = PBSUtils.randomString + def currency = USD + def rule = new BidAdjustmentRule(generic: [(dealId): [new AdjustmentRule(adjustmentType: adjustmentType, value: ruleValue, currency: currency)]]) + bidRequest.ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(mediaType, rule) + bidRequest.imp.add(Imp.defaultImpression) + bidRequest.cur = [currency] + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + seatbid.first.bid.first.dealid = dealId + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted for big with dealId" + response.seatbid.first.bid.find { it.dealid == dealId } + assert response.seatbid.first.bid.findAll() { it.dealid == dealId }.price == [getAdjustedPrice(originalPrice, ruleValue as BigDecimal, adjustmentType)] + + and: "Price shouldn't be updated for bid with different dealId" + assert response.seatbid.first.bid.findAll() { it.dealid != dealId }.price == bidResponse.seatbid.first.bid.findAll() { it.dealid != dealId }.price + + and: "Response currency should stay the same" + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + assert response.seatbid.first.bid.ext.origbidcpm.sort() == bidResponse.seatbid.first.bid.price.sort() + assert response.seatbid.first.bid.ext.first.origbidcur == bidResponse.cur + assert response.seatbid.first.bid.ext.last.origbidcur == bidResponse.cur + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType | ruleValue | mediaType | bidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + } + + def "PBS should adjust bid price for matching bidder when account config has bidAdjustments"() { + given: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def currency = USD + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + and: "Account in the DB with bidAdjustments" + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: ruleValue, currency: currency)]]) + def accountConfig = new AccountAuctionConfig(bidAdjustments: BidAdjustment.getDefaultWithSingleMediaTypeRule(mediaType, rule)) + def account = new Account(uuid: bidRequest.accountId, config: new AccountConfig(auction: accountConfig)) + accountDao.save(account) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted" + assert response.seatbid.first.bid.first.price == getAdjustedPrice(originalPrice, ruleValue as BigDecimal, adjustmentType) + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType | ruleValue | mediaType | bidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + } + + def "PBS should prioritize BidAdjustmentRule from request when account and request config bidAdjustments conflict"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: ruleValue, currency: currency)]]) + bidRequest.ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(mediaType, rule) + bidRequest.cur = [currency] + + and: "Account in the DB with bidAdjustments" + def accountRule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: ruleValue, currency: currency)]]) + def accountConfig = new AccountAuctionConfig(bidAdjustments: BidAdjustment.getDefaultWithSingleMediaTypeRule(mediaType, accountRule)) + def account = new Account(uuid: bidRequest.accountId, config: new AccountConfig(auction: accountConfig)) + accountDao.save(account) + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted according to request config" + assert response.seatbid.first.bid.first.price == getAdjustedPrice(originalPrice, ruleValue as BigDecimal, adjustmentType) + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType | ruleValue | mediaType | bidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + MULTIPLIER | getRandomDecimal(MIN_ADJUST_VALUE, MAX_MULTIPLIER_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + CPM | getRandomDecimal(MIN_ADJUST_VALUE, MAX_CPM_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | BANNER | BidRequest.defaultBidRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | AUDIO | BidRequest.defaultAudioRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | NATIVE | BidRequest.defaultNativeRequest + STATIC | getRandomDecimal(MIN_ADJUST_VALUE, MAX_STATIC_ADJUST_VALUE) | ANY | BidRequest.defaultBidRequest + } + + def "PBS should prioritize exact bid price adjustment for matching bidder when request has exact and general bidAdjustment"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def exactRulePrice = PBSUtils.randomPrice + def currency = USD + def exactRule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: STATIC, value: exactRulePrice, currency: currency)]]) + def generalRule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: STATIC, value: PBSUtils.randomPrice, currency: currency)]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = new BidAdjustment(mediaType: [(BANNER): exactRule, (ANY): generalRule]) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted according to exact rule" + assert response.seatbid.first.bid.first.price == getAdjustedPrice(originalPrice, exactRulePrice, STATIC) + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + } + + def "PBS should adjust bid price for matching bidder in provided order when bidAdjustments have multiple matching rules"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def firstRule = new AdjustmentRule(adjustmentType: firstRuleType, value: PBSUtils.randomPrice, currency: currency) + def secondRule = new AdjustmentRule(adjustmentType: secondRuleType, value: PBSUtils.randomPrice, currency: currency) + def bidAdjustmentMultyRule = new BidAdjustmentRule(generic: [(WILDCARD): [firstRule, secondRule]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, bidAdjustmentMultyRule) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted" + def rawAdjustedBidPrice = getAdjustedPrice(originalPrice, firstRule.value as BigDecimal, firstRule.adjustmentType) + def adjustedBidPrice = getAdjustedPrice(rawAdjustedBidPrice, secondRule.value as BigDecimal, secondRule.adjustmentType) + assert response.seatbid.first.bid.first.price == adjustedBidPrice + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + firstRuleType | secondRuleType + MULTIPLIER | CPM + MULTIPLIER | STATIC + MULTIPLIER | MULTIPLIER + CPM | CPM + CPM | STATIC + CPM | MULTIPLIER + STATIC | CPM + STATIC | STATIC + STATIC | MULTIPLIER + } + + def "PBS should convert CPM currency before adjustment when it different from original response currency"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def adjustmentRule = new AdjustmentRule(adjustmentType: CPM, value: PBSUtils.randomPrice, currency: GBP) + def bidAdjustmentMultyRule = new BidAdjustmentRule(generic: [(WILDCARD): [adjustmentRule]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [EUR] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, bidAdjustmentMultyRule) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = USD + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted" + def convertedAdjustment = convertCurrency(adjustmentRule.value, adjustmentRule.currency, bidResponse.cur) + def adjustedBidPrice = getAdjustedPrice(originalPrice, convertedAdjustment, adjustmentRule.adjustmentType) + assert response.seatbid.first.bid.first.price == convertCurrency(adjustedBidPrice, bidResponse.cur, bidRequest.cur.first) + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == bidRequest.cur + } + + def "PBS should change original currency when static bidAdjustments and original response have different currencies"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def adjustmentRule = new AdjustmentRule(adjustmentType: STATIC, value: PBSUtils.randomPrice, currency: GBP) + def bidAdjustmentMultyRule = new BidAdjustmentRule(generic: [(WILDCARD): [adjustmentRule]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [EUR] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, bidAdjustmentMultyRule) + } + + and: "Default bid response with JPY currency" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = USD + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted and converted to original request cur" + assert response.seatbid.first.bid.first.price == convertCurrency(adjustmentRule.value, adjustmentRule.currency, bidRequest.cur.first) + assert response.cur == bidRequest.cur.first + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == bidRequest.cur + } + + def "PBS should apply bidAdjustments after bidAdjustmentFactors when both are present"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def bidAdjustmentFactorsPrice = PBSUtils.randomPrice + def adjustmentRule = new AdjustmentRule(adjustmentType: adjustmentType, value: PBSUtils.randomPrice, currency: currency) + def bidAdjustmentMultyRule = new BidAdjustmentRule(generic: [(WILDCARD): [adjustmentRule]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, bidAdjustmentMultyRule) + ext.prebid.bidAdjustmentFactors = new BidAdjustmentFactors(adjustments: [(GENERIC): bidAdjustmentFactorsPrice]) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted" + def bidAdjustedPrice = originalPrice * bidAdjustmentFactorsPrice + assert response.seatbid.first.bid.first.price == getAdjustedPrice(bidAdjustedPrice, adjustmentRule.value, adjustmentType) + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType << [MULTIPLIER, CPM, STATIC] + } + + def "PBS shouldn't adjust bid price for matching bidder when request has invalid value bidAdjustments config"() { + given: "Start time" + def startTime = Instant.now() + + and: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: ruleValue, currency: currency)]]) + bidRequest.ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(mediaType, rule) + bidRequest.cur = [currency] + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "PBS should ignore bidAdjustments for this request" + assert response.seatbid.first.bid.first.price == originalPrice + assert response.cur == bidResponse.cur + + and: "Should add a warning when in debug mode" + def errorMessage = "bid adjustment from request was invalid: the found rule [adjtype=${adjustmentType}, " + + "value=${ruleValue}, currency=${currency}] in ${mediaType.value}.generic.* is invalid" as String + assert response.ext.warnings[PREBID]?.code == [999] + assert response.ext.warnings[PREBID]?.message == [errorMessage] + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "PBS log should contain error" + def logs = pbsService.getLogsByTime(startTime) + assert getLogsByText(logs, errorMessage) + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType | ruleValue | mediaType | bidRequest + MULTIPLIER | MIN_ADJUST_VALUE - 1 | BANNER | BidRequest.defaultBidRequest + MULTIPLIER | MIN_ADJUST_VALUE - 1 | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + MULTIPLIER | MIN_ADJUST_VALUE - 1 | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + MULTIPLIER | MIN_ADJUST_VALUE - 1 | AUDIO | BidRequest.defaultAudioRequest + MULTIPLIER | MIN_ADJUST_VALUE - 1 | NATIVE | BidRequest.defaultNativeRequest + MULTIPLIER | MIN_ADJUST_VALUE - 1 | ANY | BidRequest.defaultNativeRequest + MULTIPLIER | MAX_MULTIPLIER_ADJUST_VALUE + 1 | BANNER | BidRequest.defaultBidRequest + MULTIPLIER | MAX_MULTIPLIER_ADJUST_VALUE + 1 | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + MULTIPLIER | MAX_MULTIPLIER_ADJUST_VALUE + 1 | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + MULTIPLIER | MAX_MULTIPLIER_ADJUST_VALUE + 1 | AUDIO | BidRequest.defaultAudioRequest + MULTIPLIER | MAX_MULTIPLIER_ADJUST_VALUE + 1 | NATIVE | BidRequest.defaultNativeRequest + MULTIPLIER | MAX_MULTIPLIER_ADJUST_VALUE + 1 | ANY | BidRequest.defaultNativeRequest + + CPM | MIN_ADJUST_VALUE - 1 | BANNER | BidRequest.defaultBidRequest + CPM | MIN_ADJUST_VALUE - 1 | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + CPM | MIN_ADJUST_VALUE - 1 | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + CPM | MIN_ADJUST_VALUE - 1 | AUDIO | BidRequest.defaultAudioRequest + CPM | MIN_ADJUST_VALUE - 1 | NATIVE | BidRequest.defaultNativeRequest + CPM | MIN_ADJUST_VALUE - 1 | ANY | BidRequest.defaultNativeRequest + CPM | MAX_CPM_ADJUST_VALUE + 1 | BANNER | BidRequest.defaultBidRequest + CPM | MAX_CPM_ADJUST_VALUE + 1 | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + CPM | MAX_CPM_ADJUST_VALUE + 1 | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + CPM | MAX_CPM_ADJUST_VALUE + 1 | AUDIO | BidRequest.defaultAudioRequest + CPM | MAX_CPM_ADJUST_VALUE + 1 | NATIVE | BidRequest.defaultNativeRequest + CPM | MAX_CPM_ADJUST_VALUE + 1 | ANY | BidRequest.defaultNativeRequest + + STATIC | MIN_ADJUST_VALUE - 1 | BANNER | BidRequest.defaultBidRequest + STATIC | MIN_ADJUST_VALUE - 1 | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + STATIC | MIN_ADJUST_VALUE - 1 | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + STATIC | MIN_ADJUST_VALUE - 1 | AUDIO | BidRequest.defaultAudioRequest + STATIC | MIN_ADJUST_VALUE - 1 | NATIVE | BidRequest.defaultNativeRequest + STATIC | MIN_ADJUST_VALUE - 1 | ANY | BidRequest.defaultNativeRequest + STATIC | MAX_STATIC_ADJUST_VALUE + 1 | BANNER | BidRequest.defaultBidRequest + STATIC | MAX_STATIC_ADJUST_VALUE + 1 | VIDEO_IN_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_STREAM } + STATIC | MAX_STATIC_ADJUST_VALUE + 1 | VIDEO_OUT_STREAM | BidRequest.defaultVideoRequest.tap { imp.first.video.placement = IN_ARTICLE } + STATIC | MAX_STATIC_ADJUST_VALUE + 1 | AUDIO | BidRequest.defaultAudioRequest + STATIC | MAX_STATIC_ADJUST_VALUE + 1 | NATIVE | BidRequest.defaultNativeRequest + STATIC | MAX_STATIC_ADJUST_VALUE + 1 | ANY | BidRequest.defaultNativeRequest + } + + def "PBS shouldn't adjust bid price for matching bidder when request has different bidder name in bidAdjustments config"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def rule = new BidAdjustmentRule(alias: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: PBSUtils.randomPrice, currency: currency)]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, rule) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "PBS should ignore bidAdjustments for this request" + assert response.seatbid.first.bid.first.price == originalPrice + assert response.cur == bidResponse.cur + + and: "Response shouldn't contain any warnings" + assert !response.ext.warnings + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType << [MULTIPLIER, CPM, STATIC] + } + + def "PBS shouldn't adjust bid price for matching bidder when cpm or static bidAdjustments doesn't have currency value"() { + given: "Start time" + def startTime = Instant.now() + + and: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def adjustmentPrice = PBSUtils.randomPrice.toDouble() + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: adjustmentPrice, currency: null)]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, rule) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "PBS should ignore bidAdjustments for this request" + assert response.seatbid.first.bid.first.price == originalPrice + assert response.cur == bidResponse.cur + + and: "Should add a warning when in debug mode" + def errorMessage = "bid adjustment from request was invalid: the found rule [adjtype=${adjustmentType}, " + + "value=${adjustmentPrice}, currency=null] in banner.generic.* is invalid" as String + assert response.ext.warnings[PREBID]?.code == [999] + assert response.ext.warnings[PREBID]?.message == [errorMessage] + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "PBS log should contain error" + def logs = pbsService.getLogsByTime(startTime) + assert getLogsByText(logs, errorMessage) + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType << [CPM, STATIC] + } + + def "PBS shouldn't adjust bid price for matching bidder when bidAdjustments have unknown mediatype"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def adjustmentPrice = PBSUtils.randomPrice + def currency = USD + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: adjustmentType, value: adjustmentPrice, currency: null)]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(UNKNOWN, rule) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "PBS should ignore bidAdjustments for this request" + assert response.seatbid.first.bid.first.price == originalPrice + assert response.cur == bidResponse.cur + + and: "Response shouldn't contain any warnings" + assert !response.ext.warnings + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType << [MULTIPLIER, CPM, STATIC] + } + + def "PBS shouldn't adjust bid price for matching bidder when bidAdjustments have unknown adjustmentType"() { + given: "Start time" + def startTime = Instant.now() + + and: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def adjustmentPrice = PBSUtils.randomPrice.toDouble() + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: AdjustmentType.UNKNOWN, value: adjustmentPrice, currency: currency)]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, rule) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "PBS should ignore bidAdjustments for this request" + assert response.seatbid.first.bid.first.price == originalPrice + assert response.cur == bidResponse.cur + + and: "Should add a warning when in debug mode" + def errorMessage = "bid adjustment from request was invalid: the found rule [adjtype=UNKNOWN, " + + "value=$adjustmentPrice, currency=$currency] in banner.generic.* is invalid" as String + assert response.ext.warnings[PREBID]?.code == [999] + assert response.ext.warnings[PREBID]?.message == [errorMessage] + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "PBS log should contain error" + def logs = pbsService.getLogsByTime(startTime) + assert getLogsByText(logs, errorMessage) + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + } + + def "PBS shouldn't adjust bid price for matching bidder when multiplier bidAdjustments doesn't have currency value"() { + given: "Default BidRequest with ext.prebid.bidAdjustments" + def currency = USD + def adjustmentPrice = PBSUtils.randomPrice + def rule = new BidAdjustmentRule(generic: [(WILDCARD): [new AdjustmentRule(adjustmentType: MULTIPLIER, value: adjustmentPrice, currency: null)]]) + def bidRequest = BidRequest.defaultBidRequest.tap { + cur = [currency] + ext.prebid.bidAdjustments = BidAdjustment.getDefaultWithSingleMediaTypeRule(BANNER, rule) + } + + and: "Default bid response" + def originalPrice = PBSUtils.randomPrice + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest).tap { + cur = currency + seatbid.first.bid.first.price = originalPrice + } + bidder.setResponse(bidRequest.id, bidResponse) + + when: "PBS processes auction request" + def response = pbsService.sendAuctionRequest(bidRequest) + + then: "Final bid price should be adjusted" + assert response.seatbid.first.bid.first.price == getAdjustedPrice(originalPrice, adjustmentPrice, MULTIPLIER) + assert response.cur == bidResponse.cur + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Response shouldn't contain any warnings" + assert !response.ext.warnings + + and: "Original bid price and currency should be presented in bid.ext" + verifyAll(response.seatbid.first.bid.first.ext) { + origbidcpm == originalPrice + origbidcur == bidResponse.cur + } + + and: "Bidder request should contain currency from request" + def bidderRequest = bidder.getBidderRequest(bidRequest.id) + assert bidderRequest.cur == [currency] + + where: + adjustmentType << [CPM, STATIC] + } + + private static Map getExternalCurrencyConverterConfig() { + ["auction.ad-server-currency" : DEFAULT_CURRENCY as String, + "currency-converter.external-rates.enabled" : "true", + "currency-converter.external-rates.url" : "$networkServiceContainer.rootUri/currency".toString(), + "currency-converter.external-rates.default-timeout-ms": "4000", + "currency-converter.external-rates.refresh-period-ms" : "900000"] + } + + private static BigDecimal convertCurrency(BigDecimal price, Currency fromCurrency, Currency toCurrency) { + return (price * getConversionRate(fromCurrency, toCurrency)).setScale(PRICE_PRECISION, RoundingMode.HALF_EVEN) + } + + private static BigDecimal getConversionRate(Currency fromCurrency, Currency toCurrency) { + def conversionRate + if (fromCurrency == toCurrency) { + conversionRate = 1 + } else if (toCurrency in DEFAULT_CURRENCY_RATES?[fromCurrency]) { + conversionRate = DEFAULT_CURRENCY_RATES[fromCurrency][toCurrency] + } else if (fromCurrency in DEFAULT_CURRENCY_RATES?[toCurrency]) { + conversionRate = 1 / DEFAULT_CURRENCY_RATES[toCurrency][fromCurrency] + } else { + conversionRate = getCrossConversionRate(fromCurrency, toCurrency) + } + conversionRate + } + + private static BigDecimal getCrossConversionRate(Currency fromCurrency, Currency toCurrency) { + for (Map rates : DEFAULT_CURRENCY_RATES.values()) { + def fromRate = rates?[fromCurrency] + def toRate = rates?[toCurrency] + + if (fromRate && toRate) { + return toRate / fromRate + } + } + + null + } + + private static BigDecimal getAdjustedPrice(BigDecimal originalPrice, + BigDecimal adjustedValue, + AdjustmentType adjustmentType) { + switch (adjustmentType) { + case MULTIPLIER: + return PBSUtils.roundDecimal(originalPrice * adjustedValue, BID_ADJUST_PRECISION) + case CPM: + return PBSUtils.roundDecimal(originalPrice - adjustedValue, BID_ADJUST_PRECISION) + case STATIC: + return adjustedValue + default: + return originalPrice + } } } diff --git a/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java b/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java index 8b7dd0589b0..9bfbc9cb143 100644 --- a/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java +++ b/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java @@ -1,9 +1,7 @@ package org.prebid.server.auction; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Imp; -import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,35 +9,26 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.prebid.server.VertxTest; -import org.prebid.server.auction.adjustment.BidAdjustmentFactorResolver; import org.prebid.server.auction.model.AuctionContext; import org.prebid.server.auction.model.AuctionParticipation; import org.prebid.server.auction.model.BidRejectionTracker; import org.prebid.server.auction.model.BidderRequest; import org.prebid.server.auction.model.BidderResponse; +import org.prebid.server.bidadjustments.BidAdjustmentsProcessor; import org.prebid.server.bidder.model.BidderBid; import org.prebid.server.bidder.model.BidderError; import org.prebid.server.bidder.model.BidderSeatBid; -import org.prebid.server.currency.CurrencyConversionService; -import org.prebid.server.exception.PreBidException; import org.prebid.server.floors.PriceFloorEnforcer; import org.prebid.server.proto.openrtb.ext.request.ExtRequest; -import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentFactors; -import org.prebid.server.proto.openrtb.ext.request.ExtRequestCurrency; import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; -import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; -import org.prebid.server.proto.openrtb.ext.response.BidType; import org.prebid.server.validation.ResponseBidValidator; import org.prebid.server.validation.model.ValidationResult; import java.math.BigDecimal; -import java.util.EnumMap; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.function.Function; import java.util.function.UnaryOperator; import static java.util.Collections.emptyMap; @@ -48,16 +37,10 @@ import static java.util.function.UnaryOperator.identity; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mock.Strictness.LENIENT; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -import static org.prebid.server.proto.openrtb.ext.response.BidType.audio; import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; -import static org.prebid.server.proto.openrtb.ext.response.BidType.video; -import static org.prebid.server.proto.openrtb.ext.response.BidType.xNative; @ExtendWith(MockitoExtension.class) public class BidsAdjusterTest extends VertxTest { @@ -65,9 +48,6 @@ public class BidsAdjusterTest extends VertxTest { @Mock(strictness = LENIENT) private ResponseBidValidator responseBidValidator; - @Mock(strictness = LENIENT) - private CurrencyConversionService currencyService; - @Mock(strictness = LENIENT) private PriceFloorEnforcer priceFloorEnforcer; @@ -75,7 +55,7 @@ public class BidsAdjusterTest extends VertxTest { private DsaEnforcer dsaEnforcer; @Mock(strictness = LENIENT) - private BidAdjustmentFactorResolver bidAdjustmentFactorResolver; + private BidAdjustmentsProcessor bidAdjustmentsProcessor; private BidsAdjuster target; @@ -83,696 +63,51 @@ public class BidsAdjusterTest extends VertxTest { public void setUp() { given(responseBidValidator.validate(any(), any(), any(), any())).willReturn(ValidationResult.success()); - given(currencyService.convertCurrency(any(), any(), any(), any())) - .willAnswer(invocationOnMock -> invocationOnMock.getArgument(0)); - given(priceFloorEnforcer.enforce(any(), any(), any(), any())).willAnswer(inv -> inv.getArgument(1)); given(dsaEnforcer.enforce(any(), any(), any())).willAnswer(inv -> inv.getArgument(1)); - given(bidAdjustmentFactorResolver.resolve(any(ImpMediaType.class), any(), any())).willReturn(null); - - givenTarget(); - } - - private void givenTarget() { - target = new BidsAdjuster( - responseBidValidator, - currencyService, - bidAdjustmentFactorResolver, - priceFloorEnforcer, - dsaEnforcer, - jacksonMapper); - } - - @Test - public void shouldReturnBidsWithUpdatedPriceCurrencyConversion() { - // given - final BidderResponse bidderResponse = givenBidderResponse( - Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).build()); - final BidRequest bidRequest = givenBidRequest( - singletonList(givenImp(singletonMap("bidder", 2), identity())), identity()); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - final BigDecimal updatedPrice = BigDecimal.valueOf(5.0); - given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(updatedPrice); - } - - @Test - public void shouldReturnSameBidPriceIfNoChangesAppliedToBidPrice() { - // given - final BidderResponse bidderResponse = givenBidderResponse( - Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).build()); - final BidRequest bidRequest = givenBidRequest( - singletonList(givenImp(singletonMap("bidder", 2), identity())), identity()); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - given(currencyService.convertCurrency(any(), any(), any(), any())) - .willAnswer(invocation -> invocation.getArgument(0)); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(2.0)); - } - - @Test - public void shouldDropBidIfPrebidExceptionWasThrownDuringCurrencyConversion() { - // given - final BidderResponse bidderResponse = givenBidderResponse( - Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).build()); - final BidRequest bidRequest = givenBidRequest( - singletonList(givenImp(singletonMap("bidder", 2), identity())), identity()); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - given(currencyService.convertCurrency(any(), any(), any(), any())) - .willThrow(new PreBidException("Unable to convert bid currency CUR to desired ad server currency USD")); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - - final BidderError expectedError = - BidderError.generic("Unable to convert bid currency CUR to desired ad server currency USD"); - final BidderSeatBid firstSeatBid = result.getFirst().getBidderResponse().getSeatBid(); - assertThat(firstSeatBid.getBids()).isEmpty(); - assertThat(firstSeatBid.getErrors()).containsOnly(expectedError); - } - - @Test - public void shouldUpdateBidPriceWithCurrencyConversionAndPriceAdjustmentFactor() { - // given - final BidderResponse bidderResponse = givenBidderResponse( - Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).build()); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder().build(); - givenAdjustments.addFactor("bidder", BigDecimal.TEN); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) - .willReturn(BigDecimal.TEN); - given(currencyService.convertCurrency(any(), any(), any(), any())) - .willReturn(BigDecimal.TEN); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); + given(bidAdjustmentsProcessor.enrichWithAdjustedBids(any(), any(), any())) + .willAnswer(inv -> inv.getArgument(0)); - // then - final BigDecimal updatedPrice = BigDecimal.valueOf(100); - final BidderSeatBid firstSeatBid = result.getFirst().getBidderResponse().getSeatBid(); - assertThat(firstSeatBid.getBids()) - .extracting(BidderBid::getBid) - .flatExtracting(Bid::getPrice) - .containsOnly(updatedPrice); - assertThat(firstSeatBid.getErrors()).isEmpty(); + target = new BidsAdjuster(responseBidValidator, priceFloorEnforcer, bidAdjustmentsProcessor, dsaEnforcer); } @Test - public void shouldUpdatePriceForOneBidAndDropAnotherIfPrebidExceptionHappensForSecondBid() { + public void shouldReturnBidsAdjustedByBidAdjustmentsProcessor() { // given - final BigDecimal firstBidderPrice = BigDecimal.valueOf(2.0); - final BigDecimal secondBidderPrice = BigDecimal.valueOf(3.0); - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder().impid("impId1").price(firstBidderPrice).build(), "CUR1"), - givenBidderBid(Bid.builder().impid("impId2").price(secondBidderPrice).build(), "CUR2") - )) - .build(), - 1); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), - identity()); - - final BigDecimal updatedPrice = BigDecimal.valueOf(10.0); - given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice) - .willThrow( - new PreBidException("Unable to convert bid currency CUR2 to desired ad server currency USD")); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - verify(currencyService).convertCurrency(eq(firstBidderPrice), eq(bidRequest), eq("CUR1"), any()); - verify(currencyService).convertCurrency(eq(secondBidderPrice), eq(bidRequest), eq("CUR2"), any()); - - assertThat(result).hasSize(1); - - final ObjectNode expectedBidExt = mapper.createObjectNode(); - expectedBidExt.put("origbidcpm", new BigDecimal("2.0")); - expectedBidExt.put("origbidcur", "CUR1"); - final Bid expectedBid = Bid.builder().impid("impId1").price(updatedPrice).ext(expectedBidExt).build(); - final BidderBid expectedBidderBid = BidderBid.of(expectedBid, banner, "CUR1"); - final BidderError expectedError = - BidderError.generic("Unable to convert bid currency CUR2 to desired ad server currency USD"); - - final BidderSeatBid firstSeatBid = result.getFirst().getBidderResponse().getSeatBid(); - assertThat(firstSeatBid.getBids()).containsOnly(expectedBidderBid); - assertThat(firstSeatBid.getErrors()).containsOnly(expectedError); - } - - @Test - public void shouldRespondWithOneBidAndErrorWhenBidResponseContainsOneUnsupportedCurrency() { - // given - final BigDecimal firstBidderPrice = BigDecimal.valueOf(2.0); - final BigDecimal secondBidderPrice = BigDecimal.valueOf(10.0); - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder().impid("impId1").price(firstBidderPrice).build(), "USD"), - givenBidderBid(Bid.builder().impid("impId2").price(secondBidderPrice).build(), "CUR") - )) - .build(), - 1); - - final BidRequest bidRequest = BidRequest.builder() - .cur(singletonList("BAD")) - .imp(singletonList(givenImp(doubleMap("bidder1", 2, "bidder2", 3), - identity()))).build(); - - final BigDecimal updatedPrice = BigDecimal.valueOf(20); - given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice); - given(currencyService.convertCurrency(any(), any(), eq("CUR"), eq("BAD"))) - .willThrow(new PreBidException("Unable to convert bid currency CUR to desired ad server currency BAD")); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - verify(currencyService).convertCurrency(eq(firstBidderPrice), eq(bidRequest), eq("USD"), eq("BAD")); - verify(currencyService).convertCurrency(eq(secondBidderPrice), eq(bidRequest), eq("CUR"), eq("BAD")); - - assertThat(result).hasSize(1); - - final ObjectNode expectedBidExt = mapper.createObjectNode(); - expectedBidExt.put("origbidcpm", new BigDecimal("2.0")); - expectedBidExt.put("origbidcur", "USD"); - final Bid expectedBid = Bid.builder().impid("impId1").price(updatedPrice).ext(expectedBidExt).build(); - final BidderBid expectedBidderBid = BidderBid.of(expectedBid, banner, "USD"); - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .containsOnly(expectedBidderBid); - - final BidderError expectedError = - BidderError.generic("Unable to convert bid currency CUR to desired ad server currency BAD"); - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getErrors) - .containsOnly(expectedError); - } - - @Test - public void shouldUpdateBidPriceWithCurrencyConversionAndAddWarningAboutMultipleCurrency() { - // given - final BigDecimal bidderPrice = BigDecimal.valueOf(2.0); - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder().impid("impId1").price(bidderPrice).build(), "USD") - )) - .build(), - 1); - - final BidRequest bidRequest = givenBidRequest( - singletonList(givenImp(singletonMap("bidder", 2), identity())), - builder -> builder.cur(List.of("CUR1", "CUR2", "CUR2"))); - - final BigDecimal updatedPrice = BigDecimal.valueOf(10.0); - given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - verify(currencyService).convertCurrency(eq(bidderPrice), eq(bidRequest), eq("USD"), eq("CUR1")); - - assertThat(result).hasSize(1); - - final BidderSeatBid firstSeatBid = result.getFirst().getBidderResponse().getSeatBid(); - assertThat(firstSeatBid.getBids()) - .extracting(BidderBid::getBid) - .flatExtracting(Bid::getPrice) - .containsOnly(updatedPrice); - - final BidderError expectedWarning = BidderError.badInput( - "a single currency (CUR1) has been chosen for the request. " - + "ORTB 2.6 requires that all responses are in the same currency."); - assertThat(firstSeatBid.getWarnings()).containsOnly(expectedWarning); - } - - @Test - public void shouldUpdateBidPriceWithCurrencyConversionForMultipleBid() { - // given - final BigDecimal bidder1Price = BigDecimal.valueOf(1.5); - final BigDecimal bidder2Price = BigDecimal.valueOf(2); - final BigDecimal bidder3Price = BigDecimal.valueOf(3); + final BidderBid bidToAdjust = + givenBidderBid(Bid.builder().id("bidId1").impid("impId1").price(BigDecimal.ONE).build(), "USD"); final BidderResponse bidderResponse = BidderResponse.of( "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder().impid("impId1").price(bidder1Price).build(), "EUR"), - givenBidderBid(Bid.builder().impid("impId2").price(bidder2Price).build(), "GBP"), - givenBidderBid(Bid.builder().impid("impId3").price(bidder3Price).build(), "USD") - )) - .build(), + BidderSeatBid.builder().bids(List.of(bidToAdjust)).build(), 1); final BidRequest bidRequest = givenBidRequest( - singletonList(givenImp(Map.of("bidder1", 1), identity())), - builder -> builder.cur(singletonList("USD"))); - - final BigDecimal updatedPrice = BigDecimal.valueOf(10.0); - given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice); - given(currencyService.convertCurrency(any(), any(), eq("USD"), any())).willReturn(bidder3Price); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - verify(currencyService).convertCurrency(eq(bidder1Price), eq(bidRequest), eq("EUR"), eq("USD")); - verify(currencyService).convertCurrency(eq(bidder2Price), eq(bidRequest), eq("GBP"), eq("USD")); - verify(currencyService).convertCurrency(eq(bidder3Price), eq(bidRequest), eq("USD"), eq("USD")); - verifyNoMoreInteractions(currencyService); - - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsOnly(bidder3Price, updatedPrice, updatedPrice); - } - - @Test - public void shouldReturnBidsWithAdjustedPricesWhenAdjustmentFactorPresent() { - // given - final BidderResponse bidderResponse = givenBidderResponse( - Bid.builder().impid("impId").price(BigDecimal.valueOf(2)).build()); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder().build(); - givenAdjustments.addFactor("bidder", BigDecimal.valueOf(2.468)); - given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) - .willReturn(BigDecimal.valueOf(2.468)); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(4.936)); - } - - @Test - public void shouldReturnBidsWithAdjustedPricesWithVideoInstreamMediaTypeIfVideoPlacementEqualsOne() { - // given - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder() - .impid("123") - .price(BigDecimal.valueOf(2)).build(), - "USD", video) - )) - .build(), - 1); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() - .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, - singletonMap("bidder", BigDecimal.valueOf(3.456))))) - .build(); - given(bidAdjustmentFactorResolver.resolve(ImpMediaType.video, givenAdjustments, "bidder")) - .willReturn(BigDecimal.valueOf(3.456)); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> - impBuilder.id("123").video(Video.builder().placement(1).build()))), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(6.912)); - } - - @Test - public void shouldReturnBidsWithAdjustedPricesWithVideoInstreamMediaTypeIfVideoPlacementIsMissing() { - // given - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder() - .impid("123") - .price(BigDecimal.valueOf(2)).build(), - "USD", video) - )) - .build(), - 1); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() - .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, - singletonMap("bidder", BigDecimal.valueOf(3.456))))) - .build(); - given(bidAdjustmentFactorResolver.resolve(ImpMediaType.video, givenAdjustments, "bidder")) - .willReturn(BigDecimal.valueOf(3.456)); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> - impBuilder.id("123").video(Video.builder().build()))), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(6.912)); - } - - @Test - public void shouldReturnBidAdjustmentMediaTypeNullIfImpIdNotEqualBidImpId() { - // given - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder() - .impid("123") - .price(BigDecimal.valueOf(2)).build(), - "USD", video) - )) - .build(), - 1); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() - .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, - singletonMap("bidder", BigDecimal.valueOf(3.456))))) - .build(); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> - impBuilder.id("123").video(Video.builder().placement(10).build()))), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(2)); - } - - @Test - public void shouldReturnBidAdjustmentMediaTypeVideoOutStreamIfImpIdEqualBidImpIdAndPopulatedPlacement() { - // given - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder() - .impid("123") - .price(BigDecimal.valueOf(2)).build(), - "USD", video) - )) - .build(), - 1); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() - .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, - singletonMap("bidder", BigDecimal.valueOf(3.456))))) - .build(); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> - impBuilder.id("123").video(Video.builder().placement(10).build()))), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(2)); - } - - @Test - public void shouldReturnBidsWithAdjustedPricesWhenAdjustmentMediaFactorPresent() { - // given - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder().price(BigDecimal.valueOf(2)).build(), "USD", banner), - givenBidderBid(Bid.builder().price(BigDecimal.ONE).build(), "USD", xNative), - givenBidderBid(Bid.builder().price(BigDecimal.ONE).build(), "USD", audio))) - .build(), - 1); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() - .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.banner, - singletonMap("bidder", BigDecimal.valueOf(3.456))))) - .build(); - given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) - .willReturn(BigDecimal.valueOf(3.456)); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(6.912), BigDecimal.valueOf(1), BigDecimal.valueOf(1)); - } - - @Test - public void shouldAdjustPriceWithPriorityForMediaTypeAdjustment() { - // given - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder() - .impid("123") - .price(BigDecimal.valueOf(2)).build(), - "USD") - )) - .build(), - 1); - - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() - .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.banner, - singletonMap("bidder", BigDecimal.valueOf(3.456))))) - .build(); - givenAdjustments.addFactor("bidder", BigDecimal.valueOf(2.468)); - given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) - .willReturn(BigDecimal.valueOf(3.456)); - - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .bidadjustmentfactors(givenAdjustments) - .auctiontimestamp(1000L) - .build()))); - - final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); - final AuctionContext auctionContext = givenAuctionContext(bidRequest); - - // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); - - // then - assertThat(result) - .extracting(AuctionParticipation::getBidderResponse) - .extracting(BidderResponse::getSeatBid) - .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.valueOf(6.912)); - } - - @Test - public void shouldReturnBidsWithoutAdjustingPricesWhenAdjustmentFactorNotPresentForBidder() { - // given - final BidderResponse bidderResponse = BidderResponse.of( - "bidder", - BidderSeatBid.builder() - .bids(List.of( - givenBidderBid(Bid.builder() - .impid("123") - .price(BigDecimal.ONE).build(), - "USD") - )) - .build(), - 1); + List.of(givenImp(singletonMap("bidder1", 1), builder -> builder.id("impId1"))), + identity()); - final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder().build(); - givenAdjustments.addFactor("some-other-bidder", BigDecimal.TEN); + final BidderBid adjustedBid = + givenBidderBid(Bid.builder().id("bidId1").impid("impId1").price(BigDecimal.TEN).build(), "USD"); - final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), - builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() - .aliases(emptyMap()) - .auctiontimestamp(1000L) - .currency(ExtRequestCurrency.of(null, false)) - .bidadjustmentfactors(givenAdjustments) - .build()))); + given(bidAdjustmentsProcessor.enrichWithAdjustedBids(any(), any(), any())) + .willReturn(AuctionParticipation.builder() + .bidder("bidder1") + .bidderResponse(BidderResponse.of( + "bidder1", BidderSeatBid.of(singletonList(adjustedBid)), 0)) + .build()); final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); final AuctionContext auctionContext = givenAuctionContext(bidRequest); // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); + final List result = target.validateAndAdjustBids( + auctionParticipations, auctionContext, null); // then assertThat(result) .extracting(AuctionParticipation::getBidderResponse) .extracting(BidderResponse::getSeatBid) .flatExtracting(BidderSeatBid::getBids) - .extracting(BidderBid::getBid) - .extracting(Bid::getPrice) - .containsExactly(BigDecimal.ONE); + .containsExactly(adjustedBid); } @Test @@ -806,8 +141,8 @@ public void shouldReturnBidsAcceptedByPriceFloorEnforcer() { final AuctionContext auctionContext = givenAuctionContext(bidRequest); // when - final List result = target - .validateAndAdjustBids(auctionParticipations, auctionContext, null); + final List result = target.validateAndAdjustBids( + auctionParticipations, auctionContext, null); // then assertThat(result) @@ -945,13 +280,37 @@ public void shouldTolerateResponseBidValidationWarnings() { "BidId `bidId1` validation messages: Warning: Error: bid validation warning.")); } - private BidderResponse givenBidderResponse(Bid bid) { - return BidderResponse.of( + @Test + public void shouldAddWarningAboutMultipleCurrency() { + // given + final BidderResponse bidderResponse = BidderResponse.of( "bidder", BidderSeatBid.builder() - .bids(singletonList(givenBidderBid(bid))) + .bids(List.of( + givenBidderBid(Bid.builder().impid("impId1").price(BigDecimal.valueOf(2.0)).build(), + "CUR1"))) .build(), 1); + + final BidRequest bidRequest = givenBidRequest( + singletonList(givenImp(singletonMap("bidder", 2), identity())), + builder -> builder.cur(List.of("CUR1", "CUR2", "CUR2"))); + + final List auctionParticipations = givenAuctionParticipation(bidderResponse, bidRequest); + final AuctionContext auctionContext = givenAuctionContext(bidRequest); + + // when + final List result = target + .validateAndAdjustBids(auctionParticipations, auctionContext, null); + + // then + assertThat(result).hasSize(1); + + final BidderSeatBid firstSeatBid = result.getFirst().getBidderResponse().getSeatBid(); + final BidderError expectedWarning = BidderError.badInput( + "a single currency (CUR1) has been chosen for the request. " + + "ORTB 2.6 requires that all responses are in the same currency."); + assertThat(firstSeatBid.getWarnings()).containsOnly(expectedWarning); } private List givenAuctionParticipation( @@ -983,7 +342,7 @@ private static BidRequest givenBidRequest(List imp, .build(); } - private static Imp givenImp(T ext, Function impBuilderCustomizer) { + private static Imp givenImp(T ext, UnaryOperator impBuilderCustomizer) { return impBuilderCustomizer.apply(Imp.builder() .id(UUID.randomUUID().toString()) .ext(mapper.valueToTree(singletonMap( @@ -998,15 +357,4 @@ private static BidderBid givenBidderBid(Bid bid) { private static BidderBid givenBidderBid(Bid bid, String currency) { return BidderBid.of(bid, banner, currency); } - - private static BidderBid givenBidderBid(Bid bid, String currency, BidType type) { - return BidderBid.of(bid, type, currency); - } - - private static Map doubleMap(K key1, V value1, K key2, V value2) { - final Map map = new HashMap<>(); - map.put(key1, value1); - map.put(key2, value2); - return map; - } } diff --git a/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java b/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java index dedb325e241..96889137331 100644 --- a/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java +++ b/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java @@ -36,6 +36,9 @@ import org.prebid.server.auction.model.debug.DebugContext; import org.prebid.server.auction.privacy.contextfactory.AuctionPrivacyContextFactory; import org.prebid.server.auction.versionconverter.BidRequestOrtbVersionConversionManager; +import org.prebid.server.bidadjustments.BidAdjustmentsRetriever; +import org.prebid.server.bidadjustments.model.BidAdjustmentType; +import org.prebid.server.bidadjustments.model.BidAdjustments; import org.prebid.server.cookie.CookieDeprecationService; import org.prebid.server.exception.InvalidRequestException; import org.prebid.server.geolocation.model.GeoInfo; @@ -49,14 +52,18 @@ import org.prebid.server.proto.openrtb.ext.request.ExtRegs; import org.prebid.server.proto.openrtb.ext.request.ExtRegsDsa; import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidData; import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidDataEidPermissions; import org.prebid.server.settings.model.Account; import java.util.ArrayList; +import java.util.List; +import java.util.Map; import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static org.apache.commons.lang3.StringUtils.EMPTY; @@ -102,6 +109,8 @@ public class AuctionRequestFactoryTest extends VertxTest { private DebugResolver debugResolver; @Mock(strictness = LENIENT) private GeoLocationServiceWrapper geoLocationServiceWrapper; + @Mock(strictness = LENIENT) + private BidAdjustmentsRetriever bidAdjustmentsRetriever; private AuctionRequestFactory target; @@ -188,6 +197,7 @@ public void setUp() { .will(invocationOnMock -> invocationOnMock.getArgument(0)); given(geoLocationServiceWrapper.lookup(any())) .willReturn(Future.succeededFuture(GeoInfo.builder().vendor("vendor").build())); + given(bidAdjustmentsRetriever.retrieve(any())).willReturn(BidAdjustments.of(emptyMap())); target = new AuctionRequestFactory( Integer.MAX_VALUE, @@ -203,7 +213,8 @@ public void setUp() { auctionPrivacyContextFactory, debugResolver, jacksonMapper, - geoLocationServiceWrapper); + geoLocationServiceWrapper, + bidAdjustmentsRetriever); } @Test @@ -238,7 +249,8 @@ public void shouldReturnFailedFutureIfRequestBodyExceedsMaxRequestSize() { auctionPrivacyContextFactory, debugResolver, jacksonMapper, - geoLocationServiceWrapper); + geoLocationServiceWrapper, + bidAdjustmentsRetriever); given(routingContext.getBodyAsString()).willReturn("body"); @@ -714,6 +726,27 @@ public void shouldReturnPopulatedPrivacyContextAndGetWhenPrivacyEnforcementRetur assertThat(result.getGeoInfo()).isEqualTo(geoInfo); } + @Test + public void shouldReturnPopulatedBidAdjustments() { + // given + givenValidBidRequest(); + + final BidAdjustments bidAdjustments = BidAdjustments.of(Map.of( + "rule1", List.of( + ExtRequestBidAdjustmentsRule.builder().adjType(BidAdjustmentType.CPM).build()), + "rule2", List.of( + ExtRequestBidAdjustmentsRule.builder().adjType(BidAdjustmentType.CPM).build(), + ExtRequestBidAdjustmentsRule.builder().adjType(BidAdjustmentType.STATIC).build()))); + + given(bidAdjustmentsRetriever.retrieve(any())).willReturn(bidAdjustments); + + // when + final AuctionContext result = target.enrichAuctionContext(defaultActionContext).result(); + + // then + assertThat(result.getBidAdjustments()).isEqualTo(bidAdjustments); + } + @Test public void shouldConvertBidRequestToInternalOpenRTBVersion() { // given diff --git a/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidatorTest.java b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidatorTest.java new file mode 100644 index 00000000000..0c98ff6af3b --- /dev/null +++ b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentRulesValidatorTest.java @@ -0,0 +1,306 @@ +package org.prebid.server.bidadjustments; + +import org.junit.jupiter.api.Test; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustments; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; +import org.prebid.server.validation.ValidationException; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.CPM; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.MULTIPLIER; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.STATIC; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.UNKNOWN; + +public class BidAdjustmentRulesValidatorTest { + + @Test + public void validateShouldDoNothingWhenBidAdjustmentsIsNull() throws ValidationException { + // when & then + BidAdjustmentRulesValidator.validate(null); + } + + @Test + public void validateShouldDoNothingWhenMediatypesIsEmpty() throws ValidationException { + // when & then + BidAdjustmentRulesValidator.validate(ExtRequestBidAdjustments.builder().build()); + } + + @Test + public void validateShouldSkipMediatypeValidationWhenMediatypesIsNotSupported() throws ValidationException { + // given + final ExtRequestBidAdjustmentsRule invalidRule = ExtRequestBidAdjustmentsRule.builder() + .value(new BigDecimal("-999")) + .build(); + + // when & then + BidAdjustmentRulesValidator.validate(ExtRequestBidAdjustments.builder() + .mediatype(Map.of("invalid", Map.of("bidderName", Map.of("*", List.of(invalidRule))))) + .build()); + } + + @Test + public void validateShouldFailWhenBiddersAreAbsent() { + // given + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Collections.emptyMap())) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("no bidders found in banner"); + } + + @Test + public void validateShouldFailWhenDealsAreAbsent() { + // given + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", Collections.emptyMap()))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("no deals found in banner.bidderName"); + } + + @Test + public void validateShouldFailWhenRulesIsEmpty() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", null); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("no bid adjustment rules found in banner.bidderName.*"); + } + + @Test + public void validateShouldDoNothingWhenRulesAreEmpty() throws ValidationException { + // when & then + BidAdjustmentRulesValidator.validate(ExtRequestBidAdjustments.builder() + .mediatype(Map.of("video_instream", Map.of("bidderName", Map.of("*", List.of())))) + .build()); + } + + @Test + public void validateShouldFailWhenRuleHasUnknownType() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(ExtRequestBidAdjustmentsRule.builder() + .adjType(UNKNOWN) + .value(BigDecimal.ONE) + .currency("USD") + .build())); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=UNKNOWN, value=1, currency=USD] " + + "in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenCpmRuleDoesNotHaveCurrency() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenCpm("1", "USD"), givenCpm("1", null))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=CPM, value=1, currency=null] in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenCpmRuleDoesHasNegativeValue() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenCpm("0", "USD"), givenCpm("-1", "USD"))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=CPM, value=-1, currency=USD] in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenCpmRuleDoesHasValueMoreThanMaxInt() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenCpm("0", "USD"), givenCpm("2147483647", "USD"))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=CPM, value=2147483647, currency=USD] " + + "in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenStaticRuleDoesNotHaveCurrency() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenStatic("1", "USD"), givenStatic("1", null))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=STATIC, value=1, currency=null] " + + "in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenStaticRuleDoesHasNegativeValue() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenStatic("0", "USD"), givenStatic("-1", "USD"))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=STATIC, value=-1, currency=USD] " + + "in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenStaticRuleDoesHasValueMoreThanMaxInt() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenStatic("0", "USD"), givenStatic("2147483647", "USD"))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=STATIC, value=2147483647, currency=USD] " + + "in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenMultiplierRuleDoesHasNegativeValue() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenMultiplier("0"), givenMultiplier("-1"))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=MULTIPLIER, value=-1, currency=null] " + + "in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldFailWhenMultiplierRuleDoesHasValueMoreThan100() { + // given + final Map> rules = new HashMap<>(); + rules.put("*", List.of(givenMultiplier("0"), givenMultiplier("100"))); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of("banner", Map.of("bidderName", rules))) + .build(); + + // when & then + assertThatThrownBy(() -> BidAdjustmentRulesValidator.validate(givenBidAdjustments)) + .isInstanceOf(ValidationException.class) + .hasMessage("the found rule [adjtype=MULTIPLIER, value=100, currency=null] " + + "in banner.bidderName.* is invalid"); + } + + @Test + public void validateShouldDoNothingWhenAllRulesAreValid() throws ValidationException { + // given + final List givenRules = List.of( + givenMultiplier("1"), + givenCpm("2", "USD"), + givenStatic("3", "EUR")); + + final Map>> givenRulesMap = Map.of( + "bidderName", + Map.of("dealId", givenRules)); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of( + "audio", givenRulesMap, + "native", givenRulesMap, + "video-instream", givenRulesMap, + "video-outstream", givenRulesMap, + "banner", givenRulesMap, + "video", givenRulesMap, + "unknown", givenRulesMap, + "*", Map.of( + "*", Map.of("*", givenRules), + "bidderName", Map.of( + "*", givenRules, + "dealId", givenRules)))) + .build(); + + //when & then + BidAdjustmentRulesValidator.validate(givenBidAdjustments); + } + + private static ExtRequestBidAdjustmentsRule givenStatic(String value, String currency) { + return ExtRequestBidAdjustmentsRule.builder() + .adjType(STATIC) + .currency(currency) + .value(new BigDecimal(value)) + .build(); + } + + private static ExtRequestBidAdjustmentsRule givenCpm(String value, String currency) { + return ExtRequestBidAdjustmentsRule.builder() + .adjType(CPM) + .currency(currency) + .value(new BigDecimal(value)) + .build(); + } + + private static ExtRequestBidAdjustmentsRule givenMultiplier(String value) { + return ExtRequestBidAdjustmentsRule.builder() + .adjType(MULTIPLIER) + .value(new BigDecimal(value)) + .build(); + } +} diff --git a/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessorTest.java b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessorTest.java new file mode 100644 index 00000000000..33cfe50e09c --- /dev/null +++ b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsProcessorTest.java @@ -0,0 +1,823 @@ +package org.prebid.server.bidadjustments; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.auction.adjustment.BidAdjustmentFactorResolver; +import org.prebid.server.auction.model.AuctionParticipation; +import org.prebid.server.auction.model.BidderRequest; +import org.prebid.server.auction.model.BidderResponse; +import org.prebid.server.bidadjustments.model.BidAdjustments; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.BidderSeatBid; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentFactors; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustments; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestCurrency; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; +import org.prebid.server.proto.openrtb.ext.response.BidType; + +import java.math.BigDecimal; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.UnaryOperator; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.prebid.server.proto.openrtb.ext.response.BidType.audio; +import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; +import static org.prebid.server.proto.openrtb.ext.response.BidType.video; +import static org.prebid.server.proto.openrtb.ext.response.BidType.xNative; + +@ExtendWith(MockitoExtension.class) +public class BidAdjustmentsProcessorTest extends VertxTest { + + @Mock(strictness = LENIENT) + private CurrencyConversionService currencyService; + @Mock(strictness = LENIENT) + private BidAdjustmentFactorResolver bidAdjustmentFactorResolver; + @Mock(strictness = LENIENT) + private BidAdjustmentsResolver bidAdjustmentsResolver; + + private BidAdjustmentsProcessor target; + + @BeforeEach + public void before() { + given(currencyService.convertCurrency(any(), any(), any(), any())) + .willAnswer(invocationOnMock -> invocationOnMock.getArgument(0)); + given(bidAdjustmentsResolver.resolve(any(), any(), any(), any(), any(), any())) + .willAnswer(invocationOnMock -> invocationOnMock.getArgument(0)); + + target = new BidAdjustmentsProcessor( + currencyService, + bidAdjustmentFactorResolver, + bidAdjustmentsResolver, + jacksonMapper); + } + + @Test + public void shouldReturnBidsWithUpdatedPriceCurrencyConversionAndAdjusted() { + // given + final BidderResponse bidderResponse = givenBidderResponse( + Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).dealid("dealId").build()); + final BidRequest bidRequest = givenBidRequest( + singletonList(givenImp(singletonMap("bidder", 2), identity())), identity()); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + final Price adjustedPrice = Price.of("EUR", BigDecimal.valueOf(5.0)); + given(bidAdjustmentsResolver.resolve(any(), any(), any(), any(), any(), any())).willReturn(adjustedPrice); + + final BigDecimal expectedPrice = new BigDecimal("123.5"); + given(currencyService.convertCurrency(any(), any(), eq("EUR"), eq("UAH"))).willReturn(expectedPrice); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBidCurrency, bidderBid -> bidderBid.getBid().getPrice()) + .containsExactly(tuple("UAH", expectedPrice)); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(2.0))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.banner), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldReturnSameBidPriceIfNoChangesAppliedToBidPrice() { + // given + final BidderResponse bidderResponse = givenBidderResponse( + Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).build()); + final BidRequest bidRequest = givenBidRequest( + singletonList(givenImp(singletonMap("bidder", 2), identity())), identity()); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + given(currencyService.convertCurrency(any(), any(), any(), any())) + .willAnswer(invocation -> invocation.getArgument(0)); + given(bidAdjustmentsResolver.resolve(any(), any(), any(), any(), any(), any())) + .willAnswer(invocationOnMock -> invocationOnMock.getArgument(0)); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.valueOf(2.0)); + } + + @Test + public void shouldDropBidIfPrebidExceptionWasThrownDuringCurrencyConversion() { + // given + final BidderResponse bidderResponse = givenBidderResponse( + Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).build()); + final BidRequest bidRequest = givenBidRequest( + singletonList(givenImp(singletonMap("bidder", 2), identity())), identity()); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + given(currencyService.convertCurrency(any(), any(), any(), any())) + .willThrow(new PreBidException("Unable to convert bid currency CUR to desired ad server currency USD")); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + final BidderError expectedError = BidderError.generic( + "Unable to convert bid currency CUR to desired ad server currency USD"); + final BidderSeatBid firstSeatBid = result.getBidderResponse().getSeatBid(); + assertThat(firstSeatBid.getBids()).isEmpty(); + assertThat(firstSeatBid.getErrors()).containsOnly(expectedError); + } + + @Test + public void shouldDropBidIfPrebidExceptionWasThrownDuringBidAdjustmentResolving() { + // given + final BidderResponse bidderResponse = givenBidderResponse( + Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).build()); + final BidRequest bidRequest = givenBidRequest( + singletonList(givenImp(singletonMap("bidder", 2), identity())), identity()); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + given(currencyService.convertCurrency(any(), any(), any(), any())) + .willAnswer(invocation -> invocation.getArgument(0)); + given(bidAdjustmentsResolver.resolve(any(), any(), any(), any(), any(), any())) + .willThrow(new PreBidException("Unable to convert bid currency CUR to desired ad server currency USD")); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + final BidderError expectedError = BidderError.generic( + "Unable to convert bid currency CUR to desired ad server currency USD"); + final BidderSeatBid firstSeatBid = result.getBidderResponse().getSeatBid(); + assertThat(firstSeatBid.getBids()).isEmpty(); + assertThat(firstSeatBid.getErrors()).containsOnly(expectedError); + } + + @Test + public void shouldUpdateBidPriceWithCurrencyConversionAndPriceAdjustmentFactorAndBidAdjustments() { + // given + final BidderResponse bidderResponse = givenBidderResponse( + Bid.builder().impid("impId").price(BigDecimal.valueOf(2.0)).dealid("dealId").build()); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder().build(); + givenAdjustments.addFactor("bidder", BigDecimal.TEN); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) + .willReturn(BigDecimal.TEN); + final Price adjustedPrice = Price.of("EUR", BigDecimal.valueOf(5.0)); + given(bidAdjustmentsResolver.resolve(any(), any(), any(), any(), any(), any())).willReturn(adjustedPrice); + final BigDecimal expectedPrice = new BigDecimal("123.5"); + given(currencyService.convertCurrency(any(), any(), eq("EUR"), eq("UAH"))).willReturn(expectedPrice); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + final BidderSeatBid seatBid = result.getBidderResponse().getSeatBid(); + assertThat(seatBid.getBids()) + .extracting(BidderBid::getBidCurrency, bidderBid -> bidderBid.getBid().getPrice()) + .containsExactly(tuple("UAH", expectedPrice)); + assertThat(seatBid.getErrors()).isEmpty(); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(20.0))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.banner), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldUpdatePriceForOneBidAndDropAnotherIfPrebidExceptionHappensForSecondBid() { + // given + final BigDecimal firstBidderPrice = BigDecimal.valueOf(2.0); + final BigDecimal secondBidderPrice = BigDecimal.valueOf(3.0); + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder().impid("impId1").price(firstBidderPrice).build(), "CUR1"), + givenBidderBid(Bid.builder().impid("impId2").price(secondBidderPrice).build(), "CUR2") + )) + .build(), + 1); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), + identity()); + + final BigDecimal updatedPrice = BigDecimal.valueOf(10.0); + given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice) + .willThrow( + new PreBidException("Unable to convert bid currency CUR2 to desired ad server currency USD")); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target + .enrichWithAdjustedBids(auctionParticipation, bidRequest, null); + + // then + verify(currencyService).convertCurrency(eq(firstBidderPrice), eq(bidRequest), eq("CUR1"), any()); + verify(currencyService).convertCurrency(eq(secondBidderPrice), eq(bidRequest), eq("CUR2"), any()); + + final ObjectNode expectedBidExt = mapper.createObjectNode(); + expectedBidExt.put("origbidcpm", new BigDecimal("2.0")); + expectedBidExt.put("origbidcur", "CUR1"); + final Bid expectedBid = Bid.builder().impid("impId1").price(updatedPrice).ext(expectedBidExt).build(); + final BidderBid expectedBidderBid = BidderBid.of(expectedBid, banner, "UAH"); + final BidderError expectedError = + BidderError.generic("Unable to convert bid currency CUR2 to desired ad server currency USD"); + + final BidderSeatBid firstSeatBid = result.getBidderResponse().getSeatBid(); + assertThat(firstSeatBid.getBids()).containsOnly(expectedBidderBid); + assertThat(firstSeatBid.getErrors()).containsOnly(expectedError); + } + + @Test + public void shouldRespondWithOneBidAndErrorWhenBidResponseContainsOneUnsupportedCurrency() { + // given + final BigDecimal firstBidderPrice = BigDecimal.valueOf(2.0); + final BigDecimal secondBidderPrice = BigDecimal.valueOf(10.0); + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder().impid("impId1").price(firstBidderPrice).build(), "USD"), + givenBidderBid(Bid.builder().impid("impId2").price(secondBidderPrice).build(), "EUR") + )) + .build(), + 1); + + final BidRequest bidRequest = BidRequest.builder() + .cur(singletonList("CUR")) + .imp(singletonList(givenImp(doubleMap("bidder1", 2, "bidder2", 3), + identity()))).build(); + + final BigDecimal updatedPrice = BigDecimal.valueOf(20); + given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice); + given(currencyService.convertCurrency(any(), any(), eq("EUR"), eq("CUR"))) + .willThrow(new PreBidException("Unable to convert bid currency EUR to desired ad server currency CUR")); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + verify(currencyService).convertCurrency(eq(firstBidderPrice), eq(bidRequest), eq("USD"), eq("CUR")); + verify(currencyService).convertCurrency(eq(secondBidderPrice), eq(bidRequest), eq("EUR"), eq("CUR")); + + final ObjectNode expectedBidExt = mapper.createObjectNode(); + expectedBidExt.put("origbidcpm", new BigDecimal("2.0")); + expectedBidExt.put("origbidcur", "USD"); + final Bid expectedBid = Bid.builder().impid("impId1").price(updatedPrice).ext(expectedBidExt).build(); + final BidderBid expectedBidderBid = BidderBid.of(expectedBid, banner, "CUR"); + assertThat(result.getBidderResponse().getSeatBid().getBids()).containsOnly(expectedBidderBid); + + final BidderError expectedError = BidderError.generic( + "Unable to convert bid currency EUR to desired ad server currency CUR"); + assertThat(result.getBidderResponse().getSeatBid().getErrors()).containsOnly(expectedError); + } + + @Test + public void shouldUpdateBidPriceWithCurrencyConversionForMultipleBid() { + // given + final BigDecimal bidder1Price = BigDecimal.valueOf(1.5); + final BigDecimal bidder2Price = BigDecimal.valueOf(2); + final BigDecimal bidder3Price = BigDecimal.valueOf(3); + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder().impid("impId1").price(bidder1Price).build(), "EUR"), + givenBidderBid(Bid.builder().impid("impId2").price(bidder2Price).build(), "GBP"), + givenBidderBid(Bid.builder().impid("impId3").price(bidder3Price).build(), "USD") + )) + .build(), + 1); + + final BidRequest bidRequest = givenBidRequest( + singletonList(givenImp(Map.of("bidder1", 1), identity())), + builder -> builder.cur(singletonList("USD"))); + + final BigDecimal updatedPrice = BigDecimal.valueOf(10.0); + given(currencyService.convertCurrency(any(), any(), any(), any())).willReturn(updatedPrice); + given(currencyService.convertCurrency(any(), any(), eq("USD"), any())).willReturn(bidder3Price); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + verify(currencyService).convertCurrency(eq(bidder1Price), eq(bidRequest), eq("EUR"), eq("USD")); + verify(currencyService).convertCurrency(eq(bidder2Price), eq(bidRequest), eq("GBP"), eq("USD")); + verify(currencyService).convertCurrency(eq(bidder3Price), eq(bidRequest), eq("USD"), eq("USD")); + verifyNoMoreInteractions(currencyService); + + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsOnly(bidder3Price, updatedPrice, updatedPrice); + + verify(bidAdjustmentsResolver, times(3)).resolve(any(), any(), any(), any(), any(), any()); + } + + @Test + public void shouldReturnBidsWithAdjustedPricesWhenAdjustmentFactorPresent() { + // given + final BidderResponse bidderResponse = givenBidderResponse( + Bid.builder().impid("impId").price(BigDecimal.valueOf(2)).dealid("dealId").build()); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder().build(); + givenAdjustments.addFactor("bidder", BigDecimal.valueOf(2.468)); + given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) + .willReturn(BigDecimal.valueOf(2.468)); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.valueOf(4.936)); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(4.936))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.banner), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldReturnBidsWithAdjustedPricesWithVideoInstreamMediaTypeIfVideoPlacementEqualsOne() { + // given + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder() + .impid("123") + .price(BigDecimal.valueOf(2)) + .dealid("dealId") + .build(), + "USD", video))) + .build(), + 1); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() + .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, + singletonMap("bidder", BigDecimal.valueOf(3.456))))) + .build(); + given(bidAdjustmentFactorResolver.resolve(ImpMediaType.video, givenAdjustments, "bidder")) + .willReturn(BigDecimal.valueOf(3.456)); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> + impBuilder.id("123").video(Video.builder().placement(1).build()))), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.valueOf(6.912)); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(6.912))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.video_instream), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldReturnBidsWithAdjustedPricesWithVideoInstreamMediaTypeIfVideoPlacementIsMissing() { + // given + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder() + .impid("123") + .price(BigDecimal.valueOf(2)) + .dealid("dealId") + .build(), + "USD", video))) + .build(), + 1); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() + .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, + singletonMap("bidder", BigDecimal.valueOf(3.456))))) + .build(); + given(bidAdjustmentFactorResolver.resolve(ImpMediaType.video, givenAdjustments, "bidder")) + .willReturn(BigDecimal.valueOf(3.456)); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> + impBuilder.id("123").video(Video.builder().build()))), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + // when + final AuctionParticipation result = target + .enrichWithAdjustedBids(auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.valueOf(6.912)); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(6.912))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.video_instream), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldReturnBidAdjustmentMediaTypeNullIfImpIdNotEqualBidImpId() { + // given + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder() + .impid("125") + .price(BigDecimal.valueOf(2)) + .dealid("dealId") + .build(), + "USD", video))) + .build(), + 1); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() + .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, + singletonMap("bidder", BigDecimal.valueOf(3.456))))) + .build(); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> + impBuilder.id("123").video(Video.builder().placement(10).build()))), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target + .enrichWithAdjustedBids(auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.valueOf(2)); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(2))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.video_instream), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldReturnBidAdjustmentMediaTypeVideoOutStreamIfImpIdEqualBidImpIdAndPopulatedPlacement() { + // given + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder() + .impid("123") + .price(BigDecimal.valueOf(2)) + .dealid("dealId") + .build(), + "USD", video))) + .build(), + 1); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() + .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.video, + singletonMap("bidder", BigDecimal.valueOf(3.456))))) + .build(); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), impBuilder -> + impBuilder.id("123").video(Video.builder().placement(10).build()))), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target + .enrichWithAdjustedBids(auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.valueOf(2)); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(2))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.video_outstream), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldReturnBidsWithAdjustedPricesWhenAdjustmentMediaFactorPresent() { + // given + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder().price(BigDecimal.valueOf(2)).build(), "USD", banner), + givenBidderBid(Bid.builder().price(BigDecimal.ONE).build(), "USD", xNative), + givenBidderBid(Bid.builder().price(BigDecimal.ONE).build(), "USD", audio))) + .build(), + 1); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() + .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.banner, + singletonMap("bidder", BigDecimal.valueOf(3.456))))) + .build(); + given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) + .willReturn(BigDecimal.valueOf(3.456)); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.valueOf(6.912), BigDecimal.valueOf(1), BigDecimal.valueOf(1)); + + verify(bidAdjustmentsResolver, times(3)) + .resolve(any(), any(), any(), any(), any(), any()); + } + + @Test + public void shouldAdjustPriceWithPriorityForMediaTypeAdjustment() { + // given + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder() + .impid("123") + .price(BigDecimal.valueOf(2)) + .dealid("dealId") + .build(), + "USD"))) + .build(), + 1); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder() + .mediatypes(new EnumMap<>(singletonMap(ImpMediaType.banner, + singletonMap("bidder", BigDecimal.valueOf(3.456))))) + .build(); + givenAdjustments.addFactor("bidder", BigDecimal.valueOf(2.468)); + given(bidAdjustmentFactorResolver.resolve(ImpMediaType.banner, givenAdjustments, "bidder")) + .willReturn(BigDecimal.valueOf(3.456)); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .bidadjustmentfactors(givenAdjustments) + .auctiontimestamp(1000L) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target.enrichWithAdjustedBids( + auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsOnly(BigDecimal.valueOf(6.912)); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.valueOf(6.912))), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.banner), + eq("bidder"), + eq("dealId")); + } + + @Test + public void shouldReturnBidsWithoutAdjustingPricesWhenAdjustmentFactorNotPresentForBidder() { + // given + final BidderResponse bidderResponse = BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(List.of( + givenBidderBid(Bid.builder() + .impid("123") + .price(BigDecimal.ONE) + .dealid("dealId") + .build(), + "USD"))) + .build(), + 1); + + final ExtRequestBidAdjustmentFactors givenAdjustments = ExtRequestBidAdjustmentFactors.builder().build(); + givenAdjustments.addFactor("some-other-bidder", BigDecimal.TEN); + + final BidRequest bidRequest = givenBidRequest(singletonList(givenImp(singletonMap("bidder", 2), identity())), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(emptyMap()) + .auctiontimestamp(1000L) + .currency(ExtRequestCurrency.of(null, false)) + .bidadjustmentfactors(givenAdjustments) + .build()))); + + final AuctionParticipation auctionParticipation = givenAuctionParticipation(bidderResponse, bidRequest); + + // when + final AuctionParticipation result = target + .enrichWithAdjustedBids(auctionParticipation, bidRequest, givenBidAdjustments()); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(BigDecimal.ONE); + + verify(bidAdjustmentsResolver).resolve( + eq(Price.of("USD", BigDecimal.ONE)), + eq(bidRequest), + eq(givenBidAdjustments()), + eq(ImpMediaType.banner), + eq("bidder"), + eq("dealId")); + } + + private static BidRequest givenBidRequest(List imp, + UnaryOperator bidRequestBuilderCustomizer) { + + return bidRequestBuilderCustomizer + .apply(BidRequest.builder().cur(singletonList("UAH")).imp(imp).tmax(500L)) + .build(); + } + + private static Imp givenImp(T ext, UnaryOperator impBuilderCustomizer) { + return impBuilderCustomizer.apply(Imp.builder() + .id(UUID.randomUUID().toString()) + .ext(mapper.valueToTree(singletonMap( + "prebid", ext != null ? singletonMap("bidder", ext) : emptyMap())))) + .build(); + } + + private static BidderBid givenBidderBid(Bid bid, String currency) { + return BidderBid.of(bid, banner, currency); + } + + private static BidderBid givenBidderBid(Bid bid, String currency, BidType type) { + return BidderBid.of(bid, type, currency); + } + + private static Map doubleMap(K key1, V value1, K key2, V value2) { + final Map map = new HashMap<>(); + map.put(key1, value1); + map.put(key2, value2); + return map; + } + + private static BidAdjustments givenBidAdjustments() { + return BidAdjustments.of(ExtRequestBidAdjustments.builder().build()); + } + + private BidderResponse givenBidderResponse(Bid bid) { + return BidderResponse.of( + "bidder", + BidderSeatBid.builder() + .bids(singletonList(givenBidderBid(bid, "USD"))) + .build(), + 1); + } + + private AuctionParticipation givenAuctionParticipation(BidderResponse bidderResponse, + BidRequest bidRequest) { + + final BidderRequest bidderRequest = BidderRequest.builder() + .bidRequest(bidRequest) + .build(); + + return AuctionParticipation.builder() + .bidder("bidder") + .bidderRequest(bidderRequest) + .bidderResponse(bidderResponse) + .build(); + } + +} diff --git a/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsResolverTest.java b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsResolverTest.java new file mode 100644 index 00000000000..97ca68e939e --- /dev/null +++ b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsResolverTest.java @@ -0,0 +1,243 @@ +package org.prebid.server.bidadjustments; + +import com.iab.openrtb.request.BidRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.bidadjustments.model.BidAdjustments; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; +import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mock.Strictness.LENIENT; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.CPM; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.MULTIPLIER; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.STATIC; + +@ExtendWith(MockitoExtension.class) +public class BidAdjustmentsResolverTest extends VertxTest { + + @Mock(strictness = LENIENT) + private CurrencyConversionService currencyService; + + private BidAdjustmentsResolver target; + + @BeforeEach + public void before() { + target = new BidAdjustmentsResolver(currencyService); + + given(currencyService.convertCurrency(any(), any(), any(), any())).willAnswer(invocation -> { + final BigDecimal initialPrice = (BigDecimal) invocation.getArguments()[0]; + return initialPrice.multiply(BigDecimal.TEN); + }); + } + + @Test + public void resolveShouldPickAndApplyRulesBySpecificMediaType() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "banner|*|*", List.of(givenStatic("15", "EUR")), + "*|*|*", List.of(givenStatic("25", "UAH")))); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + BidRequest.builder().build(), + givenBidAdjustments, + ImpMediaType.banner, + "bidderName", + "dealId"); + + // then + assertThat(actual).isEqualTo(Price.of("EUR", new BigDecimal("15"))); + verifyNoInteractions(currencyService); + } + + @Test + public void resolveShouldPickAndApplyRulesByWildcardMediaType() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "banner|*|*", List.of(givenCpm("15", "EUR")), + "*|*|*", List.of(givenCpm("25", "UAH")))); + + final BidRequest givenBidRequest = BidRequest.builder().build(); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + givenBidRequest, + givenBidAdjustments, + ImpMediaType.video_outstream, + "bidderName", + "dealId"); + + // then + assertThat(actual).isEqualTo(Price.of("USD", new BigDecimal("-249"))); + verify(currencyService).convertCurrency(new BigDecimal("25"), givenBidRequest, "UAH", "USD"); + } + + @Test + public void resolveShouldPickAndApplyRulesBySpecificBidder() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "*|bidderName|*", List.of(givenMultiplier("15")), + "*|*|*", List.of(givenMultiplier("25")))); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + BidRequest.builder().build(), + givenBidAdjustments, + ImpMediaType.banner, + "bidderName", + "dealId"); + + // then + assertThat(actual).isEqualTo(Price.of("USD", new BigDecimal("15"))); + verifyNoInteractions(currencyService); + } + + @Test + public void resolveShouldPickAndApplyRulesByWildcardBidder() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "*|bidderName|*", List.of(givenStatic("15", "EUR"), givenMultiplier("15")), + "*|*|*", List.of(givenStatic("25", "UAH"), givenMultiplier("25")))); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + BidRequest.builder().build(), + givenBidAdjustments, + ImpMediaType.banner, + "anotherBidderName", + "dealId"); + + // then + assertThat(actual).isEqualTo(Price.of("UAH", new BigDecimal("625"))); + verifyNoInteractions(currencyService); + } + + @Test + public void resolveShouldPickAndApplyRulesBySpecificDealId() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "*|*|dealId", List.of(givenCpm("15", "JPY"), givenStatic("15", "EUR")), + "*|*|*", List.of(givenCpm("25", "JPY"), givenStatic("25", "UAH")))); + final BidRequest givenBidRequest = BidRequest.builder().build(); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + givenBidRequest, + givenBidAdjustments, + ImpMediaType.banner, + "bidderName", + "dealId"); + + // then + assertThat(actual).isEqualTo(Price.of("EUR", new BigDecimal("15"))); + verify(currencyService).convertCurrency(new BigDecimal("15"), givenBidRequest, "JPY", "USD"); + } + + @Test + public void resolveShouldPickAndApplyRulesByWildcardDealId() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "*|*|dealId", List.of(givenMultiplier("15"), givenCpm("15", "EUR")), + "*|*|*", List.of(givenMultiplier("25"), givenCpm("25", "UAH")))); + final BidRequest givenBidRequest = BidRequest.builder().build(); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + givenBidRequest, + givenBidAdjustments, + ImpMediaType.banner, + "bidderName", + "anotherDealId"); + + // then + assertThat(actual).isEqualTo(Price.of("USD", new BigDecimal("-225"))); + verify(currencyService).convertCurrency(new BigDecimal("25"), givenBidRequest, "UAH", "USD"); + } + + @Test + public void resolveShouldPickAndApplyRulesByWildcardDealIdWhenDealIdIsNull() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "*|*|dealId", List.of(givenCpm("15", "EUR"), givenCpm("15", "JPY")), + "*|*|*", List.of(givenCpm("25", "UAH"), givenCpm("25", "JPY")))); + final BidRequest givenBidRequest = BidRequest.builder().build(); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + givenBidRequest, + givenBidAdjustments, + ImpMediaType.banner, + "bidderName", + null); + + // then + assertThat(actual).isEqualTo(Price.of("USD", new BigDecimal("-499"))); + verify(currencyService).convertCurrency(new BigDecimal("25"), givenBidRequest, "UAH", "USD"); + verify(currencyService).convertCurrency(new BigDecimal("25"), givenBidRequest, "JPY", "USD"); + } + + @Test + public void resolveShouldReturnEmptyListWhenNoMatchFound() { + // given + final BidAdjustments givenBidAdjustments = BidAdjustments.of(Map.of( + "*|*|dealId", List.of(givenStatic("15", "EUR")))); + + // when + final Price actual = target.resolve( + Price.of("USD", BigDecimal.ONE), + BidRequest.builder().build(), + givenBidAdjustments, + ImpMediaType.banner, + "bidderName", + null); + + // then + assertThat(actual).isEqualTo(Price.of("USD", BigDecimal.ONE)); + verifyNoInteractions(currencyService); + } + + private static ExtRequestBidAdjustmentsRule givenStatic(String value, String currency) { + return ExtRequestBidAdjustmentsRule.builder() + .adjType(STATIC) + .currency(currency) + .value(new BigDecimal(value)) + .build(); + } + + private static ExtRequestBidAdjustmentsRule givenCpm(String value, String currency) { + return ExtRequestBidAdjustmentsRule.builder() + .adjType(CPM) + .currency(currency) + .value(new BigDecimal(value)) + .build(); + } + + private static ExtRequestBidAdjustmentsRule givenMultiplier(String value) { + return ExtRequestBidAdjustmentsRule.builder() + .adjType(MULTIPLIER) + .value(new BigDecimal(value)) + .build(); + } +} diff --git a/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsRetrieverTest.java b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsRetrieverTest.java new file mode 100644 index 00000000000..df6caa05abd --- /dev/null +++ b/src/test/java/org/prebid/server/bidadjustments/BidAdjustmentsRetrieverTest.java @@ -0,0 +1,396 @@ +package org.prebid.server.bidadjustments; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.auction.model.debug.DebugContext; +import org.prebid.server.bidadjustments.model.BidAdjustments; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.settings.model.Account; +import org.prebid.server.settings.model.AccountAuctionConfig; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.CPM; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.STATIC; + +public class BidAdjustmentsRetrieverTest extends VertxTest { + + private BidAdjustmentsRetriever target; + + @BeforeEach + public void before() { + target = new BidAdjustmentsRetriever(jacksonMapper, new JsonMerger(jacksonMapper), 0.0d); + } + + @Test + public void retrieveShouldReturnEmptyBidAdjustmentsWhenRequestAndAccountAdjustmentsAreAbsent() { + // given + final List debugMessages = new ArrayList<>(); + + // when + final BidAdjustments actual = target.retrieve(givenAuctionContext( + null, null, debugMessages, true)); + + // then + assertThat(actual).isEqualTo(BidAdjustments.of(Collections.emptyMap())); + assertThat(debugMessages).isEmpty(); + } + + @Test + public void retrieveShouldReturnEmptyBidAdjustmentsWhenRequestIsInvalidAndAccountAdjustmentsAreAbsent() + throws JsonProcessingException { + + // given + final List debugMessages = new ArrayList<>(); + final String requestAdjustments = """ + { + "mediatype": { + "banner": { + "invalid": { + "invalid": [ + { + "adjtype": "invalid", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final ObjectNode givenRequestAdjustments = (ObjectNode) mapper.readTree(requestAdjustments); + + // when + final BidAdjustments actual = target.retrieve(givenAuctionContext( + givenRequestAdjustments, null, debugMessages, true)); + + // then + assertThat(actual).isEqualTo(BidAdjustments.of(Collections.emptyMap())); + assertThat(debugMessages) + .containsOnly("bid adjustment from request was invalid: the found rule " + + "[adjtype=UNKNOWN, value=0.1, currency=USD] in banner.invalid.invalid is invalid"); + } + + @Test + public void retrieveShouldReturnRequestBidAdjustmentsWhenAccountAdjustmentsAreAbsent() + throws JsonProcessingException { + + // given + final List debugMessages = new ArrayList<>(); + final String requestAdjustments = """ + { + "mediatype": { + "banner": { + "*": { + "*": [ + { + "adjtype": "cpm", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final ObjectNode givenRequestAdjustments = (ObjectNode) mapper.readTree(requestAdjustments); + + // when + final BidAdjustments actual = target.retrieve(givenAuctionContext( + givenRequestAdjustments, null, debugMessages, true)); + + // then + final BidAdjustments expected = BidAdjustments.of(Map.of( + "banner|*|*", + List.of(ExtRequestBidAdjustmentsRule.builder() + .adjType(CPM) + .currency("USD") + .value(new BigDecimal("0.1")) + .build()))); + + assertThat(actual).isEqualTo(expected); + assertThat(debugMessages).isEmpty(); + } + + @Test + public void retrieveShouldReturnAccountBidAdjustmentsWhenRequestAdjustmentsAreAbsent() + throws JsonProcessingException { + + // given + final List debugMessages = new ArrayList<>(); + final String requestAdjustments = """ + { + "mediatype": { + "banner": { + "*": { + "*": [ + { + "adjtype": "invalid", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final String accountAdjustments = """ + { + "mediatype": { + "audio": { + "bidder": { + "*": [ + { + "adjtype": "static", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final ObjectNode givenRequestAdjustments = (ObjectNode) mapper.readTree(requestAdjustments); + final ObjectNode givenAccountAdjustments = (ObjectNode) mapper.readTree(accountAdjustments); + + // when + final BidAdjustments actual = target.retrieve(givenAuctionContext( + givenRequestAdjustments, givenAccountAdjustments, debugMessages, true)); + + // then + final BidAdjustments expected = BidAdjustments.of(Map.of( + "audio|bidder|*", + List.of(ExtRequestBidAdjustmentsRule.builder() + .adjType(STATIC) + .currency("USD") + .value(new BigDecimal("0.1")) + .build()))); + + assertThat(actual).isEqualTo(expected); + assertThat(debugMessages) + .containsOnly("bid adjustment from request was invalid: the found rule " + + "[adjtype=UNKNOWN, value=0.1, currency=USD] in banner.*.* is invalid"); + } + + @Test + public void retrieveShouldReturnEmptyBidAdjustmentsWhenAccountAndRequestAdjustmentsAreInvalid() + throws JsonProcessingException { + + // given + final List debugMessages = new ArrayList<>(); + final String requestAdjustments = """ + { + "mediatype": { + "banner": { + "*": { + "*": [ + { + "adjtype": "invalid", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final String accountAdjustments = """ + { + "mediatype": { + "audio": { + "bidder": { + "*": [ + { + "adjtype": "invalid", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final ObjectNode givenRequestAdjustments = (ObjectNode) mapper.readTree(requestAdjustments); + final ObjectNode givenAccountAdjustments = (ObjectNode) mapper.readTree(accountAdjustments); + + // when + final BidAdjustments actual = target.retrieve(givenAuctionContext( + givenRequestAdjustments, givenAccountAdjustments, debugMessages, true)); + + // then + assertThat(actual).isEqualTo(BidAdjustments.of(Collections.emptyMap())); + assertThat(debugMessages).containsExactlyInAnyOrder( + "bid adjustment from request was invalid: the found rule " + + "[adjtype=UNKNOWN, value=0.1, currency=USD] in audio.bidder.* is invalid", + "bid adjustment from account was invalid: the found rule " + + "[adjtype=UNKNOWN, value=0.1, currency=USD] in audio.bidder.* is invalid"); + } + + @Test + public void retrieveShouldSkipAddingDebugMessagesWhenDebugIsDisabled() throws JsonProcessingException { + // given + final List debugMessages = new ArrayList<>(); + final String requestAdjustments = """ + { + "mediatype": { + "banner": { + "*": { + "*": [ + { + "adjtype": "invalid", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final String accountAdjustments = """ + { + "mediatype": { + "audio": { + "bidder": { + "*": [ + { + "adjtype": "invalid", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final ObjectNode givenRequestAdjustments = (ObjectNode) mapper.readTree(requestAdjustments); + final ObjectNode givenAccountAdjustments = (ObjectNode) mapper.readTree(accountAdjustments); + + // when + final BidAdjustments actual = target.retrieve(givenAuctionContext( + givenRequestAdjustments, givenAccountAdjustments, debugMessages, false)); + + // then + assertThat(actual).isEqualTo(BidAdjustments.of(Collections.emptyMap())); + assertThat(debugMessages).isEmpty(); + } + + @Test + public void retrieveShouldReturnMergedAccountIntoRequestAdjustments() throws JsonProcessingException { + // given + final List debugMessages = new ArrayList<>(); + final String requestAdjustments = """ + { + "mediatype": { + "banner": { + "*": { + "*": [ + { + "adjtype": "cpm", + "value": 0.1, + "currency": "USD" + } + ] + } + } + } + } + """; + + final String accountAdjustments = """ + { + "mediatype": { + "banner": { + "*": { + "dealId": [ + { + "adjtype": "cpm", + "value": 0.3, + "currency": "USD" + } + ], + "*": [ + { + "adjtype": "static", + "value": 0.2, + "currency": "USD" + } + ] + } + } + } + } + """; + + final ObjectNode givenRequestAdjustments = (ObjectNode) mapper.readTree(requestAdjustments); + final ObjectNode givenAccountAdjustments = (ObjectNode) mapper.readTree(accountAdjustments); + + // when + final BidAdjustments actual = target.retrieve(givenAuctionContext( + givenRequestAdjustments, givenAccountAdjustments, debugMessages, true)); + + // then + final BidAdjustments expected = BidAdjustments.of(Map.of( + "banner|*|dealId", + List.of(ExtRequestBidAdjustmentsRule.builder() + .adjType(CPM) + .currency("USD") + .value(new BigDecimal("0.3")) + .build()), + "banner|*|*", + List.of(ExtRequestBidAdjustmentsRule.builder() + .adjType(CPM) + .currency("USD") + .value(new BigDecimal("0.1")) + .build()))); + + assertThat(actual).isEqualTo(expected); + assertThat(debugMessages).isEmpty(); + } + + private static AuctionContext givenAuctionContext(ObjectNode requestBidAdjustments, + ObjectNode accountBidAdjustments, + List debugWarnings, + boolean debugEnabled) { + + return AuctionContext.builder() + .debugContext(DebugContext.of(debugEnabled, false, null)) + .bidRequest(BidRequest.builder() + .ext(ExtRequest.of(ExtRequestPrebid.builder().bidadjustments(requestBidAdjustments).build())) + .build()) + .account(Account.builder() + .auction(AccountAuctionConfig.builder().bidAdjustments(accountBidAdjustments).build()) + .build()) + .debugWarnings(debugWarnings) + .build(); + } + +} diff --git a/src/test/java/org/prebid/server/bidadjustments/model/BidAdjustmentsTest.java b/src/test/java/org/prebid/server/bidadjustments/model/BidAdjustmentsTest.java new file mode 100644 index 00000000000..6bc26d7ef1a --- /dev/null +++ b/src/test/java/org/prebid/server/bidadjustments/model/BidAdjustmentsTest.java @@ -0,0 +1,65 @@ +package org.prebid.server.bidadjustments.model; + +import org.junit.jupiter.api.Test; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustments; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentsRule; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.prebid.server.bidadjustments.model.BidAdjustmentType.CPM; + +public class BidAdjustmentsTest { + + @Test + public void shouldBuildRulesSet() { + // given + final List givenRules = List.of(givenRule("1"), givenRule("2")); + final Map>> givenRulesMap = Map.of( + "bidderName", + Map.of("dealId", givenRules)); + + final ExtRequestBidAdjustments givenBidAdjustments = ExtRequestBidAdjustments.builder() + .mediatype(Map.of( + "audio", givenRulesMap, + "native", givenRulesMap, + "video-instream", givenRulesMap, + "video-outstream", givenRulesMap, + "banner", givenRulesMap, + "video", givenRulesMap, + "unknown", givenRulesMap, + "*", Map.of( + "*", Map.of("*", givenRules), + "bidderName", Map.of( + "*", givenRules, + "dealId", givenRules)))) + .build(); + + // when + final BidAdjustments actual = BidAdjustments.of(givenBidAdjustments); + + // then + final BidAdjustments expected = BidAdjustments.of(Map.of( + "audio|bidderName|dealId", givenRules, + "native|bidderName|dealId", givenRules, + "video-instream|bidderName|dealId", givenRules, + "video-outstream|bidderName|dealId", givenRules, + "banner|bidderName|dealId", givenRules, + "*|*|*", givenRules, + "*|bidderName|*", givenRules, + "*|bidderName|dealId", givenRules)); + + assertThat(actual).isEqualTo(expected); + + } + + private static ExtRequestBidAdjustmentsRule givenRule(String value) { + return ExtRequestBidAdjustmentsRule.builder() + .adjType(CPM) + .currency("USD") + .value(new BigDecimal(value)) + .build(); + } +}