From 159efa6e1e897b4d03e731d2c55eb4a74c452992 Mon Sep 17 00:00:00 2001 From: przemkaczmarek <167743744+przemkaczmarek@users.noreply.github.com> Date: Mon, 19 May 2025 13:32:57 +0200 Subject: [PATCH 1/4] Zeta Global SSP: Add sid parameter and audio support, no longer generic (#3865) --- .../zeta_global_ssp/ZetaGlobalSspBidder.java | 141 +++++++++++ .../zeta_global_ssp/ExtImpZetaGlobalSSP.java | 9 + .../bidder/ZetaGlobalSspConfiguration.java | 43 ++++ src/main/resources/bidder-config/generic.yaml | 21 -- .../bidder-config/zeta_global_ssp.yaml | 24 ++ .../static/bidder-params/zeta_global_ssp.json | 13 +- .../ZetaGlobalSspBidderTest.java | 221 ++++++++++++++++++ .../test-auction-zeta_global_ssp-request.json | 4 +- .../test-zeta_global_ssp-bid-request.json | 6 +- .../test-zeta_global_ssp-bid-response.json | 10 +- .../server/it/test-application.properties | 4 +- 11 files changed, 460 insertions(+), 36 deletions(-) create mode 100644 src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java create mode 100644 src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java create mode 100644 src/main/resources/bidder-config/zeta_global_ssp.yaml create mode 100644 src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java diff --git a/src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java b/src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java new file mode 100644 index 00000000000..85454c43f2d --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java @@ -0,0 +1,141 @@ +package org.prebid.server.bidder.zeta_global_ssp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.zeta_global_ssp.ExtImpZetaGlobalSSP; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class ZetaGlobalSspBidder implements Bidder { + + private static final TypeReference> ZETA_GLOBAL_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final TypeReference> EXT_BID_TYPE_REFERENCE = + new TypeReference<>() { + }; + private static final String SID_MACRO = "{{AccountID}}"; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public ZetaGlobalSspBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(endpointUrl); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final Imp firstImp = request.getImp().getFirst(); + final ExtImpZetaGlobalSSP extImp; + + try { + extImp = parseImpExt(firstImp); + } catch (PreBidException e) { + return Result.withError(BidderError.badInput(e.getMessage())); + } + + final HttpRequest httpRequest = BidderUtil.defaultRequest( + removeImpsExt(request), + resolveEndpoint(extImp), + mapper); + + return Result.withValues(Collections.singletonList(httpRequest)); + } + + private ExtImpZetaGlobalSSP parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), ZETA_GLOBAL_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Missing bidder ext in impression with id: " + imp.getId()); + } + } + + private String resolveEndpoint(ExtImpZetaGlobalSSP extImpZetaGlobalSSP) { + return endpointUrl + .replace(SID_MACRO, Objects.toString(extImpZetaGlobalSSP.getSid(), "0")); + } + + private BidRequest removeImpsExt(BidRequest request) { + final List imps = new ArrayList<>(request.getImp()); + final Imp firstImp = imps.getFirst().toBuilder().ext(null).build(); + imps.set(0, firstImp); + + return request.toBuilder() + .imp(imps) + .build(); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + final List errors = new ArrayList<>(); + return Result.of(extractBids(bidResponse, errors), errors); + } catch (DecodeException | PreBidException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private List extractBids(BidResponse bidResponse, List errors) { + if (bidResponse == null || bidResponse.getSeatbid() == null) { + return Collections.emptyList(); + } + return bidsFromResponse(bidResponse, errors); + } + + private List bidsFromResponse(BidResponse bidResponse, List errors) { + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBid(bid, bidResponse.getCur(), errors)) + .filter(Objects::nonNull) + .toList(); + } + + private BidderBid makeBid(Bid bid, String currency, List errors) { + final BidType mediaType = getMediaType(bid, errors); + return mediaType == null ? null : BidderBid.of(bid, mediaType, currency); + } + + private BidType getMediaType(Bid bid, List errors) { + try { + return Optional.ofNullable(bid.getExt()) + .map(ext -> mapper.mapper().convertValue(ext, EXT_BID_TYPE_REFERENCE)) + .map(ExtPrebid::getPrebid) + .map(ExtBidPrebid::getType) + .orElseThrow(IllegalArgumentException::new); + } catch (IllegalArgumentException e) { + errors.add(BidderError.badServerResponse( + "Failed to parse impression \"%s\" mediatype".formatted(bid.getImpid()))); + return null; + } + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java new file mode 100644 index 00000000000..b904d2e677a --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java @@ -0,0 +1,9 @@ +package org.prebid.server.proto.openrtb.ext.request.zeta_global_ssp; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpZetaGlobalSSP { + + Integer sid; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java new file mode 100644 index 00000000000..aa98e645aa0 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java @@ -0,0 +1,43 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.zeta_global_ssp.ZetaGlobalSspBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.config.bidder.util.UsersyncerCreator; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +import jakarta.validation.constraints.NotBlank; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/zeta_global_ssp.yaml", factory = YamlPropertySourceFactory.class) +public class ZetaGlobalSspConfiguration { + + private static final String BIDDER_NAME = "zeta_global_ssp"; + + @Bean("zetaglobalsspConfigurationProperties") + @ConfigurationProperties("adapters.zetaglobalssp") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps zetaGlobalSspBidderDeps(@Qualifier("zetaglobalsspConfigurationProperties") + BidderConfigurationProperties zetaGlobalSspConfigurationProperties, + @NotBlank @Value("${external-url}") String externalUrl, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(zetaGlobalSspConfigurationProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new ZetaGlobalSspBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/generic.yaml b/src/main/resources/bidder-config/generic.yaml index 4aa01b82bbb..a6522be1122 100644 --- a/src/main/resources/bidder-config/generic.yaml +++ b/src/main/resources/bidder-config/generic.yaml @@ -41,27 +41,6 @@ adapters: - video supported-vendors: vendor-id: 0 - zeta_global_ssp: - enabled: false - endpoint: https://ssp.disqus.com/bid/prebid-server?sid=GET_SID_FROM_ZETA - endpoint-compression: gzip - meta-info: - maintainer-email: DL-Zeta-SSP@zetaglobal.com - app-media-types: - - banner - - video - site-media-types: - - banner - - video - supported-vendors: - vendor-id: 833 - usersync: - enabled: true - cookie-family-name: zeta_global_ssp - redirect: - url: https://ssp.disqus.com/redirectuser?sid=GET_SID_FROM_ZETA&gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&r={{redirect_url}} - uid-macro: 'BUYERUID' - support-cors: false blue: enabled: false endpoint: https://prebid-us-east-1.getblue.io/?src=prebid diff --git a/src/main/resources/bidder-config/zeta_global_ssp.yaml b/src/main/resources/bidder-config/zeta_global_ssp.yaml new file mode 100644 index 00000000000..4c050049d2e --- /dev/null +++ b/src/main/resources/bidder-config/zeta_global_ssp.yaml @@ -0,0 +1,24 @@ +adapters: + zeta_global_ssp: + endpoint: https://ssp.disqus.com/bid/prebid-server?sid={{AccountID}} + endpoint-compression: gzip + geoscope: + - global + modifying-vast-xml-allowed: true + meta-info: + maintainer-email: DL-Zeta-SSP@zetaglobal.com + app-media-types: + - banner + - video + - audio + site-media-types: + - banner + - video + - audio + vendor-id: 833 + usersync: + cookie-family-name: zeta_global_ssp + redirect: + url: https://ssp.disqus.com/redirectuser?gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&r={{redirect_url}} + support-cors: false + uid-macro: 'BUYERUID' diff --git a/src/main/resources/static/bidder-params/zeta_global_ssp.json b/src/main/resources/static/bidder-params/zeta_global_ssp.json index 91ff05ed089..8a6d1d0a060 100644 --- a/src/main/resources/static/bidder-params/zeta_global_ssp.json +++ b/src/main/resources/static/bidder-params/zeta_global_ssp.json @@ -1,10 +1,13 @@ { "$schema": "http://json-schema.org/draft-04/schema#", "title": "Zeta Global SSP Adapter Params", - "description": "A schema which validates params accepted by the Zeta SSP adapter", - "type": "object", - - "properties": {}, + "description": "A schema which validates params accepted by the Zeta Global SSP adapter", - "required": [] + "type": "object", + "properties": { + "sid": { + "type": "integer", + "description": "An ID which identifies the publisher" + } + } } diff --git a/src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java b/src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java new file mode 100644 index 00000000000..886af999d74 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java @@ -0,0 +1,221 @@ +package org.prebid.server.bidder.zeta_global_ssp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.bidder.theadx.TheadxBidder; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.zeta_global_ssp.ExtImpZetaGlobalSSP; +import org.prebid.server.proto.openrtb.ext.response.BidType; + +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.prebid.server.bidder.model.BidderError.Type.bad_server_response; +import static org.prebid.server.util.HttpUtil.ACCEPT_HEADER; +import static org.prebid.server.util.HttpUtil.APPLICATION_JSON_CONTENT_TYPE; +import static org.prebid.server.util.HttpUtil.CONTENT_TYPE_HEADER; +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +public class ZetaGlobalSspBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://test-url.com/{{AccountID}}"; + + private final ZetaGlobalSspBidder target = new ZetaGlobalSspBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void shouldFailOnBidderCreation() { + assertThatIllegalArgumentException().isThrownBy(() -> new TheadxBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfImpExtMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1") + .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getMessage()).contains("Missing bidder ext in impression with id: imp1"); + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldCreateSingleRequestAndRemoveImpExt() { + // given + final Imp imp1 = givenImp(imp -> imp.id("imp1").ext(givenImpExt(11))); + final Imp imp2 = givenImp(imp -> imp.id("imp2").ext(givenImpExt(44))); + final BidRequest bidRequest = givenBidRequest(imp1, imp2); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + final HttpRequest httpRequest = result.getValue().getFirst(); + + // then + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly(null, givenImpExt(44)); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getHeaders) + .satisfies(headers -> { + assertThat(headers.get(CONTENT_TYPE_HEADER)).isEqualTo(APPLICATION_JSON_CONTENT_TYPE); + assertThat(headers.get(ACCEPT_HEADER)).isEqualTo(APPLICATION_JSON_VALUE); + }); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldResolveMacroInEndpointUrl() { + // given + final Imp imp1 = givenImp(imp -> imp.id("imp1").ext(givenImpExt(11))); + final BidRequest bidRequest = givenBidRequest(imp1); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://test-url.com/11"); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyInvalid() { + // given + final BidderCall httpCall = givenHttpCall("invalid-response-body"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getMessage()).contains("Failed to decode:"); + assertThat(error.getType()).isEqualTo(bad_server_response); + }); + } + + @Test + public void makeBidsShouldReturnEmptyListIfSeatBidIsNullOrEmpty() throws JsonProcessingException { + // given + final BidderCall httpCall = + givenHttpCall(mapper.writeValueAsString(BidResponse.builder().cur("USD").build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorIfCannotResolveBidType() throws JsonProcessingException { + // given + final Bid bid = givenBid("imp1", mapper.createObjectNode()); + final BidderCall httpCall = + givenHttpCall(mapper.writeValueAsString(givenBidResponse(List.of(bid)))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getMessage()).contains("Failed to parse impression \"imp1\" mediatype"); + assertThat(error.getType()).isEqualTo(bad_server_response); + }); + } + + @Test + public void makeBidsShouldReturnBannerBidIfTypeParsedProperly() throws JsonProcessingException { + // given + final ObjectNode extWithPrebidType = mapper.createObjectNode(); + extWithPrebidType.putObject("prebid").put("type", "banner"); + final Bid validBid = givenBid("imp1", extWithPrebidType); + + final BidResponse bidResponse = givenBidResponse(List.of(validBid)); + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + final BidderBid bidderBid = result.getValue().getFirst(); + assertThat(bidderBid.getBid().getImpid()).isEqualTo("imp1"); + assertThat(bidderBid.getType()).isEqualTo(BidType.banner); + assertThat(bidderBid.getBidCurrency()).isEqualTo("USD"); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return BidRequest.builder().imp(List.of(imps)).build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder().id("imp_id").ext(givenImpExt(11))).build(); + } + + private static ObjectNode givenImpExt(Integer sid) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpZetaGlobalSSP.of(sid))); + } + + private static BidderCall givenHttpCall(String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(null).build(), + HttpResponse.of(200, null, body), + null); + } + + private static Bid givenBid(String impId, ObjectNode ext) { + return Bid.builder().impid(impId).ext(ext).build(); + } + + private static BidResponse givenBidResponse(List bids) { + return BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(bids).build())) + .build(); + } + +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json index 0a3824d6e31..e68be13ed77 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json @@ -12,7 +12,9 @@ ] }, "ext": { - "zeta_global_ssp": {} + "zeta_global_ssp": { + "sid": 11 + } } } ], diff --git a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json index 2608812c09e..d982ec42345 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json @@ -11,11 +11,7 @@ } ] }, - "secure": 1, - "ext": { - "tid": "${json-unit.any-string}", - "bidder": {} - } + "secure": 1 } ], "site": { diff --git a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json index c31fabcb822..39d74ae42cd 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json @@ -12,9 +12,15 @@ "cid": "cid001", "adm": "adm001", "h": 250, - "w": 300 + "w": 300, + "ext": { + "prebid": { + "type": "banner" + } + } } - ] + ], + "seat": "zeta_global_ssp" } ] } diff --git a/src/test/resources/org/prebid/server/it/test-application.properties b/src/test/resources/org/prebid/server/it/test-application.properties index 450ae419cb5..a19968c87c2 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -14,8 +14,6 @@ adapters.generic.aliases.cwire.enabled=true adapters.generic.aliases.cwire.endpoint=http://localhost:8090/cwire-exchange adapters.generic.aliases.infytv.enabled=true adapters.generic.aliases.infytv.endpoint=http://localhost:8090/infytv-exchange -adapters.generic.aliases.zeta_global_ssp.enabled=true -adapters.generic.aliases.zeta_global_ssp.endpoint=http://localhost:8090/zeta_global_ssp-exchange adapters.aceex.enabled=true adapters.aceex.endpoint=http://localhost:8090/aceex-exchange adapters.acuityads.enabled=true @@ -581,6 +579,8 @@ adapters.yieldone.enabled=true adapters.yieldone.endpoint=http://localhost:8090/yieldone-exchange adapters.zeroclickfraud.enabled=true adapters.zeroclickfraud.endpoint=http://{{Host}}/zeroclickfraud-exchange?sid={{SourceId}} +adapters.zeta_global_ssp.enabled=true +adapters.zeta_global_ssp.endpoint=http://localhost:8090/zeta_global_ssp-exchange adapters.aax.enabled=true adapters.aax.endpoint=http://localhost:8090/aax-exchange adapters.zmaticoo.enabled=true From 5b88cd56b80f2f6fc92ca050e0b2c865e093e99b Mon Sep 17 00:00:00 2001 From: johnwier <49074029+johnwier@users.noreply.github.com> Date: Mon, 19 May 2025 05:41:40 -0700 Subject: [PATCH 2/4] Epsilon: native support (#3880) --- .../server/bidder/epsilon/EpsilonBidder.java | 8 +- src/main/resources/bidder-config/epsilon.yaml | 2 + .../bidder/epsilon/EpsilonBidderTest.java | 154 ++++++++++++------ 3 files changed, 112 insertions(+), 52 deletions(-) diff --git a/src/main/java/org/prebid/server/bidder/epsilon/EpsilonBidder.java b/src/main/java/org/prebid/server/bidder/epsilon/EpsilonBidder.java index bc4625ba905..4be70d935fc 100644 --- a/src/main/java/org/prebid/server/bidder/epsilon/EpsilonBidder.java +++ b/src/main/java/org/prebid/server/bidder/epsilon/EpsilonBidder.java @@ -261,10 +261,12 @@ private Bid updateBidWithId(Bid bid) { private static BidType getType(String impId, List imps) { for (Imp imp : imps) { if (imp.getId().equals(impId)) { - if (imp.getVideo() != null) { - return BidType.video; - } else if (imp.getAudio() != null) { + if (imp.getAudio() != null) { return BidType.audio; + } else if (imp.getXNative() != null) { + return BidType.xNative; + } else if (imp.getVideo() != null) { + return BidType.video; } else { return BidType.banner; } diff --git a/src/main/resources/bidder-config/epsilon.yaml b/src/main/resources/bidder-config/epsilon.yaml index ba7472331d7..44970a25db2 100644 --- a/src/main/resources/bidder-config/epsilon.yaml +++ b/src/main/resources/bidder-config/epsilon.yaml @@ -12,10 +12,12 @@ adapters: - banner - video - audio + - native site-media-types: - banner - video - audio + - native supported-vendors: vendor-id: 24 usersync: diff --git a/src/test/java/org/prebid/server/bidder/epsilon/EpsilonBidderTest.java b/src/test/java/org/prebid/server/bidder/epsilon/EpsilonBidderTest.java index 872a04c0a96..f69fb0f5415 100644 --- a/src/test/java/org/prebid/server/bidder/epsilon/EpsilonBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/epsilon/EpsilonBidderTest.java @@ -6,6 +6,7 @@ import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Native; import com.iab.openrtb.request.Site; import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; @@ -21,12 +22,15 @@ import org.prebid.server.bidder.model.BidderBid; import org.prebid.server.bidder.model.BidderCall; import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.CompositeBidderResponse; import org.prebid.server.bidder.model.HttpRequest; import org.prebid.server.bidder.model.HttpResponse; import org.prebid.server.bidder.model.Result; import org.prebid.server.currency.CurrencyConversionService; import org.prebid.server.proto.openrtb.ext.ExtPrebid; import org.prebid.server.proto.openrtb.ext.request.epsilon.ExtImpEpsilon; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponse; import java.math.BigDecimal; import java.util.Collections; @@ -513,6 +517,65 @@ public void makeHttpRequestsShouldPrioritizeVideoProtocolsFromImpExtEvenIfInvali .isEmpty(); } + @Test + public void makeHttpRequestsShouldSetImpBidFloorFromImpExtIfPresentAndImpBidFloorIsInvalid() { + // given + final BidRequest bidRequest = givenBidRequest( + impBuilder -> impBuilder.bidfloor(BigDecimal.valueOf(-1.00)), + extBuilder -> extBuilder.bidfloor(BigDecimal.ONE)); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor) + .containsExactly(BigDecimal.ONE); + } + + @Test + public void makeHttpRequestsShouldNotSetImpBidFloorFromImpExt() { + // given + final BidRequest bidRequest = givenBidRequest( + impBuilder -> impBuilder.bidfloor(BigDecimal.valueOf(-1.00)), + extBuilder -> extBuilder.bidfloor(BigDecimal.valueOf(-2.00))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor) + .containsExactly(BigDecimal.valueOf(-1.00)); + } + + @Test + public void makeHttpRequestsShouldReturnConvertedBidFloorCurrency() { + // given + given(currencyConversionService.convertCurrency(any(), any(), anyString(), anyString())) + .willReturn(BigDecimal.ONE); + + final BidRequest bidRequest = givenBidRequest( + impBuilder -> impBuilder.bidfloor(BigDecimal.TEN).bidfloorcur("EUR")); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsOnly(AssertionsForClassTypes.tuple(BigDecimal.ONE, "USD")); + } + @Test public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { // given @@ -654,62 +717,55 @@ public void makeBidsShouldUpdateBidWithUUIDIfGenerateBidIdIsTrue() throws JsonPr } @Test - public void makeHttpRequestsShouldSetImpBidFloorFromImpExtIfPresentAndImpBidFloorIsInvalid() { + public void makeBidsShouldReturnResultForNativeBidsWithExpectedFields() throws JsonProcessingException { // given - final BidRequest bidRequest = givenBidRequest( - impBuilder -> impBuilder.bidfloor(BigDecimal.valueOf(-1.00)), - extBuilder -> extBuilder.bidfloor(BigDecimal.ONE)); - - // when - final Result>> result = target.makeHttpRequests(bidRequest); - - // then - assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) - .flatExtracting(BidRequest::getImp) - .extracting(Imp::getBidfloor) - .containsExactly(BigDecimal.ONE); - } - - @Test - public void makeHttpRequestsShouldNotSetImpBidFloorFromImpExt() { - // given - final BidRequest bidRequest = givenBidRequest( - impBuilder -> impBuilder.bidfloor(BigDecimal.valueOf(-1.00)), - extBuilder -> extBuilder.bidfloor(BigDecimal.valueOf(-2.00))); - - // when - final Result>> result = target.makeHttpRequests(bidRequest); - - // then - assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) - .flatExtracting(BidRequest::getImp) - .extracting(Imp::getBidfloor) - .containsExactly(BigDecimal.valueOf(-1.00)); - } - - @Test - public void makeHttpRequestsShouldReturnConvertedBidFloorCurrency() { - // given - given(currencyConversionService.convertCurrency(any(), any(), anyString(), anyString())) - .willReturn(BigDecimal.ONE); + final String nativeRequestString = + "{\"ver\":\"1.2\",\"assets\":[{\"id\":1,\"required\":1,\"title\":{\"len\":80}}"; + final String nativeResponseString = + "\"native\"{\"assets\": [{\"id\": 1, \"title\": {\"text\": \"Native test (Title)\"}}], " + + "\"link\": {\"url\": \"https://www.epsilon.com/\"}, " + + "\"imptrackers\":[\"https://iad-usadmm.dotomi.com/event\"],\"jstracker\":\"\"}"; + final BidRequest bidRequest = BidRequest.builder() + .id("native-test") + .imp(singletonList(Imp.builder() + .id("impid-0") + .xNative(Native.builder() + .request(nativeRequestString) + .ver("1.2") + .build()) + .build())) + .build(); - final BidRequest bidRequest = givenBidRequest( - impBuilder -> impBuilder.bidfloor(BigDecimal.TEN).bidfloorcur("EUR")); + final BidderCall httpCall = givenHttpCall(bidRequest, + mapper.writeValueAsString(BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(Bid.builder() + .price(BigDecimal.ONE) + .impid("impid-0") + .adm(nativeResponseString) + .mtype(4) + .cat(singletonList("IAB3")) + .build())) + .build())) + .cur("USD") + .ext(ExtBidResponse.builder().build()) + .build())); // when - final Result>> result = target.makeHttpRequests(bidRequest); + final CompositeBidderResponse result = target.makeBidderResponse(httpCall, bidRequest); // then assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()) - .extracting(HttpRequest::getPayload) - .flatExtracting(BidRequest::getImp) - .extracting(Imp::getBidfloor, Imp::getBidfloorcur) - .containsOnly(AssertionsForClassTypes.tuple(BigDecimal.ONE, "USD")); + assertThat(result.getBids()).hasSize(1) + .containsOnly(BidderBid.of( + Bid.builder() + .impid("impid-0") + .price(BigDecimal.ONE) + .adm(nativeResponseString) + .cat(singletonList("IAB3")) + .mtype(4) + .build(), + BidType.xNative, "USD")); } private static BidRequest givenBidRequest( From 46cdf39cd7185e03543a7320c56c51d32922cf84 Mon Sep 17 00:00:00 2001 From: osulzhenko <125548596+osulzhenko@users.noreply.github.com> Date: Mon, 19 May 2025 16:28:11 +0300 Subject: [PATCH 3/4] Dependencies: Change spring-boot version (#3954) --- extra/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/pom.xml b/extra/pom.xml index cb3ec69cae6..cf4343a8da4 100644 --- a/extra/pom.xml +++ b/extra/pom.xml @@ -33,7 +33,7 @@ 10.17.0 - 3.4.4 + 3.4.2 4.5.14 2.0.1.Final 4.4 From fc4c33c5dbcc98a18d703789dfb67ebbe9f57797 Mon Sep 17 00:00:00 2001 From: Anton Babak <76536883+AntoxaAntoxic@users.noreply.github.com> Date: Mon, 19 May 2025 15:31:05 +0200 Subject: [PATCH 4/4] Price Floor Logs Update (#3950) --- .../floors/BasicPriceFloorProcessor.java | 126 ++-- .../server/floors/PriceFloorFetcher.java | 63 +- .../server/floors/proto/FetchResult.java | 14 + .../config/PriceFloorsConfiguration.java | 7 +- .../functional/model/privacy/Metric.groovy | 1 - .../model/response/auction/Bid.groovy | 2 +- .../server/functional/tests/BaseSpec.groovy | 1 + .../functional/tests/BidValidationSpec.groovy | 4 +- .../functional/tests/MetricsSpec.groovy | 4 +- .../tests/bidder/openx/OpenxSpec.groovy | 2 +- .../pricefloors/PriceFloorsBaseSpec.groovy | 29 +- .../PriceFloorsFetchingSpec.groovy | 554 ++++++++++++++---- .../pricefloors/PriceFloorsRulesSpec.groovy | 10 +- .../PriceFloorsSignalingSpec.groovy | 474 ++++++++++++--- .../tests/privacy/GdprAmpSpec.groovy | 2 +- .../tests/privacy/GdprAuctionSpec.groovy | 2 +- .../privacy/GppFetchBidActivitiesSpec.groovy | 9 +- .../privacy/GppSyncUserActivitiesSpec.groovy | 9 +- .../GppTransmitEidsActivitiesSpec.groovy | 9 +- ...GppTransmitPreciseGeoActivitiesSpec.groovy | 9 +- .../GppTransmitUfpdActivitiesSpec.groovy | 9 +- .../floors/BasicPriceFloorProcessorTest.java | 505 +++++++++++++--- .../server/floors/PriceFloorFetcherTest.java | 86 +-- 23 files changed, 1514 insertions(+), 417 deletions(-) diff --git a/src/main/java/org/prebid/server/floors/BasicPriceFloorProcessor.java b/src/main/java/org/prebid/server/floors/BasicPriceFloorProcessor.java index d492981f568..6389139a5ac 100644 --- a/src/main/java/org/prebid/server/floors/BasicPriceFloorProcessor.java +++ b/src/main/java/org/prebid/server/floors/BasicPriceFloorProcessor.java @@ -21,6 +21,8 @@ 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.ExtImpPrebidFloors; import org.prebid.server.proto.openrtb.ext.request.ExtRequest; import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; @@ -50,19 +52,35 @@ public class BasicPriceFloorProcessor implements PriceFloorProcessor { private static final int MODEL_WEIGHT_MAX_VALUE = 100; private static final int MODEL_WEIGHT_MIN_VALUE = 1; + private static final String FETCH_FAILED_ERROR_MESSAGE = "Price floors processing failed: %s. " + + "Following parsing of request price floors is failed: %s"; + private static final String DYNAMIC_DATA_NOT_ALLOWED_MESSAGE = + "Price floors processing failed: Using dynamic data is not allowed. " + + "Following parsing of request price floors is failed: %s"; + private static final String INVALID_REQUEST_WARNING_MESSAGE = + "Price floors processing failed: parsing of request price floors is failed: %s"; + private static final String ERROR_LOG_MESSAGE = + "Price Floors can't be resolved for account %s and request %s, reason: %s"; + private final PriceFloorFetcher floorFetcher; private final PriceFloorResolver floorResolver; + private final Metrics metrics; private final JacksonMapper mapper; + private final double logSamplingRate; private final RandomWeightedEntrySupplier modelPicker; public BasicPriceFloorProcessor(PriceFloorFetcher floorFetcher, PriceFloorResolver floorResolver, - JacksonMapper mapper) { + Metrics metrics, + JacksonMapper mapper, + double logSamplingRate) { this.floorFetcher = Objects.requireNonNull(floorFetcher); this.floorResolver = Objects.requireNonNull(floorResolver); + this.metrics = Objects.requireNonNull(metrics); this.mapper = Objects.requireNonNull(mapper); + this.logSamplingRate = logSamplingRate; modelPicker = new RandomPositiveWeightedEntrySupplier<>(BasicPriceFloorProcessor::resolveModelGroupWeight); } @@ -82,7 +100,7 @@ public BidRequest enrichWithPriceFloors(BidRequest bidRequest, return disableFloorsForRequest(bidRequest); } - final PriceFloorRules floors = resolveFloors(account, bidRequest, errors); + final PriceFloorRules floors = resolveFloors(account, bidRequest, warnings); return updateBidRequestWithFloors(bidRequest, bidder, floors, errors, warnings); } @@ -122,49 +140,13 @@ private static PriceFloorRules extractRequestFloors(BidRequest bidRequest) { return ObjectUtil.getIfNotNull(prebid, ExtRequestPrebid::getFloors); } - private PriceFloorRules resolveFloors(Account account, BidRequest bidRequest, List errors) { + private PriceFloorRules resolveFloors(Account account, BidRequest bidRequest, List warnings) { final PriceFloorRules requestFloors = extractRequestFloors(bidRequest); final FetchResult fetchResult = floorFetcher.fetch(account); - final FetchStatus fetchStatus = ObjectUtil.getIfNotNull(fetchResult, FetchResult::getFetchStatus); - - if (fetchResult != null && fetchStatus == FetchStatus.success && shouldUseDynamicData(account, fetchResult)) { - final PriceFloorRules mergedFloors = mergeFloors(requestFloors, fetchResult.getRulesData()); - return createFloorsFrom(mergedFloors, fetchStatus, PriceFloorLocation.fetch); - } + final FetchStatus fetchStatus = fetchResult.getFetchStatus(); - if (requestFloors != null) { - try { - final Optional priceFloorsConfig = Optional.of(account) - .map(Account::getAuction) - .map(AccountAuctionConfig::getPriceFloors); - - final Long maxRules = priceFloorsConfig.map(AccountPriceFloorsConfig::getMaxRules) - .orElse(null); - final Long maxDimensions = priceFloorsConfig.map(AccountPriceFloorsConfig::getMaxSchemaDims) - .orElse(null); - - PriceFloorRulesValidator.validateRules( - requestFloors, - PriceFloorsConfigResolver.resolveMaxValue(maxRules), - PriceFloorsConfigResolver.resolveMaxValue(maxDimensions)); - - return createFloorsFrom(requestFloors, fetchStatus, PriceFloorLocation.request); - } catch (PreBidException e) { - errors.add("Failed to parse price floors from request, with a reason: %s".formatted(e.getMessage())); - conditionalLogger.error( - "Failed to parse price floors from request with id: '%s', with a reason: %s" - .formatted(bidRequest.getId(), e.getMessage()), - 0.01d); - } - } - - return createFloorsFrom(null, fetchStatus, PriceFloorLocation.noData); - } - - private static boolean shouldUseDynamicData(Account account, FetchResult fetchResult) { - final boolean isUsingDynamicDataAllowed = Optional.of(account) - .map(Account::getAuction) + final boolean isUsingDynamicDataAllowed = Optional.ofNullable(account.getAuction()) .map(AccountAuctionConfig::getPriceFloors) .map(AccountPriceFloorsConfig::getUseDynamicData) .map(BooleanUtils::isNotFalse) @@ -175,12 +157,68 @@ private static boolean shouldUseDynamicData(Account account, FetchResult fetchRe .map(rate -> ThreadLocalRandom.current().nextInt(USE_FETCH_DATA_RATE_MAX) < rate) .orElse(true); - return isUsingDynamicDataAllowed && shouldUseDynamicData; + if (fetchStatus == FetchStatus.success && isUsingDynamicDataAllowed && shouldUseDynamicData) { + final PriceFloorRules mergedFloors = mergeFloors(requestFloors, fetchResult.getRulesData()); + return createFloorsFrom(mergedFloors, fetchStatus, PriceFloorLocation.fetch); + } + + return requestFloors == null + ? createFloorsFrom(null, fetchStatus, PriceFloorLocation.noData) + : getPriceFloorRules( + bidRequest, account, requestFloors, fetchResult, isUsingDynamicDataAllowed, warnings); + } + + private PriceFloorRules getPriceFloorRules(BidRequest bidRequest, + Account account, + PriceFloorRules requestFloors, + FetchResult fetchResult, + boolean isDynamicDataAllowed, + List warnings) { + + try { + final Optional priceFloorsConfig = Optional.of(account.getAuction()) + .map(AccountAuctionConfig::getPriceFloors); + + final Long maxRules = priceFloorsConfig.map(AccountPriceFloorsConfig::getMaxRules) + .orElse(null); + final Long maxDimensions = priceFloorsConfig.map(AccountPriceFloorsConfig::getMaxSchemaDims) + .orElse(null); + + PriceFloorRulesValidator.validateRules( + requestFloors, + PriceFloorsConfigResolver.resolveMaxValue(maxRules), + PriceFloorsConfigResolver.resolveMaxValue(maxDimensions)); + + return createFloorsFrom(requestFloors, fetchResult.getFetchStatus(), PriceFloorLocation.request); + } catch (PreBidException e) { + logErrorMessage(fetchResult, isDynamicDataAllowed, e, account.getId(), bidRequest.getId(), warnings); + return createFloorsFrom(null, fetchResult.getFetchStatus(), PriceFloorLocation.noData); + } } - private PriceFloorRules mergeFloors(PriceFloorRules requestFloors, - PriceFloorData providerRulesData) { + private void logErrorMessage(FetchResult fetchResult, + boolean isDynamicDataAllowed, + PreBidException requestFloorsValidationException, + String accountId, + String requestId, + List warnings) { + + final String validationMessage = requestFloorsValidationException.getMessage(); + final String errorMessage = switch (fetchResult.getFetchStatus()) { + case inprogress -> null; + case error, timeout, none -> FETCH_FAILED_ERROR_MESSAGE.formatted( + fetchResult.getErrorMessage(), validationMessage); + case success -> isDynamicDataAllowed ? null : DYNAMIC_DATA_NOT_ALLOWED_MESSAGE.formatted(validationMessage); + }; + + if (errorMessage != null) { + warnings.add(INVALID_REQUEST_WARNING_MESSAGE.formatted(validationMessage)); + conditionalLogger.error(ERROR_LOG_MESSAGE.formatted(accountId, requestId, errorMessage), logSamplingRate); + metrics.updateAlertsMetrics(MetricName.general); + } + } + private PriceFloorRules mergeFloors(PriceFloorRules requestFloors, PriceFloorData providerRulesData) { final Price floorMinPrice = resolveFloorMinPrice(requestFloors); return (requestFloors != null ? requestFloors.toBuilder() : PriceFloorRules.builder()) diff --git a/src/main/java/org/prebid/server/floors/PriceFloorFetcher.java b/src/main/java/org/prebid/server/floors/PriceFloorFetcher.java index b7d4ac4185f..b1cc8c257b2 100644 --- a/src/main/java/org/prebid/server/floors/PriceFloorFetcher.java +++ b/src/main/java/org/prebid/server/floors/PriceFloorFetcher.java @@ -90,7 +90,10 @@ public FetchResult fetch(Account account) { final AccountFetchContext accountFetchContext = fetchedData.get(account.getId()); return accountFetchContext != null - ? FetchResult.of(accountFetchContext.getRulesData(), accountFetchContext.getFetchStatus()) + ? FetchResult.of( + accountFetchContext.getRulesData(), + accountFetchContext.getFetchStatus(), + accountFetchContext.getErrorMessage()) : fetchPriceFloorData(account); } @@ -99,20 +102,20 @@ private FetchResult fetchPriceFloorData(Account account) { final Boolean fetchEnabled = ObjectUtil.getIfNotNull(fetchConfig, AccountPriceFloorsFetchConfig::getEnabled); if (BooleanUtils.isFalse(fetchEnabled)) { - return FetchResult.of(null, FetchStatus.none); + return FetchResult.none("Fetching is disabled"); } final String accountId = account.getId(); final String fetchUrl = ObjectUtil.getIfNotNull(fetchConfig, AccountPriceFloorsFetchConfig::getUrl); if (!isUrlValid(fetchUrl)) { - logger.error("Malformed fetch.url: '%s', passed for account %s".formatted(fetchUrl, accountId)); - return FetchResult.of(null, FetchStatus.error); + logger.error("Malformed fetch.url: '%s' passed for account %s".formatted(fetchUrl, accountId)); + return FetchResult.error("Malformed fetch.url '%s' passed".formatted(fetchUrl)); } if (!fetchInProgress.contains(accountId)) { fetchPriceFloorDataAsynchronous(fetchConfig, accountId); } - return FetchResult.of(null, FetchStatus.inprogress); + return FetchResult.inProgress(); } private boolean isUrlValid(String url) { @@ -148,7 +151,7 @@ private void fetchPriceFloorDataAsynchronous(AccountPriceFloorsFetchConfig fetch fetchInProgress.add(accountId); httpClient.get(fetchUrl, timeout, resolveMaxFileSize(maxFetchFileSizeKb)) - .map(httpClientResponse -> parseFloorResponse(httpClientResponse, fetchConfig, accountId)) + .map(httpClientResponse -> parseFloorResponse(httpClientResponse, fetchConfig)) .recover(throwable -> recoverFromFailedFetching(throwable, fetchUrl, accountId)) .map(cacheInfo -> updateCache(cacheInfo, fetchConfig, accountId)) .map(priceFloorData -> createPeriodicTimerForRulesFetch(priceFloorData, fetchConfig, accountId)); @@ -159,23 +162,20 @@ private static long resolveMaxFileSize(Long maxSizeInKBytes) { } private ResponseCacheInfo parseFloorResponse(HttpClientResponse httpClientResponse, - AccountPriceFloorsFetchConfig fetchConfig, - String accountId) { + AccountPriceFloorsFetchConfig fetchConfig) { final int statusCode = httpClientResponse.getStatusCode(); if (statusCode != HttpStatus.SC_OK) { - throw new PreBidException("Failed to request for account %s, provider respond with status %s" - .formatted(accountId, statusCode)); + throw new PreBidException("Failed to request, provider respond with status %s".formatted(statusCode)); } final String body = httpClientResponse.getBody(); if (StringUtils.isBlank(body)) { - throw new PreBidException( - "Failed to parse price floor response for account %s, response body can not be empty" - .formatted(accountId)); + throw new PreBidException("Failed to parse price floor response, response body can not be empty"); } - final PriceFloorData priceFloorData = parsePriceFloorData(body, accountId); + final PriceFloorData priceFloorData = parsePriceFloorData(body); + PriceFloorRulesValidator.validateRulesData( priceFloorData, PriceFloorsConfigResolver.resolveMaxValue(fetchConfig.getMaxRules()), @@ -183,16 +183,17 @@ private ResponseCacheInfo parseFloorResponse(HttpClientResponse httpClientRespon return ResponseCacheInfo.of(priceFloorData, FetchStatus.success, + null, cacheTtlFromResponse(httpClientResponse, fetchConfig.getUrl())); } - private PriceFloorData parsePriceFloorData(String body, String accountId) { + private PriceFloorData parsePriceFloorData(String body) { final PriceFloorData priceFloorData; try { priceFloorData = mapper.decodeValue(body, PriceFloorData.class); } catch (DecodeException e) { - throw new PreBidException("Failed to parse price floor response for account %s, cause: %s" - .formatted(accountId, ExceptionUtils.getMessage(e))); + throw new PreBidException( + "Failed to parse price floor response, cause: %s".formatted(ExceptionUtils.getMessage(e))); } return priceFloorData; } @@ -220,8 +221,11 @@ private PriceFloorData updateCache(ResponseCacheInfo cacheInfo, String accountId) { final long maxAgeTimerId = createMaxAgeTimer(accountId, resolveCacheTtl(cacheInfo, fetchConfig)); - final AccountFetchContext fetchContext = - AccountFetchContext.of(cacheInfo.getRulesData(), cacheInfo.getFetchStatus(), maxAgeTimerId); + final AccountFetchContext fetchContext = AccountFetchContext.of( + cacheInfo.getRulesData(), + cacheInfo.getFetchStatus(), + cacheInfo.getErrorMessage(), + maxAgeTimerId); if (cacheInfo.getFetchStatus() == FetchStatus.success || !fetchedData.containsKey(accountId)) { fetchedData.put(accountId, fetchContext); @@ -274,23 +278,24 @@ private Future recoverFromFailedFetching(Throwable throwable, metrics.updatePriceFloorFetchMetric(MetricName.failure); final FetchStatus fetchStatus; + final String errorMessage; if (throwable instanceof TimeoutException || throwable instanceof ConnectTimeoutException) { fetchStatus = FetchStatus.timeout; - logger.error("Fetch price floor request timeout for fetch.url: '%s', account %s exceeded." - .formatted(fetchUrl, accountId)); + errorMessage = "Fetch price floor request timeout for fetch.url '%s' exceeded.".formatted(fetchUrl); } else { fetchStatus = FetchStatus.error; - logger.error( - "Failed to fetch price floor from provider for fetch.url: '%s', account = %s with a reason : %s " - .formatted(fetchUrl, accountId, throwable.getMessage())); + errorMessage = "Failed to fetch price floor from provider for fetch.url '%s', with a reason: %s" + .formatted(fetchUrl, throwable.getMessage()); } - return Future.succeededFuture(ResponseCacheInfo.withStatus(fetchStatus)); + logger.error("Price floor fetching failed for account %s: %s".formatted(accountId, errorMessage)); + return Future.succeededFuture(ResponseCacheInfo.withError(fetchStatus, errorMessage)); } private PriceFloorData createPeriodicTimerForRulesFetch(PriceFloorData priceFloorData, AccountPriceFloorsFetchConfig fetchConfig, String accountId) { + final long accountPeriodicTimeSec = ObjectUtil.getIfNotNull(fetchConfig, AccountPriceFloorsFetchConfig::getPeriodSec); final long periodicTimeSec = @@ -318,6 +323,8 @@ private static class AccountFetchContext { FetchStatus fetchStatus; + String errorMessage; + Long maxAgeTimerId; } @@ -328,10 +335,12 @@ private static class ResponseCacheInfo { FetchStatus fetchStatus; + String errorMessage; + Long cacheTtl; - public static ResponseCacheInfo withStatus(FetchStatus status) { - return ResponseCacheInfo.of(null, status, null); + public static ResponseCacheInfo withError(FetchStatus status, String errorMessage) { + return ResponseCacheInfo.of(null, status, errorMessage, null); } } } diff --git a/src/main/java/org/prebid/server/floors/proto/FetchResult.java b/src/main/java/org/prebid/server/floors/proto/FetchResult.java index 36c4fda58e0..336bb26ee43 100644 --- a/src/main/java/org/prebid/server/floors/proto/FetchResult.java +++ b/src/main/java/org/prebid/server/floors/proto/FetchResult.java @@ -9,4 +9,18 @@ public class FetchResult { PriceFloorData rulesData; FetchStatus fetchStatus; + + String errorMessage; + + public static FetchResult none(String errorMessage) { + return FetchResult.of(null, FetchStatus.none, errorMessage); + } + + public static FetchResult error(String errorMessage) { + return FetchResult.of(null, FetchStatus.error, errorMessage); + } + + public static FetchResult inProgress() { + return FetchResult.of(null, FetchStatus.inprogress, null); + } } diff --git a/src/main/java/org/prebid/server/spring/config/PriceFloorsConfiguration.java b/src/main/java/org/prebid/server/spring/config/PriceFloorsConfiguration.java index 8a483e92a4d..16a79d6c0f6 100644 --- a/src/main/java/org/prebid/server/spring/config/PriceFloorsConfiguration.java +++ b/src/main/java/org/prebid/server/spring/config/PriceFloorsConfiguration.java @@ -20,6 +20,7 @@ import org.prebid.server.metric.Metrics; import org.prebid.server.settings.ApplicationSettings; import org.prebid.server.vertx.httpclient.HttpClient; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -84,9 +85,11 @@ PriceFloorResolver noOpPriceFloorResolver() { @ConditionalOnProperty(prefix = "price-floors", name = "enabled", havingValue = "true") PriceFloorProcessor basicPriceFloorProcessor(PriceFloorFetcher floorFetcher, PriceFloorResolver floorResolver, - JacksonMapper mapper) { + Metrics metrics, + JacksonMapper mapper, + @Value("${logging.sampling-rate:0.01}") double logSamplingRate) { - return new BasicPriceFloorProcessor(floorFetcher, floorResolver, mapper); + return new BasicPriceFloorProcessor(floorFetcher, floorResolver, metrics, mapper, logSamplingRate); } @Bean diff --git a/src/test/groovy/org/prebid/server/functional/model/privacy/Metric.groovy b/src/test/groovy/org/prebid/server/functional/model/privacy/Metric.groovy index 490c1456e53..80d779e069d 100644 --- a/src/test/groovy/org/prebid/server/functional/model/privacy/Metric.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/privacy/Metric.groovy @@ -7,7 +7,6 @@ import org.prebid.server.functional.model.request.setuid.SetuidRequest enum Metric { - ALERT_GENERAL("alerts.general"), PROCESSED_ACTIVITY_RULES_COUNT("requests.activity.processedrules.count"), ACCOUNT_PROCESSED_RULES_COUNT("requests.activity.processedrules.count"), TEMPLATE_ADAPTER_DISALLOWED_COUNT("adapter.{bidderName}.activity.{activityType}.disallowed.count"), diff --git a/src/test/groovy/org/prebid/server/functional/model/response/auction/Bid.groovy b/src/test/groovy/org/prebid/server/functional/model/response/auction/Bid.groovy index 22e29b76908..127ed32bfd9 100644 --- a/src/test/groovy/org/prebid/server/functional/model/response/auction/Bid.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/response/auction/Bid.groovy @@ -60,7 +60,7 @@ class Bid implements ObjectMapperWrapper { new Bid().tap { id = UUID.randomUUID() impid = imp.id - price = PBSUtils.getRandomPrice() + price = imp.bidFloor != null ? imp.bidFloor : PBSUtils.getRandomPrice() crid = 1 height = imp.banner && imp.banner.format ? imp.banner.format.first().height : null weight = imp.banner && imp.banner.format ? imp.banner.format.first().weight : null diff --git a/src/test/groovy/org/prebid/server/functional/tests/BaseSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/BaseSpec.groovy index 63ba2516ff4..a4fda13f829 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/BaseSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/BaseSpec.groovy @@ -40,6 +40,7 @@ abstract class BaseSpec extends Specification implements ObjectMapperWrapper { private static final int MIN_TIMEOUT = DEFAULT_TIMEOUT private static final int DEFAULT_TARGETING_PRECISION = 1 private static final String DEFAULT_CACHE_DIRECTORY = "/app/prebid-server/data" + protected static final String ALERT_GENERAL = "alerts.general" protected static final Map GENERIC_ALIAS_CONFIG = ["adapters.generic.aliases.alias.enabled" : "true", "adapters.generic.aliases.alias.endpoint": "$networkServiceContainer.rootUri/auction".toString()] diff --git a/src/test/groovy/org/prebid/server/functional/tests/BidValidationSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/BidValidationSpec.groovy index 579a8e16597..9dd17c31e0a 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/BidValidationSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/BidValidationSpec.groovy @@ -63,7 +63,7 @@ class BidValidationSpec extends BaseSpec { and: "Bid validation metric value is incremented" def metrics = strictPrebidService.sendCollectedMetricsRequest() - assert metrics["alerts.general"] == 1 + assert metrics[ALERT_GENERAL] == 1 where: bidRequest << [BidRequest.getDefaultBidRequest(DistributionChannel.APP).tap { @@ -105,7 +105,7 @@ class BidValidationSpec extends BaseSpec { and: "Bid validation metric value is incremented" def metrics = softPrebidService.sendCollectedMetricsRequest() - assert metrics["alerts.general"] == 1 + assert metrics[ALERT_GENERAL] == 1 and: "PBS log should contain message" def logs = softPrebidService.getLogsByTime(startTime) diff --git a/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy index 92ef175681e..ee3e808a56e 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy @@ -146,7 +146,7 @@ class MetricsSpec extends BaseSpec { assert metrics["adapter.generic.requests.type.openrtb2-dooh" as String] == 1 and: "alert.general metric should be updated" - assert metrics["alerts.general" as String] == 1 + assert metrics[ALERT_GENERAL] == 1 and: "Other channel types should not be populated" assert !metrics["account.${accountId}.requests.type.openrtb2-web" as String] @@ -175,7 +175,7 @@ class MetricsSpec extends BaseSpec { assert metrics["adapter.generic.requests.type.openrtb2-app" as String] == 1 and: "alert.general metric should be updated" - assert metrics["alerts.general" as String] == 1 + assert metrics[ALERT_GENERAL] == 1 and: "Other channel types should not be populated" assert !metrics["account.${accountId}.requests.type.openrtb2-dooh" as String] diff --git a/src/test/groovy/org/prebid/server/functional/tests/bidder/openx/OpenxSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/bidder/openx/OpenxSpec.groovy index 8e6d7f1a57d..97a0015cea5 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/bidder/openx/OpenxSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/bidder/openx/OpenxSpec.groovy @@ -444,7 +444,7 @@ class OpenxSpec extends BaseSpec { and: "Alert.general metric should be updated" def metrics = pbsService.sendCollectedMetricsRequest() - assert metrics["alerts.general" as String] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS shouldn't populate fledge or igi config when bidder respond with igb"() { diff --git a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsBaseSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsBaseSpec.groovy index 8b3f5d936bd..a604264b264 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsBaseSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsBaseSpec.groovy @@ -15,6 +15,7 @@ import org.prebid.server.functional.model.request.auction.BidRequest import org.prebid.server.functional.model.request.auction.BidRequestExt import org.prebid.server.functional.model.request.auction.DistributionChannel import org.prebid.server.functional.model.request.auction.ExtPrebidFloors +import org.prebid.server.functional.model.request.auction.FetchStatus import org.prebid.server.functional.model.request.auction.Prebid import org.prebid.server.functional.model.request.auction.Video import org.prebid.server.functional.model.response.currencyrates.CurrencyRatesResponse @@ -34,17 +35,34 @@ abstract class PriceFloorsBaseSpec extends BaseSpec { public static final BigDecimal FLOOR_MIN = 0.5 public static final BigDecimal FLOOR_MAX = 2 - public static final Map FLOORS_CONFIG = ["price-floors.enabled" : "true", - "settings.default-account-config": encode(defaultAccountConfigSettings)] + public static final Map FLOORS_CONFIG = ["price-floors.enabled": "true"] + + protected static final FloorsProvider floorsProvider = new FloorsProvider(networkServiceContainer) protected static final String BASIC_FETCH_URL = networkServiceContainer.rootUri + FloorsProvider.FLOORS_ENDPOINT protected static final int MAX_MODEL_WEIGHT = 100 + protected static final Closure INVALID_CONFIG_METRIC = { account -> "alerts.account_config.${account}.price-floors" } + + protected static final Closure URL_EMPTY_ERROR = { url -> "Failed to fetch price floor from provider for fetch.url '${url}'" + } + protected static final String FETCHING_DISABLED_ERROR = "Fetching is disabled" + protected static final Closure PRICE_FLOORS_ERROR_LOG = { bidRequest, reason, warningMessage -> + "Price Floors can't be resolved for account ${bidRequest.accountId} and request ${bidRequest.id}, reason: ${PRICE_FLOORS_WARNING_MESSAGE(reason, warningMessage)}" + } + protected static final Closure WARNING_MESSAGE = { message -> + "Price floors processing failed: parsing of request price floors is failed: $message" + } + protected static final Closure FETCHING_FLOORS_ERROR_LOG = { bidRequest, warningMessage -> + "Price floor fetching failed for account ${bidRequest.accountId}: ${URL_EMPTY_ERROR("$BASIC_FETCH_URL${bidRequest.accountId}")}, with a reason: $warningMessage" + } + private static final Closure PRICE_FLOORS_WARNING_MESSAGE = { reason, details -> + "Price floors processing failed: $reason. Following parsing of request price floors is failed: $details" + } private static final int DEFAULT_MODEL_WEIGHT = 1 private static final int CURRENCY_CONVERSION_PRECISION = 3 private static final int FLOOR_VALUE_PRECISION = 4 - protected static final FloorsProvider floorsProvider = new FloorsProvider(networkServiceContainer) protected final PrebidServerService floorsPbsService = pbsServiceFactory.getService(FLOORS_CONFIG + GENERIC_ALIAS_CONFIG) def setupSpec() { @@ -121,8 +139,9 @@ abstract class PriceFloorsBaseSpec extends BaseSpec { protected void cacheFloorsProviderRules(BidRequest bidRequest, PrebidServerService pbsService = floorsPbsService, - BidderName bidderName = BidderName.GENERIC) { - PBSUtils.waitUntil({ getRequests(pbsService.sendAuctionRequest(bidRequest))[bidderName.value]?.first?.ext?.prebid?.floors?.fetchStatus != INPROGRESS }, + BidderName bidderName = BidderName.GENERIC, + FetchStatus fetchStatus = INPROGRESS) { + PBSUtils.waitUntil({ getRequests(pbsService.sendAuctionRequest(bidRequest))[bidderName.value]?.first?.ext?.prebid?.floors?.fetchStatus != fetchStatus }, 5000, 1000) } diff --git a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsFetchingSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsFetchingSpec.groovy index c5ce101b156..b40f6e8cac2 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsFetchingSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsFetchingSpec.groovy @@ -17,11 +17,13 @@ import java.time.Instant import static org.mockserver.model.HttpStatusCode.BAD_REQUEST_400 import static org.prebid.server.functional.model.Currency.EUR import static org.prebid.server.functional.model.Currency.JPY +import static org.prebid.server.functional.model.bidder.BidderName.GENERIC import static org.prebid.server.functional.model.pricefloors.Country.MULTIPLE import static org.prebid.server.functional.model.pricefloors.MediaType.BANNER import static org.prebid.server.functional.model.request.auction.DistributionChannel.APP import static org.prebid.server.functional.model.request.auction.DistributionChannel.SITE import static org.prebid.server.functional.model.request.auction.FetchStatus.ERROR +import static org.prebid.server.functional.model.request.auction.FetchStatus.INPROGRESS import static org.prebid.server.functional.model.request.auction.FetchStatus.NONE import static org.prebid.server.functional.model.request.auction.FetchStatus.SUCCESS import static org.prebid.server.functional.model.request.auction.Location.FETCH @@ -43,8 +45,15 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { private static final int DEFAULT_FLOOR_VALUE_MIN = 0 private static final int FLOOR_MIN = 0 - private static final Closure INVALID_CONFIG_METRIC = { account -> "alerts.account_config.${account}.price-floors" } private static final String FETCH_FAILURE_METRIC = "price-floors.fetch.failure" + private static final String PRICE_FLOOR_VALUES_MISSING = 'Price floor rules values can\'t be null or empty, but were null' + private static final String MODEL_WEIGHT_INVALID = "Price floor modelGroup modelWeight must be in range(1-100), but was %s" + private static final String SKIP_RATE_INVALID = "Price floor modelGroup skipRate must be in range(0-100), but was %s" + private static Instant startTime + + def setupSpec() { + startTime = Instant.now() + } def "PBS should activate floors feature when price-floors.enabled = true in PBS config"() { given: "Pbs with PF configuration" @@ -121,7 +130,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def logs = floorsPbsService.getLogsByTime(startTime) def floorsLogs = getLogsByText(logs, bidRequest.accountId) assert floorsLogs.size() == 1 - assert floorsLogs.first().contains("Malformed fetch.url: 'null', passed for account $bidRequest.accountId") + assert floorsLogs.first().contains("alformed fetch.url: 'null' passed for account $bidRequest.accountId") and: "PBS floors validation failure should not reject the entire auction" assert !response.seatbid.isEmpty() @@ -525,7 +534,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def startTime = Instant.now() and: "Default BidRequest" - def bidRequest = BidRequest.defaultBidRequest + def bidRequest = BidRequest.getDefaultBidRequest() and: "Account with enabled fetch and fetch.url in the DB" def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { @@ -545,7 +554,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { floorsProvider.setResponse(bidRequest.accountId, floorsResponse) and: "PBS fetch rules from floors provider" - cacheFloorsProviderRules(bidRequest, floorsPbsService) + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -554,14 +563,19 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def metrics = floorsPbsService.sendCollectedMetricsRequest() assert metrics[FETCH_FAILURE_METRIC] == 1 - then: "PBS should fetch data" + and: "PBS should fetch data" assert floorsProvider.getRequestCount(bidRequest.accountId) == 1 + and: "PBS should not add warning or errors" + assert !response.ext.warnings + assert !response.ext.errors + and: "PBS log should contain error" def logs = floorsPbsService.getLogsByTime(startTime) + def message = "Price floor data useFetchDataRate must be in range(0-100), but was $accounntUseFetchDataRate" def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("reason : Price floor data useFetchDataRate must be in range(0-100), but was $accounntUseFetchDataRate") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -589,6 +603,90 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { ] } + def "PBS should log merged error and increase metrics when useFetchDataRate have invalid value from provider and request"() { + given: "BidRequest with invalid floors" + def requestUseFetchDataRate = PBSUtils.getRandomNegativeNumber() + def bidRequest = getBidRequestWithFloors().tap { + it.ext.prebid.floors.data.useFetchDataRate = requestUseFetchDataRate + } + + and: "Account with enabled fetch and fetch.url in the DB" + def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { + config.auction.priceFloors.useDynamicData = true + } + accountDao.save(account) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Set Floors Provider response" + def providerUseFetchDataRate = PBSUtils.getRandomNegativeNumber() + def floorValue = PBSUtils.randomFloorValue + def floorsResponse = PriceFloorData.priceFloorData.tap { + modelGroups[0].values = [(rule): floorValue] + useFetchDataRate = providerUseFetchDataRate + } + floorsProvider.setResponse(bidRequest.accountId, floorsResponse) + + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, ERROR) + + and: "Test start time" + def startTime = Instant.now() + + when: "PBS processes auction request" + def response = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "metric should be updated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[FETCH_FAILURE_METRIC] == 1 + + and: "PBS should add single warning" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == + [WARNING_MESSAGE("Price floor data useFetchDataRate must be in range(0-100), but was $requestUseFetchDataRate")] + + and: "PBS should not add error to request" + assert !response.ext.errors + + and: "PBS should fetch data" + assert floorsProvider.getRequestCount(bidRequest.accountId) == 1 + + and: "PBS log should contain combined error log" + def logs = floorsPbsService.getLogsByTime(startTime) + def fetchingErrorLogs = getLogsByText(logs, "Price Floors can't be resolved for account $bidRequest.accountId " + + "and request $bidRequest.id, reason: Price floors processing failed: Failed to fetch price floor from provider " + + "for fetch.url '$BASIC_FETCH_URL${bidRequest.accountId}', with a reason: " + + "Price floor data useFetchDataRate must be in range(0-100), but was $providerUseFetchDataRate. " + + "Following parsing of request price floors is failed: " + + "Price floor data useFetchDataRate must be in range(0-100), but was $requestUseFetchDataRate") + assert fetchingErrorLogs.size() == 1 + + and: "Floors validation failure cannot reject the entire auction" + assert !response.seatbid?.isEmpty() + + and: "Bidder request should contain floors data from floors provider" + def bidderRequest = bidder.getBidderRequests(bidRequest.id).last + verifyAll(bidderRequest) { + imp.bidFloor == bidRequest.imp.bidFloor + imp.bidFloorCur == bidRequest.imp.bidFloorCur + + !imp[0].ext?.prebid?.floors?.floorRule + !imp[0].ext?.prebid?.floors?.floorRuleValue + !imp[0].ext?.prebid?.floors?.floorValue + + !ext?.prebid?.floors?.floorProvider + !ext?.prebid?.floors?.skipRate + !ext?.prebid?.floors?.data + ext?.prebid?.floors?.location == NO_DATA + ext?.prebid?.floors?.fetchStatus == ERROR + } + } + def "PBS should process floors from request when use-dynamic-data = false"() { given: "Pbs with PF configuration with useDynamicData" def defaultAccountConfigSettings = defaultAccountConfigSettings.tap { @@ -645,7 +743,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { floorsProvider.setResponse(accountId, BAD_REQUEST_400) and: "PBS fetch rules from floors provider" - cacheFloorsProviderRules(bidRequest, floorsPbsService) + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -657,12 +755,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Failed to request, provider respond with status 400" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL) + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Failed to request for " + - "account $accountId, provider respond with status 400") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -687,6 +784,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def invalidJson = "{{}}" floorsProvider.setResponse(accountId, invalidJson) + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -697,12 +797,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Failed to parse price floor response, cause: DecodeException: Failed to decode" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$accountId") + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Failed to parse price floor " + - "response for account $accountId, cause: DecodeException: Failed to decode") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -726,6 +825,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "Set Floors Provider response with invalid json" floorsProvider.setResponse(accountId, "") + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -736,12 +838,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Failed to parse price floor response, response body can not be empty" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL + accountId) + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Failed to parse price floor " + - "response for account $accountId, response body can not be empty" as String) + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -768,6 +869,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } floorsProvider.setResponse(accountId, floorsResponse) + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -778,12 +882,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Price floor rules should contain at least one model group" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL + accountId) + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor rules should contain " + - "at least one model group " as String) + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -810,6 +913,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } floorsProvider.setResponse(accountId, floorsResponse) + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -821,11 +927,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "PBS log should contain error" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL + accountId) + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor rules values can't " + - "be null or empty, but were null" as String) + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, PRICE_FLOOR_VALUES_MISSING)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -855,6 +959,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } floorsProvider.setResponse(accountId, floorsResponse) + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -865,12 +972,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Price floor rules number 2 exceeded its maximum number $maxRules" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL + accountId) + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor rules number " + - "2 exceeded its maximum number $maxRules") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -901,6 +1007,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "Set Floors Provider response with timeout" floorsProvider.setResponseWithTimeout(accountId) + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, pbsService, GENERIC, NONE) + when: "PBS processes auction request" def response = pbsService.sendAuctionRequest(bidRequest) @@ -912,10 +1021,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "PBS log should contain error" def logs = pbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL) + def floorsLogs = getLogsByText(logs, "Price floor fetching failed for account $bidRequest.accountId: " + + "Fetch price floor request timeout for fetch.url '$BASIC_FETCH_URL$accountId' exceeded") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Fetch price floor request timeout for fetch.url: '$BASIC_FETCH_URL$accountId', " + - "account $accountId exceeded") and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -944,6 +1052,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def responseSize = convertKilobyteSizeToByte(maxSize) + 75 floorsProvider.setResponse(accountId, floorsResponse, ["Content-Length": responseSize as String]) + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -954,12 +1065,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Response size $responseSize exceeded ${convertKilobyteSizeToByte(maxSize)} bytes limit" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL + accountId) + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Response size " + - "$responseSize exceeded ${convertKilobyteSizeToByte(maxSize)} bytes limit") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -1277,6 +1387,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1284,11 +1398,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(bidRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor floorMin " + - "must be positive float, but was $invalidFloorMin"] + and: "Response should contain warning" + def message = "Price floor floorMin must be positive float, but was $invalidFloorMin" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] } def "PBS should validate rules from request when request doesn't contain modelGroups"() { @@ -1305,6 +1418,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1312,11 +1429,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(bidRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor rules " + - "should contain at least one model group"] + and: "Response should contain warning" + def message = "Price floor rules should contain at least one model group" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] } def "PBS should validate rules from request when request doesn't contain values"() { @@ -1333,6 +1449,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1340,11 +1460,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(bidRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor rules values " + - "can't be null or empty, but were null"] + and: "Response should contain warning" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(PRICE_FLOOR_VALUES_MISSING)] } def "PBS should validate rules from request when modelWeight from request is invalid"() { @@ -1365,6 +1483,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1372,11 +1494,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequest(bidRequest.id) assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor modelGroup modelWeight " + - "must be in range(1-100), but was $invalidModelWeight"] + and: "Response should contain warning" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(MODEL_WEIGHT_INVALID.formatted(invalidModelWeight))] + where: invalidModelWeight << [0, MAX_MODEL_WEIGHT + 1] } @@ -1404,6 +1525,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(ampStoredRequest) + bidder.setResponse(ampStoredRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAmpRequest(ampRequest) @@ -1411,11 +1536,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(ampStoredRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor modelGroup modelWeight " + - "must be in range(1-100), but was $invalidModelWeight"] + and: "Response should contain warning" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(MODEL_WEIGHT_INVALID.formatted(invalidModelWeight))] where: invalidModelWeight << [0, MAX_MODEL_WEIGHT + 1] @@ -1444,6 +1567,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "PBS fetch rules from floors provider" cacheFloorsProviderRules(bidRequest) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1451,11 +1578,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(bidRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor root skipRate " + - "must be in range(0-100), but was $invalidSkipRate"] + and: "Response should contain warning" + def message = "Price floor root skipRate must be in range(0-100), but was $invalidSkipRate" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] where: invalidSkipRate << [SKIP_RATE_MIN - 1, SKIP_RATE_MAX + 1] @@ -1484,6 +1610,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "PBS fetch rules from floors provider" cacheFloorsProviderRules(bidRequest) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1491,11 +1621,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(bidRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor data skipRate " + - "must be in range(0-100), but was $invalidSkipRate"] + and: "Response should contain warning" + def message = "Price floor data skipRate must be in range(0-100), but was $invalidSkipRate" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] where: invalidSkipRate << [SKIP_RATE_MIN - 1, SKIP_RATE_MAX + 1] @@ -1524,6 +1653,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "PBS fetch rules from floors provider" cacheFloorsProviderRules(bidRequest) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1531,11 +1664,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(bidRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor modelGroup skipRate " + - "must be in range(0-100), but was $invalidSkipRate"] + and: "Response should contain warning" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(SKIP_RATE_INVALID.formatted(invalidSkipRate))] where: invalidSkipRate << [SKIP_RATE_MIN - 1, SKIP_RATE_MAX + 1] @@ -1560,6 +1691,10 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1567,11 +1702,230 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { def bidderRequest = bidder.getBidderRequests(bidRequest.id).last() assert bidderRequest.imp[0].bidFloor == floorValue - and: "Response should contain error" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: Price floor modelGroup default " + - "must be positive float, but was $invalidDefaultFloorValue"] + and: "Response should contain warning" + def message = "Price floor modelGroup default must be positive float, but was $invalidDefaultFloorValue" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + } + + def "PBS shouldn't emit error in log and response when floors is not in request and floors fetching disabled for account"() { + given: "Account with disabled fetching" + def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { + config.auction.priceFloors.fetch.enabled = false + config.auction.priceFloors.fetch.url = null + } + accountDao.save(account) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS shouldn't log warning or errors" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + + and: "PBS shouldn't log a errors" + def message = "Price floor rules data must be present" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert !floorsLogs.size() + + and: "PBS request on response object status should be in progress" + assert getRequests(bidResponse)[GENERIC.value]?.first?.ext?.prebid?.floors?.fetchStatus == NONE + + and: "PBS bidderRequest status should be in progress" + def bidderRequest = bidder.getBidderRequests(bidRequest.id) + assert bidderRequest.ext.prebid.floors.fetchStatus == [NONE] + + and: "Alerts.general metrics shouldn't be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert !metrics[ALERT_GENERAL] + + where: + bidRequest << [BidRequest.getDefaultBidRequest(), getBidRequestWithFloors().tap { it.ext.prebid.floors = null }] + } + + def "PBS should emit error in log and response when floor data is empty and floors fetching disabled for account and #requestFloorEnabled for request"() { + given: "Default BidRequest with empty floors.data" + def bidRequest = bidRequestWithFloors.tap { + it.ext.prebid.floors.enabled = requestFloorEnabled + it.ext.prebid.floors.data = null + } + + and: "Account with disabled fetching" + def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { + it.config.auction.priceFloors.fetch.enabled = false + } + accountDao.save(account) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS should log a warning" + def message = "Price floor rules data must be present" + assert bidResponse.ext?.warnings[PREBID]*.code == [999] + assert bidResponse.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should not add errors" + assert !bidResponse.ext.errors + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "PBS request on response object status should be not in progress" + assert getRequests(bidResponse)[GENERIC.value]?.first?.ext?.prebid?.floors?.fetchStatus == NONE + + and: "PBS request status shouldn't be in progress" + def bidderRequest = bidder.getBidderRequests(bidRequest.id) + assert bidderRequest.ext.prebid.floors.fetchStatus == [NONE] + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 + + where: + requestFloorEnabled << [null, true] + } + + def "PBS shouldn't emit error in log and response when floor data is empty and floors fetching disabled for account and floors disabled for request"() { + given: "Default BidRequest with empty floors.data" + def bidRequest = bidRequestWithFloors.tap { + it.ext.prebid.floors.enabled = false + it.ext.prebid.floors.data = null + } + + and: "Account with disabled fetching" + def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { + it.config.auction.priceFloors.fetch.enabled = false + } + accountDao.save(account) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS shouldn't log warning or errors" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + + and: "PBS shouldn't log a errors" + def message = "Price floor rules data must be present" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert !floorsLogs.size() + + and: "Alerts.general metrics shouldn't be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert !metrics[ALERT_GENERAL] + } + + def "PBS shouldn't emit error in log and response when data is invalid and floors fetching enabled for account"() { + given: "Default BidRequest with empty floors.data" + def bidRequest = bidRequestWithFloors.tap { + ext.prebid.floors.enabled = requestEnabledFloors + ext.prebid.floors.data = null + } + + and: "Account with enabled fetching" + def account = getAccountWithEnabledFetch(bidRequest.accountId) + accountDao.save(account) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS shouldn't log warning or errors" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + + and: "PBS shouldn't log a errors" + def message = "Price floor rules data must be present" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert !floorsLogs.size() + + and: "PBS request on response object status should be in progress" + assert getRequests(bidResponse)[GENERIC.value]?.first?.ext?.prebid?.floors?.fetchStatus == INPROGRESS + + and: "PBS bidderRequest status should be in progress" + def bidderRequest = bidder.getBidderRequests(bidRequest.id) + assert bidderRequest.ext.prebid.floors.fetchStatus == [INPROGRESS] + + and: "Alerts.general metrics shouldn't be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert !metrics[ALERT_GENERAL] + + where: + requestEnabledFloors << [null, true] + } + + def "PBS shouldn't emit error in log and response when data is invalid and floors disabled for account"() { + given: "Default BidRequest with empty floors.data" + def bidRequest = bidRequestWithFloors.tap { + ext.prebid.floors.data = null + } + + and: "Account with disabled fetching" + def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { + config.auction.priceFloors.enabled = false + config.auction.priceFloors.fetch.enabled = false + } + accountDao.save(account) + + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS should not add warning or errors" + assert !bidResponse.ext.warnings + assert !bidResponse.ext.errors + + and: "PBS request on response object status should be empty" + assert getRequests(bidResponse)[GENERIC.value]?.first?.ext?.prebid?.floors?.fetchStatus == null + + and: "PBS request status should be empty" + def bidderRequest = bidder.getBidderRequests(bidRequest.id) + assert bidderRequest.ext.prebid.floors.fetchStatus.every { it == null } + + and: "Alerts.general metrics shouldn't be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert !metrics[ALERT_GENERAL] } def "PBS should not invalidate previously good fetched data when floors provider return invalid data"() { @@ -1651,7 +2005,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { floorsProvider.setResponse(accountId, floorsResponse) and: "PBS fetch rules from floors provider" - cacheFloorsProviderRules(bidRequest) + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1669,11 +2023,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "PBS log should contain error" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL) + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor modelGroup modelWeight" + - " must be in range(1-100), but was $invalidModelWeight") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, MODEL_WEIGHT_INVALID.formatted(invalidModelWeight))) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -1710,7 +2062,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { floorsProvider.setResponse(accountId, floorsResponse) and: "PBS fetch rules from floors provider" - cacheFloorsProviderRules(bidRequest) + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1727,12 +2079,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Price floor data skipRate must be in range(0-100), but was $invalidSkipRate" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$accountId") + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor data skipRate" + - " must be in range(0-100), but was $invalidSkipRate") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -1769,7 +2120,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { floorsProvider.setResponse(accountId, floorsResponse) and: "PBS fetch rules from floors provider" - cacheFloorsProviderRules(bidRequest) + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1787,11 +2138,9 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { and: "PBS log should contain error" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$accountId") + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor modelGroup skipRate" + - " must be in range(0-100), but was $invalidSkipRate") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, SKIP_RATE_INVALID.formatted(invalidSkipRate))) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() @@ -1828,7 +2177,7 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { floorsProvider.setResponse(accountId, floorsResponse) and: "PBS fetch rules from floors provider" - cacheFloorsProviderRules(bidRequest) + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, NONE) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) @@ -1845,12 +2194,11 @@ class PriceFloorsFetchingSpec extends PriceFloorsBaseSpec { assert metrics[FETCH_FAILURE_METRIC] == 1 and: "PBS log should contain error" + def message = "Price floor modelGroup default must be positive float, but was $invalidDefaultFloor" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$accountId") + def floorsLogs = getLogsByText(logs, "$BASIC_FETCH_URL$bidRequest.accountId") assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor modelGroup default" + - " must be positive float, but was $invalidDefaultFloor") + assert floorsLogs[0].contains(FETCHING_FLOORS_ERROR_LOG(bidRequest, message)) and: "Floors validation failure cannot reject the entire auction" assert !response.seatbid?.isEmpty() diff --git a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsRulesSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsRulesSpec.groovy index 5d985596679..96247fe0157 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsRulesSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsRulesSpec.groovy @@ -61,6 +61,7 @@ import static org.prebid.server.functional.model.request.auction.FetchStatus.ERR import static org.prebid.server.functional.model.request.auction.Location.NO_DATA import static org.prebid.server.functional.model.request.auction.Prebid.Channel import static org.prebid.server.functional.model.response.auction.BidRejectionReason.RESPONSE_REJECTED_DUE_TO_PRICE_FLOOR +import static org.prebid.server.functional.model.response.auction.ErrorType.PREBID import static org.prebid.server.functional.testcontainers.Dependencies.getNetworkServiceContainer class PriceFloorsRulesSpec extends PriceFloorsBaseSpec { @@ -217,10 +218,15 @@ class PriceFloorsRulesSpec extends PriceFloorsBaseSpec { assert bidderRequest.ext?.prebid?.floors?.location == NO_DATA assert bidderRequest.ext?.prebid?.floors?.fetchStatus == ERROR - and: "PBS should not contain errors, warnings" - assert !response.ext?.warnings + and: "PBS should not contain errors" assert !response.ext?.errors + and: "PBS should log a warning" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message.first.contains("Cannot deserialize value of type " + + "`org.prebid.server.floors.model.PriceFloorField` " + + "from String \"bogus\": not one of the values accepted for Enum class") + and: "PBS should not reject the entire auction" assert !response.seatbid.isEmpty() } diff --git a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsSignalingSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsSignalingSpec.groovy index 93f26180213..a885da9e86b 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsSignalingSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/pricefloors/PriceFloorsSignalingSpec.groovy @@ -29,15 +29,20 @@ import static org.prebid.server.functional.model.pricefloors.MediaType.VIDEO import static org.prebid.server.functional.model.pricefloors.PriceFloorField.MEDIA_TYPE import static org.prebid.server.functional.model.pricefloors.PriceFloorField.SITE_DOMAIN import static org.prebid.server.functional.model.request.auction.DistributionChannel.APP +import static org.prebid.server.functional.model.request.auction.FetchStatus.SUCCESS import static org.prebid.server.functional.model.response.auction.ErrorType.PREBID class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { - private static final Closure INVALID_CONFIG_METRIC = { account -> "alerts.account_config.${account}.price-floors" } private static final int MAX_SCHEMA_DIMENSIONS_SIZE = 1 private static final int MAX_RULES_SIZE = 1 + private static Instant startTime - def "PBS should skip signalling for request with rules when ext.prebid.floors.enabled = false in request"() { + def setupSpec() { + startTime = Instant.now() + } + + def "PBS should skip signaling for request with rules when ext.prebid.floors.enabled = false in request"() { given: "Default BidRequest with disabled floors" def bidRequest = bidRequestWithFloors.tap { ext.prebid.floors.enabled = requestEnabled @@ -49,24 +54,45 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, SUCCESS) + when: "PBS processes auction request" - floorsPbsService.sendAuctionRequest(bidRequest) + def response = floorsPbsService.sendAuctionRequest(bidRequest) then: "Bidder request bidFloor should correspond request" - def bidderRequest = bidder.getBidderRequest(bidRequest.id) + def bidderRequest = bidder.getBidderRequests(bidRequest.id).last assert bidderRequest.imp[0].bidFloor == bidRequest.imp[0].bidFloor assert !bidderRequest.ext?.prebid?.floors?.enabled and: "PBS should not fetch rules from floors provider" assert floorsProvider.getRequestCount(bidRequest.site.publisher.id) == 0 + and: "PBS should not add warning or errors" + assert !response.ext.warnings + assert !response.ext.errors + + and: "PBS shouldn't log a errors" + def message = "Price floor rules data must be present" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert !floorsLogs.size() + + and: "Alerts.general metrics shouldn't be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert !metrics[ALERT_GENERAL] + where: requestEnabled | accountEnabled false | true true | false } - def "PBS should skip signalling for request without rules when ext.prebid.floors.enabled = false in request"() { + def "PBS should skip signaling for request without rules when ext.prebid.floors.enabled = false in request"() { given: "Default BidRequest" def bidRequest = BidRequest.getDefaultBidRequest(APP).tap { ext.prebid.floors = new ExtPrebidFloors(enabled: false) @@ -145,7 +171,7 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { assert floorsProvider.getRequestCount(bidRequest.site.publisher.id) == 1 } - def "PBS should not signalling when neither fetched floors nor ext.prebid.floors exist, imp.bidFloor is not defined"() { + def "PBS should not signaling when neither fetched floors nor ext.prebid.floors exist, imp.bidFloor is not defined"() { given: "Default BidRequest" def bidRequest = BidRequest.defaultBidRequest @@ -173,7 +199,7 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { assert floorsProvider.getRequestCount(bidRequest.site.publisher.id) == 1 } - def "PBS should make PF signalling when skipRate = #skipRate"() { + def "PBS should make PF signaling when skipRate = #skipRate"() { given: "Default BidRequest with bidFloor, bidFloorCur" def bidRequest = BidRequest.defaultBidRequest.tap { imp[0].bidFloor = PBSUtils.randomFloorValue @@ -209,7 +235,7 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { skipRate << [0, null] } - def "PBS should not make PF signalling, enforcing when skipRate = 100"() { + def "PBS should not make PF signaling, enforcing when skipRate = 100"() { given: "Default BidRequest with bidFloor, bidFloorCur" def bidRequest = BidRequest.defaultBidRequest.tap { imp[0].bidFloor = 0.8 @@ -243,7 +269,7 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { assert bidderRequest.ext?.prebid?.floors?.skipRate == 100 assert bidderRequest.ext?.prebid?.floors?.skipped - and: "PBS should not made signalling" + and: "PBS should not made signaling" assert !bidderRequest.imp[0].ext?.prebid?.floors where: @@ -280,7 +306,7 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS should not log warning" + then: "PBS should not log warning or errors" assert !response.ext.warnings assert !response.ext.errors @@ -319,8 +345,9 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { when: "PBS processes amp request" def response = floorsPbsService.sendAmpRequest(ampRequest) - then: "PBS should not log warning" + then: "PBS should not log warning or errors" assert !response.ext.warnings + assert !response.ext.errors and: "Bidder request should contain bidFloor from the request" def bidderRequest = bidder.getBidderRequest(ampStoredRequest.id) @@ -520,7 +547,7 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { assert bidderRequest.imp.last().bidFloor == videoFloorValue } - def "PBS shouldn't emit errors when request schema.fields than floor-config.max-schema-dims"() { + def "PBS shouldn't emit warning when request schema.fields equal to floor-config.max-schema-dims"() { given: "Bid request with schema 2 fields" def bidRequest = bidRequestWithFloors.tap { ext.prebid.floors.maxSchemaDims = PBSUtils.getRandomNumber(2) @@ -531,18 +558,23 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { def account = getAccountWithEnabledFetch(accountId) accountDao.save(account) - and: "Set bidder response" + and: "Default bid response" def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS shouldn't log a errors" - assert !response.ext?.errors + then: "PBS should not add warning or errors" + assert !response.ext.warnings + assert !response.ext.errors + + and: "Alerts.general metrics shouldn't be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert !metrics[ALERT_GENERAL] } - def "PBS should emit errors when request has more rules than price-floor.max-rules"() { + def "PBS should emit warning when request has more rules than price-floor.max-rules"() { given: "BidRequest with 2 rules" def requestFloorValue = PBSUtils.randomFloorValue def bidRequest = bidRequestWithFloors.tap { @@ -551,9 +583,10 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { (new Rule(mediaType: BANNER, country: Country.MULTIPLE).rule): requestFloorValue] } - and: "Account with maxRules in the DB" + and: "Account with maxRules and disabled fetching in the DB" def accountId = bidRequest.site.publisher.id def account = getAccountWithEnabledFetch(accountId).tap { + config.auction.priceFloors.fetch.enabled = false config.auction.priceFloors.maxRules = maxRules config.auction.priceFloors.maxRulesSnakeCase = maxRulesSnakeCase } @@ -564,14 +597,20 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { bidResponse.seatbid.first().bid.first().price = requestFloorValue bidder.setResponse(bidRequest.id, bidResponse) + and: "Flush metrics" + flushMetrics(floorsPbsService) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS should log a errors" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: " + - "Price floor rules number ${getRuleSize(bidRequest)} exceeded its maximum number ${MAX_RULES_SIZE}"] + then: "PBS should log a warning" + def message = "Price floor rules number ${getRuleSize(bidRequest)} exceeded its maximum number ${MAX_RULES_SIZE}" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 where: maxRules | maxRulesSnakeCase @@ -579,30 +618,40 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { null | MAX_RULES_SIZE } - def "PBS should emit errors when request has more schema.fields than floor-config.max-schema-dims"() { + def "PBS should emit warning when request has more schema.fields than floor-config.max-schema-dims"() { given: "BidRequest with schema 2 fields" def bidRequest = bidRequestWithFloors - and: "Account with maxSchemaDims in the DB" + and: "Account with maxSchemaDims and disabled fetching in the DB" def accountId = bidRequest.site.publisher.id def account = getAccountWithEnabledFetch(accountId).tap { + config.auction.priceFloors.fetch.enabled = false config.auction.priceFloors.maxSchemaDims = maxSchemaDims config.auction.priceFloors.maxSchemaDimsSnakeCase = maxSchemaDimsSnakeCase } accountDao.save(account) - and: "Set bidder response" + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS should log a errors" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: " + - "Price floor schema dimensions ${getSchemaSize(bidRequest)} exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}"] + then: "PBS should log a warning" + def message = "Price floor schema dimensions ${getSchemaSize(bidRequest)} exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 where: maxSchemaDims | maxSchemaDimsSnakeCase @@ -610,7 +659,7 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { null | MAX_SCHEMA_DIMENSIONS_SIZE } - def "PBS should emit errors when request has more schema.fields than default-account.max-schema-dims"() { + def "PBS should emit warning when request has more schema.fields than default-account.max-schema-dims"() { given: "Floor config with default account" def accountConfig = getDefaultAccountConfigSettings().tap { auction.priceFloors.maxSchemaDims = MAX_SCHEMA_DIMENSIONS_SIZE @@ -627,45 +676,50 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { and: "Account with maxSchemaDims in the DB" def accountId = bidRequest.site.publisher.id def account = getAccountWithEnabledFetch(accountId).tap { + config.auction.priceFloors.maxSchemaDims = PBSUtils.randomNegativeNumber } accountDao.save(account) - and: "Set bidder response" + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS should log a errors" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: " + - "Price floor schema dimensions ${getSchemaSize(bidRequest)} " + - "exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}"] + then: "PBS should log a warning" + def message = "Price floor schema dimensions ${getSchemaSize(bidRequest)} exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] - and: "Metric alerts.account_config.ACCOUNT.price-floors should be update" + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Metrics should be updated" def metrics = floorsPbsService.sendCollectedMetricsRequest() assert metrics[INVALID_CONFIG_METRIC(bidRequest.accountId) as String] == 1 + assert metrics[ALERT_GENERAL] == 1 cleanup: "Stop and remove pbs container" pbsServiceFactory.removeContainer(pbsFloorConfig) } - def "PBS should emit errors when request has more schema.fields than default-account.fetch.max-schema-dims"() { - given: "Test start time" - def startTime = Instant.now() - - and: "BidRequest with schema 2 fields" + def "PBS should emit warning when request has more schema.fields than default-account.fetch.max-schema-dims"() { + given: "BidRequest with schema 2 fields" def bidRequest = bidRequestWithFloors and: "Floor config with default account" def accountConfig = getDefaultAccountConfigSettings().tap { - auction.priceFloors.fetch.enabled = true + auction.priceFloors.fetch.enabled = false auction.priceFloors.fetch.url = BASIC_FETCH_URL + bidRequest.site.publisher.id auction.priceFloors.fetch.maxSchemaDims = MAX_SCHEMA_DIMENSIONS_SIZE - auction.priceFloors.maxSchemaDims = null + auction.priceFloors.maxSchemaDims = MAX_SCHEMA_DIMENSIONS_SIZE } def pbsFloorConfig = GENERIC_ALIAS_CONFIG + ["price-floors.enabled" : "true", "settings.default-account-config": encode(accountConfig)] @@ -683,48 +737,67 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) - and: "Set bidder response" + and: "Default bid response" def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" - floorsPbsService.sendAuctionRequest(bidRequest) + def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS should log a errors" + then: "Response should includer error warning" + def message = "Price floor schema dimensions ${getSchemaSize(bidRequest)} exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS shouldn't log a errors" def logs = floorsPbsService.getLogsByTime(startTime) - def floorsLogs = getLogsByText(logs, BASIC_FETCH_URL + accountId) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) assert floorsLogs.size() == 1 - assert floorsLogs[0].contains("Failed to fetch price floor from provider for fetch.url: " + - "'$BASIC_FETCH_URL$accountId', account = $accountId with a reason : Price floor schema dimensions ${getSchemaSize(bidRequest)} " + - "exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}") - and: "Metric alerts.account_config.ACCOUNT.price-floors should be update" + and: "Metrics should be updated" def metrics = floorsPbsService.sendCollectedMetricsRequest() assert metrics[INVALID_CONFIG_METRIC(bidRequest.accountId) as String] == 1 + assert metrics[ALERT_GENERAL] == 1 cleanup: "Stop and remove pbs container" pbsServiceFactory.removeContainer(pbsFloorConfig) } - def "PBS should emit errors when request has more schema.fields than fetch.max-schema-dims"() { - given: "Default BidRequest with floorMin" + def "PBS should emit warning when request has more schema.fields than fetch.max-schema-dims"() { + given: "BidRequest with schema 2 fields" def bidRequest = bidRequestWithFloors and: "Account with disabled fetch in the DB" def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { + config.auction.priceFloors.fetch.enabled = false config.auction.priceFloors.maxSchemaDims = maxSchemaDims config.auction.priceFloors.maxSchemaDimsSnakeCase = maxSchemaDimsSnakeCase } accountDao.save(account) + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS should log a errors" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: " + - "Price floor schema dimensions ${getSchemaSize(bidRequest)} exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}"] + then: "PBS should log a warning" + def message = "Price floor schema dimensions ${getSchemaSize(bidRequest)} exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 where: maxSchemaDims | maxSchemaDimsSnakeCase @@ -736,28 +809,40 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { given: "BidRequest with schema 2 fields" def bidRequest = bidRequestWithFloors - and: "Account with maxSchemaDims in the DB" + and: "Account with maxSchemaDims and disabled fetching in the DB" def accountId = bidRequest.site.publisher.id def floorSchemaFilesSize = getSchemaSize(bidRequest) def account = getAccountWithEnabledFetch(accountId).tap { + config.auction.priceFloors.fetch.enabled = false config.auction.priceFloors.maxSchemaDims = MAX_SCHEMA_DIMENSIONS_SIZE config.auction.priceFloors.fetch.maxSchemaDims = floorSchemaFilesSize } accountDao.save(account) - and: "Set bidder response" + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS should log a errors" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: " + - "Price floor schema dimensions ${floorSchemaFilesSize} " + - "exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}"] + then: "PBS should log a warning" + def message = "Price floor schema dimensions ${floorSchemaFilesSize} " + + "exceeded its maximum number ${MAX_SCHEMA_DIMENSIONS_SIZE}" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 } def "PBS shouldn't fail with error and maxSchemaDims take precede over fetch.maxSchemaDims when requested both"() { @@ -772,22 +857,22 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { } accountDao.save(account) - and: "Set bidder response" + and: "Default bid response" def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) bidder.setResponse(bidRequest.id, bidResponse) when: "PBS processes auction request" def response = floorsPbsService.sendAuctionRequest(bidRequest) - then: "PBS shouldn't log a errors" - assert !response.ext?.errors + then: "PBS shouldn't add warnings or errors" + assert !response.ext?.warnings } - def "PBS should emit errors when stored request has more rules than price-floor.max-rules for amp request"() { + def "PBS should emit warning when stored request has more rules than price-floor.max-rules for amp request"() { given: "Default AmpRequest" def ampRequest = AmpRequest.defaultAmpRequest - and: "Default stored request with 2 rules " + and: "Default stored request with 2 rules" def requestFloorValue = PBSUtils.randomFloorValue def ampStoredRequest = BidRequest.defaultStoredRequest.tap { ext.prebid.floors = ExtPrebidFloors.extPrebidFloors @@ -798,8 +883,9 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { def storedRequest = StoredRequest.getStoredRequest(ampRequest, ampStoredRequest) storedRequestDao.save(storedRequest) - and: "Account with maxRules in the DB" + and: "Account with maxRules and disabled fetching in the DB" def account = getAccountWithEnabledFetch(ampRequest.account as String).tap { + config.auction.priceFloors.fetch.enabled = false config.auction.priceFloors.maxRules = maxRules config.auction.priceFloors.maxRulesSnakeCase = maxRulesSnakeCase } @@ -810,15 +896,28 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { bidResponse.seatbid.first().bid.first().price = requestFloorValue bidder.setResponse(ampStoredRequest.id, bidResponse) + and: "Flush metrics" + flushMetrics(floorsPbsService) + when: "PBS processes amp request" def response = floorsPbsService.sendAmpRequest(ampRequest) - then: "PBS should log a errors" - assert response.ext?.errors[PREBID]*.code == [999] - assert response.ext?.errors[PREBID]*.message == - ["Failed to parse price floors from request, with a reason: " + - "Price floor rules number ${getRuleSize(ampStoredRequest)} " + - "exceeded its maximum number ${MAX_RULES_SIZE}"] + then: "PBS should log a warning" + def message = "Price floor rules number ${getRuleSize(ampStoredRequest)} " + + "exceeded its maximum number ${MAX_RULES_SIZE}" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, "Price Floors can't be resolved for account $ampRequest.account and " + + "request $ampStoredRequest.id, reason: Price floors processing failed: Fetching is disabled. " + + "Following parsing of request price floors is failed: $message") + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 where: maxRules | maxRulesSnakeCase @@ -826,6 +925,219 @@ class PriceFloorsSignalingSpec extends PriceFloorsBaseSpec { null | MAX_RULES_SIZE } + def "PBS should emit error in log and response when floors skipRate is out of range"() { + given: "BidRequest with invalid skipRate" + def bidRequest = bidRequestWithFloors.tap { + ext.prebid.floors.data.skipRate = requestSkipRate + } + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + then: "PBS should log a warning" + def message = "Price floor data skipRate must be in range(0-100), but was $requestSkipRate" + assert bidResponse.ext?.warnings[PREBID]*.code == [999] + assert bidResponse.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 + + where: + requestSkipRate << [PBSUtils.randomNegativeNumber, PBSUtils.getRandomNumber(100)] + } + + def "PBS should emit error in log and response when floors modelGroups is empty"() { + given: "BidRequest with empty modelGroups" + def bidRequest = bidRequestWithFloors.tap { + ext.prebid.floors.data.modelGroups = requestModelGroups + } + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS should log a warning" + def message = "Price floor rules should contain at least one model group" + assert bidResponse.ext?.warnings[PREBID]*.code == [999] + assert bidResponse.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 + + where: + requestModelGroups << [null, []] + } + + def "PBS should emit error in log and response when modelGroup modelWeight is out of range"() { + given: "BidRequest with invalid modelWeight" + def bidRequest = bidRequestWithFloors.tap { + ext.prebid.floors.data.modelGroups = [ + new FloorModelGroup(modelWeight: requestModelWeight) + ] + } + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS should log a warning" + def message = "Price floor modelGroup modelWeight must be in range(1-100), but was $requestModelWeight" + assert bidResponse.ext?.warnings[PREBID]*.code == [999] + assert bidResponse.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 + + where: + requestModelWeight << [PBSUtils.randomNegativeNumber, PBSUtils.getRandomNumber(100)] + } + + def "PBS should emit error in log and response when modelGroup skipRate is out of range"() { + given: "BidRequest with invalid modelGroup skipRate" + def requestModelGroupsSkipRate = PBSUtils.getRandomNumber(100) + def bidRequest = bidRequestWithFloors.tap { + ext.prebid.floors.data.modelGroups = [ + new FloorModelGroup(skipRate: requestModelGroupsSkipRate) + ] + } + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS should log a warning" + def message = "Price floor modelGroup skipRate must be in range(0-100), but was $requestModelGroupsSkipRate" + assert bidResponse.ext?.warnings[PREBID]*.code == [999] + assert bidResponse.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log an errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 + } + + def "PBS should emit error in log and response when modelGroup defaultFloor is negative"() { + given: "BidRequest with negative defaultFloor" + def requestModelGroupsSkipRate = PBSUtils.randomNegativeNumber + + def bidRequest = bidRequestWithFloors.tap { + ext.prebid.floors.data.modelGroups = [ + new FloorModelGroup(defaultFloor: requestModelGroupsSkipRate) + ] + } + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + and: "Default bid response" + def response = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, response) + + when: "PBS processes auction request" + def bidResponse = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS should log a warning" + def message = "Price floor modelGroup default must be positive float, but was $requestModelGroupsSkipRate" + assert bidResponse.ext?.warnings[PREBID]*.code == [999] + assert bidResponse.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, FETCHING_DISABLED_ERROR, message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 + } + + def "PBS should emit error in log and response when account have disabled dynamic data config"() { + given: "Bid request without floors" + def bidRequest = getBidRequestWithFloors().tap { + ext.prebid.floors.data = null + } + + and: "Account with disabled dynamic data" + def account = getAccountWithEnabledFetch(bidRequest.accountId).tap { + config.auction.priceFloors.useDynamicData = false + } + accountDao.save(account) + + and: "PBS fetch rules from floors provider" + cacheFloorsProviderRules(bidRequest, floorsPbsService, GENERIC, SUCCESS) + + and: "Default bid response" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + bidder.setResponse(bidRequest.id, bidResponse) + + and: "Flush metrics" + flushMetrics(floorsPbsService) + + when: "PBS processes auction request" + def response = floorsPbsService.sendAuctionRequest(bidRequest) + + then: "PBS should log a warning" + def message = "Price floor rules data must be present" + assert response.ext?.warnings[PREBID]*.code == [999] + assert response.ext?.warnings[PREBID]*.message == [WARNING_MESSAGE(message)] + + and: "PBS should log a errors" + def logs = floorsPbsService.getLogsByTime(startTime) + def floorsLogs = getLogsByText(logs, PRICE_FLOORS_ERROR_LOG(bidRequest, 'Using dynamic data is not allowed', message)) + assert floorsLogs.size() == 1 + + and: "Alerts.general metrics should be populated" + def metrics = floorsPbsService.sendCollectedMetricsRequest() + assert metrics[ALERT_GENERAL] == 1 + } + private static int getSchemaSize(BidRequest bidRequest) { bidRequest?.ext?.prebid?.floors?.data?.modelGroups[0].schema.fields.size() } diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAmpSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAmpSpec.groovy index 771fac9bd84..717c9f32d5b 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAmpSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAmpSpec.groovy @@ -394,7 +394,7 @@ class GdprAmpSpec extends PrivacyBaseSpec { and: "Alerts.general metrics should be populated" def metrics = privacyPbsService.sendCollectedMetricsRequest() - assert metrics["alerts.general"] == 1 + assert metrics[ALERT_GENERAL] == 1 and: "Bidder should be called" assert bidder.getBidderRequest(ampStoredRequest.id) diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAuctionSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAuctionSpec.groovy index 7302ad79aed..5fc0f5d7bca 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAuctionSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprAuctionSpec.groovy @@ -342,7 +342,7 @@ class GdprAuctionSpec extends PrivacyBaseSpec { and: "Alerts.general metrics should be populated" def metrics = privacyPbsService.sendCollectedMetricsRequest() - assert metrics["alerts.general"] == 1 + assert metrics[ALERT_GENERAL] == 1 and: "Bid response should contain seatBid" assert response.seatbid.size() == 1 diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppFetchBidActivitiesSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppFetchBidActivitiesSpec.groovy index 46634fda48f..2dd15697102 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppFetchBidActivitiesSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppFetchBidActivitiesSpec.groovy @@ -60,7 +60,6 @@ import static org.prebid.server.functional.model.pricefloors.Country.USA import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ACCOUNT_DISALLOWED_COUNT import static org.prebid.server.functional.model.privacy.Metric.ACCOUNT_PROCESSED_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ADAPTER_DISALLOWED_COUNT -import static org.prebid.server.functional.model.privacy.Metric.ALERT_GENERAL import static org.prebid.server.functional.model.privacy.Metric.PROCESSED_ACTIVITY_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_REQUEST_DISALLOWED_COUNT import static org.prebid.server.functional.model.request.GppSectionId.USP_V1 @@ -694,7 +693,7 @@ class GppFetchBidActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 where: gppAccountsConfig << [[new AccountGppConfig(code: IAB_US_GENERAL, config: new GppModuleConfig(skipSids: [US_NAT_V1]), enabled: false), @@ -868,7 +867,7 @@ class GppFetchBidActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS auction call when custom privacy regulation with normalizing should ignore call to bidder"() { @@ -1425,7 +1424,7 @@ class GppFetchBidActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when privacy module contain invalid property should respond with an error"() { @@ -1624,7 +1623,7 @@ class GppFetchBidActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when custom privacy regulation with normalizing should ignore call to bidder"() { diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppSyncUserActivitiesSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppSyncUserActivitiesSpec.groovy index a6d9d6e7aad..c95acb92475 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppSyncUserActivitiesSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppSyncUserActivitiesSpec.groovy @@ -59,7 +59,6 @@ import static org.prebid.server.functional.model.config.UsNationalPrivacySection import static org.prebid.server.functional.model.pricefloors.Country.CAN import static org.prebid.server.functional.model.pricefloors.Country.USA import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ADAPTER_DISALLOWED_COUNT -import static org.prebid.server.functional.model.privacy.Metric.ALERT_GENERAL import static org.prebid.server.functional.model.privacy.Metric.PROCESSED_ACTIVITY_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_REQUEST_DISALLOWED_COUNT import static org.prebid.server.functional.model.request.GppSectionId.USP_V1 @@ -624,7 +623,7 @@ class GppSyncUserActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS cookie sync call when privacy module contain invalid code should include proper responded with bidders URLs"() { @@ -784,7 +783,7 @@ class GppSyncUserActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS cookie sync when custom privacy regulation with normalizing should exclude bidders URLs"() { @@ -1482,7 +1481,7 @@ class GppSyncUserActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS setuid request call when privacy module contain invalid code should respond with valid bidders UIDs cookies"() { @@ -1657,7 +1656,7 @@ class GppSyncUserActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS setuid call when custom privacy regulation with normalizing should reject bidders with status code invalidStatusCode"() { diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitEidsActivitiesSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitEidsActivitiesSpec.groovy index 73dfda85a23..a825f10c287 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitEidsActivitiesSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitEidsActivitiesSpec.groovy @@ -59,7 +59,6 @@ import static org.prebid.server.functional.model.pricefloors.Country.USA import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ACCOUNT_DISALLOWED_COUNT import static org.prebid.server.functional.model.privacy.Metric.ACCOUNT_PROCESSED_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ADAPTER_DISALLOWED_COUNT -import static org.prebid.server.functional.model.privacy.Metric.ALERT_GENERAL import static org.prebid.server.functional.model.privacy.Metric.PROCESSED_ACTIVITY_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_REQUEST_DISALLOWED_COUNT import static org.prebid.server.functional.model.request.GppSectionId.USP_V1 @@ -983,7 +982,7 @@ class GppTransmitEidsActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS auction call when privacy module contain invalid property should respond with an error"() { @@ -1145,7 +1144,7 @@ class GppTransmitEidsActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS auction call when custom privacy regulation with normalizing that match custom config should have empty EIDS fields"() { @@ -2008,7 +2007,7 @@ class GppTransmitEidsActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when privacy module contain invalid property should respond with an error"() { @@ -2206,7 +2205,7 @@ class GppTransmitEidsActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when custom privacy regulation with normalizing should change request consent and call to bidder"() { diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitPreciseGeoActivitiesSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitPreciseGeoActivitiesSpec.groovy index c0c9495bfc3..4707633d01d 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitPreciseGeoActivitiesSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitPreciseGeoActivitiesSpec.groovy @@ -58,7 +58,6 @@ import static org.prebid.server.functional.model.pricefloors.Country.USA import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ACCOUNT_DISALLOWED_COUNT import static org.prebid.server.functional.model.privacy.Metric.ACCOUNT_PROCESSED_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ADAPTER_DISALLOWED_COUNT -import static org.prebid.server.functional.model.privacy.Metric.ALERT_GENERAL import static org.prebid.server.functional.model.privacy.Metric.PROCESSED_ACTIVITY_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_REQUEST_DISALLOWED_COUNT import static org.prebid.server.functional.model.request.GppSectionId.USP_V1 @@ -1359,7 +1358,7 @@ class GppTransmitPreciseGeoActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS auction call when privacy module contain invalid code should respond with an error"() { @@ -1573,7 +1572,7 @@ class GppTransmitPreciseGeoActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS auction call when custom privacy regulation with normalizing should change request consent and call to bidder"() { @@ -2613,7 +2612,7 @@ class GppTransmitPreciseGeoActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when privacy module contain invalid code should respond with an error"() { @@ -2863,7 +2862,7 @@ class GppTransmitPreciseGeoActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when custom privacy regulation with normalizing should change request consent and call to bidder"() { diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitUfpdActivitiesSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitUfpdActivitiesSpec.groovy index 920196ea576..1dc12037a23 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitUfpdActivitiesSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GppTransmitUfpdActivitiesSpec.groovy @@ -69,7 +69,6 @@ import static org.prebid.server.functional.model.pricefloors.Country.USA import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ACCOUNT_DISALLOWED_COUNT import static org.prebid.server.functional.model.privacy.Metric.ACCOUNT_PROCESSED_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_ADAPTER_DISALLOWED_COUNT -import static org.prebid.server.functional.model.privacy.Metric.ALERT_GENERAL import static org.prebid.server.functional.model.privacy.Metric.PROCESSED_ACTIVITY_RULES_COUNT import static org.prebid.server.functional.model.privacy.Metric.TEMPLATE_REQUEST_DISALLOWED_COUNT import static org.prebid.server.functional.model.request.GppSectionId.USP_V1 @@ -1330,7 +1329,7 @@ class GppTransmitUfpdActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS auction call when privacy module contain invalid property should respond with an error"() { @@ -1525,7 +1524,7 @@ class GppTransmitUfpdActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS auction call when custom privacy regulation with normalizing that match custom config should have empty UFPD fields"() { @@ -2637,7 +2636,7 @@ class GppTransmitUfpdActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when privacy module contain invalid property should respond with an error"() { @@ -2868,7 +2867,7 @@ class GppTransmitUfpdActivitiesSpec extends PrivacyBaseSpec { and: "Metrics for disallowed activities should be updated" def metrics = activityPbsService.sendCollectedMetricsRequest() - assert metrics[ALERT_GENERAL.getValue()] == 1 + assert metrics[ALERT_GENERAL] == 1 } def "PBS amp call when custom privacy regulation with normalizing should change request consent and call to bidder"() { diff --git a/src/test/java/org/prebid/server/floors/BasicPriceFloorProcessorTest.java b/src/test/java/org/prebid/server/floors/BasicPriceFloorProcessorTest.java index 78ce82dd3fd..f70471b1b4d 100644 --- a/src/test/java/org/prebid/server/floors/BasicPriceFloorProcessorTest.java +++ b/src/test/java/org/prebid/server/floors/BasicPriceFloorProcessorTest.java @@ -20,6 +20,8 @@ import org.prebid.server.floors.model.PriceFloorSchema; import org.prebid.server.floors.proto.FetchResult; import org.prebid.server.floors.proto.FetchStatus; +import org.prebid.server.metric.MetricName; +import org.prebid.server.metric.Metrics; import org.prebid.server.proto.openrtb.ext.request.ExtImpPrebidFloors; import org.prebid.server.proto.openrtb.ext.request.ExtRequest; import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; @@ -51,12 +53,14 @@ public class BasicPriceFloorProcessorTest extends VertxTest { private PriceFloorFetcher priceFloorFetcher; @Mock private PriceFloorResolver floorResolver; + @Mock + private Metrics metrics; private BasicPriceFloorProcessor target; @BeforeEach public void setUp() { - target = new BasicPriceFloorProcessor(priceFloorFetcher, floorResolver, jacksonMapper); + target = new BasicPriceFloorProcessor(priceFloorFetcher, floorResolver, metrics, jacksonMapper, 0.0d); } @Test @@ -70,7 +74,7 @@ public void shouldSetRulesEnabledFieldToFalseIfPriceFloorsDisabledForAccount() { new ArrayList<>()); // then - verifyNoInteractions(priceFloorFetcher); + verifyNoInteractions(priceFloorFetcher, metrics); assertThat(result).isEqualTo(givenBidRequest(identity(), PriceFloorRules.builder().enabled(false).build())); } @@ -85,7 +89,7 @@ public void shouldSetRulesEnabledFieldToFalseIfPriceFloorsDisabledForRequest() { new ArrayList<>()); // then - verifyNoInteractions(priceFloorFetcher); + verifyNoInteractions(priceFloorFetcher, metrics); assertThat(result) .extracting(BidRequest::getExt) @@ -98,8 +102,8 @@ public void shouldSetRulesEnabledFieldToFalseIfPriceFloorsDisabledForRequest() { @Test public void shouldUseFloorsDataFromProviderIfPresent() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.floorProvider("provider.com")); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -118,16 +122,16 @@ public void shouldUseFloorsDataFromProviderIfPresent() { .data(providerFloorsData) .fetchStatus(FetchStatus.success) .location(PriceFloorLocation.fetch))); + verifyNoInteractions(metrics); + } @Test public void shouldUseFloorsFromProviderIfUseDynamicDataAndUseFetchDataRateAreAbsent() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors - .floorProvider("provider.com") - .useFetchDataRate(null)); + final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.useFetchDataRate(null)); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -145,16 +149,15 @@ public void shouldUseFloorsFromProviderIfUseDynamicDataAndUseFetchDataRateAreAbs .data(providerFloorsData) .fetchStatus(FetchStatus.success) .location(PriceFloorLocation.fetch))); + verifyNoInteractions(metrics); } @Test public void shouldUseFloorsFromProviderIfUseDynamicDataIsAbsentAndUseFetchDataRateIs100() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors - .floorProvider("provider.com") - .useFetchDataRate(100)); + final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.useFetchDataRate(100)); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -172,16 +175,15 @@ public void shouldUseFloorsFromProviderIfUseDynamicDataIsAbsentAndUseFetchDataRa .data(providerFloorsData) .fetchStatus(FetchStatus.success) .location(PriceFloorLocation.fetch))); + verifyNoInteractions(metrics); } @Test public void shouldNotUseFloorsFromProviderIfUseDynamicDataIsAbsentAndUseFetchDataRateIs0() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors - .floorProvider("provider.com") - .useFetchDataRate(0)); + final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.useFetchDataRate(0)); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -199,15 +201,44 @@ public void shouldNotUseFloorsFromProviderIfUseDynamicDataIsAbsentAndUseFetchDat assertThat(actualRules) .extracting(PriceFloorRules::getLocation) .isEqualTo(PriceFloorLocation.noData); + verifyNoInteractions(metrics); } @Test - public void shouldUseFloorsFromProviderIfUseDynamicDataIsTrueAndUseFetchDataRateIsAbsent() { + public void shouldTolerateInvalidFloorsFromRequestWhenFetchIsSuccessAndUseFetchDataRateIs0() { // given final PriceFloorData providerFloorsData = givenFloorData(floors -> floors .floorProvider("provider.com") - .useFetchDataRate(null)); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + .useFetchDataRate(0)); + + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); + final ArrayList warnings = new ArrayList<>(); + + // when + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.data(null))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.success) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + + assertThat(warnings).isEmpty(); + verifyNoInteractions(metrics); + } + + @Test + public void shouldUseFloorsFromProviderIfUseDynamicDataIsTrueAndUseFetchDataRateIsAbsent() { + // given + final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.useFetchDataRate(null)); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -226,65 +257,330 @@ public void shouldUseFloorsFromProviderIfUseDynamicDataIsTrueAndUseFetchDataRate .floorMin(BigDecimal.ONE) .fetchStatus(FetchStatus.success) .location(PriceFloorLocation.fetch))); + verifyNoInteractions(metrics); } @Test - public void shouldNotUseFloorsFromProviderIfUseDynamicDataIsFalseAndUseFetchDataRateIsAbsent() { + public void shouldNotUseFloorsFromProviderIfUseDynamicDataIsFalse() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors - .floorProvider("provider.com") - .useFetchDataRate(null)); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when + final ArrayList warnings = new ArrayList<>(); final BidRequest result = target.enrichWithPriceFloors( givenBidRequest(identity(), null), givenAccount(floorsConfig -> floorsConfig.useDynamicData(false)), "bidder", new ArrayList<>(), - new ArrayList<>()); + warnings); // then - final PriceFloorRules actualRules = extractFloors(result); - assertThat(actualRules) - .extracting(PriceFloorRules::getFetchStatus) - .isEqualTo(FetchStatus.success); - assertThat(actualRules) - .extracting(PriceFloorRules::getLocation) - .isEqualTo(PriceFloorLocation.noData); + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.success) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); } @Test - public void shouldNotUseFloorsFromProviderIfUseDynamicDataIsFalseAndUseFetchDataRateIs100() { + public void shouldNotTolerateInvalidFloorsFromRequestWhenFetchIsSuccessAndUseDynamicDataIsFalse() { // given final PriceFloorData providerFloorsData = givenFloorData(floors -> floors .floorProvider("provider.com") - .useFetchDataRate(100)); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + .useFetchDataRate(0)); + + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); + final ArrayList warnings = new ArrayList<>(); // when final BidRequest result = target.enrichWithPriceFloors( - givenBidRequest(identity(), null), + givenBidRequest(identity(), givenFloors(floors -> floors.data(null))), givenAccount(floorsConfig -> floorsConfig.useDynamicData(false)), "bidder", new ArrayList<>(), - new ArrayList<>()); + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.success) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + + verify(metrics).updateAlertsMetrics(MetricName.general); + assertThat(warnings).containsExactly("Price floors processing failed: " + + "parsing of request price floors is failed: Price floor rules data must be present"); + } + + @Test + public void shouldUseFloorsFromRequestIfUseDynamicDataIsFalse() { + // given + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.floorMin(BigDecimal.ONE))), + givenAccount(floorsConfig -> floorsConfig.useDynamicData(false)), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors + .enabled(true) + .skipped(false) + .fetchStatus(FetchStatus.success) + .floorMin(BigDecimal.ONE) + .location(PriceFloorLocation.request))); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); + } + + @Test + public void shouldNotUseFloorsWhenProviderFetchingIsDisabled() { + // given + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())) + .willReturn(FetchResult.of(providerFloorsData, FetchStatus.none, "errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), null), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.none) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); + } + + @Test + public void shouldNotTolerateInvalidFloorsFromRequestWhenFetchIsDisabled() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.data(null))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.none) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + verify(metrics).updateAlertsMetrics(MetricName.general); + assertThat(warnings).containsExactly("Price floors processing failed: " + + "parsing of request price floors is failed: Price floor rules data must be present"); + } + + @Test + public void shouldNotUseFloorsWhenProviderFetchingIsFailed() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.error("errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), null), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.error) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); + } + + @Test + public void shouldNotTolerateInvalidFloorsFromRequestWhenFetchIsFailed() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.error("errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.data(null))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.error) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + verify(metrics).updateAlertsMetrics(MetricName.general); + assertThat(warnings).containsExactly("Price floors processing failed: " + + "parsing of request price floors is failed: Price floor rules data must be present"); + } + + @Test + public void shouldNotUseFloorsWhenProviderFetchingIsFailedWithTimeout() { + // given + given(priceFloorFetcher.fetch(any())) + .willReturn(FetchResult.of(null, FetchStatus.timeout, "errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), null), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.timeout) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); + } + + @Test + public void shouldNotTolerateInvalidFloorsFromRequestWhenFetchIsFailedWithTimeout() { + // given + given(priceFloorFetcher.fetch(any())) + .willReturn(FetchResult.of(null, FetchStatus.timeout, "errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.data(null))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); // then final PriceFloorRules actualRules = extractFloors(result); assertThat(actualRules) .extracting(PriceFloorRules::getFetchStatus) - .isEqualTo(FetchStatus.success); + .isEqualTo(FetchStatus.timeout); assertThat(actualRules) .extracting(PriceFloorRules::getLocation) .isEqualTo(PriceFloorLocation.noData); + verify(metrics).updateAlertsMetrics(MetricName.general); + assertThat(warnings).containsExactly("Price floors processing failed: " + + "parsing of request price floors is failed: Price floor rules data must be present"); + } + + @Test + public void shouldUseFloorsFromRequestIfFetchingIsDisabled() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.floorMin(BigDecimal.ONE))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors + .enabled(true) + .skipped(false) + .fetchStatus(FetchStatus.none) + .floorMin(BigDecimal.ONE) + .location(PriceFloorLocation.request))); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); + } + + @Test + public void shouldUseFloorsFromRequestIfFetchingIsFailed() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.error("errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.floorMin(BigDecimal.ONE))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors + .enabled(true) + .skipped(false) + .fetchStatus(FetchStatus.error) + .floorMin(BigDecimal.ONE) + .location(PriceFloorLocation.request))); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); + } + + @Test + public void shouldUseFloorsFromRequestIfFetchingIsFailedWithTimeout() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(null, FetchStatus.timeout, "errorMessage")); + + // when + final ArrayList warnings = new ArrayList<>(); + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.floorMin(BigDecimal.ONE))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors + .enabled(true) + .skipped(false) + .fetchStatus(FetchStatus.timeout) + .floorMin(BigDecimal.ONE) + .location(PriceFloorLocation.request))); + verifyNoInteractions(metrics); + assertThat(warnings).isEmpty(); } @Test public void shouldMergeProviderWithRequestFloors() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.floorProvider("provider.com")); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -315,8 +611,8 @@ public void shouldMergeProviderWithRequestFloors() { @Test public void shouldReturnProviderFloorsWhenNotEnabledByRequestAndEnforceRateAndFloorPriceAreAbsent() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.floorProvider("provider.com")); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -348,8 +644,8 @@ public void shouldReturnFloorsWithFloorMinAndCurrencyFromRequestWhenPresent() { .floorMin(BigDecimal.ONE) .data(givenFloorData(floorsDataConfig -> floorsDataConfig.currency("USD")))); - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.floorProvider("provider.com")); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -368,7 +664,7 @@ public void shouldReturnFloorsWithFloorMinAndCurrencyFromRequestWhenPresent() { @Test public void shouldUseFloorsFromRequestIfProviderFloorsMissing() { // given - given(priceFloorFetcher.fetch(any())).willReturn(null); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); // when final BidRequest result = target.enrichWithPriceFloors( @@ -380,17 +676,19 @@ public void shouldUseFloorsFromRequestIfProviderFloorsMissing() { // then assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors - .enabled(true) - .skipped(false) - .floorMin(BigDecimal.ONE) - .location(PriceFloorLocation.request))); + .fetchStatus(FetchStatus.none) + .enabled(true) + .skipped(false) + .floorMin(BigDecimal.ONE) + .location(PriceFloorLocation.request))); + verifyNoInteractions(metrics); } @Test public void shouldTolerateUsingFloorsFromRequestWhenRulesNumberMoreThanMaxRulesNumber() { // given - given(priceFloorFetcher.fetch(any())).willReturn(null); - final ArrayList errors = new ArrayList<>(); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); + final ArrayList warnings = new ArrayList<>(); // when final BidRequest result = target.enrichWithPriceFloors( @@ -404,25 +702,27 @@ public void shouldTolerateUsingFloorsFromRequestWhenRulesNumberMoreThanMaxRulesN )), givenAccount(floorConfigBuilder -> floorConfigBuilder.maxRules(1L)), "bidder", - errors, - new ArrayList<>()); + new ArrayList<>(), + warnings); // then assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.none) .enabled(true) .skipped(false) .location(PriceFloorLocation.noData) .build()); - assertThat(errors).containsOnly("Failed to parse price floors from request, with a reason: " + assertThat(warnings).containsOnly("Price floors processing failed: " + + "parsing of request price floors is failed: " + "Price floor rules number 2 exceeded its maximum number 1"); } @Test public void shouldTolerateUsingFloorsFromRequestWhenDimensionsNumberMoreThanMaxDimensionsNumber() { // given - given(priceFloorFetcher.fetch(any())).willReturn(null); - final ArrayList errors = new ArrayList<>(); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); + final ArrayList warnings = new ArrayList<>(); // when final BidRequest result = target.enrichWithPriceFloors( @@ -436,24 +736,52 @@ public void shouldTolerateUsingFloorsFromRequestWhenDimensionsNumberMoreThanMaxD )), givenAccount(floorConfigBuilder -> floorConfigBuilder.maxSchemaDims(1L)), "bidder", - errors, - new ArrayList<>()); + new ArrayList<>(), + warnings); // then assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.none) .enabled(true) .skipped(false) .location(PriceFloorLocation.noData) .build()); - assertThat(errors).containsOnly("Failed to parse price floors from request, with a reason: " + assertThat(warnings).containsOnly("Price floors processing failed: " + + "parsing of request price floors is failed: " + "Price floor schema dimensions 2 exceeded its maximum number 1"); } + @Test + public void shouldTolerateInvalidFloorsFromRequestWhenFetchIsInProgress() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.inProgress()); + final ArrayList warnings = new ArrayList<>(); + + // when + final BidRequest result = target.enrichWithPriceFloors( + givenBidRequest(identity(), givenFloors(floors -> floors.data(null))), + givenAccount(identity()), + "bidder", + new ArrayList<>(), + warnings); + + // then + assertThat(extractFloors(result)).isEqualTo(PriceFloorRules.builder() + .fetchStatus(FetchStatus.inprogress) + .enabled(true) + .skipped(false) + .location(PriceFloorLocation.noData) + .build()); + + assertThat(warnings).isEmpty(); + verifyNoInteractions(metrics); + } + @Test public void shouldTolerateMissingRequestAndProviderFloors() { // given - given(priceFloorFetcher.fetch(any())).willReturn(null); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); // when final BidRequest result = target.enrichWithPriceFloors( @@ -475,6 +803,9 @@ public void shouldTolerateMissingRequestAndProviderFloors() { @Test public void shouldNotSkipFloorsIfRootSkipRateIsOff() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); + // when final BidRequest result = target.enrichWithPriceFloors( givenBidRequest(identity(), givenFloors(floors -> floors.skipRate(0))), @@ -486,6 +817,7 @@ public void shouldNotSkipFloorsIfRootSkipRateIsOff() { // then assertThat(extractFloors(result)) .isEqualTo(givenFloors(floors -> floors + .fetchStatus(FetchStatus.none) .enabled(true) .skipped(false) .skipRate(0) @@ -494,6 +826,9 @@ public void shouldNotSkipFloorsIfRootSkipRateIsOff() { @Test public void shouldSkipFloorsIfRootSkipRateIsOn() { + // given + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); + // when final BidRequest result = target.enrichWithPriceFloors( givenBidRequest(identity(), givenFloors(floors -> floors.skipRate(100))), @@ -504,16 +839,18 @@ public void shouldSkipFloorsIfRootSkipRateIsOn() { // then assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors - .skipRate(100) - .enabled(true) - .skipped(true) - .location(PriceFloorLocation.request))); + .fetchStatus(FetchStatus.none) + .skipRate(100) + .enabled(true) + .skipped(true) + .location(PriceFloorLocation.request))); } @Test public void shouldSkipFloorsIfDataSkipRateIsOn() { // given final PriceFloorData priceFloorData = givenFloorData(floorData -> floorData.skipRate(100)); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); // when final BidRequest result = target.enrichWithPriceFloors( @@ -525,11 +862,13 @@ public void shouldSkipFloorsIfDataSkipRateIsOn() { // then assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors - .enabled(true) - .skipRate(100) - .data(priceFloorData) - .skipped(true) - .location(PriceFloorLocation.request))); + .fetchStatus(FetchStatus.none) + .enabled(true) + .skipRate(100) + .floorProvider("provider.com") + .data(priceFloorData) + .skipped(true) + .location(PriceFloorLocation.request))); } @Test @@ -538,6 +877,7 @@ public void shouldSkipFloorsIfModelGroupSkipRateIsOn() { final PriceFloorData priceFloorData = givenFloorData(floorData -> floorData .skipRate(0) .modelGroups(singletonList(givenModelGroup(group -> group.skipRate(100))))); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); // when final BidRequest result = target.enrichWithPriceFloors( @@ -549,11 +889,13 @@ public void shouldSkipFloorsIfModelGroupSkipRateIsOn() { // then assertThat(extractFloors(result)).isEqualTo(givenFloors(floors -> floors - .data(priceFloorData) - .skipRate(100) - .enabled(true) - .skipped(true) - .location(PriceFloorLocation.request))); + .fetchStatus(FetchStatus.none) + .data(priceFloorData) + .skipRate(100) + .floorProvider("provider.com") + .enabled(true) + .skipped(true) + .location(PriceFloorLocation.request))); } @Test @@ -562,6 +904,7 @@ public void shouldNotUpdateImpsIfSelectedModelGroupIsMissing() { final List imps = singletonList(givenImp(identity())); final PriceFloorRules requestFloors = givenFloors(floors -> floors .data(givenFloorData(floorData -> floorData.modelGroups(null)))); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); // when final BidRequest result = target.enrichWithPriceFloors( @@ -582,6 +925,7 @@ public void shouldUseSelectedModelGroup() { final PriceFloorModelGroup modelGroup = givenModelGroup(identity()); final PriceFloorRules requestFloors = givenFloors(floors -> floors .data(givenFloorData(floorData -> floorData.modelGroups(singletonList(modelGroup))))); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); // when target.enrichWithPriceFloors( @@ -603,8 +947,8 @@ public void shouldUseSelectedModelGroup() { @Test public void shouldCopyFloorProviderValueFromDataLevel() { // given - final PriceFloorData providerFloorsData = givenFloorData(floors -> floors.floorProvider("provider.com")); - given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success)); + final PriceFloorData providerFloorsData = givenFloorData(identity()); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.of(providerFloorsData, FetchStatus.success, null)); // when final BidRequest result = target.enrichWithPriceFloors( @@ -630,6 +974,7 @@ public void shouldNotUpdateImpsIfBidFloorNotResolved() { .data(givenFloorData(floorData -> floorData .modelGroups(singletonList(givenModelGroup(identity())))))); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); given(floorResolver.resolve(any(), any(), any(), eq("bidder"), any())).willReturn(null); // when @@ -660,6 +1005,7 @@ public void shouldUpdateImpsIfBidFloorResolved() { final List imps = singletonList(givenImp(impBuilder -> impBuilder.ext(givenImpExt))); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); given(floorResolver.resolve(any(), any(), any(), eq("bidder"), any())) .willReturn(PriceFloorResult.of("rule", BigDecimal.ONE, BigDecimal.TEN, "USD")); @@ -692,6 +1038,7 @@ public void shouldTolerateFloorResolvingError() { final PriceFloorRules requestFloors = givenFloors(floors -> floors .data(givenFloorData(floorData -> floorData.modelGroups(singletonList(givenModelGroup(identity())))))); + given(priceFloorFetcher.fetch(any())).willReturn(FetchResult.none("errorMessage")); given(floorResolver.resolve(any(), any(), any(), eq("bidder"), any())) .willThrow(new IllegalStateException("error")); @@ -712,6 +1059,7 @@ private static Account givenAccount( UnaryOperator floorsConfigCustomizer) { return Account.builder() + .id("accountId") .auction(AccountAuctionConfig.builder() .priceFloors(floorsConfigCustomizer.apply(AccountPriceFloorsConfig.builder()).build()) .build()) @@ -722,6 +1070,7 @@ private static BidRequest givenBidRequest(UnaryOperator floorsCustomizer) { return floorsCustomizer.apply(PriceFloorRules.builder() + .enabled(true) .data(PriceFloorData.builder() .modelGroups(singletonList(PriceFloorModelGroup.builder() .value("someKey", BigDecimal.ONE) @@ -750,6 +1100,7 @@ private static PriceFloorData givenFloorData( UnaryOperator floorDataCustomizer) { return floorDataCustomizer.apply(PriceFloorData.builder() + .floorProvider("provider.com") .modelGroups(singletonList(PriceFloorModelGroup.builder() .value("someKey", BigDecimal.ONE) .schema(PriceFloorSchema.of("|", List.of(size))) diff --git a/src/test/java/org/prebid/server/floors/PriceFloorFetcherTest.java b/src/test/java/org/prebid/server/floors/PriceFloorFetcherTest.java index 4fe2e31180b..542559bd862 100644 --- a/src/test/java/org/prebid/server/floors/PriceFloorFetcherTest.java +++ b/src/test/java/org/prebid/server/floors/PriceFloorFetcherTest.java @@ -104,8 +104,8 @@ public void fetchShouldReturnPriceFloorFetchedFromProviderAndCache() { verifyNoMoreInteractions(httpClient); final FetchResult priceFloorRulesCached = priceFloorFetcher.fetch(givenAccount); - assertThat(priceFloorRulesCached.getFetchStatus()).isEqualTo(FetchStatus.success); - assertThat(priceFloorRulesCached.getRulesData()).isEqualTo(givenPriceFloorData()); + assertThat(priceFloorRulesCached) + .isEqualTo(FetchResult.of(givenPriceFloorData(), FetchStatus.success, null)); } @@ -119,8 +119,7 @@ public void fetchShouldReturnEmptyRulesAndInProgressStatusForTheFirstInvocation( final FetchResult fetchResult = priceFloorFetcher.fetch(givenAccount(identity())); // then - assertThat(fetchResult.getRulesData()).isNull(); - assertThat(fetchResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(fetchResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); } @@ -134,13 +133,13 @@ public void fetchShouldReturnEmptyRulesAndInProgressStatusForTheFirstInvocationA final FetchResult firstInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); // then - assertThat(firstInvocationResult.getRulesData()).isNull(); - assertThat(firstInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(firstInvocationResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); - assertThat(secondInvocationResult.getRulesData()).isNull(); - assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(secondInvocationResult).isEqualTo(FetchResult.error( + "Failed to fetch price floor from provider for fetch.url " + + "'http://test.host.com', with a reason: failed")); } @Test @@ -158,8 +157,10 @@ public void fetchShouldReturnEmptyRulesAndInProgressStatusForTheFirstInvocationA verify(vertx).setTimer(eq(1200000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); - assertThat(secondInvocationResult.getRulesData()).isNull(); - assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.timeout); + assertThat(secondInvocationResult).isEqualTo(FetchResult.of( + null, + FetchStatus.timeout, + "Fetch price floor request timeout for fetch.url 'http://test.host.com' exceeded.")); } @Test @@ -317,8 +318,8 @@ public void fetchShouldNotPrepareAnyRequestsWhenFetchUrlIsMalformedAndReturnErro // then verifyNoInteractions(httpClient); - assertThat(fetchResult.getRulesData()).isNull(); - assertThat(fetchResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(fetchResult).isEqualTo(FetchResult.error( + "Malformed fetch.url 'MalformedURl' passed")); verifyNoInteractions(vertx); } @@ -329,8 +330,7 @@ public void fetchShouldNotPrepareAnyRequestsWhenFetchUrlIsBlankAndReturnErrorSta // then verifyNoInteractions(httpClient); - assertThat(fetchResult.getRulesData()).isNull(); - assertThat(fetchResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(fetchResult).isEqualTo(FetchResult.error("Malformed fetch.url ' ' passed")); verifyNoInteractions(vertx); } @@ -341,8 +341,7 @@ public void fetchShouldNotPrepareAnyRequestsWhenFetchUrlIsNotProvidedAndReturnEr // then verifyNoInteractions(httpClient); - assertThat(fetchResult.getRulesData()).isNull(); - assertThat(fetchResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(fetchResult).isEqualTo(FetchResult.error("Malformed fetch.url 'null' passed")); verifyNoInteractions(vertx); } @@ -353,8 +352,7 @@ public void fetchShouldNotPrepareAnyRequestsWhenFetchEnabledIsFalseAndReturnNone // then verifyNoInteractions(httpClient); - assertThat(fetchResult.getRulesData()).isNull(); - assertThat(fetchResult.getFetchStatus()).isEqualTo(FetchStatus.none); + assertThat(fetchResult).isEqualTo(FetchResult.none("Fetching is disabled")); verifyNoInteractions(vertx); } @@ -370,13 +368,13 @@ public void fetchShouldReturnEmptyRulesAndErrorStatusForSecondCallAndCreatePerio // then verify(httpClient).get(anyString(), anyLong(), anyLong()); - assertThat(firstInvocationResult.getRulesData()).isNull(); - assertThat(firstInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(firstInvocationResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); verify(vertx).setTimer(eq(1500000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); - assertThat(secondInvocationResult.getRulesData()).isNull(); - assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(secondInvocationResult).isEqualTo(FetchResult.error( + "Failed to fetch price floor from provider for fetch.url 'http://test.host.com', " + + "with a reason: Failed to request, provider respond with status 400")); verifyNoMoreInteractions(vertx); } @@ -392,13 +390,16 @@ public void fetchShouldReturnEmptyRulesWithErrorStatusAndCreatePeriodicTimerWhen // then verify(httpClient).get(anyString(), anyLong(), anyLong()); - assertThat(firstInvocationResult.getRulesData()).isNull(); - assertThat(firstInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(firstInvocationResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); verify(vertx).setTimer(eq(1500000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); assertThat(secondInvocationResult.getRulesData()).isNull(); assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(secondInvocationResult.getErrorMessage()).startsWith( + "Failed to fetch price floor from provider for fetch.url 'http://test.host.com', " + + "with a reason: Failed to parse price floor response, " + + "cause: DecodeException: Failed to decode:"); verifyNoMoreInteractions(vertx); } @@ -414,13 +415,14 @@ public void fetchShouldReturnEmptyRulesWithErrorStatusForSecondCallAndCreatePeri // then verify(httpClient).get(anyString(), anyLong(), anyLong()); - assertThat(firstInvocationResult.getRulesData()).isNull(); - assertThat(firstInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(firstInvocationResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); verify(vertx).setTimer(eq(1500000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); - assertThat(secondInvocationResult.getRulesData()).isNull(); - assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(secondInvocationResult).isEqualTo(FetchResult.error( + "Failed to fetch price floor from provider for fetch.url 'http://test.host.com', " + + "with a reason: Failed to parse price floor response, " + + "response body can not be empty")); verifyNoMoreInteractions(vertx); } @@ -436,13 +438,14 @@ public void fetchShouldReturnEmptyRulesWithErrorStatusForSecondCallAndCreatePeri // then verify(httpClient).get(anyString(), anyLong(), anyLong()); - assertThat(firstInvocationResult.getRulesData()).isNull(); - assertThat(firstInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(firstInvocationResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); verify(vertx).setTimer(eq(1500000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); - assertThat(secondInvocationResult.getRulesData()).isNull(); - assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(secondInvocationResult).isEqualTo(FetchResult.error( + "Failed to fetch price floor from provider for fetch.url 'http://test.host.com', " + + "with a reason: Failed to parse price floor response, " + + "response body can not be empty")); verifyNoMoreInteractions(vertx); } @@ -460,8 +463,7 @@ public void fetchShouldNotCallPriceFloorProviderWhileFetchIsAlreadyInProgress() verify(httpClient).get(anyString(), anyLong(), anyLong()); verifyNoMoreInteractions(httpClient); - assertThat(secondFetch.getRulesData()).isNull(); - assertThat(secondFetch.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(secondFetch).isEqualTo(FetchResult.inProgress()); fetchPromise.tryComplete( HttpClientResponse.of(200, MultiMap.caseInsensitiveMultiMap() @@ -490,13 +492,13 @@ public void fetchShouldReturnNullAndCreatePeriodicTimerWhenResponseExceededRules // then verify(httpClient).get(anyString(), anyLong(), anyLong()); - assertThat(firstInvocationResult.getRulesData()).isNull(); - assertThat(firstInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(firstInvocationResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); verify(vertx).setTimer(eq(1500000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); - assertThat(secondInvocationResult.getRulesData()).isNull(); - assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(secondInvocationResult).isEqualTo(FetchResult.error( + "Failed to fetch price floor from provider for fetch.url 'http://test.host.com', " + + "with a reason: Price floor rules number 2 exceeded its maximum number 1")); verifyNoMoreInteractions(vertx); } @@ -518,13 +520,13 @@ public void fetchShouldReturnNullAndCreatePeriodicTimerWhenResponseExceededDimen // then verify(httpClient).get(anyString(), anyLong(), anyLong()); - assertThat(firstInvocationResult.getRulesData()).isNull(); - assertThat(firstInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.inprogress); + assertThat(firstInvocationResult).isEqualTo(FetchResult.inProgress()); verify(vertx).setTimer(eq(1200000L), any()); verify(vertx).setTimer(eq(1500000L), any()); final FetchResult secondInvocationResult = priceFloorFetcher.fetch(givenAccount(identity())); - assertThat(secondInvocationResult.getRulesData()).isNull(); - assertThat(secondInvocationResult.getFetchStatus()).isEqualTo(FetchStatus.error); + assertThat(secondInvocationResult).isEqualTo(FetchResult.error( + "Failed to fetch price floor from provider for fetch.url 'http://test.host.com', " + + "with a reason: Price floor rules values can't be null or empty, but were {}")); verifyNoMoreInteractions(vertx); }