From ea2edb28b3762f6420d67662512d6ffd35a455dd Mon Sep 17 00:00:00 2001 From: antonbabak Date: Wed, 11 Jun 2025 16:11:34 +0200 Subject: [PATCH 1/3] Ad Podding --- .../server/auction/BidResponseCreator.java | 35 +- .../prebid/server/auction/BidsAdjuster.java | 12 +- .../server/auction/ExchangeService.java | 6 + .../auction/TargetingKeywordsCreator.java | 20 +- .../AdPoddingBidDeduplicationService.java | 152 +++ .../AdPoddingImpDowngradingService.java | 247 +++++ .../auction/model/BidRejectionReason.java | 5 + .../org/prebid/server/bidder/BidderInfo.java | 4 + .../org/prebid/server/metric/Metrics.java | 5 + .../server/metric/ValidationMetrics.java | 7 + .../ext/response/ExtBidPrebidMeta.java | 1 + .../model/AccountAdPoddingConfig.java | 9 + .../settings/model/AccountAuctionConfig.java | 4 + .../model/AccountBidValidationConfig.java | 5 + .../spring/config/ServiceConfiguration.java | 30 +- .../model/BidderConfigurationProperties.java | 3 + .../DefaultBidderConfigurationProperties.java | 3 + .../config/bidder/util/BidderInfoCreator.java | 1 + .../server/validation/ImpValidator.java | 62 +- .../validation/ResponseBidValidator.java | 173 +++- src/main/resources/application.yaml | 1 + .../server/auction/BidsAdjusterTest.java | 15 +- .../server/auction/ExchangeServiceTest.java | 54 +- .../auction/TargetingKeywordsCreatorTest.java | 38 +- .../AdPoddingBidDeduplicationServiceTest.java | 293 ++++++ .../AdPoddingImpDowngradingServiceTest.java | 260 +++++ .../BidderMediaTypeProcessorTest.java | 1 + .../MultiFormatMediaTypeProcessorTest.java | 1 + .../enforcement/CcpaEnforcementTest.java | 2 + .../server/bidder/BidderCatalogTest.java | 8 + .../bidder/HttpBidderRequestEnricherTest.java | 2 + .../info/BidderDetailsHandlerTest.java | 1 + .../org/prebid/server/metric/MetricsTest.java | 12 + .../settings/FileApplicationSettingsTest.java | 7 +- .../validation/BidderParamValidatorTest.java | 1 + .../server/validation/ImpValidatorTest.java | 113 +++ .../validation/ResponseBidValidatorTest.java | 944 +++++++++++++++--- 37 files changed, 2358 insertions(+), 179 deletions(-) create mode 100644 src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java create mode 100644 src/main/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingService.java create mode 100644 src/main/java/org/prebid/server/settings/model/AccountAdPoddingConfig.java create mode 100644 src/test/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationServiceTest.java create mode 100644 src/test/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingServiceTest.java diff --git a/src/main/java/org/prebid/server/auction/BidResponseCreator.java b/src/main/java/org/prebid/server/auction/BidResponseCreator.java index dd8f79c1920..5cab1020d97 100644 --- a/src/main/java/org/prebid/server/auction/BidResponseCreator.java +++ b/src/main/java/org/prebid/server/auction/BidResponseCreator.java @@ -1549,7 +1549,6 @@ private Bid toBid(BidInfo bidInfo, final TargetingInfo targetingInfo = bidInfo.getTargetingInfo(); final BidType bidType = bidInfo.getBidType(); final Bid bid = bidInfo.getBid(); - final CacheInfo cacheInfo = bidInfo.getCacheInfo(); final String cacheId = cacheInfo != null ? cacheInfo.getCacheId() : null; final String videoCacheId = cacheInfo != null ? cacheInfo.getVideoCacheId() : null; @@ -1562,9 +1561,10 @@ private Bid toBid(BidInfo bidInfo, final boolean isWinningBid = targetingInfo.isWinningBid(); final String seat = targetingInfo.getSeat(); final String categoryDuration = bidInfo.getCategory(); + final Integer bidDuration = prepareBidDuration(bid, targeting); targetingKeywords = keywordsCreator != null - ? keywordsCreator.makeFor( - bid, seat, isWinningBid, cacheId, bidType.getName(), videoCacheId, categoryDuration, account) + ? keywordsCreator.makeFor(bid, seat, isWinningBid, cacheId, + bidType.getName(), videoCacheId, categoryDuration, account, bidDuration) : null; } else { targetingKeywords = null; @@ -1573,10 +1573,8 @@ private Bid toBid(BidInfo bidInfo, final CacheAsset bids = cacheId != null ? toCacheAsset(cacheId) : null; final CacheAsset vastXml = videoCacheId != null ? toCacheAsset(videoCacheId) : null; final ExtResponseCache cache = bids != null || vastXml != null ? ExtResponseCache.of(bids, vastXml) : null; - final ObjectNode originalBidExt = bid.getExt(); final Boolean dealsTierSatisfied = bidInfo.getSatisfiedPriority(); - final boolean bidRankingEnabled = isBidRankingEnabled(account); final ExtBidPrebid updatedExtBidPrebid = @@ -1625,6 +1623,30 @@ private static boolean isBidRankingEnabled(Account account) { .orElse(false); } + private Integer prepareBidDuration(Bid bid, ExtRequestTargeting targeting) { + final List durationRangePerSec = targeting.getDurationrangesec(); + if (CollectionUtils.isEmpty(durationRangePerSec)) { + return null; + } + + final Integer duration = ObjectUtils.firstNonNull(bid.getDur(), getBidMetaDuration(bid)); + return durationRangePerSec.stream() + .sorted() + .filter(bucket -> bucket >= duration) + .findFirst() + // should never occur. See ResponseBidValidator + .orElseThrow(); + } + + private Integer getBidMetaDuration(Bid bid) { + return Optional.ofNullable(bid.getExt()) + .filter(ext -> ext.hasNonNull("prebid")) + .map(ext -> convertValue(ext, "prebid", ExtBidPrebid.class)) + .map(ExtBidPrebid::getMeta) + .map(ExtBidPrebidMeta::getDur) + .orElse(null); + } + private String createNativeMarkup(String bidAdm, Imp correspondingImp) { final Response nativeMarkup; try { @@ -1717,9 +1739,7 @@ private static boolean eventsEnabledForChannel(AuctionContext auctionContext) { .map(AccountAnalyticsConfig::getAuctionEvents) .map(AccountAuctionEventConfig::getEvents) .orElseGet(AccountAnalyticsConfig::fallbackAuctionEvents); - final String channelFromRequest = channelFromRequest(auctionContext.getBidRequest()); - return channelConfig.entrySet().stream() .filter(entry -> StringUtils.equalsIgnoreCase(channelFromRequest, entry.getKey())) .findFirst() @@ -1731,7 +1751,6 @@ private static String channelFromRequest(BidRequest bidRequest) { final ExtRequest ext = bidRequest.getExt(); final ExtRequestPrebid prebid = ext != null ? ext.getPrebid() : null; final ExtRequestPrebidChannel channel = prebid != null ? prebid.getChannel() : null; - return channel != null ? recogniseChannelName(channel.getName()) : null; } diff --git a/src/main/java/org/prebid/server/auction/BidsAdjuster.java b/src/main/java/org/prebid/server/auction/BidsAdjuster.java index 63b0f4b6db0..b651bbe9558 100644 --- a/src/main/java/org/prebid/server/auction/BidsAdjuster.java +++ b/src/main/java/org/prebid/server/auction/BidsAdjuster.java @@ -2,6 +2,7 @@ import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.response.Bid; +import org.prebid.server.auction.adpodding.AdPoddingBidDeduplicationService; import org.prebid.server.auction.aliases.BidderAliases; import org.prebid.server.auction.model.AuctionContext; import org.prebid.server.auction.model.AuctionParticipation; @@ -27,16 +28,19 @@ public class BidsAdjuster { private final PriceFloorEnforcer priceFloorEnforcer; private final BidAdjustmentsProcessor bidAdjustmentsProcessor; private final DsaEnforcer dsaEnforcer; + private final AdPoddingBidDeduplicationService bidDeduplicationService; public BidsAdjuster(ResponseBidValidator responseBidValidator, PriceFloorEnforcer priceFloorEnforcer, BidAdjustmentsProcessor bidAdjustmentsProcessor, - DsaEnforcer dsaEnforcer) { + DsaEnforcer dsaEnforcer, + AdPoddingBidDeduplicationService bidDeduplicationService) { this.responseBidValidator = Objects.requireNonNull(responseBidValidator); this.priceFloorEnforcer = Objects.requireNonNull(priceFloorEnforcer); this.bidAdjustmentsProcessor = Objects.requireNonNull(bidAdjustmentsProcessor); this.dsaEnforcer = Objects.requireNonNull(dsaEnforcer); + this.bidDeduplicationService = Objects.requireNonNull(bidDeduplicationService); } public List validateAndAdjustBids(List auctionParticipations, @@ -49,7 +53,11 @@ public List validateAndAdjustBids(List bidAdjustmentsProcessor.enrichWithAdjustedBids( auctionParticipation, bidRequest)) - + .map(auctionParticipation -> bidDeduplicationService.deduplicate( + auctionContext.getBidRequest(), + auctionParticipation, + auctionContext.getAccount(), + auctionContext.getBidRejectionTrackers().get(auctionParticipation.getBidder()))) .map(auctionParticipation -> priceFloorEnforcer.enforce( bidRequest, auctionParticipation, diff --git a/src/main/java/org/prebid/server/auction/ExchangeService.java b/src/main/java/org/prebid/server/auction/ExchangeService.java index 04191d0839d..7e1966e8722 100644 --- a/src/main/java/org/prebid/server/auction/ExchangeService.java +++ b/src/main/java/org/prebid/server/auction/ExchangeService.java @@ -29,6 +29,7 @@ import org.prebid.server.activity.infrastructure.payload.ActivityInvocationPayload; import org.prebid.server.activity.infrastructure.payload.impl.ActivityInvocationPayloadImpl; import org.prebid.server.activity.infrastructure.payload.impl.BidRequestActivityInvocationPayload; +import org.prebid.server.auction.adpodding.AdPoddingImpDowngradingService; import org.prebid.server.auction.aliases.AlternateBidderCodesConfig; import org.prebid.server.auction.aliases.BidderAliases; import org.prebid.server.auction.mediatypeprocessor.MediaTypeProcessingResult; @@ -156,6 +157,7 @@ public class ExchangeService { private final PriceFloorAdjuster priceFloorAdjuster; private final PriceFloorProcessor priceFloorProcessor; private final BidsAdjuster bidsAdjuster; + private final AdPoddingImpDowngradingService impDowngradingService; private final Metrics metrics; private final Clock clock; private final JacksonMapper mapper; @@ -183,6 +185,7 @@ public ExchangeService(double logSamplingRate, PriceFloorAdjuster priceFloorAdjuster, PriceFloorProcessor priceFloorProcessor, BidsAdjuster bidsAdjuster, + AdPoddingImpDowngradingService impDowngradingService, Metrics metrics, Clock clock, JacksonMapper mapper, @@ -210,6 +213,7 @@ public ExchangeService(double logSamplingRate, this.priceFloorAdjuster = Objects.requireNonNull(priceFloorAdjuster); this.priceFloorProcessor = Objects.requireNonNull(priceFloorProcessor); this.bidsAdjuster = Objects.requireNonNull(bidsAdjuster); + this.impDowngradingService = Objects.requireNonNull(impDowngradingService); this.metrics = Objects.requireNonNull(metrics); this.clock = Objects.requireNonNull(clock); this.mapper = Objects.requireNonNull(mapper); @@ -887,6 +891,7 @@ private List prepareImps(String bidder, .map(imp -> imp.toBuilder().ext(imp.getExt().deepCopy()).build()) .map(imp -> impAdjuster.adjust(imp, bidder, bidderAliases, debugWarnings)) .map(imp -> prepareImp(imp, bidder, bidRequest, transmitTid, useFirstPartyData, account, debugWarnings)) + .flatMap(imp -> impDowngradingService.downgrade(imp, bidder, bidderAliases).stream()) .toList(); } @@ -919,6 +924,7 @@ private ObjectNode prepareImpExt(String bidder, ObjectNode impExt, boolean transmitTid, boolean useFirstPartyData) { + final JsonNode bidderNode = bidderParamsFromImpExt(impExt).get(bidder); final JsonNode impExtPrebid = cleanUpImpExtPrebid(impExt.get(PREBID_EXT)); Optional.ofNullable(impExtPrebid).ifPresentOrElse( diff --git a/src/main/java/org/prebid/server/auction/TargetingKeywordsCreator.java b/src/main/java/org/prebid/server/auction/TargetingKeywordsCreator.java index b873210c1f3..2fac1d38ce2 100644 --- a/src/main/java/org/prebid/server/auction/TargetingKeywordsCreator.java +++ b/src/main/java/org/prebid/server/auction/TargetingKeywordsCreator.java @@ -7,7 +7,6 @@ import java.math.BigDecimal; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -72,6 +71,11 @@ public class TargetingKeywordsCreator { */ private static final String CATEGORY_DURATION_KEY = "_pb_cat_dur"; + /** + * Stores bid's duration + */ + private static final String BID_DURATION_KEY = "_dur"; + /** * Stores bid's format. For example "video" or "banner". */ @@ -154,7 +158,8 @@ Map makeFor(Bid bid, String format, String vastCacheId, String categoryDuration, - Account account) { + Account account, + Integer bidDuration) { final Map keywords = makeFor( bidder, @@ -167,7 +172,8 @@ Map makeFor(Bid bid, categoryDuration, format, bid.getDealid(), - account); + account, + bidDuration); if (resolver == null) { return truncateKeys(keywords); @@ -192,7 +198,8 @@ private Map makeFor(String bidder, String categoryDuration, String format, String dealId, - Account account) { + Account account, + Integer bidDuration) { final boolean includeDealBid = alwaysIncludeDeals && StringUtils.isNotEmpty(dealId); final KeywordMap keywordMap = new KeywordMap( @@ -200,7 +207,7 @@ private Map makeFor(String bidder, winningBid, includeWinners, includeBidderKeys || includeDealBid, - Collections.emptySet()); + Set.of(this.keyPrefix + BID_DURATION_KEY)); final String roundedCpm = isPriceGranularityValid() ? CpmRange.fromCpm(price, priceGranularity, account) @@ -239,6 +246,9 @@ private Map makeFor(String bidder, if (StringUtils.isNotBlank(categoryDuration)) { keywordMap.put(this.keyPrefix + CATEGORY_DURATION_KEY, categoryDuration); } + if (bidDuration != null) { + keywordMap.put(this.keyPrefix + BID_DURATION_KEY, bidDuration.toString()); + } return keywordMap.asMap(); } diff --git a/src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java b/src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java new file mode 100644 index 00000000000..6823096a500 --- /dev/null +++ b/src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java @@ -0,0 +1,152 @@ +package org.prebid.server.auction.adpodding; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iab.openrtb.request.Audio; +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.apache.commons.lang3.ObjectUtils; +import org.prebid.server.auction.model.AuctionParticipation; +import org.prebid.server.auction.model.BidRejectionReason; +import org.prebid.server.auction.model.BidRejectionTracker; +import org.prebid.server.auction.model.BidderResponse; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderSeatBid; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidMeta; +import org.prebid.server.settings.model.Account; +import org.prebid.server.settings.model.AccountAdPoddingConfig; +import org.prebid.server.settings.model.AccountAuctionConfig; +import org.prebid.server.util.ObjectUtil; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public class AdPoddingBidDeduplicationService { + + private final ObjectMapper mapper; + + public AdPoddingBidDeduplicationService(JacksonMapper mapper) { + this.mapper = Objects.requireNonNull(mapper).mapper(); + } + + public AuctionParticipation deduplicate(BidRequest bidRequest, + AuctionParticipation auctionParticipation, + Account account, + BidRejectionTracker bidRejectionTracker) { + + final Boolean deduplicationEnabled = Optional.ofNullable(account.getAuction()) + .map(AccountAuctionConfig::getAdPodding) + .map(AccountAdPoddingConfig::getDeduplicate) + .orElse(false); + + if (!deduplicationEnabled) { + return auctionParticipation; + } + + final BidderResponse bidderResponse = auctionParticipation.getBidderResponse(); + final BidderSeatBid seatBid = bidderResponse.getSeatBid(); + final List bids = seatBid.getBids(); + final List remainedBids = bids.stream() + .filter(bid -> bid.getType() != BidType.video && bid.getType() != BidType.audio) + .toList(); + + final List winningBids = new ArrayList<>(remainedBids); + + deduplicateBids( + bidRequest.getImp(), + winningBids, + bidRejectionTracker, + bids, + imp -> ObjectUtil.getIfNotNull(imp.getVideo(), Video::getPodid) != null, + bidderBid -> bidderBid.getType() == BidType.video); + + deduplicateBids( + bidRequest.getImp(), + winningBids, + bidRejectionTracker, + bids, + imp -> ObjectUtil.getIfNotNull(imp.getAudio(), Audio::getPodid) != null, + bidderBid -> bidderBid.getType() == BidType.audio); + + return auctionParticipation.with(bidderResponse.with(seatBid.with(winningBids))); + } + + private void deduplicateBids(List imps, + List winningBids, + BidRejectionTracker bidRejectionTracker, + List bids, + Predicate impFilter, + Predicate bidFilter) { + + final List filteredImps = imps.stream() + .filter(impFilter) + .toList(); + + final Map> impIdToBids = bids.stream() + .filter(bidFilter) + .collect(Collectors.groupingBy(bid -> correspondingImp(bid.getBid().getImpid(), filteredImps).getId())); + + for (List filteredBids : impIdToBids.values()) { + BigDecimal highestCpmPerSecond = BigDecimal.ZERO; + BidderBid winnerBid = null; + for (BidderBid bidderBid : filteredBids) { + final Bid bid = bidderBid.getBid(); + final Integer duration = ObjectUtils.firstNonNull(bid.getDur(), getBidMetaDuration(bid)); + final BigDecimal cpmPerSecond = bid.getPrice() + .divide(BigDecimal.valueOf(duration), 4, RoundingMode.HALF_EVEN); + + if (cpmPerSecond.compareTo(highestCpmPerSecond) > 0) { + highestCpmPerSecond = cpmPerSecond; + if (winnerBid != null) { + bidRejectionTracker.rejectBid(winnerBid, BidRejectionReason.RESPONSE_REJECTED_DUPLICATE); + } + winnerBid = bidderBid; + } else { + bidRejectionTracker.rejectBid(bidderBid, BidRejectionReason.RESPONSE_REJECTED_DUPLICATE); + } + } + + if (winnerBid != null) { + winningBids.add(winnerBid); + } + } + } + + private static Imp correspondingImp(String impId, List imps) { + return imps.stream() + .filter(imp -> impId.startsWith(imp.getId())) + .findFirst() + // should never occur. See ResponseBidValidator + .orElseThrow(() -> new PreBidException("Bid with impId %s doesn't have matched imp".formatted(impId))); + } + + private Integer getBidMetaDuration(Bid bid) { + return Optional.ofNullable(bid.getExt()) + .filter(ext -> ext.hasNonNull("prebid")) + .map(this::convertValue) + .map(ExtBidPrebid::getMeta) + .map(ExtBidPrebidMeta::getDur) + .orElse(null); + } + + private ExtBidPrebid convertValue(JsonNode jsonNode) { + try { + return mapper.convertValue(jsonNode.get("prebid"), ExtBidPrebid.class); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/src/main/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingService.java b/src/main/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingService.java new file mode 100644 index 00000000000..1ccbfeadfa0 --- /dev/null +++ b/src/main/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingService.java @@ -0,0 +1,247 @@ +package org.prebid.server.auction.adpodding; + +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Video; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.auction.aliases.BidderAliases; +import org.prebid.server.auction.versionconverter.OrtbVersion; +import org.prebid.server.bidder.BidderCatalog; +import org.prebid.server.bidder.BidderInfo; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public class AdPoddingImpDowngradingService { + + private static final String IMP_ID_PATTERN = "%s-%s"; + + private final BidderCatalog bidderCatalog; + + public AdPoddingImpDowngradingService(BidderCatalog bidderCatalog) { + this.bidderCatalog = Objects.requireNonNull(bidderCatalog); + } + + public List downgrade(Imp imp, String bidder, BidderAliases aliases) { + final String resolvedBidder = aliases.resolveBidder(bidder); + final BidderInfo bidderInfo = bidderCatalog.bidderInfoByName(resolvedBidder); + final OrtbVersion ortbVersion = bidderInfo.getOrtbVersion(); + final boolean adpodSupported = bidderInfo.isAdpodSupported(); + final Audio audio = imp.getAudio(); + final Video video = imp.getVideo(); + + if ((audio == null && video == null) || (ortbVersion == OrtbVersion.ORTB_2_6 && adpodSupported)) { + return Collections.singletonList(imp); + } + + final List downgradedImps = new ArrayList<>(); + + if (video != null && video.getPodid() != null) { + if (video.getPoddur() == null) { + downgradedImps.addAll(structuredImps(imp, video, downgradedImps.size())); + } else { + downgradedImps.addAll(dynamicImps(imp, video, downgradedImps.size())); + } + } + + if (audio != null && audio.getPodid() != null) { + if (audio.getPoddur() == null) { + downgradedImps.addAll(structuredImps(imp, audio, downgradedImps.size())); + } else { + downgradedImps.addAll(dynamicImps(imp, audio, downgradedImps.size())); + } + } + + //todo: TBD + return downgradedImps.isEmpty() ? Collections.singletonList(imp) : downgradedImps; + } + + private static List structuredImps(Imp imp, Video video, int startIndex) { + final Integer minDuration = video.getMinduration(); + final Integer maxDuration = video.getMaxduration(); + final List rqddurs = video.getRqddurs(); + if ((minDuration != null && maxDuration != null) || CollectionUtils.isEmpty(rqddurs)) { + return Collections.emptyList(); + } + + return buildStructuredImps( + imp, + startIndex, + rqddurs, + (builder, minDur, maxDur) -> builder + .audio(null) + .video(downgradeVideo(video, minDur, maxDur))); + } + + private static List structuredImps(Imp imp, Audio audio, int startIndex) { + final Integer minDuration = audio.getMinduration(); + final Integer maxDuration = audio.getMaxduration(); + final List rqddurs = audio.getRqddurs(); + if ((minDuration != null && maxDuration != null) || CollectionUtils.isEmpty(rqddurs)) { + return Collections.emptyList(); + } + + return buildStructuredImps( + imp, + startIndex, + rqddurs, + (builder, minDur, maxDur) -> builder + .video(null) + .audio(downgradeAudio(audio, minDur, maxDur))); + } + + private static List buildStructuredImps(Imp imp, + int startIndex, + List rqddurs, + MediaSpecificBuilder mediaBuilder) { + + final List structuredImps = new ArrayList<>(); + for (int i = startIndex, j = 0; j < rqddurs.size(); i++, j++) { + final Imp structuredImp = mediaBuilder + .apply(imp.toBuilder(), rqddurs.get(j), rqddurs.get(j)) + .id(IMP_ID_PATTERN.formatted(imp.getId(), i)) + .build(); + + structuredImps.add(structuredImp); + } + + return structuredImps; + } + + private static Collection dynamicImps(Imp imp, Video video, int startIndex) { + final Integer minduration = video.getMinduration(); + final Integer maxduration = video.getMaxduration(); + + final int impCount = calculateImpCountForPod( + video.getMaxseq(), + video.getRqddurs(), + video.getPoddur(), + video.getMinduration(), + video.getMaxduration()); + + if (impCount == 0) { + return Collections.emptyList(); + } + + return buildDynamicImps( + imp, + minduration, + maxduration, + startIndex, + impCount, + (builder, minDur, maxDur) -> builder + .audio(null) + .video(downgradeVideo(video, minDur, maxDur))); + } + + private static Collection dynamicImps(Imp imp, Audio audio, int startIndex) { + final Integer minduration = audio.getMinduration(); + final Integer maxduration = audio.getMaxduration(); + + final int impCount = calculateImpCountForPod( + audio.getMaxseq(), + audio.getRqddurs(), + audio.getPoddur(), + minduration, + maxduration); + + if (impCount == 0) { + return Collections.emptyList(); + } + + return buildDynamicImps( + imp, + minduration, + maxduration, + startIndex, + impCount, + (builder, minDur, maxDur) -> builder + .video(null) + .audio(downgradeAudio(audio, minDur, maxDur))); + } + + private static int calculateImpCountForPod(Integer maxseq, + List rqddurs, + Integer poddur, + Integer minduration, + Integer maxduration) { + + if (maxseq != null && maxseq > 0) { + return maxseq; + } else if (CollectionUtils.isNotEmpty(rqddurs)) { + final Integer minRequiredDuration = rqddurs.stream() + .filter(Objects::nonNull) + .min(Comparator.naturalOrder()) + .orElse(null); + + if (minRequiredDuration != null && minRequiredDuration > 0) { + return poddur / minRequiredDuration; + } + } else if (minduration != null && minduration > 0) { + return poddur / minduration; + } else if (maxduration != null && maxduration > 0) { + return poddur / maxduration; + } + + return 0; + } + + private static List buildDynamicImps(Imp imp, + Integer minduration, + Integer maxduration, + int startIndex, + int count, + MediaSpecificBuilder mediaBuilder) { + + final List dynamicImps = new ArrayList<>(); + + for (int i = startIndex, j = 0; j < count; i++, j++) { + final Imp dynamicImp = mediaBuilder + .apply(imp.toBuilder(), minduration, maxduration) + .id(IMP_ID_PATTERN.formatted(imp.getId(), i)) + .build(); + dynamicImps.add(dynamicImp); + } + + return dynamicImps; + } + + private static Video downgradeVideo(Video video, Integer minDuration, Integer maxDuration) { + return video.toBuilder() + .minduration(minDuration) + .maxduration(maxDuration) + .maxseq(null) + .poddur(null) + .podid(null) + .podseq(null) + .rqddurs(null) + .slotinpod(null) + .mincpmpersec(null) + .poddedupe(null) + .build(); + } + + private static Audio downgradeAudio(Audio audio, Integer minDuration, Integer maxDuration) { + return audio.toBuilder() + .minduration(minDuration) + .maxduration(maxDuration) + .maxseq(null) + .poddur(null) + .podid(null) + .podseq(null) + .rqddurs(null) + .slotinpod(null) + .mincpmpersec(null) + .build(); + } + + @FunctionalInterface + private interface MediaSpecificBuilder { + Imp.ImpBuilder apply(Imp.ImpBuilder builder, Integer minDuration, Integer maxDuration); + } + +} diff --git a/src/main/java/org/prebid/server/auction/model/BidRejectionReason.java b/src/main/java/org/prebid/server/auction/model/BidRejectionReason.java index fc3ee36bd2a..89b48471404 100644 --- a/src/main/java/org/prebid/server/auction/model/BidRejectionReason.java +++ b/src/main/java/org/prebid/server/auction/model/BidRejectionReason.java @@ -74,6 +74,11 @@ public enum BidRejectionReason { */ RESPONSE_REJECTED_BELOW_FLOOR(301), + /** + * The bidder returns a bid that has been rejected as a duplicate. + */ + RESPONSE_REJECTED_DUPLICATE(302), + /** * The bidder returns a bid that doesn't meet the price deal floor. */ diff --git a/src/main/java/org/prebid/server/bidder/BidderInfo.java b/src/main/java/org/prebid/server/bidder/BidderInfo.java index 1ff8f323701..ca2ab49ef57 100644 --- a/src/main/java/org/prebid/server/bidder/BidderInfo.java +++ b/src/main/java/org/prebid/server/bidder/BidderInfo.java @@ -16,6 +16,8 @@ public class BidderInfo { OrtbVersion ortbVersion; + boolean adpodSupported; + boolean debugAllowed; boolean usesHttps; @@ -44,6 +46,7 @@ public class BidderInfo { public static BidderInfo create(boolean enabled, OrtbVersion ortbVersion, + boolean adpodSupported, boolean debugAllowed, String endpoint, String aliasOf, @@ -63,6 +66,7 @@ public static BidderInfo create(boolean enabled, return of( enabled, ortbVersion, + adpodSupported, debugAllowed, StringUtils.startsWith(endpoint, "https://"), aliasOf, diff --git a/src/main/java/org/prebid/server/metric/Metrics.java b/src/main/java/org/prebid/server/metric/Metrics.java index 00295decad3..20eb81688b2 100644 --- a/src/main/java/org/prebid/server/metric/Metrics.java +++ b/src/main/java/org/prebid/server/metric/Metrics.java @@ -382,6 +382,11 @@ public void updateSecureValidationMetrics(String bidder, String accountId, Metri forAccount(accountId).response().validation().secure().incCounter(type); } + public void updateAdPoddingValidationMetrics(String bidder, String accountId, MetricName type) { + forAdapter(bidder).response().validation().pod().incCounter(type); + forAccount(accountId).response().validation().pod().incCounter(type); + } + public void updateSeatValidationMetrics(String bidder) { forAdapter(bidder).response().validation().incCounter(MetricName.seat); } diff --git a/src/main/java/org/prebid/server/metric/ValidationMetrics.java b/src/main/java/org/prebid/server/metric/ValidationMetrics.java index 93011e1e49d..6d9e8197bd7 100644 --- a/src/main/java/org/prebid/server/metric/ValidationMetrics.java +++ b/src/main/java/org/prebid/server/metric/ValidationMetrics.java @@ -12,6 +12,7 @@ class ValidationMetrics extends UpdatableMetrics { private final SpecificValidationMetrics sizeValidationMetrics; private final SpecificValidationMetrics secureValidationMetrics; + private final SpecificValidationMetrics adPoddingValidationMetrics; ValidationMetrics(MetricRegistry metricRegistry, CounterType counterType, String prefix) { super(Objects.requireNonNull(metricRegistry), Objects.requireNonNull(counterType), @@ -21,6 +22,8 @@ class ValidationMetrics extends UpdatableMetrics { metricRegistry, counterType, createPrefix(prefix), "size"); secureValidationMetrics = new SpecificValidationMetrics( metricRegistry, counterType, createPrefix(prefix), "secure"); + adPoddingValidationMetrics = new SpecificValidationMetrics( + metricRegistry, counterType, createPrefix(prefix), "pod"); } private static Function nameCreator(String prefix) { @@ -38,4 +41,8 @@ SpecificValidationMetrics size() { SpecificValidationMetrics secure() { return secureValidationMetrics; } + + SpecificValidationMetrics pod() { + return adPoddingValidationMetrics; + } } diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/response/ExtBidPrebidMeta.java b/src/main/java/org/prebid/server/proto/openrtb/ext/response/ExtBidPrebidMeta.java index eaba36abca0..3b588335d0c 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/response/ExtBidPrebidMeta.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/response/ExtBidPrebidMeta.java @@ -69,4 +69,5 @@ public class ExtBidPrebidMeta { String seat; + Integer dur; } diff --git a/src/main/java/org/prebid/server/settings/model/AccountAdPoddingConfig.java b/src/main/java/org/prebid/server/settings/model/AccountAdPoddingConfig.java new file mode 100644 index 00000000000..c1d31fadfa2 --- /dev/null +++ b/src/main/java/org/prebid/server/settings/model/AccountAdPoddingConfig.java @@ -0,0 +1,9 @@ +package org.prebid.server.settings.model; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class AccountAdPoddingConfig { + + Boolean deduplicate; +} 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 e41f005df54..e104e94466e 100644 --- a/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java +++ b/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java @@ -60,4 +60,8 @@ public class AccountAuctionConfig { AccountCacheConfig cache; AccountBidRankingConfig ranking; + + @JsonAlias("ad-podding") + @JsonProperty("adpodding") + AccountAdPoddingConfig adPodding; } diff --git a/src/main/java/org/prebid/server/settings/model/AccountBidValidationConfig.java b/src/main/java/org/prebid/server/settings/model/AccountBidValidationConfig.java index a6cfa2d19a7..d63bce5ba78 100644 --- a/src/main/java/org/prebid/server/settings/model/AccountBidValidationConfig.java +++ b/src/main/java/org/prebid/server/settings/model/AccountBidValidationConfig.java @@ -10,4 +10,9 @@ public class AccountBidValidationConfig { @JsonProperty("banner_creative_max_size") @JsonAlias("banner-creative-max-size") BidValidationEnforcement bannerMaxSizeEnforcement; + + @JsonProperty("ad_podding") + @JsonAlias("ad-podding") + BidValidationEnforcement adPoddingEnforcement; + } 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 aa57b0b04b7..d755842a648 100644 --- a/src/main/java/org/prebid/server/spring/config/ServiceConfiguration.java +++ b/src/main/java/org/prebid/server/spring/config/ServiceConfiguration.java @@ -11,6 +11,8 @@ import org.apache.commons.lang3.StringUtils; import org.prebid.server.activity.ActivitiesConfigResolver; import org.prebid.server.activity.infrastructure.creator.ActivityInfrastructureCreator; +import org.prebid.server.auction.adpodding.AdPoddingBidDeduplicationService; +import org.prebid.server.auction.adpodding.AdPoddingImpDowngradingService; import org.prebid.server.auction.AmpResponsePostProcessor; import org.prebid.server.auction.BidResponseCreator; import org.prebid.server.auction.BidResponsePostProcessor; @@ -918,6 +920,7 @@ ExchangeService exchangeService( PriceFloorAdjuster priceFloorAdjuster, PriceFloorProcessor priceFloorProcessor, BidsAdjuster bidsAdjuster, + AdPoddingImpDowngradingService adPoddingImpDowngradingService, Metrics metrics, Clock clock, JacksonMapper mapper, @@ -946,6 +949,7 @@ ExchangeService exchangeService( priceFloorAdjuster, priceFloorProcessor, bidsAdjuster, + adPoddingImpDowngradingService, metrics, clock, mapper, @@ -957,9 +961,15 @@ ExchangeService exchangeService( BidsAdjuster bidsAdjuster(ResponseBidValidator responseBidValidator, PriceFloorEnforcer priceFloorEnforcer, DsaEnforcer dsaEnforcer, - BidAdjustmentsProcessor bidAdjustmentsProcessor) { + BidAdjustmentsProcessor bidAdjustmentsProcessor, + AdPoddingBidDeduplicationService adPoddingBidDeduplicationService) { - return new BidsAdjuster(responseBidValidator, priceFloorEnforcer, bidAdjustmentsProcessor, dsaEnforcer); + return new BidsAdjuster( + responseBidValidator, + priceFloorEnforcer, + bidAdjustmentsProcessor, + dsaEnforcer, + adPoddingBidDeduplicationService); } @Bean @@ -1117,12 +1127,18 @@ BidderParamValidator bidderParamValidator(BidderCatalog bidderCatalog, JacksonMa ResponseBidValidator responseValidator( @Value("${auction.validations.banner-creative-max-size}") BidValidationEnforcement bannerMaxSizeEnforcement, @Value("${auction.validations.secure-markup}") BidValidationEnforcement secureMarkupEnforcement, + @Value("${auction.validations.ad-podding:skip}") BidValidationEnforcement adPoddingEnforcement, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper, Metrics metrics) { return new ResponseBidValidator( bannerMaxSizeEnforcement, secureMarkupEnforcement, + adPoddingEnforcement, + currencyConversionService, metrics, + mapper, logSamplingRate); } @@ -1262,6 +1278,16 @@ BidAdjustmentsProcessor bidAdjustmentsProcessor(CurrencyConversionService curren mapper); } + @Bean + AdPoddingImpDowngradingService adPoddingImpDowngradingService(BidderCatalog bidderCatalog) { + return new AdPoddingImpDowngradingService(bidderCatalog); + } + + @Bean + AdPoddingBidDeduplicationService adPoddingBidDeduplicationService(JacksonMapper jacksonMapper) { + return new AdPoddingBidDeduplicationService(jacksonMapper); + } + private static List splitToList(String listAsString) { return splitToCollection(listAsString, ArrayList::new); } diff --git a/src/main/java/org/prebid/server/spring/config/bidder/model/BidderConfigurationProperties.java b/src/main/java/org/prebid/server/spring/config/bidder/model/BidderConfigurationProperties.java index a13e1aef3ec..a7d998c833c 100644 --- a/src/main/java/org/prebid/server/spring/config/bidder/model/BidderConfigurationProperties.java +++ b/src/main/java/org/prebid/server/spring/config/bidder/model/BidderConfigurationProperties.java @@ -29,6 +29,8 @@ public class BidderConfigurationProperties { private OrtbVersion ortbVersion; + private Boolean adpodSupported; + @NotBlank private String endpoint; @@ -63,6 +65,7 @@ public BidderConfigurationProperties() { private void init() { enabled = ObjectUtils.defaultIfNull(enabled, defaultProperties.getEnabled()); ortbVersion = ObjectUtils.defaultIfNull(ortbVersion, defaultProperties.getOrtbVersion()); + adpodSupported = ObjectUtils.defaultIfNull(adpodSupported, defaultProperties.getAdpodSupported()); pbsEnforcesCcpa = ObjectUtils.defaultIfNull(pbsEnforcesCcpa, defaultProperties.getPbsEnforcesCcpa()); modifyingVastXmlAllowed = ObjectUtils.defaultIfNull( modifyingVastXmlAllowed, defaultProperties.getModifyingVastXmlAllowed()); diff --git a/src/main/java/org/prebid/server/spring/config/bidder/model/DefaultBidderConfigurationProperties.java b/src/main/java/org/prebid/server/spring/config/bidder/model/DefaultBidderConfigurationProperties.java index 7996a2e220c..55d0520a480 100644 --- a/src/main/java/org/prebid/server/spring/config/bidder/model/DefaultBidderConfigurationProperties.java +++ b/src/main/java/org/prebid/server/spring/config/bidder/model/DefaultBidderConfigurationProperties.java @@ -19,6 +19,9 @@ public class DefaultBidderConfigurationProperties { @NotNull private OrtbVersion ortbVersion; + @NotNull + private Boolean adpodSupported; + @NotNull private Boolean pbsEnforcesCcpa; diff --git a/src/main/java/org/prebid/server/spring/config/bidder/util/BidderInfoCreator.java b/src/main/java/org/prebid/server/spring/config/bidder/util/BidderInfoCreator.java index 8780225ff9f..103f277efea 100644 --- a/src/main/java/org/prebid/server/spring/config/bidder/util/BidderInfoCreator.java +++ b/src/main/java/org/prebid/server/spring/config/bidder/util/BidderInfoCreator.java @@ -19,6 +19,7 @@ public static BidderInfo create(BidderConfigurationProperties configurationPrope return BidderInfo.create( configurationProperties.getEnabled(), configurationProperties.getOrtbVersion(), + configurationProperties.getAdpodSupported(), configurationProperties.getDebug().getAllow(), configurationProperties.getEndpoint(), aliasOf, diff --git a/src/main/java/org/prebid/server/validation/ImpValidator.java b/src/main/java/org/prebid/server/validation/ImpValidator.java index 9a31aa6e52d..0af078fac45 100644 --- a/src/main/java/org/prebid/server/validation/ImpValidator.java +++ b/src/main/java/org/prebid/server/validation/ImpValidator.java @@ -26,6 +26,7 @@ import com.iab.openrtb.request.ntv.PlacementType; import com.iab.openrtb.request.ntv.Protocol; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.prebid.server.auction.aliases.BidderAliases; @@ -37,6 +38,7 @@ import org.prebid.server.util.StreamUtil; import java.io.IOException; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -96,8 +98,8 @@ private void validateImp(Imp imp, String msgPrefix) throws ValidationException { final boolean isInterstitialImp = Objects.equals(imp.getInstl(), 1); validateBanner(imp.getBanner(), isInterstitialImp, msgPrefix); - validateVideoMimes(imp.getVideo(), msgPrefix); - validateAudioMimes(imp.getAudio(), msgPrefix); + validateVideo(imp.getVideo(), msgPrefix); + validateAudio(imp.getAudio(), msgPrefix); validatePmp(imp.getPmp(), msgPrefix); } @@ -618,17 +620,57 @@ private void validateFormat(Format format, String msgPrefix, int formatIndex) th } } - private void validateVideoMimes(Video video, String msgPrefix) throws ValidationException { - if (video != null) { - validateMimes(video.getMimes(), - "%s.video.mimes must contain at least one supported MIME type", msgPrefix); + private void validateVideo(Video video, String msgPrefix) throws ValidationException { + if (video == null) { + return; } + + validateAdPoddingFields( + video.getRqddurs(), + video.getPoddur(), + video.getMaxseq(), + video.getMincpmpersec(), + video.getMaxduration(), + video.getMinduration(), + msgPrefix + ".video"); + + validateMimes(video.getMimes(), "%s.video.mimes must contain at least one supported MIME type", msgPrefix); } - private void validateAudioMimes(Audio audio, String msgPrefix) throws ValidationException { - if (audio != null) { - validateMimes(audio.getMimes(), - "%s.audio.mimes must contain at least one supported MIME type", msgPrefix); + private void validateAudio(Audio audio, String msgPrefix) throws ValidationException { + if (audio == null) { + return; + } + + validateAdPoddingFields( + audio.getRqddurs(), + audio.getPoddur(), + audio.getMaxseq(), + audio.getMincpmpersec(), + audio.getMaxduration(), + audio.getMinduration(), + msgPrefix + ".audio"); + + validateMimes(audio.getMimes(), + "%s.audio.mimes must contain at least one supported MIME type", msgPrefix); + } + + private static void validateAdPoddingFields(List rqddurs, + Integer poddur, + Integer maxseq, + BigDecimal mincpmpersec, + Integer maxduration, + Integer minduration, + String msgPrefix) throws ValidationException { + + if (CollectionUtils.isNotEmpty(rqddurs) && ObjectUtils.anyNotNull(maxduration, minduration)) { + throw new ValidationException("%s.minduration and maxduration must not be specified " + + "while rqddurs contains at least one element", msgPrefix); + } + + if (poddur == null && ObjectUtils.anyNotNull(maxseq, mincpmpersec)) { + throw new ValidationException("%s.maxseq and mincpmpersec must not be specified " + + "when poddur is not specified", msgPrefix); } } diff --git a/src/main/java/org/prebid/server/validation/ResponseBidValidator.java b/src/main/java/org/prebid/server/validation/ResponseBidValidator.java index cca8f88faf7..93b5020eeea 100644 --- a/src/main/java/org/prebid/server/validation/ResponseBidValidator.java +++ b/src/main/java/org/prebid/server/validation/ResponseBidValidator.java @@ -1,11 +1,16 @@ package org.prebid.server.validation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iab.openrtb.request.Audio; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Format; import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; @@ -14,23 +19,32 @@ import org.prebid.server.auction.model.BidRejectionReason; import org.prebid.server.auction.model.BidRejectionTracker; import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.json.JacksonMapper; import org.prebid.server.log.ConditionalLogger; import org.prebid.server.log.Logger; import org.prebid.server.log.LoggerFactory; import org.prebid.server.metric.MetricName; import org.prebid.server.metric.Metrics; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestTargeting; import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidMeta; import org.prebid.server.settings.model.Account; import org.prebid.server.settings.model.AccountAuctionConfig; import org.prebid.server.settings.model.AccountBidValidationConfig; import org.prebid.server.settings.model.BidValidationEnforcement; import org.prebid.server.validation.model.ValidationResult; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.Currency; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; /** @@ -45,6 +59,8 @@ public class ResponseBidValidator { new ConditionalLogger("secure_creatives_validation", logger); private static final ConditionalLogger creativeSizeLogger = new ConditionalLogger("creative_size_validation", logger); + private static final ConditionalLogger adPoddingLogger = + new ConditionalLogger("ad_podding_validation", logger); private static final ConditionalLogger alternateBidderCodeLogger = new ConditionalLogger("alternate_bidder_code_validation", logger); @@ -53,18 +69,27 @@ public class ResponseBidValidator { private final BidValidationEnforcement bannerMaxSizeEnforcement; private final BidValidationEnforcement secureMarkupEnforcement; + private final BidValidationEnforcement adPoddingEnforcement; + private final CurrencyConversionService currencyConversionService; private final Metrics metrics; + private final ObjectMapper mapper; private final double logSamplingRate; public ResponseBidValidator(BidValidationEnforcement bannerMaxSizeEnforcement, BidValidationEnforcement secureMarkupEnforcement, + BidValidationEnforcement adPoddingEnforcement, + CurrencyConversionService currencyConversionService, Metrics metrics, + JacksonMapper mapper, double logSamplingRate) { this.bannerMaxSizeEnforcement = Objects.requireNonNull(bannerMaxSizeEnforcement); this.secureMarkupEnforcement = Objects.requireNonNull(secureMarkupEnforcement); + this.adPoddingEnforcement = Objects.requireNonNull(adPoddingEnforcement); + this.currencyConversionService = Objects.requireNonNull(currencyConversionService); this.metrics = Objects.requireNonNull(metrics); + this.mapper = Objects.requireNonNull(mapper).mapper(); this.logSamplingRate = logSamplingRate; } @@ -98,6 +123,17 @@ public ValidationResult validate(BidderBid bidderBid, bidRejectionTracker)); } + if (bidderBid.getType() == BidType.video || bidderBid.getType() == BidType.audio) { + warnings.addAll(validateAdPoddingFields( + bidderBid, + bidder, + bidRequest, + account, + correspondingImp, + aliases, + bidRejectionTracker)); + } + warnings.addAll(validateSecureMarkup( bidderBid, bidder, @@ -228,13 +264,10 @@ private List validateBannerFields(BidderBid bidderBid, } private BidValidationEnforcement effectiveBannerMaxSizeEnforcement(Account account) { - final AccountAuctionConfig accountAuctionConfig = account.getAuction(); - final AccountBidValidationConfig validationConfig = - accountAuctionConfig != null ? accountAuctionConfig.getBidValidations() : null; - final BidValidationEnforcement accountBannerMaxSizeEnforcement = - validationConfig != null ? validationConfig.getBannerMaxSizeEnforcement() : null; - - return ObjectUtils.defaultIfNull(accountBannerMaxSizeEnforcement, bannerMaxSizeEnforcement); + return Optional.ofNullable(account.getAuction()) + .map(AccountAuctionConfig::getBidValidations) + .map(AccountBidValidationConfig::getBannerMaxSizeEnforcement) + .orElse(bannerMaxSizeEnforcement); } private static Format maxSizeForBanner(Imp imp) { @@ -306,6 +339,132 @@ private static boolean markupIsNotSecure(String adm) { || !StringUtils.containsAny(adm, SECURE_MARKUP_MARKERS); } + private List validateAdPoddingFields(BidderBid bidderBid, + String bidder, + BidRequest bidRequest, + Account account, + Imp correspondingImp, + BidderAliases aliases, + BidRejectionTracker bidRejectionTracker) throws ValidationException { + + final BidValidationEnforcement adPoddingEnforcement = effectiveAdPoddingEnforcement(account); + if (adPoddingEnforcement == BidValidationEnforcement.skip) { + return Collections.emptyList(); + } + + final Bid bid = bidderBid.getBid(); + final String bidCurrency = bidderBid.getBidCurrency(); + + if (isNotValidVideo(bid, correspondingImp.getVideo(), bidRequest, bidCurrency) + || isNotValidAudio(bid, correspondingImp.getAudio(), bidRequest, bidCurrency)) { + + final String accountId = account.getId(); + final String message = """ + BidResponse validation `%s`: bidder `%s` response triggers ad podding \ + validation for bid %s, account=%s, referrer=%s""".formatted( + adPoddingEnforcement, + bidder, + bid.getId(), + accountId, + getReferer(bidRequest)); + + return singleWarningOrValidationException( + adPoddingEnforcement, + metricName -> metrics.updateAdPoddingValidationMetrics( + aliases.resolveBidder(bidder), accountId, metricName), + adPoddingLogger, + message, + bidRejectionTracker, + bidderBid, + //todo: clarify the code + BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + return Collections.emptyList(); + } + + private boolean isNotValidVideo(Bid bid, Video video, BidRequest bidRequest, String bidCurrency) { + return video != null + && video.getPodid() != null + && isNotValidAdPoddingFields(bid, video.getRqddurs(), video.getMinduration(), video.getMaxduration(), + video.getMincpmpersec(), bidRequest, bidCurrency); + } + + private boolean isNotValidAudio(Bid bid, Audio audio, BidRequest bidRequest, String bidCurrency) { + return audio != null + && audio.getPodid() != null + && isNotValidAdPoddingFields(bid, audio.getRqddurs(), audio.getMinduration(), audio.getMaxduration(), + audio.getMincpmpersec(), bidRequest, bidCurrency); + } + + private boolean isNotValidAdPoddingFields(Bid bid, + List requiredDurations, + Integer minDuration, + Integer maxDuration, + BigDecimal mincpmpersec, + BidRequest bidRequest, + String bidCurrency) { + + final Integer duration = ObjectUtils.firstNonNull(bid.getDur(), getBidMetaDuration(bid), 0); + final Integer highestDurationBucket = Optional.ofNullable(bidRequest.getExt()) + .map(ExtRequest::getPrebid) + .map(ExtRequestPrebid::getTargeting) + .map(ExtRequestTargeting::getDurationrangesec) + .filter(CollectionUtils::isNotEmpty) + .map(List::getLast) + .orElse(null); + + return duration <= 0 + || (minDuration != null && duration < minDuration) + || (maxDuration != null && duration > maxDuration) + || (requiredDurations != null && !requiredDurations.contains(duration)) + || (highestDurationBucket != null && duration > highestDurationBucket) + || isMinCpmLessThanBidPrice(bid, mincpmpersec, bidRequest, bidCurrency, duration); + } + + private boolean isMinCpmLessThanBidPrice(Bid bid, + BigDecimal mincpmpersec, + BidRequest bidRequest, + String bidCurrency, + Integer duration) { + if (mincpmpersec == null) { + return false; + } + + final BigDecimal minCpm = mincpmpersec.multiply(new BigDecimal(duration)); + final String requestCurrency = bidRequest.getCur().getFirst(); + final BigDecimal convertedMinCpm = currencyConversionService.convertCurrency( + minCpm, + bidRequest, + requestCurrency, + bidCurrency); + + return convertedMinCpm.compareTo(bid.getPrice()) < 0; + } + + private Integer getBidMetaDuration(Bid bid) { + return Optional.ofNullable(bid.getExt()) + .filter(ext -> ext.hasNonNull("prebid")) + .map(this::convertValue) + .map(ExtBidPrebid::getMeta) + .map(ExtBidPrebidMeta::getDur) + .orElse(null); + } + + private ExtBidPrebid convertValue(JsonNode jsonNode) { + try { + return mapper.convertValue(jsonNode.get("prebid"), ExtBidPrebid.class); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private BidValidationEnforcement effectiveAdPoddingEnforcement(Account account) { + return Optional.ofNullable(account.getAuction()) + .map(AccountAuctionConfig::getBidValidations) + .map(AccountBidValidationConfig::getAdPoddingEnforcement) + .orElse(adPoddingEnforcement); + } + private List singleWarningOrValidationException( BidValidationEnforcement enforcement, Consumer metricsRecorder, diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 64ce4e516a4..c99c5a27bc4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -94,6 +94,7 @@ profile: profile adapter-defaults: enabled: false ortb-version: "2.5" + adpod-supported: false ortb: multiformat-supported: true pbs-enforces-ccpa: true diff --git a/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java b/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java index 4d0dc685827..659ce3b9848 100644 --- a/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java +++ b/src/test/java/org/prebid/server/auction/BidsAdjusterTest.java @@ -9,6 +9,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.prebid.server.VertxTest; +import org.prebid.server.auction.adpodding.AdPoddingBidDeduplicationService; import org.prebid.server.auction.model.AuctionContext; import org.prebid.server.auction.model.AuctionParticipation; import org.prebid.server.auction.model.BidRejectionTracker; @@ -57,6 +58,9 @@ public class BidsAdjusterTest extends VertxTest { @Mock(strictness = LENIENT) private BidAdjustmentsProcessor bidAdjustmentsProcessor; + @Mock(strictness = LENIENT) + private AdPoddingBidDeduplicationService adPoddingBidDeduplicationService; + private BidsAdjuster target; @BeforeEach @@ -67,8 +71,15 @@ public void setUp() { given(dsaEnforcer.enforce(any(), any(), any())).willAnswer(inv -> inv.getArgument(1)); given(bidAdjustmentsProcessor.enrichWithAdjustedBids(any(), any())) .willAnswer(inv -> inv.getArgument(0)); - - target = new BidsAdjuster(responseBidValidator, priceFloorEnforcer, bidAdjustmentsProcessor, dsaEnforcer); + given(adPoddingBidDeduplicationService.deduplicate(any(), any(), any(), any())) + .willAnswer(inv -> inv.getArgument(1)); + + target = new BidsAdjuster( + responseBidValidator, + priceFloorEnforcer, + bidAdjustmentsProcessor, + dsaEnforcer, + adPoddingBidDeduplicationService); } @Test diff --git a/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java b/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java index a913772645e..77d37252321 100644 --- a/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java +++ b/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java @@ -25,6 +25,7 @@ import com.iab.openrtb.request.SupplyChain; import com.iab.openrtb.request.SupplyChainNode; import com.iab.openrtb.request.User; +import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; import com.iab.openrtb.response.BidResponse; import com.iab.openrtb.response.SeatBid; @@ -43,6 +44,7 @@ import org.prebid.server.activity.Activity; import org.prebid.server.activity.ComponentType; import org.prebid.server.activity.infrastructure.ActivityInfrastructure; +import org.prebid.server.auction.adpodding.AdPoddingImpDowngradingService; import org.prebid.server.auction.mediatypeprocessor.MediaTypeProcessingResult; import org.prebid.server.auction.mediatypeprocessor.MediaTypeProcessor; import org.prebid.server.auction.model.AuctionContext; @@ -272,6 +274,9 @@ public class ExchangeServiceTest extends VertxTest { @Mock(strictness = LENIENT) private BidsAdjuster bidsAdjuster; + @Mock(strictness = LENIENT) + private AdPoddingImpDowngradingService adPoddingImpDowngradingService; + @Mock private Metrics metrics; @@ -305,6 +310,7 @@ public void setUp() { true, null, false, + false, null, null, null, @@ -342,7 +348,11 @@ public void setUp() { given(fpdResolver.resolveImpExt(any(), anyBoolean())) .willAnswer(invocation -> invocation.getArgument(0)); - given(impAdjuster.adjust(any(), any(), any(), any())).willAnswer(invocation -> invocation.getArgument(0)); + given(impAdjuster.adjust(any(), any(), any(), any())) + .willAnswer(invocation -> invocation.getArgument(0)); + + given(adPoddingImpDowngradingService.downgrade(any(), any(), any())) + .willAnswer(invocation -> singletonList((Imp) invocation.getArgument(0))); given(supplyChainResolver.resolveForBidder(anyString(), any())).willReturn(null); @@ -528,6 +538,45 @@ public void shouldExtractRequestWithBidderSpecificExtension() { assertThat(actualImp.getExt()).isEqualTo(givenImp.getExt()); } + @Test + public void shouldIncreaseImpNumberWhenDowngradingReturningTwoImps() { + // given + givenBidder(givenEmptySeatBid()); + + final Imp givenImp = givenImp(singletonMap("someBidder", 1), builder -> builder + .id("impId") + .video(Video.builder().maxduration(10).minduration(5).maxseq(2).podid(1).build())); + + final BidRequest bidRequest = givenBidRequest( + singletonList(givenImp), + builder -> builder.id("requestId").tmax(500L)); + + final Imp downgradedImp1 = givenImp.toBuilder() + .id("impId-0") + .video(Video.builder().maxduration(10).minduration(5).build()) + .build(); + + final Imp downgradedImp2 = givenImp.toBuilder() + .id("impId-1") + .video(Video.builder().maxduration(10).minduration(5).build()) + .build(); + + given(adPoddingImpDowngradingService.downgrade(any(), any(), any())) + .willReturn(List.of(downgradedImp1, downgradedImp2)); + + // when + target.holdAuction(givenRequestContext(bidRequest)); + + // then + final BidRequest capturedBidRequest = captureBidRequest(); + assertThat(capturedBidRequest).isEqualTo(BidRequest.builder() + .id("requestId") + .cur(singletonList("USD")) + .imp(List.of(downgradedImp1, downgradedImp2)) + .tmax(500L) + .build()); + } + @Test public void shouldExtractRequestWithCurrencyRatesExtension() { // given @@ -4023,6 +4072,7 @@ public void shouldResponseWithEmptySeatBidIfBidderNotSupportRequestCurrency() { true, null, false, + false, null, null, null, @@ -4104,6 +4154,7 @@ public void shouldPassAdjustedTimeoutToAdapterAndToBidResponseCreator() { true, null, false, + false, null, null, null, @@ -4228,6 +4279,7 @@ private void givenTarget(boolean enabledStrictAppSiteDoohValidation) { priceFloorAdjuster, priceFloorProcessor, bidsAdjuster, + adPoddingImpDowngradingService, metrics, clock, jacksonMapper, diff --git a/src/test/java/org/prebid/server/auction/TargetingKeywordsCreatorTest.java b/src/test/java/org/prebid/server/auction/TargetingKeywordsCreatorTest.java index 49ab351205a..60470c2e799 100644 --- a/src/test/java/org/prebid/server/auction/TargetingKeywordsCreatorTest.java +++ b/src/test/java/org/prebid/server/auction/TargetingKeywordsCreatorTest.java @@ -46,7 +46,7 @@ public void shouldReturnTargetingKeywordsForOrdinaryBidOpenrtb() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder1", false, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", false, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).containsOnly( @@ -77,7 +77,8 @@ public void shouldReturnTargetingKeywordsWithEntireKeysOpenrtb() { null, null, defaultKeyPrefix) - .makeFor(bid, "veryververyverylongbidder1", false, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "veryververyverylongbidder1", false, null, null, + null, null, Account.empty("accountId"), null); // then assertThat(keywords).containsOnly( @@ -113,7 +114,7 @@ public void shouldReturnTargetingKeywordsForWinningBidOpenrtb() { null, defaultKeyPrefix) .makeFor(bid, "bidder1", true, "cacheId1", "banner", - "videoCacheId1", "categoryDuration", Account.empty("accountId")); + "videoCacheId1", "categoryDuration", Account.empty("accountId"), null); // then assertThat(keywords).containsOnly( @@ -156,7 +157,7 @@ public void shouldIncludeFormatOpenrtb() { null, null, defaultKeyPrefix) - .makeFor(bid, "", true, null, "banner", null, null, Account.empty("accountId")); + .makeFor(bid, "", true, null, "banner", null, null, Account.empty("accountId"), null); // then assertThat(keywords).contains(entry("hb_format", "banner")); @@ -182,7 +183,7 @@ public void shouldNotIncludeCacheIdAndDealIdAndSizeOpenrtb() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).doesNotContainKeys("hb_cache_id_bidder", "hb_deal_bidder", "hb_size_bidder", @@ -209,7 +210,7 @@ public void shouldReturnEnvKeyForAppRequestOpenrtb() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).contains( @@ -237,7 +238,7 @@ public void shouldNotIncludeWinningBidTargetingIfIncludeWinnersFlagIsFalse() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).doesNotContainKeys("hb_bidder", "hb_pb"); @@ -263,7 +264,7 @@ public void shouldIncludeWinningBidTargetingIfIncludeWinnersFlagIsTrue() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).containsKeys("hb_bidder", "hb_pb"); @@ -289,7 +290,7 @@ public void shouldNotIncludeBidderKeysTargetingIfIncludeBidderKeysFlagIsFalse() null, null, defaultKeyPrefix) - .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).doesNotContainKeys("hb_bidder_bidder1", "hb_pb_bidder1"); @@ -315,7 +316,7 @@ public void shouldIncludeBidderKeysTargetingIfIncludeBidderKeysFlagIsTrue() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).containsKeys("hb_bidder_bidder1", "hb_pb_bidder1"); @@ -341,7 +342,7 @@ public void shouldTruncateTargetingBidderKeywordsIfTruncateAttrCharsIsDefined() null, null, defaultKeyPrefix) - .makeFor(bid, "someVeryLongBidderName", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "someVeryLongBidderName", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).hasSize(2) @@ -368,7 +369,7 @@ public void shouldTruncateTargetingWithoutBidderSuffixKeywordsIfTruncateAttrChar null, null, defaultKeyPrefix) - .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).hasSize(2) @@ -395,7 +396,7 @@ public void shouldTruncateTargetingAndDropDuplicatedWhenTruncateIsTooShort() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder", true, null, null, null, null, Account.empty("accountId"), null); // then // Without truncating: "hb_bidder", "hb_bidder_bidder", "hb_env", "hb_env_bidder", "hb_pb", "hb_pb_bidder" @@ -423,7 +424,7 @@ public void shouldNotTruncateTargetingKeywordsIfTruncateAttrCharsIsNotDefined() null, null, defaultKeyPrefix) - .makeFor(bid, "someVeryLongBidderName", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "someVeryLongBidderName", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).hasSize(2) @@ -456,7 +457,8 @@ public void shouldTruncateKeysFromResolver() { null, resolver, defaultKeyPrefix) - .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", true, null, null, + null, null, Account.empty("accountId"), null); // then assertThat(keywords).contains(entry("key_longer_than_twen", "value1")); @@ -488,7 +490,7 @@ public void shouldIncludeKeywordsFromResolver() { null, resolver, defaultKeyPrefix) - .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", true, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).contains(entry("keyword1", "value1")); @@ -514,7 +516,7 @@ public void shouldIncludeDealBidTargetingIfAlwaysIncludeDealsFlagIsTrue() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder1", false, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", false, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).containsOnlyKeys("hb_bidder_bidder1", "hb_deal_bidder1", "hb_pb_bidder1"); @@ -540,7 +542,7 @@ public void shouldNotIncludeDealBidTargetingIfAlwaysIncludeDealsFlagIsFalse() { null, null, defaultKeyPrefix) - .makeFor(bid, "bidder1", false, null, null, null, null, Account.empty("accountId")); + .makeFor(bid, "bidder1", false, null, null, null, null, Account.empty("accountId"), null); // then assertThat(keywords).doesNotContainKeys("hb_bidder_bidder1", "hb_deal_bidder1", "hb_pb_bidder1"); diff --git a/src/test/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationServiceTest.java b/src/test/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationServiceTest.java new file mode 100644 index 00000000000..f21a63c17cd --- /dev/null +++ b/src/test/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationServiceTest.java @@ -0,0 +1,293 @@ +package org.prebid.server.auction.adpodding; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Audio; +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.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.model.AuctionParticipation; +import org.prebid.server.auction.model.BidRejectionReason; +import org.prebid.server.auction.model.BidRejectionTracker; +import org.prebid.server.auction.model.BidderResponse; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderSeatBid; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.settings.model.Account; +import org.prebid.server.settings.model.AccountAdPoddingConfig; +import org.prebid.server.settings.model.AccountAuctionConfig; + +import java.math.BigDecimal; +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +public class AdPoddingBidDeduplicationServiceTest extends VertxTest { + + private final AdPoddingBidDeduplicationService target = new AdPoddingBidDeduplicationService(jacksonMapper); + + @Mock + private BidRejectionTracker bidRejectionTracker; + + @Test + public void deduplicateShouldReturnOriginalParticipationWhenDeduplicationIsDisabledInAccount() { + // given + final Account account = givenAccount(false); + final AuctionParticipation originalParticipation = givenAuctionParticipation(emptyList()); + + // when + final AuctionParticipation result = target.deduplicate( + BidRequest.builder().build(), originalParticipation, account, bidRejectionTracker); + + // then + assertThat(result).isSameAs(originalParticipation); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void deduplicateShouldReturnOriginalParticipationWhenAdPodConfigIsMissing() { + // given + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder().build()) + .build(); + final AuctionParticipation originalParticipation = givenAuctionParticipation(emptyList()); + + // when + final AuctionParticipation result = target.deduplicate( + BidRequest.builder().build(), originalParticipation, account, bidRejectionTracker); + + // then + assertThat(result).isSameAs(originalParticipation); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void deduplicateShouldCorrectlyDeduplicateVideoBidsAndKeepTheHighestCpm() { + // given + final Imp imp = givenVideoImp("impId"); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(imp)).build(); + + final BidderBid losingBid1 = givenVideoBid("impId-0", BigDecimal.valueOf(2.0), 10); + final BidderBid winningBid = givenVideoBid("impId-1", BigDecimal.valueOf(5.0), 20); + final BidderBid losingBid2 = givenVideoBid("impId-2", BigDecimal.valueOf(3.0), 15); + + final AuctionParticipation participation = givenAuctionParticipation( + List.of(losingBid1, winningBid, losingBid2)); + + // when + final AuctionParticipation result = target.deduplicate( + bidRequest, + participation, + givenAccount(true), + bidRejectionTracker); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .containsExactly(winningBid); + verify(bidRejectionTracker).rejectBid(eq(losingBid1), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + verify(bidRejectionTracker).rejectBid(eq(losingBid2), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + } + + @Test + public void deduplicateShouldUseMetaDurationWhenBidDurationIsMissing() { + // given + final Imp imp = givenVideoImp("impId"); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(imp)).build(); + + final BidderBid losingBid = givenVideoBid("impId-0", BigDecimal.valueOf(2.0), 10); + final BidderBid winningBid = givenVideoBidWithMetaDur("impId-1", BigDecimal.valueOf(6.0), 20); + + final AuctionParticipation participation = givenAuctionParticipation(List.of(losingBid, winningBid)); + + // when + final AuctionParticipation result = target.deduplicate( + bidRequest, + participation, + givenAccount(true), + bidRejectionTracker); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()).containsExactly(winningBid); + verify(bidRejectionTracker).rejectBid(eq(losingBid), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + } + + @Test + public void deduplicateShouldCorrectlyDeduplicateAudioBids() { + // given + final Imp imp = givenAudioImp("impId1"); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(imp)).build(); + + final BidderBid losingBid = givenAudioBid("impId1-0", BigDecimal.valueOf(1.0), 5); + final BidderBid winningBid = givenAudioBid("impId1-1", BigDecimal.valueOf(4.0), 10); + + final AuctionParticipation participation = givenAuctionParticipation(List.of(losingBid, winningBid)); + + // when + final AuctionParticipation result = target.deduplicate( + bidRequest, + participation, + givenAccount(true), + bidRejectionTracker); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()).containsExactly(winningBid); + verify(bidRejectionTracker).rejectBid(eq(losingBid), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + } + + @Test + public void deduplicateShouldHandleMultiplePodsAndMediaTypesCorrectly() { + // given + final Imp videoImp = givenVideoImp("videoImp"); + final Imp audioImp = givenAudioImp("audioImp"); + final BidRequest bidRequest = BidRequest.builder().imp(List.of(videoImp, audioImp)).build(); + + final BidderBid videoWinner = givenVideoBid("videoImp-0", BigDecimal.valueOf(10.0), 20); + final BidderBid videoLoser = givenVideoBid("videoImp-1", BigDecimal.valueOf(5.0), 20); + + final BidderBid audioWinner = givenAudioBid("audioImp-0", BigDecimal.valueOf(8.0), 10); + final BidderBid audioLoser = givenAudioBid("audioImp-1", BigDecimal.valueOf(6.0), 10); + + final BidderBid bannerBid = givenBid(BidType.banner, identity()); + + final AuctionParticipation participation = givenAuctionParticipation( + List.of(videoWinner, videoLoser, audioWinner, audioLoser, bannerBid)); + + // when + final AuctionParticipation result = target.deduplicate( + bidRequest, + participation, + givenAccount(true), + bidRejectionTracker); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .containsExactlyInAnyOrder(videoWinner, audioWinner, bannerBid); + verify(bidRejectionTracker).rejectBid(eq(videoLoser), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + verify(bidRejectionTracker).rejectBid(eq(audioLoser), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + } + + @Test + public void deduplicateShouldKeepFirstBidInCaseOfTieInCpm() { + // given + final Imp imp = givenVideoImp("impId"); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(imp)).build(); + + final BidderBid firstBid = givenVideoBid("impId-0", BigDecimal.valueOf(2.0), 10); + final BidderBid secondBid = givenVideoBid("impId-1", BigDecimal.valueOf(4.0), 20); + + final AuctionParticipation participation = givenAuctionParticipation(List.of(firstBid, secondBid)); + + // when + final AuctionParticipation result = target.deduplicate( + bidRequest, + participation, + givenAccount(true), + bidRejectionTracker); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()).containsExactly(firstBid); + verify(bidRejectionTracker).rejectBid(eq(secondBid), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + } + + @Test + public void deduplicateShouldHandleSingleImpWithBothVideoAndAudioPods() { + // given + final Imp imp = Imp.builder().id("imp") + .video(Video.builder().podid(1).build()) + .audio(Audio.builder().podid(2).build()) + .build(); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(imp)).build(); + + final BidderBid videoWinner = givenVideoBid("imp-1", BigDecimal.valueOf(10.0), 20); + final BidderBid videoLoser = givenVideoBid("imp-2", BigDecimal.valueOf(5.0), 20); + + final BidderBid audioWinner = givenAudioBid("imp-3", BigDecimal.valueOf(8.0), 10); + final BidderBid audioLoser = givenAudioBid("imp-4", BigDecimal.valueOf(6.0), 10); + + final AuctionParticipation participation = givenAuctionParticipation( + List.of(videoWinner, videoLoser, audioWinner, audioLoser)); + + // when + final AuctionParticipation result = target.deduplicate( + bidRequest, + participation, + givenAccount(true), + bidRejectionTracker); + + // then + assertThat(result.getBidderResponse().getSeatBid().getBids()) + .containsExactlyInAnyOrder(videoWinner, audioWinner); + + verify(bidRejectionTracker).rejectBid(eq(videoLoser), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + verify(bidRejectionTracker).rejectBid(eq(audioLoser), eq(BidRejectionReason.RESPONSE_REJECTED_DUPLICATE)); + } + + private static AuctionParticipation givenAuctionParticipation(List bids) { + return AuctionParticipation.builder() + .bidderResponse(BidderResponse.of("bidder", BidderSeatBid.of(bids), 100)) + .build(); + } + + private static Imp givenVideoImp(String id) { + return Imp.builder() + .id(id) + .video(Video.builder().podid(1).build()) + .build(); + } + + private static Imp givenAudioImp(String id) { + return Imp.builder() + .id(id) + .audio(Audio.builder().podid(1).build()) + .build(); + } + + private static BidderBid givenVideoBid(String impId, BigDecimal price, int duration) { + return givenBid(BidType.video, builder -> builder.impid(impId).price(price).dur(duration)); + } + + private static BidderBid givenAudioBid(String impId, BigDecimal price, int duration) { + return givenBid(BidType.audio, builder -> builder.impid(impId).price(price).dur(duration)); + } + + private static BidderBid givenBid(BidType type, UnaryOperator bidCustomizer) { + final Bid.BidBuilder bidBuilder = Bid.builder() + .id("bidId") + .impid("impId") + .crid("crid"); + + return BidderBid.of(bidCustomizer.apply(bidBuilder).build(), type, "USD"); + } + + private Account givenAccount(boolean deduplicate) { + return Account.builder() + .auction(AccountAuctionConfig.builder() + .adPodding(AccountAdPoddingConfig.of(deduplicate)) + .build()) + .build(); + } + + private BidderBid givenVideoBidWithMetaDur(String impId, BigDecimal price, int duration) { + final ObjectNode bidExt = mapper.createObjectNode(); + final ObjectNode prebidNode = mapper.createObjectNode(); + final ObjectNode metaNode = mapper.createObjectNode(); + metaNode.put("dur", duration); + prebidNode.set("meta", metaNode); + bidExt.set("prebid", prebidNode); + + return givenBid(BidType.video, builder -> builder.impid(impId).price(price).dur(null).ext(bidExt)); + } +} diff --git a/src/test/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingServiceTest.java b/src/test/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingServiceTest.java new file mode 100644 index 00000000000..03dbc18e8a6 --- /dev/null +++ b/src/test/java/org/prebid/server/auction/adpodding/AdPoddingImpDowngradingServiceTest.java @@ -0,0 +1,260 @@ +package org.prebid.server.auction.adpodding; + +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Video; +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.aliases.BidderAliases; +import org.prebid.server.auction.versionconverter.OrtbVersion; +import org.prebid.server.bidder.BidderCatalog; +import org.prebid.server.bidder.BidderInfo; + +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +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.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class AdPoddingImpDowngradingServiceTest extends VertxTest { + + private static final String BIDDER_NAME = "test_bidder"; + private static final String IMP_ID = "imp_id"; + + @Mock + private BidderCatalog bidderCatalog; + @Mock + private BidderAliases bidderAliases; + @Mock + private BidderInfo bidderInfo; + + private AdPoddingImpDowngradingService target; + + @BeforeEach + public void setUp() { + given(bidderCatalog.bidderInfoByName(anyString())).willReturn(bidderInfo); + given(bidderAliases.resolveBidder(anyString())).willReturn(BIDDER_NAME); + + given(bidderInfo.getOrtbVersion()).willReturn(OrtbVersion.ORTB_2_5); + given(bidderInfo.isAdpodSupported()).willReturn(false); + + target = new AdPoddingImpDowngradingService(bidderCatalog); + } + + @Test + public void downgradeShouldReturnOriginalImpWhenBidderSupportsAdPods() { + // given + given(bidderInfo.getOrtbVersion()).willReturn(OrtbVersion.ORTB_2_6); + given(bidderInfo.isAdpodSupported()).willReturn(true); + + final Imp imp = givenImp(givenVideo(identity())); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(1).containsExactly(imp); + verify(bidderCatalog).bidderInfoByName(BIDDER_NAME); + } + + @Test + public void downgradeShouldReturnOriginalImpWhenImpHasNoMedia() { + // given + final Imp imp = Imp.builder().id(IMP_ID).build(); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(1).containsExactly(imp); + } + + @Test + public void downgradeShouldReturnOriginalImpWhenMediaHasNoPodId() { + // given + final Imp imp = givenImp(givenVideo(video -> video.podid(null))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(1).containsExactly(imp); + } + + @Test + public void downgradeShouldReturnEmptyListForStructuredVideoWhenRqddursIsEmpty() { + // given + final Imp imp = givenImp(givenVideo(video -> video.poddur(null).rqddurs(emptyList()))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).containsExactly(imp); + } + + @Test + public void downgradeShouldReturnDowngradedImpsForStructuredVideo() { + // given + final Imp imp = givenImp(givenVideo(video -> video.poddur(null).rqddurs(asList(15, 30, 45)))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(3) + .extracting(Imp::getId, Imp::getVideo) + .containsExactly( + tuple("imp_id-0", Video.builder().minduration(15).maxduration(15).build()), + tuple("imp_id-1", Video.builder().minduration(30).maxduration(30).build()), + tuple("imp_id-2", Video.builder().minduration(45).maxduration(45).build())); + } + + @Test + public void downgradeShouldReturnEmptyListForDynamicWhenCountIsZero() { + final Imp imp = givenImp(givenVideo(video -> video + .poddur(60) + .maxseq(null) + .rqddurs(null) + .minduration(null) + .maxduration(null))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).containsExactly(imp); + } + + @Test + public void downgradeShouldCalculateDynamicCountFromMaxseq() { + // given + final Imp imp = givenImp(givenVideo(video -> video + .poddur(60) + .maxseq(5) + .minduration(10) + .maxduration(15))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(5).extracting(Imp::getId) + .containsExactly("imp_id-0", "imp_id-1", "imp_id-2", "imp_id-3", "imp_id-4"); + assertThat(result).hasSize(5) + .extracting(Imp::getVideo) + .allSatisfy(video -> Video.builder().minduration(10).maxduration(15).build()); + } + + @Test + public void downgradeShouldCalculateDynamicCountFromMinRqddurs() { + // given + final Imp imp = givenImp(givenVideo(video -> video + .poddur(60) + .maxseq(null) + .rqddurs(asList(30, 10, 20)) + .minduration(10) + .maxduration(15))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(6) + .extracting(Imp::getId) + .containsExactly("imp_id-0", "imp_id-1", "imp_id-2", "imp_id-3", "imp_id-4", "imp_id-5"); + assertThat(result).hasSize(6) + .extracting(Imp::getVideo) + .allSatisfy(video -> Video.builder().minduration(10).maxduration(15).build()); + } + + @Test + public void downgradeShouldCalculateDynamicCountFromMinDuration() { + // given + final Imp imp = givenImp(givenVideo(video -> video + .poddur(60) + .maxseq(null) + .rqddurs(null) + .minduration(15) + .maxduration(20))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(4) + .extracting(Imp::getId) + .containsExactly("imp_id-0", "imp_id-1", "imp_id-2", "imp_id-3"); + assertThat(result).hasSize(4) + .extracting(Imp::getVideo) + .allSatisfy(video -> Video.builder().minduration(15).maxduration(20).build()); + } + + @Test + public void downgradeShouldCalculateDynamicCountFromMaxDuration() { + // given + final Imp imp = givenImp(givenVideo(video -> video + .poddur(60) + .maxseq(null) + .rqddurs(null) + .minduration(null) + .maxduration(20))); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(3) + .extracting(Imp::getId) + .containsExactly("imp_id-0", "imp_id-1", "imp_id-2"); + assertThat(result).hasSize(3) + .extracting(Imp::getVideo) + .allSatisfy(video -> Video.builder().minduration(null).maxduration(20).build()); + } + + @Test + public void downgradeShouldDowngradeBothVideoAndAudioPodsInSameImp() { + // given + final Video video = givenVideo(v -> v.poddur(null).rqddurs(asList(15, 20))); + final Audio audio = givenAudio(a -> a.poddur(30).maxseq(3).minduration(10).maxduration(30)); + final Imp imp = Imp.builder().id(IMP_ID).video(video).audio(audio).build(); + + // when + final List result = target.downgrade(imp, BIDDER_NAME, bidderAliases); + + // then + assertThat(result).hasSize(5) + .extracting(Imp::getId, Imp::getVideo, Imp::getAudio) + .containsExactly( + tuple("imp_id-0", Video.builder().minduration(15).maxduration(15).build(), null), + tuple("imp_id-1", Video.builder().minduration(20).maxduration(20).build(), null), + tuple("imp_id-2", null, Audio.builder().minduration(10).maxduration(30).build()), + tuple("imp_id-3", null, Audio.builder().minduration(10).maxduration(30).build()), + tuple("imp_id-4", null, Audio.builder().minduration(10).maxduration(30).build())); + } + + private Imp givenImp(Video video) { + return Imp.builder().id(IMP_ID).video(video).build(); + } + + private Video givenVideo(UnaryOperator customizer) { + final Video.VideoBuilder builder = Video.builder().podid(1); + return customizer.apply(builder).build(); + } + + private Audio givenAudio(UnaryOperator customizer) { + final Audio.AudioBuilder builder = Audio.builder().podid(1); + return customizer.apply(builder).build(); + } +} diff --git a/src/test/java/org/prebid/server/auction/mediatypeprocessor/BidderMediaTypeProcessorTest.java b/src/test/java/org/prebid/server/auction/mediatypeprocessor/BidderMediaTypeProcessorTest.java index d6e002489f8..44fac79c666 100644 --- a/src/test/java/org/prebid/server/auction/mediatypeprocessor/BidderMediaTypeProcessorTest.java +++ b/src/test/java/org/prebid/server/auction/mediatypeprocessor/BidderMediaTypeProcessorTest.java @@ -163,6 +163,7 @@ private static BidderInfo givenBidderInfo(List appMediaTypes, true, OrtbVersion.ORTB_2_6, false, + false, "endpoint", null, "maintainerEmail", diff --git a/src/test/java/org/prebid/server/auction/mediatypeprocessor/MultiFormatMediaTypeProcessorTest.java b/src/test/java/org/prebid/server/auction/mediatypeprocessor/MultiFormatMediaTypeProcessorTest.java index a8da73fe320..bc4cae8239d 100644 --- a/src/test/java/org/prebid/server/auction/mediatypeprocessor/MultiFormatMediaTypeProcessorTest.java +++ b/src/test/java/org/prebid/server/auction/mediatypeprocessor/MultiFormatMediaTypeProcessorTest.java @@ -267,6 +267,7 @@ private static BidderInfo givenBidderInfo(boolean multiFormatSupported) { true, OrtbVersion.ORTB_2_6, false, + false, "endpoint", null, "maintainerEmail", diff --git a/src/test/java/org/prebid/server/auction/privacy/enforcement/CcpaEnforcementTest.java b/src/test/java/org/prebid/server/auction/privacy/enforcement/CcpaEnforcementTest.java index 0133741f248..0e1ba3a06fa 100644 --- a/src/test/java/org/prebid/server/auction/privacy/enforcement/CcpaEnforcementTest.java +++ b/src/test/java/org/prebid/server/auction/privacy/enforcement/CcpaEnforcementTest.java @@ -67,6 +67,7 @@ public void setUp() { true, null, false, + false, null, null, null, @@ -211,6 +212,7 @@ public void enforceShouldSkipNoSaleBiddersAndNotEnforcedByBidderConfig() { true, null, false, + false, null, null, null, diff --git a/src/test/java/org/prebid/server/bidder/BidderCatalogTest.java b/src/test/java/org/prebid/server/bidder/BidderCatalogTest.java index 347279cd3b5..cd83f2a93c2 100644 --- a/src/test/java/org/prebid/server/bidder/BidderCatalogTest.java +++ b/src/test/java/org/prebid/server/bidder/BidderCatalogTest.java @@ -86,6 +86,7 @@ public void metaInfoByNameShouldReturnMetaInfoForKnownBidderIgnoringCase() { final BidderInfo bidderInfo = BidderInfo.create( true, null, + false, true, null, null, @@ -120,6 +121,7 @@ public void isAliasShouldReturnTrueForAliasIgnoringCase() { final BidderInfo bidderInfo = BidderInfo.create( true, null, + false, true, null, null, @@ -145,6 +147,7 @@ public void isAliasShouldReturnTrueForAliasIgnoringCase() { final BidderInfo aliasInfo = BidderInfo.create( true, null, + false, true, null, "BIDder", @@ -183,6 +186,7 @@ public void resolveBaseBidderShouldReturnBaseBidderName() { .bidderInfo(BidderInfo.create( true, null, + false, true, null, "bidder", @@ -251,6 +255,7 @@ public void usersyncReadyBiddersShouldReturnBiddersThatCanSync() { final BidderInfo infoOfBidderWithUsersyncConfig = BidderInfo.create( true, null, + false, true, null, "bidder-with-usersync", @@ -270,6 +275,7 @@ public void usersyncReadyBiddersShouldReturnBiddersThatCanSync() { final BidderInfo infoOfBidderWithoutUsersyncConfig = BidderInfo.create( true, null, + false, true, null, "bidder-without-usersync", @@ -289,6 +295,7 @@ public void usersyncReadyBiddersShouldReturnBiddersThatCanSync() { final BidderInfo infoOfDisabledBidderWithUsersyncConfig = BidderInfo.create( false, null, + false, true, null, "bidder-with-usersync", @@ -359,6 +366,7 @@ public void nameByVendorIdShouldReturnBidderNameForVendorId() { final BidderInfo bidderInfo = BidderInfo.create( true, null, + false, true, null, null, diff --git a/src/test/java/org/prebid/server/bidder/HttpBidderRequestEnricherTest.java b/src/test/java/org/prebid/server/bidder/HttpBidderRequestEnricherTest.java index a6004aa2ece..63a340456c0 100644 --- a/src/test/java/org/prebid/server/bidder/HttpBidderRequestEnricherTest.java +++ b/src/test/java/org/prebid/server/bidder/HttpBidderRequestEnricherTest.java @@ -162,6 +162,7 @@ public void shouldAddContentEncodingHeaderIfRequiredByBidderConfig() { true, null, false, + false, null, null, null, @@ -201,6 +202,7 @@ public void shouldAddContentEncodingHeaderIfRequiredByBidderAliasConfig() { true, null, false, + false, null, null, null, diff --git a/src/test/java/org/prebid/server/handler/info/BidderDetailsHandlerTest.java b/src/test/java/org/prebid/server/handler/info/BidderDetailsHandlerTest.java index 36bd669610f..73a629573ca 100644 --- a/src/test/java/org/prebid/server/handler/info/BidderDetailsHandlerTest.java +++ b/src/test/java/org/prebid/server/handler/info/BidderDetailsHandlerTest.java @@ -186,6 +186,7 @@ private static BidderInfo givenBidderInfo(boolean enabled, String endpoint, Stri return BidderInfo.create( enabled, null, + false, true, endpoint, aliasOf, diff --git a/src/test/java/org/prebid/server/metric/MetricsTest.java b/src/test/java/org/prebid/server/metric/MetricsTest.java index 243ef33e66d..39f988d5236 100644 --- a/src/test/java/org/prebid/server/metric/MetricsTest.java +++ b/src/test/java/org/prebid/server/metric/MetricsTest.java @@ -682,6 +682,18 @@ public void updateSecureValidationMetricsShouldIncrementMetrics() { assertThat(metricRegistry.counter("account.accountId.response.validation.secure.err").getCount()).isEqualTo(2); } + @Test + public void updateAdPoddingValidationMetricsShouldIncrementMetrics() { + // when + metrics.updateAdPoddingValidationMetrics(RUBICON, ACCOUNT_ID, MetricName.err); + metrics.updateAdPoddingValidationMetrics(CONVERSANT, ACCOUNT_ID, MetricName.err); + + // then + assertThat(metricRegistry.counter("adapter.rubicon.response.validation.pod.err").getCount()).isEqualTo(1); + assertThat(metricRegistry.counter("adapter.conversant.response.validation.pod.err").getCount()).isEqualTo(1); + assertThat(metricRegistry.counter("account.accountId.response.validation.pod.err").getCount()).isEqualTo(2); + } + @Test public void updateSeatValidationMetricsShouldIncrementMetrics() { // when diff --git a/src/test/java/org/prebid/server/settings/FileApplicationSettingsTest.java b/src/test/java/org/prebid/server/settings/FileApplicationSettingsTest.java index 4a2c731659d..ed24d35a82f 100644 --- a/src/test/java/org/prebid/server/settings/FileApplicationSettingsTest.java +++ b/src/test/java/org/prebid/server/settings/FileApplicationSettingsTest.java @@ -20,7 +20,6 @@ import org.prebid.server.settings.model.AccountGdprConfig; import org.prebid.server.settings.model.AccountPrivacyConfig; import org.prebid.server.settings.model.AccountStatus; -import org.prebid.server.settings.model.BidValidationEnforcement; import org.prebid.server.settings.model.EnabledForRequestType; import org.prebid.server.settings.model.EnforcePurpose; import org.prebid.server.settings.model.Purpose; @@ -47,6 +46,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.prebid.server.settings.model.BidValidationEnforcement.enforce; @ExtendWith(MockitoExtension.class) public class FileApplicationSettingsTest extends VertxTest { @@ -96,7 +96,8 @@ public void getAccountByIdShouldReturnPresentAccount() { + "truncate-target-attr: 20," + "default-integration: web," + "bid-validations: {" - + "banner-creative-max-size: enforce" + + "banner-creative-max-size: enforce," + + "ad-podding: enforce" + "}," + "events: {" + "enabled: true" @@ -151,7 +152,7 @@ public void getAccountByIdShouldReturnPresentAccount() { .videoCacheTtl(100) .truncateTargetAttr(20) .defaultIntegration("web") - .bidValidations(AccountBidValidationConfig.of(BidValidationEnforcement.enforce)) + .bidValidations(AccountBidValidationConfig.of(enforce, enforce)) .events(AccountEventsConfig.of(true)) .build()) .privacy(AccountPrivacyConfig.builder() diff --git a/src/test/java/org/prebid/server/validation/BidderParamValidatorTest.java b/src/test/java/org/prebid/server/validation/BidderParamValidatorTest.java index 9c207aa17bf..5fd23a21296 100644 --- a/src/test/java/org/prebid/server/validation/BidderParamValidatorTest.java +++ b/src/test/java/org/prebid/server/validation/BidderParamValidatorTest.java @@ -403,6 +403,7 @@ private static BidderInfo givenBidderInfo(String aliasOf) { return BidderInfo.create( true, null, + false, true, "https://endpoint.com", aliasOf, diff --git a/src/test/java/org/prebid/server/validation/ImpValidatorTest.java b/src/test/java/org/prebid/server/validation/ImpValidatorTest.java index fa109dc8f58..506579edc8e 100644 --- a/src/test/java/org/prebid/server/validation/ImpValidatorTest.java +++ b/src/test/java/org/prebid/server/validation/ImpValidatorTest.java @@ -30,6 +30,7 @@ import org.prebid.server.proto.openrtb.ext.request.ExtStoredAuctionResponse; import org.prebid.server.proto.openrtb.ext.request.ExtStoredBidResponse; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; @@ -124,6 +125,62 @@ public void validateImpsShouldReturnValidationMessageWhenVideoAttributeIsPresent .hasMessage("request.imp[0].video.mimes must contain at least one supported MIME type"); } + @Test + public void validateImpsShouldReturnValidationMessageWhenVideoRqddursAreNotEmptyAndMaxDurationIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .video(Video.builder().rqddurs(List.of(1)).maxduration(1).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].video.minduration and maxduration must not be specified " + + "while rqddurs contains at least one element"); + } + + @Test + public void validateImpsShouldReturnValidationMessageWhenVideoRqddursAreNotEmptyAndMinDurationIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .video(Video.builder().rqddurs(List.of(1)).minduration(1).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].video.minduration and maxduration must not be specified " + + "while rqddurs contains at least one element"); + } + + @Test + public void validateImpsShouldReturnValidationMessageWhenVideoPodDurIsNullAndMinCpmPerSecIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .video(Video.builder().poddur(null).mincpmpersec(BigDecimal.ONE).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].video.maxseq and mincpmpersec must not be specified " + + "when poddur is not specified"); + } + + @Test + public void validateImpsShouldReturnValidationMessageWhenVideoPodDurIsNullAndMaxSeqIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .video(Video.builder().poddur(null).maxseq(1).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].video.maxseq and mincpmpersec must not be specified " + + "when poddur is not specified"); + } + @Test public void validateImpsShouldReturnValidationMessageWhenAudioAttributePresentButAudioMimesMissed() { // given @@ -137,6 +194,62 @@ public void validateImpsShouldReturnValidationMessageWhenAudioAttributePresentBu .hasMessage("request.imp[0].audio.mimes must contain at least one supported MIME type"); } + @Test + public void validateImpsShouldReturnValidationMessageWhenAudioRqddursAreNotEmptyAndMaxDurationIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .audio(Audio.builder().rqddurs(List.of(1)).maxduration(1).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].audio.minduration and maxduration must not be specified " + + "while rqddurs contains at least one element"); + } + + @Test + public void validateImpsShouldReturnValidationMessageWhenAudioRqddursAreNotEmptyAndMinDurationIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .audio(Audio.builder().rqddurs(List.of(1)).minduration(1).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].audio.minduration and maxduration must not be specified " + + "while rqddurs contains at least one element"); + } + + @Test + public void validateImpsShouldReturnValidationMessageWhenAudioPodDurIsNullAndMinCpmPerSecIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .audio(Audio.builder().poddur(null).mincpmpersec(BigDecimal.ONE).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].audio.maxseq and mincpmpersec must not be specified " + + "when poddur is not specified"); + } + + @Test + public void validateImpsShouldReturnValidationMessageWhenAudioPodDurIsNullAndMaxSeqIsSpecified() { + // given + final List givenImps = singletonList(validImpBuilder() + .audio(Audio.builder().poddur(null).maxseq(1).build()) + .build()); + + // when & then + assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) + .isInstanceOf(ValidationException.class) + .hasMessage("request.imp[0].audio.maxseq and mincpmpersec must not be specified " + + "when poddur is not specified"); + } + @Test public void validateImpsShouldReturnValidationMessageWhenBannerHasNullFormatAndNoSizes() { // given diff --git a/src/test/java/org/prebid/server/validation/ResponseBidValidatorTest.java b/src/test/java/org/prebid/server/validation/ResponseBidValidatorTest.java index 91969151268..86abebade4c 100644 --- a/src/test/java/org/prebid/server/validation/ResponseBidValidatorTest.java +++ b/src/test/java/org/prebid/server/validation/ResponseBidValidatorTest.java @@ -1,10 +1,13 @@ package org.prebid.server.validation; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Audio; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Format; import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,15 +20,21 @@ import org.prebid.server.auction.model.BidRejectionReason; import org.prebid.server.auction.model.BidRejectionTracker; import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.currency.CurrencyConversionService; import org.prebid.server.metric.MetricName; import org.prebid.server.metric.Metrics; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestTargeting; import org.prebid.server.proto.openrtb.ext.response.BidType; import org.prebid.server.settings.model.Account; import org.prebid.server.settings.model.AccountAuctionConfig; import org.prebid.server.settings.model.AccountBidValidationConfig; +import org.prebid.server.settings.model.BidValidationEnforcement; import org.prebid.server.validation.model.ValidationResult; import java.math.BigDecimal; +import java.util.List; import java.util.Map; import java.util.function.UnaryOperator; @@ -33,12 +42,15 @@ import static java.util.Collections.singletonList; 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.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mock.Strictness.LENIENT; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; import static org.prebid.server.settings.model.BidValidationEnforcement.enforce; import static org.prebid.server.settings.model.BidValidationEnforcement.skip; import static org.prebid.server.settings.model.BidValidationEnforcement.warn; @@ -51,7 +63,8 @@ public class ResponseBidValidatorTest extends VertxTest { @Mock private Metrics metrics; - + @Mock(strictness = LENIENT) + private CurrencyConversionService currencyConversionService; @Mock private BidRejectionTracker bidRejectionTracker; @@ -62,17 +75,27 @@ public class ResponseBidValidatorTest extends VertxTest { @BeforeEach public void setUp() { - target = new ResponseBidValidator(enforce, enforce, metrics, 0.01); + target = new ResponseBidValidator( + enforce, + enforce, + enforce, + currencyConversionService, + metrics, + jacksonMapper, + 0.01); given(bidderAliases.resolveBidder(anyString())).willReturn(BIDDER_NAME); given(bidderAliases.isAllowedAlternateBidderCode(anyString(), anyString())).willReturn(true); + + given(currencyConversionService.convertCurrency(any(), any(), anyString(), anyString())) + .willAnswer(invocation -> invocation.getArgument(0)); } @Test public void validateShouldFailedIfBidderBidCurrencyIsIncorrect() { // when final ValidationResult result = target.validate( - givenBid(BidType.banner, "invalid", identity()), + givenBid(banner, "invalid", identity()), BIDDER_NAME, givenAuctionContext(), bidderAliases); @@ -100,7 +123,7 @@ public void validateShouldFailIfMissingBid() { public void validateShouldFailIfBidHasNoId() { // when final ValidationResult result = target.validate( - givenBid(builder -> builder.id(null)), BIDDER_NAME, givenAuctionContext(), bidderAliases); + givenBid(banner, builder -> builder.id(null)), BIDDER_NAME, givenAuctionContext(), bidderAliases); // then assertThat(result.getErrors()).containsOnly("Bid missing required field 'id'"); @@ -111,10 +134,10 @@ public void validateShouldFailIfBidHasNoId() { public void validateShouldFailIfBidHasNoImpId() { // when final ValidationResult result = target.validate( - givenBid(builder -> builder.impid(null)), BIDDER_NAME, givenAuctionContext(), bidderAliases); + givenBid(banner, builder -> builder.impid(null)), BIDDER_NAME, givenAuctionContext(), bidderAliases); // then - assertThat(result.getErrors()).containsOnly("Bid \"bidId1\" missing required field 'impid'"); + assertThat(result.getErrors()).containsOnly("Bid \"bidId\" missing required field 'impid'"); verifyNoInteractions(bidRejectionTracker); } @@ -122,9 +145,9 @@ public void validateShouldFailIfBidHasNoImpId() { public void validateShouldSuccessForDealZeroPriceBid() { // when final ValidationResult result = target.validate( - givenVideoBid(builder -> builder.price(BigDecimal.valueOf(0)).dealid("dealId")), + givenBid(BidType.video, builder -> builder.price(BigDecimal.valueOf(0)).dealid("dealId")), BIDDER_NAME, - givenAuctionContext(), + givenAuctionContext(givenVideoImp(identity(), identity())), bidderAliases); // then @@ -136,25 +159,26 @@ public void validateShouldSuccessForDealZeroPriceBid() { public void validateShouldFailIfBidHasNoCrid() { // when final ValidationResult result = target.validate( - givenBid(builder -> builder.crid(null)), BIDDER_NAME, givenAuctionContext(), bidderAliases); + givenBid(banner, builder -> builder.crid(null)), BIDDER_NAME, givenAuctionContext(), bidderAliases); // then - assertThat(result.getErrors()).containsOnly("Bid \"bidId1\" missing creative ID"); + assertThat(result.getErrors()).containsOnly("Bid \"bidId\" missing creative ID"); verifyNoInteractions(bidRejectionTracker); } @Test public void validateShouldFailIfBannerBidHasNoWidthAndHeight() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.w(null).h(null)); + // when - final BidderBid givenBid = givenBid(builder -> builder.w(null).h(null)); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, givenAuctionContext(), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly(""" + assertThat(result.getErrors()).containsOnly(""" BidResponse validation `enforce`: bidder `bidder` response triggers \ - creative size validation for bid bidId1, account=account, referrer=unknown, \ + creative size validation for bid bidId, account=account, referrer=unknown, \ max imp size='100x200', bid response size='nullxnull'"""); verify(bidRejectionTracker) .rejectBid(givenBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE_SIZE_NOT_ALLOWED); @@ -162,16 +186,17 @@ public void validateShouldFailIfBannerBidHasNoWidthAndHeight() { @Test public void validateShouldFailIfBannerBidWidthIsGreaterThanImposedByImp() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.w(150).h(150)); + // when - final BidderBid givenBid = givenBid(builder -> builder.w(150).h(150)); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, givenAuctionContext(), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly(""" + assertThat(result.getErrors()).containsOnly(""" BidResponse validation `enforce`: bidder `bidder` response triggers \ - creative size validation for bid bidId1, account=account, referrer=unknown, \ + creative size validation for bid bidId, account=account, referrer=unknown, \ max imp size='100x200', bid response size='150x150'"""); verify(bidRejectionTracker) .rejectBid(givenBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE_SIZE_NOT_ALLOWED); @@ -179,16 +204,17 @@ public void validateShouldFailIfBannerBidWidthIsGreaterThanImposedByImp() { @Test public void validateShouldFailIfBannerBidHeightIsGreaterThanImposedByImp() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.w(50).h(250)); + // when - final BidderBid givenBid = givenBid(builder -> builder.w(50).h(250)); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, givenAuctionContext(), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly(""" + assertThat(result.getErrors()).containsOnly(""" BidResponse validation `enforce`: bidder `bidder` response triggers \ - creative size validation for bid bidId1, account=account, referrer=unknown, \ + creative size validation for bid bidId, account=account, referrer=unknown, \ max imp size='100x200', bid response size='50x250'"""); verify(bidRejectionTracker) .rejectBid(givenBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE_SIZE_NOT_ALLOWED); @@ -210,13 +236,14 @@ public void validateShouldReturnSuccessIfNonBannerBidHasAnySize() { @Test public void validateShouldTolerateMissingImpExtBidderNode() { - // when - final BidRequest bidRequest = givenRequest(impBuilder -> impBuilder + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder .ext(mapper.createObjectNode() .set("prebid", mapper.createObjectNode()))); + // when final ValidationResult result = target.validate( - givenBid(BidType.video, builder -> builder.w(3).h(3)), + givenBid(banner, builder -> builder.w(3).h(3)), BIDDER_NAME, givenAuctionContext(bidRequest), bidderAliases); @@ -230,11 +257,11 @@ public void validateShouldTolerateMissingImpExtBidderNode() { public void validateShouldReturnSuccessIfBannerBidHasInvalidSizeButAccountDoesNotEnforceValidation() { // when final ValidationResult result = target.validate( - givenBid(builder -> builder.w(150).h(150)), + givenBid(banner, builder -> builder.w(150).h(150)), BIDDER_NAME, givenAuctionContext( givenAccount(builder -> builder.auction(AccountAuctionConfig.builder() - .bidValidations(AccountBidValidationConfig.of(skip)) + .bidValidations(AccountBidValidationConfig.of(skip, enforce)) .build()))), bidderAliases); @@ -247,32 +274,32 @@ public void validateShouldReturnSuccessIfBannerBidHasInvalidSizeButAccountDoesNo public void validateShouldFailIfBidHasNoCorrespondingImp() { // when final ValidationResult result = target.validate( - givenBid(builder -> builder.impid("nonExistentsImpid")), + givenBid(banner, builder -> builder.impid("nonExistentsImpid")), BIDDER_NAME, givenAuctionContext(), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly("Bid \"bidId1\" has no corresponding imp in request"); + assertThat(result.getErrors()).containsOnly("Bid \"bidId\" has no corresponding imp in request"); verifyNoInteractions(bidRejectionTracker); } @Test public void validateShouldFailIfBidHasInsecureMarkerInCreativeInSecureContext() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.adm("http://site.com/creative.jpg")); + // when - final BidderBid givenBid = givenBid(builder -> builder.adm("http://site.com/creative.jpg")); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly(""" + assertThat(result.getErrors()).containsOnly(""" BidResponse validation `enforce`: bidder `bidder` response triggers \ - secure creative validation for bid bidId1, account=account, referrer=unknown, \ + secure creative validation for bid bidId, account=account, referrer=unknown, \ adm=http://site.com/creative.jpg"""); verify(bidRejectionTracker) .rejectBid(givenBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE_NOT_SECURE); @@ -280,19 +307,20 @@ public void validateShouldFailIfBidHasInsecureMarkerInCreativeInSecureContext() @Test public void validateShouldFailIfBidHasInsecureEncodedMarkerInCreativeInSecureContext() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.adm("http%3A//site.com/creative.jpg")); + // when - final BidderBid givenBid = givenBid(builder -> builder.adm("http%3A//site.com/creative.jpg")); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly(""" + assertThat(result.getErrors()).containsOnly(""" BidResponse validation `enforce`: bidder `bidder` response triggers \ - secure creative validation for bid bidId1, account=account, referrer=unknown, \ + secure creative validation for bid bidId, account=account, referrer=unknown, \ adm=http%3A//site.com/creative.jpg"""); verify(bidRejectionTracker) .rejectBid(givenBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE_NOT_SECURE); @@ -300,19 +328,20 @@ public void validateShouldFailIfBidHasInsecureEncodedMarkerInCreativeInSecureCon @Test public void validateShouldFailIfBidHasNoSecureMarkersInCreativeInSecureContext() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.adm("//site.com/creative.jpg")); + // when - final BidderBid givenBid = givenBid(builder -> builder.adm("//site.com/creative.jpg")); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly(""" + assertThat(result.getErrors()).containsOnly(""" BidResponse validation `enforce`: bidder `bidder` response triggers \ - secure creative validation for bid bidId1, account=account, referrer=unknown, \ + secure creative validation for bid bidId, account=account, referrer=unknown, \ adm=//site.com/creative.jpg"""); verify(bidRejectionTracker) .rejectBid(givenBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE_NOT_SECURE); @@ -322,7 +351,7 @@ public void validateShouldFailIfBidHasNoSecureMarkersInCreativeInSecureContext() public void validateShouldReturnSuccessIfBidHasInsecureCreativeInInsecureContext() { // when final ValidationResult result = target.validate( - givenBid(builder -> builder.adm("http://site.com/creative.jpg")), + givenBid(banner, builder -> builder.adm("http://site.com/creative.jpg")), BIDDER_NAME, givenAuctionContext(), bidderAliases); @@ -338,12 +367,11 @@ public void validateShouldFailedIfVideoBidHasNoNurlAndAdm() { final ValidationResult result = target.validate( givenBid(BidType.video, builder -> builder.adm(null).nurl(null)), BIDDER_NAME, - givenAuctionContext(), + givenAuctionContext(givenVideoImp(identity(), identity())), bidderAliases); // then - assertThat(result.getErrors()) - .containsOnly("Bid \"bidId1\" with video type missing adm and nurl"); + assertThat(result.getErrors()).containsOnly("Bid \"bidId\" with video type missing adm and nurl"); verify(metrics).updateAdapterRequestErrorMetric(BIDDER_NAME, MetricName.badserverresponse); verifyNoInteractions(bidRejectionTracker); } @@ -354,7 +382,7 @@ public void validateShouldReturnSuccessfulResultForValidVideoBidWithNurl() { final ValidationResult result = target.validate( givenBid(BidType.video, builder -> builder.adm(null)), BIDDER_NAME, - givenAuctionContext(), + givenAuctionContext(givenVideoImp(identity(), identity())), bidderAliases); // then @@ -368,7 +396,7 @@ public void validateShouldReturnSuccessfulResultForValidVideoBidWithAdm() { final ValidationResult result = target.validate( givenBid(BidType.video, builder -> builder.nurl(null)), BIDDER_NAME, - givenAuctionContext(), + givenAuctionContext(givenVideoImp(identity(), identity())), bidderAliases); // then @@ -380,9 +408,9 @@ public void validateShouldReturnSuccessfulResultForValidVideoBidWithAdm() { public void validateShouldReturnSuccessfulResultForValidBid() { // when final ValidationResult result = target.validate( - givenBid(identity()), + givenBid(banner, identity()), BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then @@ -393,11 +421,13 @@ public void validateShouldReturnSuccessfulResultForValidBid() { @Test public void validateShouldReturnSuccessIfBannerSizeValidationNotEnabled() { // given - target = new ResponseBidValidator(skip, enforce, metrics, 0.01); + target = new ResponseBidValidator( + skip, enforce, enforce, + currencyConversionService, metrics, jacksonMapper, 0.01); // when final ValidationResult result = target.validate( - givenBid(identity()), + givenBid(banner, identity()), BIDDER_NAME, givenAuctionContext(), bidderAliases); @@ -410,10 +440,12 @@ public void validateShouldReturnSuccessIfBannerSizeValidationNotEnabled() { @Test public void validateShouldReturnSuccessWithWarningIfBannerSizeEnforcementIsWarn() { // given - target = new ResponseBidValidator(warn, enforce, metrics, 0.01); + target = new ResponseBidValidator( + warn, enforce, enforce, + currencyConversionService, metrics, jacksonMapper, 0.01); + final BidderBid givenBid = givenBid(banner, builder -> builder.w(null).h(null)); // when - final BidderBid givenBid = givenBid(builder -> builder.w(null).h(null)); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, @@ -422,10 +454,9 @@ public void validateShouldReturnSuccessWithWarningIfBannerSizeEnforcementIsWarn( // then assertThat(result.hasErrors()).isFalse(); - assertThat(result.getWarnings()) - .containsOnly(""" + assertThat(result.getWarnings()).containsOnly(""" BidResponse validation `warn`: bidder `bidder` response triggers \ - creative size validation for bid bidId1, account=account, referrer=unknown, \ + creative size validation for bid bidId, account=account, referrer=unknown, \ max imp size='100x200', bid response size='nullxnull'"""); verifyNoInteractions(bidRejectionTracker); } @@ -433,13 +464,15 @@ public void validateShouldReturnSuccessWithWarningIfBannerSizeEnforcementIsWarn( @Test public void validateShouldReturnSuccessIfSecureMarkupValidationNotEnabled() { // given - target = new ResponseBidValidator(enforce, skip, metrics, 0.01); + target = new ResponseBidValidator( + enforce, skip, enforce, + currencyConversionService, metrics, jacksonMapper, 0.01); // when final ValidationResult result = target.validate( - givenBid(builder -> builder.adm("http://site.com/creative.jpg")), + givenBid(banner, builder -> builder.adm("http://site.com/creative.jpg")), BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then @@ -450,30 +483,33 @@ public void validateShouldReturnSuccessIfSecureMarkupValidationNotEnabled() { @Test public void validateShouldReturnSuccessWithWarningIfSecureMarkupEnforcementIsWarn() { // given - target = new ResponseBidValidator(enforce, warn, metrics, 0.01); + target = new ResponseBidValidator( + enforce, warn, enforce, + currencyConversionService, metrics, jacksonMapper, 0.01); // when - final BidderBid givenBid = givenBid(builder -> builder.adm("http://site.com/creative.jpg")); + final BidderBid givenBid = givenBid(banner, builder -> builder.adm("http://site.com/creative.jpg")); final ValidationResult result = target.validate( givenBid, BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then assertThat(result.hasErrors()).isFalse(); - assertThat(result.getWarnings()) - .containsOnly(""" + assertThat(result.getWarnings()).containsOnly(""" BidResponse validation `warn`: bidder `bidder` response triggers \ - secure creative validation for bid bidId1, account=account, referrer=unknown, \ + secure creative validation for bid bidId, account=account, referrer=unknown, \ adm=http://site.com/creative.jpg"""); verifyNoInteractions(bidRejectionTracker); } @Test public void validateShouldIncrementSizeValidationErrMetrics() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.w(150).h(200)); + // when - final BidderBid givenBid = givenBid(builder -> builder.w(150).h(200)); target.validate(givenBid, BIDDER_NAME, givenAuctionContext(), bidderAliases); // then @@ -485,10 +521,12 @@ public void validateShouldIncrementSizeValidationErrMetrics() { @Test public void validateShouldIncrementSizeValidationWarnMetrics() { // given - target = new ResponseBidValidator(warn, warn, metrics, 0.01); + target = new ResponseBidValidator( + warn, warn, warn, + currencyConversionService, metrics, jacksonMapper, 0.01); // when - final BidderBid givenBid = givenBid(builder -> builder.w(150).h(200)); + final BidderBid givenBid = givenBid(banner, builder -> builder.w(150).h(200)); target.validate(givenBid, BIDDER_NAME, givenAuctionContext(), bidderAliases); // then @@ -498,12 +536,14 @@ public void validateShouldIncrementSizeValidationWarnMetrics() { @Test public void validateShouldIncrementSecureValidationErrMetrics() { + // given + final BidderBid givenBid = givenBid(banner, builder -> builder.adm("http://site.com/creative.jpg")); + // when - final BidderBid givenBid = givenBid(builder -> builder.adm("http://site.com/creative.jpg")); target.validate( givenBid, BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then @@ -515,14 +555,16 @@ public void validateShouldIncrementSecureValidationErrMetrics() { @Test public void validateShouldIncrementSecureValidationWarnMetrics() { // given - target = new ResponseBidValidator(warn, warn, metrics, 0.01); + target = new ResponseBidValidator( + warn, warn, warn, + currencyConversionService, metrics, jacksonMapper, 0.01); + final BidderBid givenBid = givenBid(banner, builder -> builder.adm("http://site.com/creative.jpg")); // when - final BidderBid givenBid = givenBid(builder -> builder.adm("http://site.com/creative.jpg")); target.validate( givenBid, BIDDER_NAME, - givenAuctionContext(givenBidRequest(builder -> builder.secure(1))), + givenAuctionContext(givenBidRequest(impBuilder -> impBuilder.secure(1))), bidderAliases); // then @@ -534,7 +576,7 @@ public void validateShouldIncrementSecureValidationWarnMetrics() { public void validateShouldNotFailOnSeatValidationWhenSeatEqualsIgnoringCaseToBidder() { // when final ValidationResult result = target.validate( - givenBid(identity()).toBuilder().seat("biDDEr").build(), + givenBid(banner, identity()).toBuilder().seat("biDDEr").build(), BIDDER_NAME, givenAuctionContext(), bidderAliases); @@ -549,7 +591,7 @@ public void validateShouldNotFailOnSeatValidationWhenSeatEqualsIgnoringCaseToBid @Test public void validateShouldFailOnSeatValidationWhenSeatIsNotAllowed() { // given - final BidderBid givenBid = givenBid(identity()).toBuilder().seat("seat").build(); + final BidderBid givenBid = givenBid(banner, identity()).toBuilder().seat("seat").build(); given(bidderAliases.isAllowedAlternateBidderCode(BIDDER_NAME, "seat")).willReturn(false); // when @@ -569,7 +611,7 @@ public void validateShouldFailOnSeatValidationWhenSeatIsNotAllowed() { @Test public void validateShouldNotFailOnSeatValidationWhenSeatIsAllowed() { // given - final BidderBid givenBid = givenBid(identity()).toBuilder().seat("seat").build(); + final BidderBid givenBid = givenBid(banner, identity()).toBuilder().seat("seat").build(); given(bidderAliases.isAllowedAlternateBidderCode(BIDDER_NAME, "seat")).willReturn(true); // when @@ -586,51 +628,707 @@ public void validateShouldNotFailOnSeatValidationWhenSeatIsAllowed() { verifyNoInteractions(bidRejectionTracker); } - private BidRequest givenRequest(UnaryOperator impCustomizer) { - final ObjectNode ext = mapper.createObjectNode().set( - "prebid", mapper.createObjectNode().set( - "bidder", mapper.createObjectNode().set( - BIDDER_NAME, mapper.createObjectNode().put( - "dealsonly", true)))); + @Test + public void validateShouldSkipAdPoddingValidationWhenGlobalConfigIsSkip() { + // given + target = new ResponseBidValidator( + enforce, enforce, skip, + currencyConversionService, metrics, jacksonMapper, 0.01); - final Imp.ImpBuilder impBuilder = Imp.builder() - .id("impId1") - .ext(ext); - final Imp imp = impCustomizer.apply(impBuilder).build(); + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(0)); - return BidRequest.builder().imp(singletonList(imp)).build(); + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(givenVideoImp(identity(), identity())), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); } - private static BidderBid givenVideoBid(UnaryOperator bidCustomizer) { - return givenBid(BidType.video, bidCustomizer); + @Test + public void validateShouldSkipAdPoddingValidationWhnAccountConfigIsSkip() { + // given + final Account accountWithSkip = givenAccount(accountBuilder -> accountBuilder + .auction(AccountAuctionConfig.builder() + .bidValidations(AccountBidValidationConfig.of(skip, skip)) + .build())); + + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(0)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(givenVideoImp(identity(), identity()), accountWithSkip), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); } - private static BidderBid givenBid(UnaryOperator bidCustomizer) { - return givenBid(BidType.banner, bidCustomizer); + @Test + public void validateShouldSkipAdPoddingIfVideoObjectHasNoPodIdForVideoBid() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(0)); + final Imp videoImpNoPodId = givenVideoImp(identity(), videoBuilder -> videoBuilder.podid(null)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImpNoPodId, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void validateShouldSkipAdPoddingIfAudioObjectHasNoPodIdForAudioBid() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(0)); + final Imp audioImpNoPodId = givenAudioImp(identity(), audioBuilder -> audioBuilder.podid(null)); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(audioImpNoPodId, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void validateShouldFailAdPoddingWhenBidDurationIsZeroForVideo() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(0)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(givenVideoImp(identity(), identity()), givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenBidDurationIsZeroForAudio() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(0)); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(givenAudioImp(identity(), identity()), givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(audioBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldUseVideoBidMetaDurationWhenBidDurIsNullAndFailForZeroMetaDur() { + // given + final ObjectNode bidExt = mapper.createObjectNode().set("prebid", mapper.createObjectNode() + .set("meta", mapper.createObjectNode().put("dur", 0))); + + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(null).ext(bidExt)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(givenVideoImp(identity(), identity()), givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldUseAudioBidMetaDurationWhenBidDurIsNullAndFailForZeroMetaDur() { + // given + final ObjectNode bidExt = mapper.createObjectNode().set("prebid", mapper.createObjectNode() + .set("meta", mapper.createObjectNode().put("dur", 0))); + + final BidderBid videoBid = givenBid(BidType.audio, builder -> builder.dur(null).ext(bidExt)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(givenAudioImp(identity(), identity()), givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenVideoBidExtIsNullAndBidDurIsNull() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(null).ext(null)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(givenVideoImp(identity(), identity()), givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenAudioBidExtIsNullAndBidDurIsNull() { + // given + final BidderBid videoBid = givenBid(BidType.audio, builder -> builder.dur(null).ext(null)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(givenAudioImp(identity(), identity()), givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenVideoBidDurationNotInRequiredDurations() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(10)); + final Imp videoImp = givenVideoImp(identity(), builder -> builder.rqddurs(List.of(5, 15))); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenAudioBidDurationNotInRequiredDurations() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(10)); + final Imp audioImp = givenAudioImp(identity(), builder -> builder.rqddurs(List.of(5, 15))); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(audioImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(audioBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenVideoBidDurationLessThanMinDuration() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(3)); + final Imp videoImp = givenVideoImp(identity(), videoBuilder -> videoBuilder.minduration(5)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenAudioBidDurationLessThanMinDuration() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(3)); + final Imp audioImp = givenVideoImp(identity(), videoBuilder -> videoBuilder.minduration(5)); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(audioImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(audioBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenVideoBidDurationGreaterThanMaxDuration() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(35)); + final Imp videoImp = givenVideoImp(identity(), videoBuilder -> videoBuilder.maxduration(20)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenAudioBidDurationGreaterThanMaxDuration() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(35)); + final Imp audioImp = givenAudioImp(identity(), videoBuilder -> videoBuilder.maxduration(20)); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(audioImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(audioBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenVideoBidDurationGreaterThanHighestRequestDurationBucket() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(25)); + final Imp videoImp = givenVideoImp(identity(), builder -> builder.rqddurs(singletonList(25))); + + final ExtRequest extRequest = ExtRequest.of(ExtRequestPrebid.builder() + .targeting(ExtRequestTargeting.builder().durationrangesec(asList(5, 10, 20)).build()) + .build()); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(videoImp)) + .cur(singletonList("USD")).ext(extRequest).build(); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(bidRequest, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=unknown"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenAudioBidDurationGreaterThanHighestRequestDurationBucket() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(25)); + final Imp audioImp = givenAudioImp(identity(), builder -> builder.rqddurs(singletonList(25))); + + final ExtRequest extRequest = ExtRequest.of(ExtRequestPrebid.builder() + .targeting(ExtRequestTargeting.builder().durationrangesec(asList(5, 10, 20)).build()) + .build()); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(audioImp)) + .cur(singletonList("USD")).ext(extRequest).build(); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(bidRequest, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=unknown"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(audioBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + } + + @Test + public void validateShouldFailAdPoddingWhenMinCpmPerSecCheckFailsForVideo() { + // given + final BigDecimal bidPrice = BigDecimal.valueOf(1.0); + final Integer duration = 10; + final BigDecimal mincpmpersec = BigDecimal.valueOf(0.05); + + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(duration).price(bidPrice)); + final Imp videoImp = givenVideoImp(identity(), videoBuilder -> videoBuilder + .rqddurs(singletonList(duration)) + .mincpmpersec(mincpmpersec)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(videoBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + verify(currencyConversionService).convertCurrency(eq(new BigDecimal("0.50")), any(), eq("USD"), eq("USD")); + } + + @Test + public void validateShouldFailAdPoddingWhenMinCpmPerSecCheckFailsForAudio() { + // given + final BigDecimal bidPrice = BigDecimal.valueOf(1.0); + final Integer duration = 10; + final BigDecimal mincpmpersec = BigDecimal.valueOf(0.05); + + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(duration).price(bidPrice)); + final Imp audioImp = givenAudioImp(identity(), builder -> builder + .rqddurs(singletonList(duration)) + .mincpmpersec(mincpmpersec)); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(audioImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.getErrors()).containsExactly( + "BidResponse validation `enforce`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.err); + verify(bidRejectionTracker).rejectBid(audioBid, BidRejectionReason.RESPONSE_REJECTED_INVALID_CREATIVE); + verify(currencyConversionService).convertCurrency(eq(new BigDecimal("0.50")), any(), eq("USD"), eq("USD")); + } + + @Test + public void validateShouldPassAdPoddingWhenMinCpmPerSecIsNullForVideo() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(15).price(BigDecimal.ONE)); + final Imp videoImp = givenVideoImp(identity(), builder -> builder + .rqddurs(singletonList(15)).mincpmpersec(null)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + verify(currencyConversionService, never()).convertCurrency(any(), any(), any(), any()); + } + + @Test + public void validateShouldPassAdPoddingWhenMinCpmPerSecIsNullForAudio() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(15).price(BigDecimal.ONE)); + final Imp audioImp = givenAudioImp(identity(), builder -> builder + .rqddurs(singletonList(15)).mincpmpersec(null)); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(audioImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + verify(currencyConversionService, never()).convertCurrency(any(), any(), any(), any()); + } + + @Test + public void validateShouldPassAdPoddingWhenMinCpmPerSecCheckPassesForVideo() { + // given + final Integer duration = 15; + final BigDecimal bidPrice = BigDecimal.valueOf(1.0); + final BigDecimal mincpmpersec = BigDecimal.valueOf(0.10); + + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(duration).price(bidPrice)); + final Imp videoImp = givenVideoImp(identity(), videoBuilder -> videoBuilder + .rqddurs(singletonList(duration)).minduration(10).maxduration(20).mincpmpersec(mincpmpersec)); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImp, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void validateShouldPassAdPoddingWhenMinCpmPerSecCheckPassesForAudio() { + // given + final Integer duration = 15; + final BigDecimal bidPrice = BigDecimal.valueOf(1.0); + final BigDecimal mincpmpersec = BigDecimal.valueOf(0.10); + + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(duration).price(bidPrice)); + final Imp audioImp = givenAudioImp(identity(), builder -> builder + .rqddurs(singletonList(duration)).minduration(10).maxduration(20).mincpmpersec(mincpmpersec)); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(audioImp)) + .cur(singletonList("USD")).build(); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(bidRequest, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void validateShouldPassAdPoddingForValidVideoBidWithAllChecksPassing() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(15).price(BigDecimal.ONE)); + final Imp videoImp = givenVideoImp(identity(), builder -> builder.minduration(10).maxduration(20)); + + final ExtRequest extRequest = ExtRequest.of(ExtRequestPrebid.builder() + .targeting(ExtRequestTargeting.builder().durationrangesec(asList(10, 15, 20)).build()) + .build()); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(videoImp)) + .cur(singletonList("USD")).ext(extRequest).build(); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(bidRequest, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void validateShouldPassAdPoddingForValidAudioBidWithAllChecksPassing() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(15).price(BigDecimal.ONE)); + final Imp audioImp = givenAudioImp(identity(), builder -> builder.minduration(10).maxduration(20)); + + final ExtRequest extRequest = ExtRequest.of(ExtRequestPrebid.builder() + .targeting(ExtRequestTargeting.builder().durationrangesec(asList(10, 15, 20)).build()) + .build()); + final BidRequest bidRequest = BidRequest.builder().imp(singletonList(audioImp)) + .cur(singletonList("USD")).ext(extRequest).build(); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(bidRequest, givenAccountWithAdPodEnforcement(enforce)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).isEmpty(); + verify(metrics, never()).updateAdPoddingValidationMetrics(anyString(), anyString(), any()); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void validateShouldWarnAdPoddingWhenVideoBidDurationIsZeroAndEnforcementIsWarn() { + // given + final BidderBid videoBid = givenBid(BidType.video, builder -> builder.dur(0)); + final Imp videoImp = givenVideoImp(identity(), identity()); + + // when + final ValidationResult result = target.validate( + videoBid, + BIDDER_NAME, + givenAuctionContext(videoImp, givenAccountWithAdPodEnforcement(warn)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).containsExactly( + "BidResponse validation `warn`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.warn); + verifyNoInteractions(bidRejectionTracker); + } + + @Test + public void validateShouldWarnAdPoddingWhenAudioBidDurationIsZeroAndEnforcementIsWarn() { + // given + final BidderBid audioBid = givenBid(BidType.audio, builder -> builder.dur(0)); + + // when + final ValidationResult result = target.validate( + audioBid, + BIDDER_NAME, + givenAuctionContext(givenAudioImp(identity(), identity()), givenAccountWithAdPodEnforcement(warn)), + bidderAliases); + + // then + assertThat(result.hasErrors()).isFalse(); + assertThat(result.getWarnings()).containsExactly( + "BidResponse validation `warn`: bidder `bidder` response triggers ad podding" + + " validation for bid bidId, account=account, referrer=referrer"); + verify(metrics).updateAdPoddingValidationMetrics(BIDDER_NAME, ACCOUNT_ID, MetricName.warn); + verifyNoInteractions(bidRejectionTracker); + } + + private static BidderBid givenBid(BidType type, String bidCurrency, UnaryOperator bidCustomizer) { + return BidderBid.of(bidCustomizer.apply(Bid.builder() + .id("bidId") + .adm("adm") + .nurl("nurl") + .impid("impId") + .crid("crid") + .w(1) + .h(1) + .price(BigDecimal.ONE) + .dur(15) + .adm("https://site.com/creative.jpg")) + .build(), + type, + bidCurrency); } private static BidderBid givenBid(BidType type, UnaryOperator bidCustomizer) { return givenBid(type, "USD", bidCustomizer); } - private static BidderBid givenBid(BidType type, String bidCurrency, UnaryOperator bidCustomizer) { - final Bid.BidBuilder bidBuilder = Bid.builder() - .id("bidId1") - .adm("adm1") - .nurl("nurl") - .impid("impId1") - .crid("crid1") - .w(1) - .h(1) - .adm("https://site.com/creative.jpg") - .price(BigDecimal.ONE); + private static Imp givenVideoImp(UnaryOperator impCustomizer, + UnaryOperator videoCustomizer) { - return BidderBid.of(bidCustomizer.apply(bidBuilder).build(), type, bidCurrency); + return impCustomizer.apply(Imp.builder() + .id("impId") + .video(videoCustomizer.apply(Video.builder().podid(1)).build())) + .build(); + } + + private static Imp givenAudioImp(UnaryOperator impCustomizer, + UnaryOperator audioCustomizer) { + + return impCustomizer.apply(Imp.builder() + .id("impId") + .audio(audioCustomizer.apply(Audio.builder().podid(1)).build())).build(); + } + + private BidRequest givenBidRequest(UnaryOperator impCustomizer) { + final ObjectNode ext = mapper.createObjectNode().set( + "prebid", mapper.createObjectNode().set( + "bidder", mapper.createObjectNode().set( + BIDDER_NAME, mapper.createObjectNode().put( + "dealsonly", true)))); + + final Imp imp = impCustomizer.apply(Imp.builder() + .id("impId") + .banner(Banner.builder() + .format(asList( + Format.builder().w(100).h(200).build(), + Format.builder().w(50).h(50).build())) + .build()) + .ext(ext)) + .build(); + + return BidRequest.builder().imp(singletonList(imp)).cur(singletonList("USD")).build(); } private AuctionContext givenAuctionContext(BidRequest bidRequest, Account account) { return AuctionContext.builder() - .bidRejectionTrackers(Map.of("bidder", bidRejectionTracker)) + .bidRejectionTrackers(Map.of(BIDDER_NAME, bidRejectionTracker)) .account(account) .bidRequest(bidRequest) .build(); @@ -648,20 +1346,17 @@ private AuctionContext givenAuctionContext() { return givenAuctionContext(givenBidRequest(identity()), givenAccount()); } - private static BidRequest givenBidRequest(UnaryOperator impCustomizer) { - final Imp.ImpBuilder impBuilder = Imp.builder() - .id("impId1") - .banner(Banner.builder() - .format(asList(Format.builder().w(100).h(200).build(), Format.builder().w(50).h(50).build())) - .build()) - .ext(mapper.createObjectNode().set( - "prebid", mapper.createObjectNode().set( - "bidder", mapper.createObjectNode() - .putNull(BIDDER_NAME)))); - - return BidRequest.builder() - .imp(singletonList(impCustomizer.apply(impBuilder).build())) + private AuctionContext givenAuctionContext(Imp imp, Account account) { + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(imp)) + .site(Site.builder().page("referrer").build()) + .cur(singletonList("USD")) .build(); + return givenAuctionContext(bidRequest, account); + } + + private AuctionContext givenAuctionContext(Imp imp) { + return givenAuctionContext(imp, givenAccount()); } private static Account givenAccount() { @@ -671,4 +1366,11 @@ private static Account givenAccount() { private static Account givenAccount(UnaryOperator accountCustomizer) { return accountCustomizer.apply(Account.builder().id(ACCOUNT_ID)).build(); } + + private Account givenAccountWithAdPodEnforcement(BidValidationEnforcement enforcement) { + return givenAccount(accountBuilder -> accountBuilder + .auction(AccountAuctionConfig.builder() + .bidValidations(AccountBidValidationConfig.of(skip, enforcement)) + .build())); + } } From d5089fcad2a53076aef123c2bdc29c50689efcbd Mon Sep 17 00:00:00 2001 From: antonbabak Date: Wed, 11 Jun 2025 16:23:01 +0200 Subject: [PATCH 2/3] Build Fix --- .../ortb2/blocking/v1/Ortb2BlockingBidderRequestHookTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/extra/modules/ortb2-blocking/src/test/java/org/prebid/server/hooks/modules/ortb2/blocking/v1/Ortb2BlockingBidderRequestHookTest.java b/extra/modules/ortb2-blocking/src/test/java/org/prebid/server/hooks/modules/ortb2/blocking/v1/Ortb2BlockingBidderRequestHookTest.java index 9ecb3380322..b95561bd8ff 100644 --- a/extra/modules/ortb2-blocking/src/test/java/org/prebid/server/hooks/modules/ortb2/blocking/v1/Ortb2BlockingBidderRequestHookTest.java +++ b/extra/modules/ortb2-blocking/src/test/java/org/prebid/server/hooks/modules/ortb2/blocking/v1/Ortb2BlockingBidderRequestHookTest.java @@ -264,6 +264,7 @@ private static BidderInfo bidderInfo(OrtbVersion ortbVersion) { true, ortbVersion, false, + false, null, null, null, From d1fc10a74841ebcc6a0c29c2da28375a22e1a1f3 Mon Sep 17 00:00:00 2001 From: antonbabak Date: Thu, 12 Jun 2025 14:54:24 +0200 Subject: [PATCH 3/3] Small fix --- .../AdPoddingBidDeduplicationService.java | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java b/src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java index 6823096a500..22d6a170270 100644 --- a/src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java +++ b/src/main/java/org/prebid/server/auction/adpodding/AdPoddingBidDeduplicationService.java @@ -27,10 +27,12 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -95,32 +97,37 @@ private void deduplicateBids(List imps, .filter(impFilter) .toList(); - final Map> impIdToBids = bids.stream() + final Map, List>> impIdToBids = bids.stream() .filter(bidFilter) - .collect(Collectors.groupingBy(bid -> correspondingImp(bid.getBid().getImpid(), filteredImps).getId())); - - for (List filteredBids : impIdToBids.values()) { - BigDecimal highestCpmPerSecond = BigDecimal.ZERO; - BidderBid winnerBid = null; - for (BidderBid bidderBid : filteredBids) { - final Bid bid = bidderBid.getBid(); - final Integer duration = ObjectUtils.firstNonNull(bid.getDur(), getBidMetaDuration(bid)); - final BigDecimal cpmPerSecond = bid.getPrice() - .divide(BigDecimal.valueOf(duration), 4, RoundingMode.HALF_EVEN); - - if (cpmPerSecond.compareTo(highestCpmPerSecond) > 0) { - highestCpmPerSecond = cpmPerSecond; - if (winnerBid != null) { - bidRejectionTracker.rejectBid(winnerBid, BidRejectionReason.RESPONSE_REJECTED_DUPLICATE); + .collect(Collectors.groupingBy( + bid -> correspondingImp(bid.getBid().getImpid(), filteredImps).getId(), + //todo: what if adomain is absent + Collectors.groupingBy(bid -> new HashSet<>(bid.getBid().getAdomain())))); + + for (Map, List> adomainToBids : impIdToBids.values()) { + for (List filteredBids : adomainToBids.values()) { + BigDecimal highestCpmPerSecond = BigDecimal.ZERO; + BidderBid winnerBid = null; + for (BidderBid bidderBid : filteredBids) { + final Bid bid = bidderBid.getBid(); + final Integer duration = ObjectUtils.firstNonNull(bid.getDur(), getBidMetaDuration(bid)); + final BigDecimal cpmPerSecond = bid.getPrice() + .divide(BigDecimal.valueOf(duration), 4, RoundingMode.HALF_EVEN); + + if (cpmPerSecond.compareTo(highestCpmPerSecond) > 0) { + highestCpmPerSecond = cpmPerSecond; + if (winnerBid != null) { + bidRejectionTracker.rejectBid(winnerBid, BidRejectionReason.RESPONSE_REJECTED_DUPLICATE); + } + winnerBid = bidderBid; + } else { + bidRejectionTracker.rejectBid(bidderBid, BidRejectionReason.RESPONSE_REJECTED_DUPLICATE); } - winnerBid = bidderBid; - } else { - bidRejectionTracker.rejectBid(bidderBid, BidRejectionReason.RESPONSE_REJECTED_DUPLICATE); } - } - if (winnerBid != null) { - winningBids.add(winnerBid); + if (winnerBid != null) { + winningBids.add(winnerBid); + } } } }