From 40429928cfd93ade2df324de5a5fda95a42ede84 Mon Sep 17 00:00:00 2001 From: antonbabak Date: Tue, 15 Jul 2025 15:35:56 +0200 Subject: [PATCH 1/4] Account config for limiting number of impressions --- docs/application-settings.md | 1 + .../org/prebid/server/metric/MetricName.java | 1 + .../org/prebid/server/metric/Metrics.java | 4 ++ .../settings/model/AccountAuctionConfig.java | 3 + .../server/validation/RequestValidator.java | 50 +++++++++----- .../org/prebid/server/metric/MetricsTest.java | 9 +++ .../validation/RequestValidatorTest.java | 69 +++++++++++++++++++ 7 files changed, 120 insertions(+), 17 deletions(-) diff --git a/docs/application-settings.md b/docs/application-settings.md index 7a4a72f38d1..bf89c1c3d83 100644 --- a/docs/application-settings.md +++ b/docs/application-settings.md @@ -12,6 +12,7 @@ There are two ways to configure application settings: database and file. This do - `auction.truncate-target-attr` - Maximum targeting attributes size. Values between 1 and 255. - `auction.default-integration` - Default integration to assume. - `auction.debug-allow` - enables debug output in the auction response. Default `true`. +- `auction.impression-limit` - a max number of impressions allowed for the auction, impressions that exceed this limit will be dropped, 0 means no limit. - `auction.bid-validations.banner-creative-max-size` - Overrides creative max size validation for banners. Valid values are: - "skip": don't do anything about creative max size for this publisher diff --git a/src/main/java/org/prebid/server/metric/MetricName.java b/src/main/java/org/prebid/server/metric/MetricName.java index 84bb68f1c67..ab32c446226 100644 --- a/src/main/java/org/prebid/server/metric/MetricName.java +++ b/src/main/java/org/prebid/server/metric/MetricName.java @@ -31,6 +31,7 @@ public enum MetricName { request_time, prices, imps_requested, + imps_dropped, imps_banner, imps_video, imps_native, diff --git a/src/main/java/org/prebid/server/metric/Metrics.java b/src/main/java/org/prebid/server/metric/Metrics.java index 00295decad3..9010a7018c5 100644 --- a/src/main/java/org/prebid/server/metric/Metrics.java +++ b/src/main/java/org/prebid/server/metric/Metrics.java @@ -182,6 +182,10 @@ public void updateAppAndNoCookieAndImpsRequestedMetrics(boolean isApp, boolean l incCounter(MetricName.imps_requested, numImps); } + public void updateImpsDroppedMetric(int numImps) { + incCounter(MetricName.imps_dropped, numImps); + } + public void updateImpTypesMetrics(List imps) { final Map mediaTypeToCount = imps.stream() 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..397047bf240 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,7 @@ public class AccountAuctionConfig { AccountCacheConfig cache; AccountBidRankingConfig ranking; + + @JsonAlias("impression-limit") + Integer impressionLimit; } diff --git a/src/main/java/org/prebid/server/validation/RequestValidator.java b/src/main/java/org/prebid/server/validation/RequestValidator.java index d4b9fb06726..05a3e62ad39 100644 --- a/src/main/java/org/prebid/server/validation/RequestValidator.java +++ b/src/main/java/org/prebid/server/validation/RequestValidator.java @@ -46,6 +46,7 @@ import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; 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.util.HttpUtil; import org.prebid.server.validation.model.ValidationResult; @@ -153,28 +154,25 @@ public ValidationResult validate(Account account, validateSchains(extRequestPrebid.getSchains()); } - if (CollectionUtils.isEmpty(bidRequest.getImp())) { - throw new ValidationException("request.imp must contain at least one element"); - } - final List imps = bidRequest.getImp(); - final List errors = new ArrayList<>(); - final Map uniqueImps = new HashMap<>(); - for (int i = 0; i < imps.size(); i++) { - final String impId = imps.get(i).getId(); - if (uniqueImps.get(impId) != null) { - errors.add("request.imp[%d].id and request.imp[%d].id are both \"%s\". Imp IDs must be unique." - .formatted(uniqueImps.get(impId), i, impId)); - } - - uniqueImps.put(impId, i); + if (CollectionUtils.isEmpty(imps)) { + throw new ValidationException("request.imp must contain at least one element"); } - if (CollectionUtils.isNotEmpty(errors)) { - throw new ValidationException(String.join(System.lineSeparator(), errors)); + BidRequest requestWithImpsDropped = bidRequest; + final int impsLimit = Optional.ofNullable(account) + .map(Account::getAuction) + .map(AccountAuctionConfig::getImpressionLimit) + .orElse(0); + if (impsLimit > 0 && imps.size() > impsLimit) { + metrics.updateImpsDroppedMetric(imps.size() - impsLimit); + warnings.add(("Only first %d impressions were kept due to the limit, " + + "all the subsequent impressions have been dropped for the auction").formatted(impsLimit)); + requestWithImpsDropped = bidRequest.toBuilder().imp(imps.stream().limit(impsLimit).toList()).build(); } - impValidator.validateImps(bidRequest.getImp(), aliases, warnings); + validateUniqueImps(requestWithImpsDropped.getImp()); + impValidator.validateImps(requestWithImpsDropped.getImp(), aliases, warnings); final List channels = new ArrayList<>(); Optional.ofNullable(bidRequest.getApp()).ifPresent(ignored -> channels.add("request.app")); @@ -361,6 +359,24 @@ private void validateSchains(List schains) throws Valida } } + private static void validateUniqueImps(List imps) throws ValidationException { + final List errors = new ArrayList<>(); + final Map uniqueImps = new HashMap<>(); + for (int i = 0; i < imps.size(); i++) { + final String impId = imps.get(i).getId(); + if (uniqueImps.get(impId) != null) { + errors.add("request.imp[%d].id and request.imp[%d].id are both \"%s\". Imp IDs must be unique." + .formatted(uniqueImps.get(impId), i, impId)); + } + + uniqueImps.put(impId, i); + } + + if (CollectionUtils.isNotEmpty(errors)) { + throw new ValidationException(String.join(System.lineSeparator(), errors)); + } + } + private void validateExtBidPrebidData(ExtRequestPrebidData data, Map aliases, boolean isDebugEnabled, diff --git a/src/test/java/org/prebid/server/metric/MetricsTest.java b/src/test/java/org/prebid/server/metric/MetricsTest.java index 243ef33e66d..73e9b902168 100644 --- a/src/test/java/org/prebid/server/metric/MetricsTest.java +++ b/src/test/java/org/prebid/server/metric/MetricsTest.java @@ -332,6 +332,15 @@ public void updateAppAndNoCookieAndImpsRequestedMetricsShouldIncrementMetrics() assertThat(metricRegistry.counter("imps_requested").getCount()).isEqualTo(4); } + @Test + public void updateImpsDroppedMetricShouldIncrementMetrics() { + // when + metrics.updateImpsDroppedMetric(2); + + // then + assertThat(metricRegistry.counter("imps_dropped").getCount()).isEqualTo(2); + } + @Test public void updateDebugRequestsMetricsShouldIncrementMetrics() { // when diff --git a/src/test/java/org/prebid/server/validation/RequestValidatorTest.java b/src/test/java/org/prebid/server/validation/RequestValidatorTest.java index e4efd99feb9..0dfb2fd0532 100644 --- a/src/test/java/org/prebid/server/validation/RequestValidatorTest.java +++ b/src/test/java/org/prebid/server/validation/RequestValidatorTest.java @@ -45,6 +45,7 @@ import org.prebid.server.proto.openrtb.ext.request.ExtUserPrebid; import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; import org.prebid.server.settings.model.Account; +import org.prebid.server.settings.model.AccountAuctionConfig; import org.prebid.server.validation.model.ValidationResult; import java.math.BigDecimal; @@ -61,11 +62,13 @@ import static java.util.Collections.singletonMap; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mock.Strictness.LENIENT; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -562,6 +565,72 @@ public void validateShouldReturnValidationMessageWhenRequestHaveDuplicatedImpIds .containsOnly("request.imp[0].id and request.imp[1].id are both \"11\". Imp IDs must be unique."); } + @Test + public void validateShouldDropImpressionsOverAccountLimitAndReturnWarning() throws ValidationException { + // given + final Imp imp1 = Imp.builder().id("1").build(); + final Imp imp2 = Imp.builder().id("2").build(); + final BidRequest bidRequest = validBidRequestBuilder().imp(asList(imp1, imp2)).build(); + + final Account givenAccount = Account.builder() + .id(ACCOUNT_ID) + .auction(AccountAuctionConfig.builder().impressionLimit(1).build()) + .build(); + + // when + final ValidationResult result = target.validate(givenAccount, bidRequest, null, null); + + // then + assertThat(result.getWarnings()).hasSize(1) + .containsOnly("Only first 1 impressions were kept due to the limit, " + + "all the subsequent impressions have been dropped for the auction"); + assertThat(result.getErrors()).isEmpty(); + + verify(metrics).updateImpsDroppedMetric(1); + verify(impValidator).validateImps(eq(singletonList(imp1)), any(), any()); + } + + @Test + public void validateShouldNotDropImpressionsReturnWarningWhenAccountLimitIsSetToZero() throws ValidationException { + // given + final Imp imp1 = Imp.builder().id("1").build(); + final Imp imp2 = Imp.builder().id("2").build(); + final BidRequest bidRequest = validBidRequestBuilder().imp(asList(imp1, imp2)).build(); + + final Account givenAccount = Account.builder() + .id(ACCOUNT_ID) + .auction(AccountAuctionConfig.builder().impressionLimit(0).build()) + .build(); + + // when + final ValidationResult result = target.validate(givenAccount, bidRequest, null, null); + + // then + assertThat(result.getWarnings()).isEmpty(); + assertThat(result.getErrors()).isEmpty(); + + verify(metrics, never()).updateImpsDroppedMetric(anyInt()); + verify(impValidator).validateImps(eq(bidRequest.getImp()), any(), any()); + } + + @Test + public void validateShouldNotDropImpressionsReturnWarningWhenAccountLimitIsNotSet() throws ValidationException { + // given + final Imp imp1 = Imp.builder().id("1").build(); + final Imp imp2 = Imp.builder().id("2").build(); + final BidRequest bidRequest = validBidRequestBuilder().imp(asList(imp1, imp2)).build(); + + // when + final ValidationResult result = target.validate(Account.empty(ACCOUNT_ID), bidRequest, null, null); + + // then + assertThat(result.getWarnings()).isEmpty(); + assertThat(result.getErrors()).isEmpty(); + + verify(metrics, never()).updateImpsDroppedMetric(anyInt()); + verify(impValidator).validateImps(eq(bidRequest.getImp()), any(), any()); + } + @Test public void validateShouldNotReturnValidationMessageIfUserExtIsEmptyJsonObject() { // given From e95296013f824cc255a4936a15238ccdbe0766ad Mon Sep 17 00:00:00 2001 From: antonbabak Date: Fri, 18 Jul 2025 15:21:24 +0200 Subject: [PATCH 2/4] Account config for limiting number of impressions --- .../requestfactory/AmpRequestFactory.java | 4 ++ .../requestfactory/AuctionRequestFactory.java | 1 + .../requestfactory/Ortb2RequestFactory.java | 18 +++++ .../requestfactory/VideoRequestFactory.java | 6 ++ .../server/validation/RequestValidator.java | 50 +++++--------- .../requestfactory/AmpRequestFactoryTest.java | 2 + .../AuctionRequestFactoryTest.java | 2 + .../Ortb2RequestFactoryTest.java | 68 ++++++++++++++++++ .../VideoRequestFactoryTest.java | 2 + .../validation/RequestValidatorTest.java | 69 ------------------- 10 files changed, 120 insertions(+), 102 deletions(-) diff --git a/src/main/java/org/prebid/server/auction/requestfactory/AmpRequestFactory.java b/src/main/java/org/prebid/server/auction/requestfactory/AmpRequestFactory.java index 6d2ac3ee72d..b8fcd6d6301 100644 --- a/src/main/java/org/prebid/server/auction/requestfactory/AmpRequestFactory.java +++ b/src/main/java/org/prebid/server/auction/requestfactory/AmpRequestFactory.java @@ -414,6 +414,10 @@ private Future updateBidRequest(AuctionContext auctionContext) { .map(bidRequest -> overrideParameters(bidRequest, httpRequest, auctionContext.getPrebidErrors())) .map(bidRequest -> paramsResolver.resolve(bidRequest, auctionContext, ENDPOINT, true)) .map(bidRequest -> ortb2RequestFactory.removeEmptyEids(bidRequest, auctionContext.getDebugWarnings())) + .compose(resolvedBidRequest -> ortb2RequestFactory.limitImpressions( + account, + resolvedBidRequest, + auctionContext.getDebugWarnings())) .compose(resolvedBidRequest -> ortb2RequestFactory.validateRequest( account, resolvedBidRequest, diff --git a/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java b/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java index 628ea212fd8..13df5f4df82 100644 --- a/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java +++ b/src/main/java/org/prebid/server/auction/requestfactory/AuctionRequestFactory.java @@ -241,6 +241,7 @@ private Future updateAndValidateBidRequest(AuctionContext auctionCon return storedRequestProcessor.processAuctionRequest(account.getId(), auctionContext.getBidRequest()) .compose(auctionStoredResult -> updateBidRequest(auctionStoredResult, auctionContext)) + .compose(bidRequest -> ortb2RequestFactory.limitImpressions(account, bidRequest, debugWarnings)) .compose(bidRequest -> ortb2RequestFactory.validateRequest( account, bidRequest, httpRequest, auctionContext.getDebugContext(), debugWarnings)) .map(interstitialProcessor::process); diff --git a/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java b/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java index 5a122d5a64e..fae1569d822 100644 --- a/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java +++ b/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java @@ -6,6 +6,7 @@ import com.iab.openrtb.request.Dooh; import com.iab.openrtb.request.Eid; import com.iab.openrtb.request.Geo; +import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Publisher; import com.iab.openrtb.request.Regs; import com.iab.openrtb.request.Site; @@ -192,6 +193,23 @@ public Future activityInfrastructureFrom(AuctionContext auctionContext.getDebugContext().getTraceLevel())); } + public Future limitImpressions(Account account, BidRequest bidRequest, List warnings) { + final List imps = bidRequest.getImp(); + final int impsLimit = Optional.ofNullable(account) + .map(Account::getAuction) + .map(AccountAuctionConfig::getImpressionLimit) + .orElse(0); + + if (impsLimit > 0 && imps.size() > impsLimit) { + metrics.updateImpsDroppedMetric(imps.size() - impsLimit); + warnings.add(("Only first %d impressions were kept due to the limit, " + + "all the subsequent impressions have been dropped for the auction").formatted(impsLimit)); + return Future.succeededFuture(bidRequest.toBuilder().imp(imps.stream().limit(impsLimit).toList()).build()); + } + + return Future.succeededFuture(bidRequest); + } + public Future validateRequest(Account account, BidRequest bidRequest, HttpRequestContext httpRequestContext, diff --git a/src/main/java/org/prebid/server/auction/requestfactory/VideoRequestFactory.java b/src/main/java/org/prebid/server/auction/requestfactory/VideoRequestFactory.java index 2e41fd97a35..811db804fb9 100644 --- a/src/main/java/org/prebid/server/auction/requestfactory/VideoRequestFactory.java +++ b/src/main/java/org/prebid/server/auction/requestfactory/VideoRequestFactory.java @@ -119,6 +119,12 @@ public Future> fromRequest(RoutingContext routingC .map(auctionContext -> auctionContext.with(debugResolver.debugContextFrom(auctionContext))) + .compose(auctionContext -> ortb2RequestFactory.limitImpressions( + auctionContext.getAccount(), + auctionContext.getBidRequest(), + auctionContext.getDebugWarnings()) + .map(auctionContext::with)) + .compose(auctionContext -> ortb2RequestFactory.validateRequest( auctionContext.getAccount(), auctionContext.getBidRequest(), diff --git a/src/main/java/org/prebid/server/validation/RequestValidator.java b/src/main/java/org/prebid/server/validation/RequestValidator.java index 05a3e62ad39..d4b9fb06726 100644 --- a/src/main/java/org/prebid/server/validation/RequestValidator.java +++ b/src/main/java/org/prebid/server/validation/RequestValidator.java @@ -46,7 +46,6 @@ import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; 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.util.HttpUtil; import org.prebid.server.validation.model.ValidationResult; @@ -154,25 +153,28 @@ public ValidationResult validate(Account account, validateSchains(extRequestPrebid.getSchains()); } - final List imps = bidRequest.getImp(); - if (CollectionUtils.isEmpty(imps)) { + if (CollectionUtils.isEmpty(bidRequest.getImp())) { throw new ValidationException("request.imp must contain at least one element"); } - BidRequest requestWithImpsDropped = bidRequest; - final int impsLimit = Optional.ofNullable(account) - .map(Account::getAuction) - .map(AccountAuctionConfig::getImpressionLimit) - .orElse(0); - if (impsLimit > 0 && imps.size() > impsLimit) { - metrics.updateImpsDroppedMetric(imps.size() - impsLimit); - warnings.add(("Only first %d impressions were kept due to the limit, " - + "all the subsequent impressions have been dropped for the auction").formatted(impsLimit)); - requestWithImpsDropped = bidRequest.toBuilder().imp(imps.stream().limit(impsLimit).toList()).build(); + final List imps = bidRequest.getImp(); + final List errors = new ArrayList<>(); + final Map uniqueImps = new HashMap<>(); + for (int i = 0; i < imps.size(); i++) { + final String impId = imps.get(i).getId(); + if (uniqueImps.get(impId) != null) { + errors.add("request.imp[%d].id and request.imp[%d].id are both \"%s\". Imp IDs must be unique." + .formatted(uniqueImps.get(impId), i, impId)); + } + + uniqueImps.put(impId, i); } - validateUniqueImps(requestWithImpsDropped.getImp()); - impValidator.validateImps(requestWithImpsDropped.getImp(), aliases, warnings); + if (CollectionUtils.isNotEmpty(errors)) { + throw new ValidationException(String.join(System.lineSeparator(), errors)); + } + + impValidator.validateImps(bidRequest.getImp(), aliases, warnings); final List channels = new ArrayList<>(); Optional.ofNullable(bidRequest.getApp()).ifPresent(ignored -> channels.add("request.app")); @@ -359,24 +361,6 @@ private void validateSchains(List schains) throws Valida } } - private static void validateUniqueImps(List imps) throws ValidationException { - final List errors = new ArrayList<>(); - final Map uniqueImps = new HashMap<>(); - for (int i = 0; i < imps.size(); i++) { - final String impId = imps.get(i).getId(); - if (uniqueImps.get(impId) != null) { - errors.add("request.imp[%d].id and request.imp[%d].id are both \"%s\". Imp IDs must be unique." - .formatted(uniqueImps.get(impId), i, impId)); - } - - uniqueImps.put(impId, i); - } - - if (CollectionUtils.isNotEmpty(errors)) { - throw new ValidationException(String.join(System.lineSeparator(), errors)); - } - } - private void validateExtBidPrebidData(ExtRequestPrebidData data, Map aliases, boolean isDebugEnabled, diff --git a/src/test/java/org/prebid/server/auction/requestfactory/AmpRequestFactoryTest.java b/src/test/java/org/prebid/server/auction/requestfactory/AmpRequestFactoryTest.java index fe4c72b89ca..b521d8623d6 100644 --- a/src/test/java/org/prebid/server/auction/requestfactory/AmpRequestFactoryTest.java +++ b/src/test/java/org/prebid/server/auction/requestfactory/AmpRequestFactoryTest.java @@ -1757,6 +1757,8 @@ private void givenBidRequest( given(ortb2ImplicitParametersResolver.resolve(any(), any(), any(), anyBoolean())).willAnswer( answerWithFirstArgument()); + given(ortb2RequestFactory.limitImpressions(any(), any(), any())) + .willAnswer(invocation -> Future.succeededFuture((BidRequest) invocation.getArgument(1))); given(ortb2RequestFactory.validateRequest(any(), any(), any(), any(), any())) .willAnswer(invocation -> Future.succeededFuture((BidRequest) invocation.getArgument(1))); diff --git a/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java b/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java index 19e414e80c9..7f07e0107e7 100644 --- a/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java +++ b/src/test/java/org/prebid/server/auction/requestfactory/AuctionRequestFactoryTest.java @@ -167,6 +167,8 @@ public void setUp() { given(ortb2RequestFactory.executeRawAuctionRequestHooks(any())) .willAnswer(invocation -> Future.succeededFuture( ((AuctionContext) invocation.getArgument(0)).getBidRequest())); + given(ortb2RequestFactory.limitImpressions(any(), any(), any())) + .willAnswer(invocationOnMock -> Future.succeededFuture(invocationOnMock.getArgument(1))); given(ortb2RequestFactory.validateRequest(any(), any(), any(), any(), any())) .willAnswer(invocationOnMock -> Future.succeededFuture((BidRequest) invocationOnMock.getArgument(1))); given(ortb2RequestFactory.removeEmptyEids(any(), any())) diff --git a/src/test/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactoryTest.java b/src/test/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactoryTest.java index d5c2cb5bea3..778916127c7 100644 --- a/src/test/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactoryTest.java +++ b/src/test/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactoryTest.java @@ -7,6 +7,7 @@ import com.iab.openrtb.request.Dooh; import com.iab.openrtb.request.Eid; import com.iab.openrtb.request.Geo; +import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Publisher; import com.iab.openrtb.request.Regs; import com.iab.openrtb.request.Site; @@ -83,6 +84,7 @@ 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.Collections.emptyMap; import static java.util.Collections.singletonList; @@ -90,12 +92,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; 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.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.prebid.server.assertion.FutureAssertion.assertThat; @@ -1708,6 +1712,70 @@ public void removeEmptyEidsShouldRemoveEmptyUidsOnly() { "removed EID source3 due to empty ID"); } + @Test + public void validateShouldDropImpressionsOverAccountLimitAndReturnWarning() { + // given + final Imp imp1 = Imp.builder().id("1").build(); + final Imp imp2 = Imp.builder().id("2").build(); + final BidRequest bidRequest = givenBidRequest(request -> request.imp(asList(imp1, imp2))); + final List warning = new ArrayList<>(); + + final Account givenAccount = Account.builder() + .id(ACCOUNT_ID) + .auction(AccountAuctionConfig.builder().impressionLimit(1).build()) + .build(); + + // when + final BidRequest result = target.limitImpressions(givenAccount, bidRequest, warning).result(); + + // then + assertThat(warning).hasSize(1) + .containsOnly("Only first 1 impressions were kept due to the limit, " + + "all the subsequent impressions have been dropped for the auction"); + + verify(metrics).updateImpsDroppedMetric(1); + assertThat(result.getImp()).containsOnly(imp1); + } + + @Test + public void validateShouldNotDropImpressionsReturnWarningWhenAccountLimitIsSetToZero() { + // given + final Imp imp1 = Imp.builder().id("1").build(); + final Imp imp2 = Imp.builder().id("2").build(); + final BidRequest bidRequest = givenBidRequest(request -> request.imp(asList(imp1, imp2))); + final List warning = new ArrayList<>(); + + final Account givenAccount = Account.builder() + .id(ACCOUNT_ID) + .auction(AccountAuctionConfig.builder().impressionLimit(0).build()) + .build(); + + // when + final BidRequest result = target.limitImpressions(givenAccount, bidRequest, warning).result(); + + // then + assertThat(warning).isEmpty(); + assertThat(result).isEqualTo(bidRequest); + verify(metrics, never()).updateImpsDroppedMetric(anyInt()); + } + + @Test + public void validateShouldNotDropImpressionsReturnWarningWhenAccountLimitIsNotSet() { + // given + final Imp imp1 = Imp.builder().id("1").build(); + final Imp imp2 = Imp.builder().id("2").build(); + final BidRequest bidRequest = givenBidRequest(request -> request.imp(asList(imp1, imp2))); + final List warning = new ArrayList<>(); + + // when + final BidRequest result = target.limitImpressions(Account.empty(ACCOUNT_ID), bidRequest, warning).result(); + + // then + assertThat(warning).isEmpty(); + assertThat(result).isEqualTo(bidRequest); + verify(metrics, never()).updateImpsDroppedMetric(anyInt()); + } + private void givenTarget(int timeoutAdjustmentFactor) { target = new Ortb2RequestFactory( timeoutAdjustmentFactor, diff --git a/src/test/java/org/prebid/server/auction/requestfactory/VideoRequestFactoryTest.java b/src/test/java/org/prebid/server/auction/requestfactory/VideoRequestFactoryTest.java index ca01531b02e..ad154e06b49 100644 --- a/src/test/java/org/prebid/server/auction/requestfactory/VideoRequestFactoryTest.java +++ b/src/test/java/org/prebid/server/auction/requestfactory/VideoRequestFactoryTest.java @@ -408,6 +408,8 @@ private void givenBidRequest(BidRequest bidRequest, List podErrors) { .build()); given(ortb2RequestFactory.fetchAccountWithoutStoredRequestLookup(any())).willReturn(Future.succeededFuture()); + given(ortb2RequestFactory.limitImpressions(any(), any(), any())) + .willAnswer(invocationOnMock -> Future.succeededFuture(invocationOnMock.getArgument(1))); given(ortb2RequestFactory.validateRequest(any(), any(), any(), any(), any())) .willAnswer(invocation -> Future.succeededFuture((BidRequest) invocation.getArgument(1))); diff --git a/src/test/java/org/prebid/server/validation/RequestValidatorTest.java b/src/test/java/org/prebid/server/validation/RequestValidatorTest.java index 0dfb2fd0532..e4efd99feb9 100644 --- a/src/test/java/org/prebid/server/validation/RequestValidatorTest.java +++ b/src/test/java/org/prebid/server/validation/RequestValidatorTest.java @@ -45,7 +45,6 @@ import org.prebid.server.proto.openrtb.ext.request.ExtUserPrebid; import org.prebid.server.proto.openrtb.ext.request.ImpMediaType; import org.prebid.server.settings.model.Account; -import org.prebid.server.settings.model.AccountAuctionConfig; import org.prebid.server.validation.model.ValidationResult; import java.math.BigDecimal; @@ -62,13 +61,11 @@ import static java.util.Collections.singletonMap; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mock.Strictness.LENIENT; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -565,72 +562,6 @@ public void validateShouldReturnValidationMessageWhenRequestHaveDuplicatedImpIds .containsOnly("request.imp[0].id and request.imp[1].id are both \"11\". Imp IDs must be unique."); } - @Test - public void validateShouldDropImpressionsOverAccountLimitAndReturnWarning() throws ValidationException { - // given - final Imp imp1 = Imp.builder().id("1").build(); - final Imp imp2 = Imp.builder().id("2").build(); - final BidRequest bidRequest = validBidRequestBuilder().imp(asList(imp1, imp2)).build(); - - final Account givenAccount = Account.builder() - .id(ACCOUNT_ID) - .auction(AccountAuctionConfig.builder().impressionLimit(1).build()) - .build(); - - // when - final ValidationResult result = target.validate(givenAccount, bidRequest, null, null); - - // then - assertThat(result.getWarnings()).hasSize(1) - .containsOnly("Only first 1 impressions were kept due to the limit, " - + "all the subsequent impressions have been dropped for the auction"); - assertThat(result.getErrors()).isEmpty(); - - verify(metrics).updateImpsDroppedMetric(1); - verify(impValidator).validateImps(eq(singletonList(imp1)), any(), any()); - } - - @Test - public void validateShouldNotDropImpressionsReturnWarningWhenAccountLimitIsSetToZero() throws ValidationException { - // given - final Imp imp1 = Imp.builder().id("1").build(); - final Imp imp2 = Imp.builder().id("2").build(); - final BidRequest bidRequest = validBidRequestBuilder().imp(asList(imp1, imp2)).build(); - - final Account givenAccount = Account.builder() - .id(ACCOUNT_ID) - .auction(AccountAuctionConfig.builder().impressionLimit(0).build()) - .build(); - - // when - final ValidationResult result = target.validate(givenAccount, bidRequest, null, null); - - // then - assertThat(result.getWarnings()).isEmpty(); - assertThat(result.getErrors()).isEmpty(); - - verify(metrics, never()).updateImpsDroppedMetric(anyInt()); - verify(impValidator).validateImps(eq(bidRequest.getImp()), any(), any()); - } - - @Test - public void validateShouldNotDropImpressionsReturnWarningWhenAccountLimitIsNotSet() throws ValidationException { - // given - final Imp imp1 = Imp.builder().id("1").build(); - final Imp imp2 = Imp.builder().id("2").build(); - final BidRequest bidRequest = validBidRequestBuilder().imp(asList(imp1, imp2)).build(); - - // when - final ValidationResult result = target.validate(Account.empty(ACCOUNT_ID), bidRequest, null, null); - - // then - assertThat(result.getWarnings()).isEmpty(); - assertThat(result.getErrors()).isEmpty(); - - verify(metrics, never()).updateImpsDroppedMetric(anyInt()); - verify(impValidator).validateImps(eq(bidRequest.getImp()), any(), any()); - } - @Test public void validateShouldNotReturnValidationMessageIfUserExtIsEmptyJsonObject() { // given From f5e72ec316dfa524c163a81c2135d5ddbf2bc7e8 Mon Sep 17 00:00:00 2001 From: osulzhenko <125548596+osulzhenko@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:43:01 +0300 Subject: [PATCH 3/4] Tests: Account config for limiting number of impressions (#4072) --- .../model/config/AccountAuctionConfig.groovy | 4 + .../functional/tests/AuctionSpec.groovy | 169 +++++++++++++++++- 2 files changed, 170 insertions(+), 3 deletions(-) diff --git a/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy b/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy index bf49ce7c874..21a60bef192 100644 --- a/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy @@ -35,6 +35,7 @@ class AccountAuctionConfig { @JsonProperty("bidadjustments") BidAdjustment bidAdjustments BidRounding bidRounding + Integer impressionLimit @JsonProperty("price_granularity") PriceGranularityType priceGranularitySnakeCase @@ -54,4 +55,7 @@ class AccountAuctionConfig { AccountPriceFloorsConfig priceFloorsSnakeCase @JsonProperty("bid_rounding") BidRounding bidRoundingSnakeCase + @JsonProperty("impression_limit") + Integer impressionLimitSnakeCase + } diff --git a/src/test/groovy/org/prebid/server/functional/tests/AuctionSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/AuctionSpec.groovy index a9bc17dfe1d..1506e2e0a4d 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/AuctionSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/AuctionSpec.groovy @@ -9,6 +9,7 @@ import org.prebid.server.functional.model.db.Account import org.prebid.server.functional.model.request.auction.BidRequest import org.prebid.server.functional.model.request.auction.Device import org.prebid.server.functional.model.request.auction.DeviceExt +import org.prebid.server.functional.model.request.auction.Imp import org.prebid.server.functional.model.request.auction.PrebidStoredRequest import org.prebid.server.functional.model.request.auction.Renderer import org.prebid.server.functional.model.request.auction.RendererData @@ -47,15 +48,18 @@ class AuctionSpec extends BaseSpec { private static final Integer DEFAULT_TIMEOUT = getRandomTimeout() private static final Integer MIN_BID_ID_LENGTH = 17 private static final Integer DEFAULT_UUID_LENGTH = 36 - private static final Map PBS_CONFIG = ["auction.biddertmax.max" : MAX_TIMEOUT as String, - "auction.default-timeout-ms": DEFAULT_TIMEOUT as String] private static final Map GENERIC_CONFIG = [ "adapters.${GENERIC.value}.usersync.${USER_SYNC_TYPE.value}.url" : USER_SYNC_URL, "adapters.${GENERIC.value}.usersync.${USER_SYNC_TYPE.value}.support-cors": CORS_SUPPORT.toString()] - @Shared PrebidServerService prebidServerService = pbsServiceFactory.getService(PBS_CONFIG) + private static final String IMPS_REQUESTED_METRIC = 'imps_requested' + private static final String IMPS_DROPPED_METRIC = 'imps_dropped' + private static final Integer IMP_LIMIT = 1 + private static final Map PBS_CONFIG = ["auction.biddertmax.max" : MAX_TIMEOUT as String, + "auction.default-timeout-ms": DEFAULT_TIMEOUT as String] + def "PBS should return version in response header for auction request for #description"() { when: "PBS processes auction request" def response = defaultPbsService.sendAuctionRequestRaw(bidRequest) @@ -721,4 +725,163 @@ class AuctionSpec extends BaseSpec { cleanup: "Stop and remove pbs container" pbsServiceFactory.removeContainer(pbsConfig) } + + def "PBS should drop extra impressions with warnings when number of impressions exceeds impression-limit"() { + given: "Bid request with multiple imps" + def bidRequest = BidRequest.defaultBidRequest.tap { + imp.add(Imp.getDefaultImpression()) + } + + and: "Account in the DB with impression limit config" + def accountConfig = new AccountConfig(auction: accountAuctionConfig) + def account = new Account(uuid: bidRequest.getAccountId(), config: accountConfig) + accountDao.save(account) + + and: "Flush metrics" + flushMetrics(defaultPbsService) + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain seatNonBid" + assert !response?.ext?.seatnonbid + + and: "PBS should emit an warning" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == + ["Only first $IMP_LIMIT impressions were kept due to the limit, " + + "all the subsequent impressions have been dropped for the auction" as String] + + and: "PBS shouldn't emit an error" + assert !response.ext?.errors + + and: "Metrics for imps should be updated" + def metrics = defaultPbsService.sendCollectedMetricsRequest() + assert metrics[IMPS_DROPPED_METRIC] == bidRequest.imp.size() - IMP_LIMIT + assert metrics[IMPS_REQUESTED_METRIC] == IMP_LIMIT + + and: "Response should contain seat bid" + assert response.seatbid[0].bid.size() == IMP_LIMIT + + and: "Bidder request should contain imps according to limit" + assert bidder.getBidderRequest(bidRequest.id).imp.size() == IMP_LIMIT + + where: + accountAuctionConfig << [ + new AccountAuctionConfig(impressionLimit: IMP_LIMIT), + new AccountAuctionConfig(impressionLimitSnakeCase: IMP_LIMIT) + ] + } + + def "PBS shouldn't drop extra impressions when number of impressions equal to impression-limit"() { + given: "Bid request with multiple imps" + def bidRequest = BidRequest.defaultBidRequest.tap { + imp.add(Imp.getDefaultImpression()) + } + + and: "Account in the DB with impression limit config" + def accountConfig = new AccountConfig(auction: new AccountAuctionConfig(impressionLimit: bidRequest.imp.size())) + def account = new Account(uuid: bidRequest.getAccountId(), config: accountConfig) + accountDao.save(account) + + and: "Flush metrics" + flushMetrics(defaultPbsService) + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain seatNonBid" + assert !response?.ext?.seatnonbid + + and: "Response shouldn't contain warnings and error" + assert !response.ext?.warnings + assert !response.ext?.errors + + and: "Metrics for imps requested should be updated" + def metrics = defaultPbsService.sendCollectedMetricsRequest() + assert metrics[IMPS_REQUESTED_METRIC] == bidRequest.imp.size() + assert !metrics[IMPS_DROPPED_METRIC] + + and: "Response should contain seat bid" + assert response.seatbid[0].bid.size() == bidRequest.imp.size() + + and: "Bidder request should contain originals imps" + assert bidder.getBidderRequest(bidRequest.id).imp.size() == bidRequest.imp.size() + } + + def "PBS shouldn't drop extra impressions when number of impressions less than or equal to impression-limit"() { + given: "Bid request with multiple imps" + def bidRequest = BidRequest.defaultBidRequest.tap { + imp.add(Imp.getDefaultImpression()) + } + + and: "Account in the DB with impression limit config" + def impressionLimit = bidRequest.imp.size() + 1 + def accountConfig = new AccountConfig(auction: new AccountAuctionConfig(impressionLimit: impressionLimit)) + def account = new Account(uuid: bidRequest.getAccountId(), config: accountConfig) + accountDao.save(account) + + and: "Flush metrics" + flushMetrics(defaultPbsService) + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain seatNonBid" + assert !response?.ext?.seatnonbid + + and: "Response shouldn't contain warnings and error" + assert !response.ext?.warnings + assert !response.ext?.errors + + and: "Metrics for imps requested should be updated" + def metrics = defaultPbsService.sendCollectedMetricsRequest() + assert metrics[IMPS_REQUESTED_METRIC] == bidRequest.imp.size() + assert !metrics[IMPS_DROPPED_METRIC] + + and: "Response should contain seat bid" + assert response.seatbid[0].bid.size() == bidRequest.imp.size() + + and: "Bidder request should contain originals imps" + assert bidder.getBidderRequest(bidRequest.id).imp.size() == bidRequest.imp.size() + } + + def "PBS shouldn't drop extra impressions when impression-limit set to #impressionLimit"() { + given: "Bid request with multiple imps" + def bidRequest = BidRequest.defaultBidRequest.tap { + imp.add(Imp.getDefaultImpression()) + } + + and: "Account in the DB with impression limit config" + def accountConfig = new AccountConfig(auction: new AccountAuctionConfig(impressionLimit: impressionLimit)) + def account = new Account(uuid: bidRequest.getAccountId(), config: accountConfig) + accountDao.save(account) + + and: "Flush metrics" + flushMetrics(defaultPbsService) + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain seatNonBid" + assert !response?.ext?.seatnonbid + + and: "Response shouldn't contain warnings and error" + assert !response.ext?.warnings + assert !response.ext?.errors + + and: "Metrics for imps requested should be updated" + def metrics = defaultPbsService.sendCollectedMetricsRequest() + assert metrics[IMPS_REQUESTED_METRIC] == bidRequest.imp.size() + assert !metrics[IMPS_DROPPED_METRIC] + + and: "Response should contain seat bid" + assert response.seatbid[0].bid.size() == bidRequest.imp.size() + + and: "Bidder request should contain originals imps" + assert bidder.getBidderRequest(bidRequest.id).imp.size() == bidRequest.imp.size() + + where: + impressionLimit << [null, PBSUtils.randomNegativeNumber, 0] + } } From 2ad28df1f400461b02494d0f41cd3de7163858f0 Mon Sep 17 00:00:00 2001 From: antonbabak Date: Mon, 28 Jul 2025 10:13:19 +0200 Subject: [PATCH 4/4] fix comments --- .../server/auction/requestfactory/Ortb2RequestFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java b/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java index fae1569d822..d50b35d3717 100644 --- a/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java +++ b/src/main/java/org/prebid/server/auction/requestfactory/Ortb2RequestFactory.java @@ -204,7 +204,7 @@ public Future limitImpressions(Account account, BidRequest bidReques metrics.updateImpsDroppedMetric(imps.size() - impsLimit); warnings.add(("Only first %d impressions were kept due to the limit, " + "all the subsequent impressions have been dropped for the auction").formatted(impsLimit)); - return Future.succeededFuture(bidRequest.toBuilder().imp(imps.stream().limit(impsLimit).toList()).build()); + return Future.succeededFuture(bidRequest.toBuilder().imp(imps.subList(0, impsLimit)).build()); } return Future.succeededFuture(bidRequest);