diff --git a/sample/prebid-config.yaml b/sample/prebid-config.yaml index 509b0360c39..05ff93c827b 100644 --- a/sample/prebid-config.yaml +++ b/sample/prebid-config.yaml @@ -10,6 +10,8 @@ adapters: enabled: true rubicon: enabled: true + tpmn: + enabled: true metrics: prefix: prebid cache: diff --git a/src/main/java/org/prebid/server/bidder/tpmn/TpmnBidder.java b/src/main/java/org/prebid/server/bidder/tpmn/TpmnBidder.java new file mode 100644 index 00000000000..6932ff3e13b --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/tpmn/TpmnBidder.java @@ -0,0 +1,226 @@ +package org.prebid.server.bidder.tpmn; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.MissingNode; +import com.iab.openrtb.request.*; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.*; +import org.prebid.server.currency.CurrencyConversionService; +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.tpmn.ExtImpTpmn; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.ObjectUtil; + +import java.math.BigDecimal; +import java.util.*; + +public class TpmnBidder implements Bidder { + + private static final TypeReference> TPMN_EXT_TYPE_REFERENCE = new TypeReference<>() {}; + private static final String BIDDER_CURRENCY = "USD"; + + private final String endpointUrl; + private final JacksonMapper mapper; + private final CurrencyConversionService currencyConversionService; + + public TpmnBidder(String endpointUrl, JacksonMapper mapper, CurrencyConversionService currencyConversionService) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + this.currencyConversionService = Objects.requireNonNull(currencyConversionService); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List validImps = new ArrayList<>(); + final List errors = new ArrayList<>(); + for (Imp imp : request.getImp()) { + try { + final ExtImpTpmn extImpTpmn = parseImpExt(imp); + final Imp updatedImp = modifyImp(imp, extImpTpmn, request); + if (updatedImp != null) validImps.add(updatedImp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + continue; + } + } + + final BidRequest outgoingRequest = request.toBuilder().imp(validImps).build(); + return Result.of(Collections.singletonList(BidderUtil.defaultRequest(outgoingRequest, endpointUrl, mapper)), errors); + + } + + private ExtImpTpmn parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), TPMN_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException(e.getMessage()); + } + } + + private Imp modifyImp(Imp imp, ExtImpTpmn extImpTpmn, BidRequest request) { + Integer inventoryId = extImpTpmn.getInventoryId(); + final Imp.ImpBuilder impBuilder = imp.toBuilder().tagid(String.valueOf(inventoryId)); + final String impId = imp.getId(); + final Price resolvedBidFloor = resolveBidFloor(imp, request); + + if (imp.getBanner() != null) { + impBuilder.id(impId).banner(modifyBanner(imp.getBanner())).video(null).xNative(null); + } else if (imp.getVideo() != null) { + impBuilder.id(impId).xNative(null); + } else if (imp.getXNative() != null) { + impBuilder.id(impId).xNative(modifyNative(imp.getXNative())); + } else { + return null; + } + + return impBuilder + .bidfloor(resolvedBidFloor.getValue()) + .bidfloorcur(resolvedBidFloor.getCurrency()) + .ext(mapper.mapper().valueToTree(extImpTpmn)) + .build(); + } + + + + + private static Banner modifyBanner(Banner banner) { + final Integer w = banner.getW(); + final Integer h = banner.getH(); + final List formats = banner.getFormat(); + + if (w == null || w == 0 || h == null || h == 0) { + if (CollectionUtils.isNotEmpty(formats)) { + final Format firstFormat = formats.get(0); + return banner.toBuilder() + .w(firstFormat.getW()) + .h(firstFormat.getH()) + .build(); + } + + throw new PreBidException("Size information missing for banner"); + } + + return banner; + } + + private Native modifyNative(Native xNative) { + final JsonNode requestNode; + try { + requestNode = mapper.mapper().readTree(xNative.getRequest()); + } catch (JsonProcessingException e) { + throw new PreBidException(e.getMessage()); + } + + final JsonNode nativeNode = requestNode != null + ? requestNode.path("native") + : MissingNode.getInstance(); + + if (nativeNode.isMissingNode()) { + final JsonNode modifiedRequestNode = mapper.mapper().createObjectNode().set("native", requestNode); + return xNative.toBuilder() + .request(mapper.encodeToString(modifiedRequestNode)) + .build(); + } + + return xNative; + } + + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + + final List errors = new ArrayList<>(); + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return Result.withValues(extractBids(bidRequest, bidResponse, errors)); + } catch (DecodeException | PreBidException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private List extractBids(BidRequest bidRequest, + BidResponse bidResponse, + List errors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(TpmnBidder::isValidBid) + .map(bid -> createBidderBid(bid, bidRequest.getImp(), bidResponse.getCur(), errors)) + .toList(); + } + + private static boolean isValidBid(Bid bid) { + return BidderUtil.isValidPrice(ObjectUtil.getIfNotNull(bid, Bid::getPrice)); + } + + private static BidderBid createBidderBid(Bid bid, List imps, String currency, List errors) { + if (StringUtils.isNotEmpty(currency)) { + currency = BIDDER_CURRENCY; + } + final BidType bidType = getBidType(bid, imps); + if (bidType == null) { + errors.add(BidderError.badServerResponse( + "ignoring bid id=%s, request doesn't contain any valid impression with id=%s" + .formatted(bid.getId(), bid.getImpid()))); + + return null; + } + + return BidderBid.of(bid, bidType, currency); + } + + + private static BidType getBidType(Bid bid, List imps) { + final String impId = bid.getImpid(); + for (Imp imp : imps) { + if (imp.getId().equals(impId)) { + if (imp.getBanner() != null) { + return BidType.banner; + } else if (imp.getVideo() != null) { + return BidType.video; + } else if (imp.getXNative() != null) { + return BidType.xNative; + } + } + } + throw new PreBidException("Failed to find native/banner/video impression " + impId); + } + + + private Price resolveBidFloor(Imp imp, BidRequest bidRequest) { + final Price initialBidFloorPrice = Price.of(imp.getBidfloorcur(), imp.getBidfloor()); + return BidderUtil.shouldConvertBidFloor(initialBidFloorPrice, BIDDER_CURRENCY) + ? convertBidFloor(initialBidFloorPrice, bidRequest) + : initialBidFloorPrice; + } + + private Price convertBidFloor(Price bidFloorPrice, BidRequest bidRequest) { + final BigDecimal convertedPrice = currencyConversionService.convertCurrency( + bidFloorPrice.getValue(), + bidRequest, + bidFloorPrice.getCurrency(), + BIDDER_CURRENCY); + + return Price.of(BIDDER_CURRENCY, convertedPrice); + } + +} + diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/tpmn/ExtImpTpmn.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/tpmn/ExtImpTpmn.java new file mode 100644 index 00000000000..dfc370263d2 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/tpmn/ExtImpTpmn.java @@ -0,0 +1,14 @@ +package org.prebid.server.proto.openrtb.ext.request.tpmn; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Value; + +@Value +@AllArgsConstructor(staticName = "of") +public class ExtImpTpmn { + + @JsonProperty("inventoryId") + Integer inventoryId; + +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/TpmnConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/TpmnConfiguration.java new file mode 100644 index 00000000000..6dd12723f5a --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/TpmnConfiguration.java @@ -0,0 +1,43 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.tpmn.TpmnBidder; +import org.prebid.server.currency.CurrencyConversionService; +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.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 javax.validation.constraints.NotBlank; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/tpmn.yaml", factory = YamlPropertySourceFactory.class) +public class TpmnConfiguration { + + private static final String BIDDER_NAME = "tpmn"; + + @Bean("tpmnConfigurationProperties") + @ConfigurationProperties("adapters.tpmn") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps tpmnBidderDeps(BidderConfigurationProperties tpmnConfigurationProperties, + @NotBlank @Value("${external-url}") String externalUrl, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(tpmnConfigurationProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new TpmnBidder(config.getEndpoint(), mapper, currencyConversionService)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/tpmn.yaml b/src/main/resources/bidder-config/tpmn.yaml new file mode 100644 index 00000000000..5de3f9ec0a1 --- /dev/null +++ b/src/main/resources/bidder-config/tpmn.yaml @@ -0,0 +1,22 @@ +adapters: + tpmn: + endpoint: https://gat.tpmn.io/ortb/pbs_bidder + endpoint-compression: gzip + modifyingVastXmlAllowed: true + meta-info: + maintainer-email: prebid@tpmn.io + app-media-types: + - banner + - video + - native + site-media-types: + - banner + - video + - native + supported-vendors: + vendor-id: 0 + usersync: + cookie-family-name: tpmn + redirect: + url: https://gat.tpmn.io/sync/redirect?gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&redir={{redirect_url}} + support-cors: false diff --git a/src/main/resources/static/bidder-params/tpmn.json b/src/main/resources/static/bidder-params/tpmn.json new file mode 100644 index 00000000000..99f42ad660a --- /dev/null +++ b/src/main/resources/static/bidder-params/tpmn.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "TPMN Adapter Params", + "description": "A schema which validates params accepted by the TPMN adapter", + "type": "object", + "properties": { + "inventoryId": { + "description": "Inventory ID", + "type": "integer", + "minLength": 1 + } + }, + "required": [ + "inventoryId" + ] +} diff --git a/src/test/java/org/prebid/server/bidder/tpmn/TpmnBidderTest.java b/src/test/java/org/prebid/server/bidder/tpmn/TpmnBidderTest.java new file mode 100644 index 00000000000..53046f3fecf --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/tpmn/TpmnBidderTest.java @@ -0,0 +1,391 @@ +package org.prebid.server.bidder.tpmn; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.*; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.*; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.tpmn.ExtImpTpmn; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import static java.util.Collections.singletonList; +import static java.util.function.Function.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.BDDAssertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +public class TpmnBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://randomurl.com/"; + + private TpmnBidder tpmnBidder; + + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private CurrencyConversionService currencyConversionService; + + @Before + public void setUp() { + tpmnBidder = new TpmnBidder(ENDPOINT_URL, jacksonMapper, currencyConversionService); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new TpmnBidder("invalid_url", jacksonMapper, currencyConversionService)); + } + + @Test + public void makeHttpRequestsShouldConvertCurrencyIfRequestCurrencyDoesNotMatchBidderCurrency() { + // given + given(currencyConversionService.convertCurrency(any(), any(), anyString(), anyString())) + .willReturn(BigDecimal.TEN); + + final BidRequest bidRequest = givenBidRequest( + impBuilder -> impBuilder + .banner(Banner.builder().w(5).h(5).build()) + .bidfloor(BigDecimal.ONE).bidfloorcur("USD")); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.ONE, "USD")); + + } + + + @Test + public void makeHttpRequestsShouldTakeSizesFromFormatIfBannerSizesNotExists() { + // given + final Banner banner = Banner.builder().format(singletonList(Format.builder().h(1).w(1).build())).build(); + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.banner(banner)); + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBanner) + .containsExactly(banner.toBuilder().w(1).h(1).build()); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfBannerHasNoSizeParametersAndFormatIsEmpty() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.banner(Banner.builder().build())); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + //assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Size information missing for banner")); + } + + @Test + public void makeHttpRequestsShouldNotModifyNativeIfNativeIsPresentInNativeRequest() throws JsonProcessingException { + // given + final ObjectNode nativeRequestNode = mapper.createObjectNode().set("native", TextNode.valueOf("test")); + final String nativeRequest = mapper.writeValueAsString(nativeRequestNode); + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder + .xNative(Native.builder().request(nativeRequest).build())); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getXNative) + .containsExactly(Native.builder().request(nativeRequest).build()); + } + + @Test + public void makeHttpRequestsShouldCorrectlyResolveNative() throws JsonProcessingException { + // given + final ObjectNode nativeRequestNode = mapper.createObjectNode() + .set("test", TextNode.valueOf("test")); + final String nativeRequest = mapper.writeValueAsString(nativeRequestNode); + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder + .xNative(Native.builder().request(nativeRequest).build())); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getXNative) + .containsExactly(Native.builder() + .request(mapper.writeValueAsString(mapper.createObjectNode().set("native", + mapper.createObjectNode().set("test", TextNode.valueOf("test"))))) + .build()); + } + + @Test + public void makeHttpRequestsReturnErrorIfNativeCouldNotBeParsed() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder + .xNative(Native.builder().request("invalid_native").build())); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(bidderError -> { + assertThat(bidderError.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(bidderError.getMessage()).startsWith("Unrecognized token"); + }); + } + + @Test + public void makeHttpRequestsShouldReturnErrorsOnNotValidImps() { + // given + final BidRequest bidRequest = givenBidRequest(identity(), + impBuilder -> impBuilder + .id("234") + .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))), + impBuilder -> impBuilder + .id("123") + .video(Video.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001))))); + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getTagid) + .containsExactly(String.valueOf(10000001)); + assertThat(result.getErrors()).allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).startsWith("Cannot deserialize value of type"); + }); + } + + + @Test + public void makeHttpRequestsShouldCreateRequestPerImp() { + // given + final BidRequest bidRequest = givenBidRequest( + identity(), + impBuilder -> impBuilder + .video(Video.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001)))), + impBuilder -> impBuilder + .video(Video.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001))))); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getImp) + .extracting(List::size) + .containsOnly(2); + } + + @Test + public void makeHttpRequestsShouldSkipImpWithoutBannerOrVideoOrNative() { + // given + final BidRequest bidRequest = givenBidRequest( + identity(), + impBuilder -> impBuilder + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001)))), + impBuilder -> impBuilder + .video(Video.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001))))); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .hasSize(1); + } + + @Test + public void makeHttpRequestsShouldCorrectlyModifyImpId() { + // given + final BidRequest bidRequest = givenBidRequest(identity(), + impBuilder -> impBuilder + .id("id1") + .banner(Banner.builder().w(5).h(5).build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001)))), + impBuilder -> impBuilder + .id("id2") + .video(Video.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001)))), + impBuilder -> impBuilder + .id("id3") + .xNative(Native.builder().request("{}").build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001))))); + + // when + final Result>> result = tpmnBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("id1", "id2", "id3"); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall(null, "invalid"); + + // when + final Result> result = tpmnBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, mapper.writeValueAsString(null)); + + // when + final Result> result = tpmnBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = tpmnBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + + @Test + public void makeBidsShouldOmitBidsWithNullPrice() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + final BidderCall httpCall = givenHttpCall(bidRequest, mapper.writeValueAsString( + givenBidResponse(bidBuilder -> bidBuilder.impid("123").price(null)))); + + // when + final Result> result = tpmnBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldOmitBidsWithPriceLessOrEqualToZero() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + final BidderCall httpCall = givenHttpCall(bidRequest, mapper.writeValueAsString( + givenBidResponse(bidBuilder -> bidBuilder.impid("123").price(BigDecimal.valueOf(-1)), + bidBuilder -> bidBuilder.impid("123").price(BigDecimal.ZERO)))); + + // when + final Result> result = tpmnBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + private static BidRequest givenBidRequest( + Function bidRequestCustomizer, + Function... impCustomizers) { + + return bidRequestCustomizer.apply(BidRequest.builder() + .imp(Arrays.stream(impCustomizers) + .map(TpmnBidderTest::givenImp) + .toList()) + .device(Device.builder().os("deviceOs").ua("some-ua").build())) + .build(); + } + + private static BidRequest givenBidRequest(Function impCustomizer) { + return givenBidRequest(identity(), impCustomizer); + } + + private static Imp givenImp(Function impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("123") + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpTpmn.of(10000001))))).build(); + } + + private static BidResponse givenBidResponse(Function... bidCustomizers) { + return BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(Arrays.stream(bidCustomizers) + .map(customizer -> customizer.apply(Bid.builder()).build()) + .toList()) + .build())) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } +} + diff --git a/src/test/java/org/prebid/server/it/TpmnTest.java b/src/test/java/org/prebid/server/it/TpmnTest.java new file mode 100644 index 00000000000..9621a2b3844 --- /dev/null +++ b/src/test/java/org/prebid/server/it/TpmnTest.java @@ -0,0 +1,33 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.prebid.server.model.Endpoint; +import org.springframework.test.context.junit4.SpringRunner; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static java.util.Collections.singletonList; + +@RunWith(SpringRunner.class) +public class TpmnTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromTpmn() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/tpmn-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/tpmn/test-tpmn-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/tpmn/test-tpmn-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/tpmn/test-auction-tpmn-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/tpmn/test-auction-tpmn-response.json", response, + singletonList("tpmn")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-auction-tpmn-request.json b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-auction-tpmn-request.json new file mode 100644 index 00000000000..bedd11e7568 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-auction-tpmn-request.json @@ -0,0 +1,27 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "tpmn": { + "inventoryId": 10000001 + } + } + } + ], + "device": { + "os": "some-Os", + "ua": "some-agent" + }, + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-auction-tpmn-response.json b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-auction-tpmn-response.json new file mode 100644 index 00000000000..872c0500b42 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-auction-tpmn-response.json @@ -0,0 +1,38 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 3.33, + "adm": "adm001", + "adid": "adid001", + "cid": "cid001", + "crid": "crid001", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "type": "banner" + }, + "origbidcpm": 3.33 + } + } + ], + "seat": "tpmn", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "tpmn": "{{ tpmn.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-tpmn-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-tpmn-bid-request.json new file mode 100644 index 00000000000..7953b3dd3d1 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-tpmn-bid-request.json @@ -0,0 +1,58 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "tagid": "some-inventoryId", + "secure": 1, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "inventoryId": 10000001 + } + } + } + ], + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "some-agent", + "ip": "193.168.244.1", + "os": "some-Os" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-tpmn-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-tpmn-bid-response.json new file mode 100644 index 00000000000..a4c0edc3e09 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/tpmn/test-tpmn-bid-response.json @@ -0,0 +1,20 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 3.33, + "adid": "adid001", + "crid": "crid001", + "cid": "cid001", + "adm": "adm001", + "h": 250, + "w": 300 + } + ] + } + ] +} 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 65dc53cdde4..944c31b994f 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -333,6 +333,8 @@ adapters.thirtythreeacross.endpoint=http://localhost:8090/thirtythreeacross-exch adapters.thirtythreeacross.partner-id=partner adapters.thirtythreeacross.aliases.33across.enabled=true adapters.thirtythreeacross.aliases.ttx.enabled=true +adapters.tpmn.enabled=true +adapters.tpmn.endpoint=http://localhost:8090/tpmn-exchange adapters.trafficgate.enabled=true adapters.trafficgate.endpoint=http://localhost:8090/trafficgate-exchange adapters.ucfunnel.enabled=true