diff --git a/src/main/java/org/prebid/server/bidder/smaato/SmaatoBidder.java b/src/main/java/org/prebid/server/bidder/smaato/SmaatoBidder.java index a917460490e..7321e257b80 100644 --- a/src/main/java/org/prebid/server/bidder/smaato/SmaatoBidder.java +++ b/src/main/java/org/prebid/server/bidder/smaato/SmaatoBidder.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import com.iab.openrtb.request.App; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; @@ -10,6 +11,7 @@ import com.iab.openrtb.request.Publisher; import com.iab.openrtb.request.Site; import com.iab.openrtb.request.User; +import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; import com.iab.openrtb.response.BidResponse; import com.iab.openrtb.response.SeatBid; @@ -18,12 +20,14 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; +import org.prebid.server.auction.model.Endpoint; import org.prebid.server.bidder.Bidder; import org.prebid.server.bidder.model.BidderBid; import org.prebid.server.bidder.model.BidderError; import org.prebid.server.bidder.model.HttpCall; import org.prebid.server.bidder.model.HttpRequest; import org.prebid.server.bidder.model.Result; +import org.prebid.server.bidder.smaato.proto.SmaatoBidExt; import org.prebid.server.bidder.smaato.proto.SmaatoBidRequestExt; import org.prebid.server.bidder.smaato.proto.SmaatoImage; import org.prebid.server.bidder.smaato.proto.SmaatoImageAd; @@ -38,149 +42,82 @@ import org.prebid.server.json.JacksonMapper; import org.prebid.server.proto.openrtb.ext.ExtPrebid; import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidPbs; import org.prebid.server.proto.openrtb.ext.request.ExtSite; import org.prebid.server.proto.openrtb.ext.request.ExtUser; import org.prebid.server.proto.openrtb.ext.request.smaato.ExtImpSmaato; import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidVideo; import org.prebid.server.util.HttpUtil; +import java.time.Clock; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; +import java.util.stream.IntStream; -/** - * Smaato {@link Bidder} implementation. - */ public class SmaatoBidder implements Bidder { private static final TypeReference> SMAATO_EXT_TYPE_REFERENCE = new TypeReference>() { }; - - private static final String CLIENT_VERSION = "prebid_server_0.2"; - private static final String SMT_ADTYPE_HEADER = "X-SMT-ADTYPE"; + private static final String CLIENT_VERSION = "prebid_server_0.4"; + private static final String SMT_ADTYPE_HEADER = "X-Smt-Adtype"; + private static final String SMT_EXPIRES_HEADER = "X-Smt-Expires"; private static final String SMT_AD_TYPE_IMG = "Img"; private static final String SMT_ADTYPE_RICHMEDIA = "Richmedia"; private static final String SMT_ADTYPE_VIDEO = "Video"; + private static final int DEFAULT_TTL = 300; + private final String endpointUrl; private final JacksonMapper mapper; + private final Clock clock; - public SmaatoBidder(String endpointUrl, JacksonMapper mapper) { + public SmaatoBidder(String endpointUrl, JacksonMapper mapper, Clock clock) { this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); this.mapper = Objects.requireNonNull(mapper); + this.clock = Objects.requireNonNull(clock); } @Override public Result>> makeHttpRequests(BidRequest request) { - final List errors = new ArrayList<>(); - final List imps = new ArrayList<>(); - - String firstPublisherId = null; - for (Imp imp : request.getImp()) { - try { - final ExtImpSmaato extImpSmaato = parseImpExt(imp); - firstPublisherId = firstPublisherId == null ? extImpSmaato.getPublisherId() : firstPublisherId; - final Imp modifiedImp = modifyImp(imp, extImpSmaato.getAdspaceId()); - imps.add(modifiedImp); - } catch (PreBidException e) { - errors.add(BidderError.badInput(e.getMessage())); - } - } - - final BidRequest outgoingRequest; + final BidRequest enrichedRequest; try { - outgoingRequest = request.toBuilder() - .imp(imps) - .site(modifySite(request.getSite(), firstPublisherId)) - .app(modifyApp(request.getApp(), firstPublisherId)) - .user(modifyUser(request.getUser())) - .ext(mapper.fillExtension(ExtRequest.empty(), SmaatoBidRequestExt.of(CLIENT_VERSION))) - .build(); + enrichedRequest = enrichRequestWithCommonProperties(request); } catch (PreBidException e) { - errors.add(BidderError.badInput(e.getMessage())); - return Result.withErrors(errors); - } - - return Result.of(Collections.singletonList( - HttpRequest.builder() - .method(HttpMethod.POST) - .uri(endpointUrl) - .headers(HttpUtil.headers()) - .payload(outgoingRequest) - .body(mapper.encode(outgoingRequest)) - .build()), - errors); - } - - private ExtImpSmaato parseImpExt(Imp imp) { - try { - return mapper.mapper().convertValue(imp.getExt(), SMAATO_EXT_TYPE_REFERENCE).getBidder(); - } catch (IllegalArgumentException e) { - throw new PreBidException(e.getMessage(), e); - } - } - - private static Imp modifyImp(Imp imp, String adspaceId) { - final Imp.ImpBuilder impBuilder = imp.toBuilder(); - if (imp.getBanner() != null) { - return impBuilder.banner(modifyBanner(imp.getBanner())).tagid(adspaceId).ext(null).build(); - } - - if (imp.getVideo() != null) { - return impBuilder.tagid(adspaceId).ext(null).build(); - } - throw new PreBidException(String.format( - "invalid MediaType. SMAATO only supports Banner and Video. Ignoring ImpID=%s", imp.getId())); - } - - private static Banner modifyBanner(Banner banner) { - if (banner.getW() != null && banner.getH() != null) { - return banner; - } - final List format = banner.getFormat(); - if (CollectionUtils.isEmpty(format)) { - throw new PreBidException(String.format("No sizes provided for Banner %s", format)); - } - final Format firstFormat = format.get(0); - return banner.toBuilder().w(firstFormat.getW()).h(firstFormat.getH()).build(); - } - - private Site modifySite(Site site, String firstPublisherId) { - if (site == null) { - return null; + return Result.withError(BidderError.badInput(e.getMessage())); } - final Site.SiteBuilder siteBuilder = site.toBuilder() - .publisher(Publisher.builder().id(firstPublisherId).build()); - - final ExtSite siteExt = site.getExt(); - if (siteExt != null) { - final SmaatoSiteExtData data = convertExt(siteExt.getData(), SmaatoSiteExtData.class); - final String keywords = data != null ? data.getKeywords() : null; - siteBuilder.keywords(keywords).ext(null); + final List errors = new ArrayList<>(); + if (isVideoRequest(request)) { + return Result.of(constructPodRequests(enrichedRequest, errors), errors); } - - return siteBuilder.build(); + return Result.of(constructIndividualRequests(enrichedRequest, errors), errors); } - private App modifyApp(App app, String publishedId) { - return app != null - ? app.toBuilder().publisher(Publisher.builder().id(publishedId).build()).build() - : null; + private BidRequest enrichRequestWithCommonProperties(BidRequest bidRequest) { + return bidRequest.toBuilder() + .user(modifyUser(bidRequest.getUser())) + .site(modifySite(bidRequest.getSite())) + .ext(mapper.fillExtension(ExtRequest.empty(), SmaatoBidRequestExt.of(CLIENT_VERSION))) + .build(); } private User modifyUser(User user) { - if (user == null) { - return null; + final ExtUser userExt = getIfNotNull(user, User::getExt); + if (userExt == null) { + return user; } - final ExtUser userExt = user.getExt(); - final ObjectNode extDataNode = userExt != null ? userExt.getData() : null; + final ObjectNode extDataNode = userExt.getData(); if (extDataNode == null || extDataNode.isEmpty()) { return user; } @@ -208,6 +145,16 @@ private User modifyUser(User user) { .build(); } + private Site modifySite(Site site) { + final ExtSite siteExt = getIfNotNull(site, Site::getExt); + if (siteExt != null) { + final SmaatoSiteExtData data = convertExt(siteExt.getData(), SmaatoSiteExtData.class); + final String keywords = getIfNotNull(data, SmaatoSiteExtData::getKeywords); + return Site.builder().keywords(keywords).ext(null).build(); + } + return site; + } + private T convertExt(ObjectNode ext, Class className) { try { return mapper.mapper().convertValue(ext, className); @@ -216,14 +163,169 @@ private T convertExt(ObjectNode ext, Class className) { } } + private static boolean isVideoRequest(BidRequest bidRequest) { + final ExtRequestPrebid prebid = getIfNotNull(bidRequest.getExt(), ExtRequest::getPrebid); + final ExtRequestPrebidPbs pbs = getIfNotNull(prebid, ExtRequestPrebid::getPbs); + final String endpointName = getIfNotNull(pbs, ExtRequestPrebidPbs::getEndpoint); + + return StringUtils.equals(endpointName, Endpoint.openrtb2_video.value()); + } + + private List> constructPodRequests(BidRequest bidRequest, List errors) { + final List validImps = new ArrayList<>(); + for (Imp imp : bidRequest.getImp()) { + if (imp.getVideo() == null) { + errors.add(BidderError.badInput("Invalid MediaType. Smaato only supports Video for AdPod.")); + continue; + } + validImps.add(imp); + } + + return validImps.stream() + .collect(Collectors.groupingBy(SmaatoBidder::extractPod, Collectors.toList())) + .values().stream() + .map(impsPod -> preparePodRequest(bidRequest, impsPod, errors)) + .filter(Objects::nonNull) + .map(this::constructHttpRequest) + .collect(Collectors.toList()); + } + + private static String extractPod(Imp imp) { + return imp.getId().split("_")[0]; + } + + private BidRequest preparePodRequest(BidRequest bidRequest, List imps, List errors) { + try { + final ExtImpSmaato extImpSmaato = mapper.mapper().convertValue(imps.get(0).getExt(), + SMAATO_EXT_TYPE_REFERENCE).getBidder(); + + final String publisherId = getIfNotNullOrThrow(extImpSmaato, ExtImpSmaato::getPublisherId, "publisherId"); + final String adBreakId = getIfNotNullOrThrow(extImpSmaato, ExtImpSmaato::getAdbreakId, "adbreakId"); + + return modifyBidRequest(bidRequest, publisherId, () -> modifyImpsForAdBreak(imps, adBreakId)); + } catch (PreBidException | IllegalArgumentException e) { + errors.add(BidderError.badInput(e.getMessage())); + return null; + } + } + + private BidRequest modifyBidRequest(BidRequest bidRequest, String publisherId, Supplier> impSupplier) { + final Publisher publisher = Publisher.builder().id(publisherId).build(); + final Site site = bidRequest.getSite(); + final App app = bidRequest.getApp(); + + final BidRequest.BidRequestBuilder bidRequestBuilder = bidRequest.toBuilder(); + if (site != null) { + bidRequestBuilder.site(site.toBuilder().publisher(publisher).build()); + } else if (app != null) { + bidRequestBuilder.app(app.toBuilder().publisher(publisher).build()); + } else { + throw new PreBidException("Missing Site/App."); + } + + return bidRequestBuilder.imp(impSupplier.get()).build(); + } + + private List modifyImpsForAdBreak(List imps, String adBreakId) { + return IntStream.range(0, imps.size()) + .mapToObj(idx -> modifyImpForAdBreak(imps.get(idx), idx + 1, adBreakId)) + .collect(Collectors.toList()); + } + + private Imp modifyImpForAdBreak(Imp imp, Integer sequence, String adBreakId) { + final Video modifiedVideo = imp.getVideo().toBuilder() + .sequence(sequence) + .ext(mapper.mapper().createObjectNode().set("context", TextNode.valueOf("adpod"))) + .build(); + return imp.toBuilder() + .tagid(adBreakId) + .video(modifiedVideo) + .ext(null) + .build(); + } + + private List> constructIndividualRequests(BidRequest bidRequest, List errors) { + return splitImps(bidRequest.getImp(), errors).stream() + .map(imp -> prepareIndividualRequest(bidRequest, imp, errors)) + .filter(Objects::nonNull) + .map(this::constructHttpRequest) + .collect(Collectors.toList()); + } + + private List splitImps(List imps, List errors) { + final List splitImps = new ArrayList<>(); + + for (Imp imp : imps) { + final Banner banner = imp.getBanner(); + final Video video = imp.getVideo(); + if (video == null && banner == null) { + errors.add(BidderError.badInput("Invalid MediaType. Smaato only supports Banner and Video.")); + continue; + } + + if (video != null) { + splitImps.add(imp.toBuilder().banner(null).build()); + } + if (banner != null) { + splitImps.add(imp.toBuilder().video(null).build()); + } + } + + return splitImps; + } + + private BidRequest prepareIndividualRequest(BidRequest bidRequest, Imp imp, List errors) { + try { + final ExtImpSmaato extImpSmaato = mapper.mapper().convertValue(imp.getExt(), + SMAATO_EXT_TYPE_REFERENCE).getBidder(); + final String publisherId = getIfNotNullOrThrow(extImpSmaato, ExtImpSmaato::getPublisherId, "publisherId"); + final String adSpaceId = getIfNotNullOrThrow(extImpSmaato, ExtImpSmaato::getAdspaceId, "adspaceId"); + + return modifyBidRequest(bidRequest, publisherId, () -> modifyImpForAdSpace(imp, adSpaceId)); + } catch (PreBidException | IllegalArgumentException e) { + errors.add(BidderError.badInput(e.getMessage())); + return null; + } + } + + private List modifyImpForAdSpace(Imp imp, String adSpaceId) { + final Imp modifiedImp = imp.toBuilder() + .tagid(adSpaceId) + .banner(getIfNotNull(imp.getBanner(), SmaatoBidder::modifyBanner)) + .ext(null) + .build(); + + return Collections.singletonList(modifiedImp); + } + + private static Banner modifyBanner(Banner banner) { + if (banner.getW() != null && banner.getH() != null) { + return banner; + } + final List format = banner.getFormat(); + if (CollectionUtils.isEmpty(format)) { + throw new PreBidException("No sizes provided for Banner."); + } + final Format firstFormat = format.get(0); + return banner.toBuilder().w(firstFormat.getW()).h(firstFormat.getH()).build(); + } + + private HttpRequest constructHttpRequest(BidRequest bidRequest) { + return HttpRequest.builder() + .uri(endpointUrl) + .method(HttpMethod.POST) + .headers(HttpUtil.headers()) + .body(mapper.encode(bidRequest)) + .payload(bidRequest) + .build(); + } + @Override public Result> makeBids(HttpCall httpCall, BidRequest bidRequest) { try { final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); return extractBids(bidResponse, httpCall.getResponse().getHeaders()); - } catch (DecodeException e) { - return Result.withError(BidderError.badServerResponse(e.getMessage())); - } catch (PreBidException e) { + } catch (PreBidException | DecodeException e) { return Result.withError(BidderError.badInput(e.getMessage())); } } @@ -233,42 +335,83 @@ private Result> extractBids(BidResponse bidResponse, MultiMap he return Result.empty(); } - return Result.withValues(bidResponse.getSeatbid().stream() + final List errors = new ArrayList<>(); + final List bidderBids = bidResponse.getSeatbid().stream() .filter(Objects::nonNull) .map(SeatBid::getBid) .filter(Objects::nonNull) .flatMap(Collection::stream) - .map(bid -> bidderBid(bid, bidResponse.getCur(), headers)) + .map(bid -> bidderBid(bid, bidResponse.getCur(), headers, errors)) .filter(Objects::nonNull) - .collect(Collectors.toList())); + .collect(Collectors.toList()); + + return Result.of(bidderBids, errors); } - private BidderBid bidderBid(Bid bid, String currency, MultiMap headers) { - final String bidAdm = bid.getAdm(); - if (StringUtils.isBlank(bidAdm)) { - throw new PreBidException(String.format("Empty ad markup in bid with id: %s", bid.getId())); + private BidderBid bidderBid(Bid bid, String currency, MultiMap headers, List errors) { + try { + final String bidAdm = bid.getAdm(); + if (StringUtils.isBlank(bidAdm)) { + throw new PreBidException(String.format("Empty ad markup in bid with id: %s", bid.getId())); + } + final String markupType = getAdMarkupType(headers, bidAdm); + final BidType bidType = getBidType(markupType); + final Bid updatedBid = bid.toBuilder() + .adm(renderAdMarkup(markupType, bidAdm)) + .exp(getTtl(headers)) + .ext(buildExtPrebid(bid, bidType)) + .build(); + return BidderBid.of(updatedBid, bidType, currency); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + return null; } + } - final String markupType = getAdMarkupType(headers, bidAdm); - final Bid updateBid = bid.toBuilder().adm(renderAdMarkup(markupType, bidAdm)).build(); - return BidderBid.of(updateBid, getBidType(markupType), currency); + private ObjectNode buildExtPrebid(Bid bid, BidType bidType) { + final ExtBidPrebidVideo extBidPrebidVideo = getExtBidPrebidVideo(bid, bidType); + final ExtBidPrebid extBidPrebid = ExtBidPrebid.builder().video(extBidPrebidVideo).build(); + return mapper.mapper().valueToTree(ExtPrebid.of(extBidPrebid, null)); + } + + private ExtBidPrebidVideo getExtBidPrebidVideo(Bid bid, BidType bidType) { + final ObjectNode bidExt = bid.getExt(); + if (bidType != BidType.video || bidExt == null) { + return null; + } + + final List categories = bid.getCat(); + final String primaryCategory = CollectionUtils.isNotEmpty(categories) ? categories.get(0) : null; + try { + final SmaatoBidExt smaatoBidExt = mapper.mapper().convertValue(bidExt, SmaatoBidExt.class); + return ExtBidPrebidVideo.of(smaatoBidExt.getDuration(), primaryCategory); + } catch (IllegalArgumentException e) { + throw new PreBidException("Invalid bid.ext."); + } + } + + private int getTtl(MultiMap headers) { + try { + final long expiresAtMillis = Long.parseLong(headers.get(SMT_EXPIRES_HEADER)); + final long currentTimeMillis = clock.millis(); + return (int) Math.max((expiresAtMillis - currentTimeMillis) / 1000, 0); + } catch (NumberFormatException e) { + return DEFAULT_TTL; + } } private static String getAdMarkupType(MultiMap headers, String adm) { final String adMarkupType = headers.get(SMT_ADTYPE_HEADER); if (StringUtils.isNotBlank(adMarkupType)) { return adMarkupType; - } - if (adm.startsWith("image")) { + } else if (adm.startsWith("{\"image\":")) { return SMT_AD_TYPE_IMG; - } - if (adm.startsWith("richmedia")) { + } else if (adm.startsWith("{\"richmedia\":")) { return SMT_ADTYPE_RICHMEDIA; - } - if (adm.startsWith("%s", - clickEvent.toString(), + clickEvent, HttpUtil.encodeUrl(StringUtils.stripToEmpty(getIfNotNull(img, SmaatoImg::getCtaurl))), StringUtils.stripToEmpty(getIfNotNull(img, SmaatoImg::getUrl)), stripToZero(getIfNotNull(img, SmaatoImg::getW)), stripToZero(getIfNotNull(img, SmaatoImg::getH)), - impressionTracker.toString()); + impressionTracker); } private String extractAdmRichMedia(String adm) { @@ -332,9 +475,9 @@ private String extractAdmRichMedia(String adm) { String.format("\"\"", tracker))); return String.format("
%s%s
", - clickEvent.toString(), + clickEvent, StringUtils.stripToEmpty(getIfNotNull(richmedia.getMediadata(), SmaatoMediaData::getContent)), - impressionTracker.toString()); + impressionTracker); } private T convertAdmToAd(String value, Class className) { @@ -357,6 +500,14 @@ private static BidType getBidType(String markupType) { } } + private static R getIfNotNullOrThrow(T target, Function getter, String propertyName) { + final R result = getIfNotNull(target, getter); + if (result == null) { + throw new PreBidException(String.format("Missing %s property.", propertyName)); + } + return result; + } + private static R getIfNotNull(T target, Function getter) { return target != null ? getter.apply(target) : null; } diff --git a/src/main/java/org/prebid/server/bidder/smaato/proto/SmaatoBidExt.java b/src/main/java/org/prebid/server/bidder/smaato/proto/SmaatoBidExt.java new file mode 100644 index 00000000000..b278d2a084e --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/smaato/proto/SmaatoBidExt.java @@ -0,0 +1,11 @@ +package org.prebid.server.bidder.smaato.proto; + +import lombok.AllArgsConstructor; +import lombok.Value; + +@Value +@AllArgsConstructor(staticName = "of") +public class SmaatoBidExt { + + Integer duration; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/smaato/ExtImpSmaato.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/smaato/ExtImpSmaato.java index d976f85fc52..9f6ef557196 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/smaato/ExtImpSmaato.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/smaato/ExtImpSmaato.java @@ -4,9 +4,6 @@ import lombok.AllArgsConstructor; import lombok.Value; -/** - * Defines the contract for bidRequest.imp[i].ext.smaato - */ @AllArgsConstructor(staticName = "of") @Value public class ExtImpSmaato { @@ -16,4 +13,7 @@ public class ExtImpSmaato { @JsonProperty("adspaceId") String adspaceId; + + @JsonProperty("adbreakId") + String adbreakId; } diff --git a/src/main/java/org/prebid/server/spring/config/bidder/SmaatoConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/SmaatoConfiguration.java index 044bc17f32a..f21f0ee3aa9 100644 --- a/src/main/java/org/prebid/server/spring/config/bidder/SmaatoConfiguration.java +++ b/src/main/java/org/prebid/server/spring/config/bidder/SmaatoConfiguration.java @@ -16,6 +16,7 @@ import org.springframework.context.annotation.PropertySource; import javax.validation.constraints.NotBlank; +import java.time.Clock; @Configuration @PropertySource(value = "classpath:/bidder-config/smaato.yaml", factory = YamlPropertySourceFactory.class) @@ -30,6 +31,9 @@ public class SmaatoConfiguration { @Autowired private JacksonMapper mapper; + @Autowired + private Clock clock; + @Autowired @Qualifier("smaatoConfigurationProperties") private BidderConfigurationProperties configProperties; @@ -45,7 +49,7 @@ BidderDeps smaatoBidderDeps() { return BidderDepsAssembler.forBidder(BIDDER_NAME) .withConfig(configProperties) .usersyncerCreator(UsersyncerCreator.create(externalUrl)) - .bidderCreator(config -> new SmaatoBidder(config.getEndpoint(), mapper)) + .bidderCreator(config -> new SmaatoBidder(config.getEndpoint(), mapper, clock)) .assemble(); } } diff --git a/src/test/java/org/prebid/server/bidder/smaato/SmaatoBidderTest.java b/src/test/java/org/prebid/server/bidder/smaato/SmaatoBidderTest.java index 69dbf69cd7f..09efb5afd8b 100644 --- a/src/test/java/org/prebid/server/bidder/smaato/SmaatoBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/smaato/SmaatoBidderTest.java @@ -1,6 +1,7 @@ package org.prebid.server.bidder.smaato; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.TextNode; import com.iab.openrtb.request.App; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; @@ -9,235 +10,518 @@ import com.iab.openrtb.request.Publisher; import com.iab.openrtb.request.Site; import com.iab.openrtb.request.User; +import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; import com.iab.openrtb.response.BidResponse; import com.iab.openrtb.response.SeatBid; import io.vertx.core.MultiMap; 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.auction.model.Endpoint; import org.prebid.server.bidder.model.BidderBid; import org.prebid.server.bidder.model.BidderError; import org.prebid.server.bidder.model.HttpCall; 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.smaato.proto.SmaatoBidExt; +import org.prebid.server.bidder.smaato.proto.SmaatoBidRequestExt; import org.prebid.server.bidder.smaato.proto.SmaatoSiteExtData; import org.prebid.server.bidder.smaato.proto.SmaatoUserExtData; import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidPbs; import org.prebid.server.proto.openrtb.ext.request.ExtSite; import org.prebid.server.proto.openrtb.ext.request.ExtUser; import org.prebid.server.proto.openrtb.ext.request.smaato.ExtImpSmaato; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidVideo; +import org.prebid.server.util.HttpUtil; +import java.time.Clock; +import java.util.Arrays; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; -import static java.util.Arrays.asList; import static java.util.Collections.singletonList; -import static java.util.function.Function.identity; +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.assertj.core.api.Assertions.tuple; +import static org.mockito.Mockito.when; import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; import static org.prebid.server.proto.openrtb.ext.response.BidType.video; public class SmaatoBidderTest extends VertxTest { + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + private static final String ENDPOINT_URL = "https://test.endpoint.com"; private SmaatoBidder smaatoBidder; + @Mock + private Clock clock; + @Before public void setUp() { - smaatoBidder = new SmaatoBidder(ENDPOINT_URL, jacksonMapper); + smaatoBidder = new SmaatoBidder(ENDPOINT_URL, jacksonMapper, clock); } @Test public void creationShouldFailOnInvalidEndpointUrl() { - assertThatIllegalArgumentException().isThrownBy(() -> new SmaatoBidder("invalid_url", jacksonMapper)); + assertThatIllegalArgumentException().isThrownBy(() -> new SmaatoBidder("invalid_url", jacksonMapper, clock)); } @Test - public void makeHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() { + public void makeHttpRequestsShouldModifyUserIfUserExtDataIsPresent() { // given - final BidRequest bidRequest = givenBidRequest( - impBuilder -> impBuilder - .id("123") - .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))); + final BidRequest bidRequest = givenBidRequest(bidRequestBuilder -> + bidRequestBuilder.user(User.builder() + .ext(ExtUser.builder() + .data(mapper.valueToTree(SmaatoUserExtData.of("keywords", "gender", 1))) + .build()) + .build()), identity()); + // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then - assertThat(result.getErrors()).hasSize(1); - assertThat(result.getErrors().get(0).getMessage()).startsWith("Cannot deserialize instance"); + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getUser) + .extracting(User::getKeywords, User::getGender, User::getYob) + .containsExactly(tuple("keywords", "gender", 1)); } @Test - public void makeHttpRequestsShouldReturnErrorIfBannerOrVideoImpIsEmpty() { + public void makeHttpRequestsShouldModifySiteIfSiteExtDataIsPresent() { // given - final BidRequest bidRequest = givenBidRequest( - impBuilder -> impBuilder.id("impid").banner(null).video(null)); + final BidRequest bidRequest = givenBidRequest(bidRequestBuilder -> + bidRequestBuilder.site(Site.builder() + .ext(ExtSite.of(1, mapper.valueToTree(SmaatoSiteExtData.of("keywords")))) + .build()), identity()); + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getSite) + .extracting(Site::getKeywords, Site::getExt) + .containsExactly(tuple("keywords", null)); + } + + @Test + public void makeHttpRequestsShouldSetExt() { + // given + final BidRequest bidRequest = givenBidRequest(); // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then - assertThat(result.getErrors()).hasSize(1); - assertThat(result.getErrors()).hasSize(1) - .containsOnly(BidderError - .badInput("invalid MediaType. SMAATO only supports Banner and Video. Ignoring ImpID=impid")); + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .containsExactly(jacksonMapper.fillExtension(ExtRequest.empty(), + SmaatoBidRequestExt.of("prebid_server_0.4"))); } @Test - public void makeHttpRequestsShouldReturnErrorIfBannerSizeIsEmpty() { + public void makePodHttpRequestsShouldReturnErrorsForImpsOfInvalidMediaType() { // given - final BidRequest bidRequest = givenBidRequest( - impBuilder -> impBuilder.id("impid").banner(Banner.builder().build())); + final BidRequest bidRequest = givenVideoBidRequest(identity(), impBuilder -> impBuilder.video(null)); // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then - assertThat(result.getErrors()).hasSize(1); + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly( + BidderError.badInput("Invalid MediaType. Smaato only supports Video for AdPod.")); + } + + @Test + public void makePodHttpRequestsShouldCorrectlyConstructImpPodsAndRequests() { + // given + final BidRequest bidRequest = givenVideoBidRequest( + identity(), + impBuilder -> impBuilder.id("1_0"), + impBuilder -> impBuilder.id("1_1"), + impBuilder -> impBuilder.id("2_0")); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + final List> requests = result.getValue(); + assertThat(requests).hasSize(2); + assertThat(requests.get(0).getPayload().getImp()) + .extracting(Imp::getId) + .containsExactlyInAnyOrder("1_0", "1_1"); + assertThat(requests.get(1).getPayload().getImp()) + .extracting(Imp::getId) + .containsExactly("2_0"); + } + + @Test + public void makePodHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = givenVideoBidRequest( + impBuilder -> impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); assertThat(result.getErrors()).hasSize(1) - .containsOnly(BidderError - .badInput("No sizes provided for Banner null")); + .allSatisfy(bidderError -> { + assertThat(bidderError.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(bidderError.getMessage()).startsWith("Cannot deserialize instance"); + }); } @Test - public void makeHttpRequestsShouldNotChangeBannerWidthAndHeightIfPresent() { + public void makePodHttpRequestsShouldReturnErrorIfImpExtPublisherIdIsAbsent() { // given - final BidRequest bidRequest = givenBidRequest( - impBuilder -> impBuilder - .banner(Banner.builder() - .format(singletonList(Format.builder().w(300).h(500).build())) - .w(200) - .h(150) - .build())); + final BidRequest bidRequest = givenVideoBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of(null, null, "adbreakId"))))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Missing publisherId property.")); + } + + @Test + public void makePodHttpRequestsShouldReturnErrorIfImpExtAdBreakIdIsAbsent() { + // given + final BidRequest bidRequest = givenVideoBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpSmaato.of( + "publisherId", null, null))))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Missing adbreakId property.")); + } + + @Test + public void makePodHttpRequestsShouldEnrichSiteWithPublisherIdIfSiteIsPresentInRequest() { + // given + final BidRequest bidRequest = givenVideoBidRequest( + bidRequestBuilder -> bidRequestBuilder.site(Site.builder().build()).app(null), + impBuilder -> impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of("publisherId", null, "adBreakId"))))); // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getSite) + .extracting(Site::getPublisher) + .extracting(Publisher::getId) + .containsExactly("publisherId"); + } + + @Test + public void makePodHttpRequestsShouldEnrichAppWithPublisherIdIfSiteIsAbsentAndAppIsPresentInRequest() { + // given + final BidRequest bidRequest = givenVideoBidRequest( + bidRequestBuilder -> bidRequestBuilder.site(null).app(App.builder().build()), + impBuilder -> impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of("publisherId", null, "adBreakId"))))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getApp) + .extracting(App::getPublisher) + .extracting(Publisher::getId) + .containsExactly("publisherId"); + } + + @Test + public void makePodHttpRequestsShouldReturnErrorIfSiteAndAppAreAbsentInRequest() { + // given + final BidRequest bidRequest = givenVideoBidRequest(bidRequestBuilder -> + bidRequestBuilder.site(null).app(null), identity()); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .containsExactly(BidderError.badInput("Missing Site/App.")); + } + + @Test + public void makePodHttpRequestsShouldCorrectlyModifyImps() { + // given + final BidRequest bidRequest = givenVideoBidRequest( + identity(), + impBuilder -> impBuilder.id("1_0"), + impBuilder -> impBuilder.id("1_1")); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + BiFunction resultCustomizer = + (builder, idx) -> builder + .id(String.format("1_%d", idx)) + .tagid("adbreakId") + .ext(null) + .video(Video.builder() + .ext(mapper.createObjectNode().set("context", TextNode.valueOf("adpod"))) + .sequence(idx + 1) + .build()); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) .flatExtracting(BidRequest::getImp) - .extracting(Imp::getBanner) - .extracting(Banner::getW, Banner::getH) - .containsOnly(tuple(200, 150)); + .containsExactlyInAnyOrder( + givenVideoImp(impBuilder -> resultCustomizer.apply(impBuilder, 0)), + givenVideoImp(impBuilder -> resultCustomizer.apply(impBuilder, 1))); } @Test - public void makeHttpRequestsShouldSetBannerWidthAndHeightFromFirstFormatIfEmpty() { + public void makeIndividualHttpRequestsShouldReturnErrorsOfImpsWithInvalidMediaTypes() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.video(null).banner(null)); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()) + .containsExactly(BidderError.badInput("Invalid MediaType. Smaato only supports Banner and Video.")); + } + + @Test + public void makeIndividualHttpRequestsShouldCorrectlySplitImps() { // given final BidRequest bidRequest = givenBidRequest( + identity(), impBuilder -> impBuilder - .banner(Banner.builder() - .format(asList(Format.builder().w(300).h(500).build(), - Format.builder().w(450).h(150).build())) - .build())); + .id("123") + .banner(Banner.builder().w(1).h(1).build()) + .video(Video.builder().w(1).h(1).build()), + impBuilder -> impBuilder.id("456").banner(Banner.builder().w(1).h(1).build())); // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) + assertThat(result.getValue()).hasSize(3) + .extracting(HttpRequest::getPayload) .flatExtracting(BidRequest::getImp) - .extracting(Imp::getBanner) - .extracting(Banner::getW, Banner::getH) - .containsOnly(tuple(300, 500)); + .allMatch(imp -> Boolean.logicalXor(imp.getVideo() != null, imp.getBanner() != null)); + } @Test - public void makeHttpRequestsShouldModifyRequestSite() { + public void makeIndividualHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() { // given - final BidRequest bidRequest = BidRequest.builder() - .imp(singletonList(Imp.builder() - .id("123") - .banner(Banner.builder() - .id("banner_id").format(asList(Format.builder().w(300).h(500).build(), - Format.builder().w(450).h(150).build())).build()) - .ext(mapper.valueToTree(ExtPrebid.of(null, - ExtImpSmaato.of("publisherId", "adspaceId")))).build())) - .site(Site.builder() - .ext(ExtSite.of(null, mapper.valueToTree(SmaatoSiteExtData.of("keywords")))) - .build()) - .build(); + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(bidderError -> { + assertThat(bidderError.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(bidderError.getMessage()).startsWith("Cannot deserialize instance"); + }); + } + + @Test + public void makeIndividualHttpRequestsShouldReturnErrorIfImpExtPublisherIdIsAbsent() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of(null, "adspaceId", null))))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Missing publisherId property.")); + } + + @Test + public void makeIndividualHttpRequestsShouldReturnErrorIfImpExtAdSpaceIdIsAbsent() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpSmaato.of( + "publisherId", null, null))))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Missing adspaceId property.")); + } + + @Test + public void makeIndividualHttpRequestsShouldEnrichSiteWithPublisherIdIfSiteIsPresentInRequest() { + // given + final BidRequest bidRequest = givenBidRequest( + bidRequestBuilder -> bidRequestBuilder.site(Site.builder().build()).app(null), + impBuilder -> impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of("publisherId", "adspaceId", null))))); // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) .extracting(BidRequest::getSite) .extracting(Site::getPublisher) .extracting(Publisher::getId) - .containsOnly("publisherId"); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) - .extracting(BidRequest::getSite) - .extracting(Site::getKeywords) - .containsOnly("keywords"); + .containsExactly("publisherId"); } @Test - public void makeHttpRequestsShouldModifyRequestAppPublisherId() { + public void makeIndividualHttpRequestsShouldEnrichAppWithPublisherIdIfSiteIsAbsentAndAppIsPresentInRequest() { // given - final BidRequest bidRequest = givenBidRequest(bidRequestBuilder -> - bidRequestBuilder.app(App.builder().build()), identity()); + final BidRequest bidRequest = givenBidRequest( + bidRequestBuilder -> bidRequestBuilder.site(null).app(App.builder().build()), + impBuilder -> impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of("publisherId", "adspaceId", null))))); // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()).hasSize(1) + assertThat(result.getValue()) .extracting(HttpRequest::getPayload) .extracting(BidRequest::getApp) .extracting(App::getPublisher) .extracting(Publisher::getId) - .containsOnly("publisherId"); + .containsExactly("publisherId"); } @Test - public void makeHttpRequestsShouldModifyRequestUser() { + public void makeIndividualHttpRequestsShouldReturnErrorIfSiteAndAppAreAbsentInRequest() { // given - final BidRequest bidRequest = BidRequest.builder() - .imp(singletonList(Imp.builder() - .id("123") - .banner(Banner.builder() - .id("banner_id").format(asList(Format.builder().w(300).h(500) - .build(), - Format.builder().w(450).h(150).build())).build()) - .ext(mapper.valueToTree(ExtPrebid.of(null, - ExtImpSmaato.of("publisherId", "adspaceId")))).build())) - .user(User.builder() - .ext(ExtUser.builder() - .data(mapper.valueToTree(SmaatoUserExtData.of("keywords", "gender", 1))) - .build()) - .build()) - .build(); + final BidRequest bidRequest = givenBidRequest(bidRequestBuilder -> + bidRequestBuilder.site(null).app(null), identity()); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .containsExactly(BidderError.badInput("Missing Site/App.")); + } + + @Test + public void makeIndividualHttpRequestsShouldReturnErrorIfBannerSizesAndFormatsAreAbsent() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.banner(Banner.builder().build())); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("No sizes provided for Banner.")); + } + + @Test + public void makeIndividualHttpRequestsShouldNotModifyBannerIfBannerSizesArePresent() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.banner(Banner.builder().w(1).h(1).build())); // when final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); // then assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) - .extracting(BidRequest::getUser) - .extracting(User::getExt) - .containsOnly(ExtUser.builder().build()); - assertThat(result.getValue()).hasSize(1) - .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) - .extracting(BidRequest::getUser) - .extracting(User::getGender) - .containsOnly("gender"); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBanner) + .containsExactly(Banner.builder().w(1).h(1).build()); + } + + @Test + public void makeIndividualHttpRequestsShouldReplaceBannerSizesWithFirstFormatIfFormatsArePresent() { + // given + final Banner banner = Banner.builder().format(singletonList(Format.builder().w(2).h(2).build())).build(); + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.banner(banner)); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBanner) + .containsExactly(banner.toBuilder().w(2).h(2).build()); + } + + @Test + public void makeIndividualHttpRequestsShouldSetImpTagIdAndRemoveImpExt() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of("publisherId", "adspaceId", null))))); + + // when + final Result>> result = smaatoBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getTagid, Imp::getExt) + .containsExactly(tuple("adspaceId", null)); } @Test @@ -249,17 +533,18 @@ public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { final Result> result = smaatoBidder.makeBids(httpCall, null); // then - assertThat(result.getErrors()).hasSize(1); - assertThat(result.getErrors().get(0).getMessage()).startsWith("Failed to decode: Unrecognized token"); - assertThat(result.getErrors().get(0).getType()).isEqualTo(BidderError.Type.bad_server_response); assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(bidderError -> { + assertThat(bidderError.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(bidderError.getMessage()).startsWith("Failed to decode:"); + }); } @Test public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() throws JsonProcessingException { // given - final HttpCall httpCall = givenHttpCall(null, - mapper.writeValueAsString(null), null); + final HttpCall httpCall = givenHttpCall(null, mapper.writeValueAsString(null), null); // when final Result> result = smaatoBidder.makeBids(httpCall, null); @@ -270,122 +555,233 @@ public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() throws JsonProces } @Test - public void makeBidsShouldReturnErrorIfNoBidAdm() throws JsonProcessingException { + public void makeBidsShouldReturnErrorOnEmptyBidAdm() throws JsonProcessingException { // given - final HttpCall httpCall = givenHttpCall(BidRequest.builder().build(), - mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.id("test").impid("123"))), null); + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.id("test"))), + HttpUtil.headers()); // when final Result> result = smaatoBidder.makeBids(httpCall, null); // then - assertThat(result.getErrors()).hasSize(1) - .containsOnly(BidderError.badInput("Empty ad markup in bid with id: test")); assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Empty ad markup in bid with id: test")); } @Test public void makeBidsShouldReturnErrorIfNotSupportedMarkupType() throws JsonProcessingException { // given - final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", "anyType"); - final HttpCall httpCall = givenHttpCall(BidRequest.builder() - .imp(singletonList(Imp.builder().id("123").build())) - .build(), - mapper.writeValueAsString( - givenBidResponse(bidBuilder -> bidBuilder.impid("123").adm("adm"))), headers); + final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-Smt-Adtype", "anyType"); + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm("> result = smaatoBidder.makeBids(httpCall, null); // then - assertThat(result.getErrors()).hasSize(1) - .containsOnly(BidderError.badInput("Unknown markup type anyType")); assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Invalid markupType anyType")); + } + + @Test + public void makeBidsShouldCalculateTtlIfExpirationHeaderIsPresentInResponse() throws JsonProcessingException { + // given + when(clock.millis()).thenReturn(100L); + + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm("> result = smaatoBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getExp) + .containsExactly(9); + } + + @Test + public void makeBidsShouldSetTtlToZeroIfExpirationHeaderIsPresentInResponseButLessThanCurrentTime() + throws JsonProcessingException { + // given + when(clock.millis()).thenReturn(999999L); + + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm("> result = smaatoBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getExp) + .containsExactly(0); + } + + @Test + public void makeBidsShouldSetDefaultTtlIfExpirationHeaderIsAbsentInResponse() throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm("> result = smaatoBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getExp) + .containsExactly(300); } @Test public void makeBidsShouldReturnErrorIfMarkupTypeIsBlank() throws JsonProcessingException { // given - final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", ""); - final HttpCall httpCall = givenHttpCall(BidRequest.builder() - .imp(singletonList(Imp.builder().id("123").build())) - .build(), - mapper.writeValueAsString( - givenBidResponse(bidBuilder -> bidBuilder.impid("123").adm("adm"))), headers); + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm("adm"))), + MultiMap.caseInsensitiveMultiMap().set("X-Smt-Adtype", "")); // when final Result> result = smaatoBidder.makeBids(httpCall, null); // then - assertThat(result.getErrors()).hasSize(1) - .containsOnly(BidderError.badInput("Invalid ad markup adm")); assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("Invalid ad markup adm.")); } @Test public void makeBidsShouldReturnErrorIfAdmIsInvalid() throws JsonProcessingException { // given - final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", ""); - final HttpCall httpCall = givenHttpCall(BidRequest.builder() - .imp(singletonList(Imp.builder().id("123").build())) - .build(), - mapper.writeValueAsString( - givenBidResponse(bidBuilder -> bidBuilder.impid("123").adm("{\"image\": invalid"))), headers); + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm("{\"image\": invalid"))), + MultiMap.caseInsensitiveMultiMap().set("X-Smt-Adtype", "")); // when final Result> result = smaatoBidder.makeBids(httpCall, null); // then - assertThat(result.getErrors()).hasSize(1) - .containsOnly(BidderError.badInput("Invalid ad markup {\"image\": invalid")); assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).startsWith("Cannot decode bid.adm:"); + assertThat(result.getErrors().get(0).getType()).isEqualTo(BidderError.Type.bad_input); } @Test public void makeBidsShouldReturnCorrectBidIfAdMarkTypeIsReachmedia() throws JsonProcessingException { // given - final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", "Richmedia"); - final HttpCall httpCall = givenHttpCall(BidRequest.builder() - .imp(singletonList(Imp.builder().id("123").build())) - .build(), - mapper.writeValueAsString( - givenBidResponse(bidBuilder -> bidBuilder.impid("123").adm("{\"richmedia\":{\"mediadata\":" - + "{\"content\":\"
hello
\", \"w\":350,\"h\":50},\"impressiontrackers\":" - + "[\"//prebid-test.smaatolabs.net/track/imp/1\",\"//prebid-test.smaatolabs.net/track" - + "/imp/2\"],\"clicktrackers\":[\"//prebid-test.smaatolabs.net/track/click/1\"," - + "\"//prebid-test.smaatolabs.net/track/click/2\"]}}"))), headers); + final String adm = "{\"richmedia\":{\"mediadata\":" + + "{\"content\":\"
hello
\", \"w\":350,\"h\":50},\"impressiontrackers\":" + + "[\"//prebid-test.smaatolabs.net/track/imp/1\",\"//prebid-test.smaatolabs.net/track" + + "/imp/2\"],\"clicktrackers\":[\"//prebid-test.smaatolabs.net/track/click/1\"," + + "\"//prebid-test.smaatolabs.net/track/click/2\"]}}"; + + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm(adm))), + MultiMap.caseInsensitiveMultiMap().set("X-Smt-Adtype", "Richmedia")); // when final Result> result = smaatoBidder.makeBids(httpCall, null); // then + final String expectedAdm = + "
hello
\"\"\"\"
"; + final Bid expectedBid = Bid.builder() .impid("123") - .adm("
hello
\", \"w\":350,\"h\":50},\"impressiontrackers\":" + + "[\"//prebid-test.smaatolabs.net/track/imp/1\",\"//prebid-test.smaatolabs.net/track" + + "/imp/2\"],\"clicktrackers\":[\"//prebid-test.smaatolabs.net/track/click/1\"," + + "\"//prebid-test.smaatolabs.net/track/click/2\"]}}"; + + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm(adm))), + MultiMap.caseInsensitiveMultiMap()); + + // when + final Result> result = smaatoBidder.makeBids(httpCall, null); + + // then + final String expectedAdm = + "
hello
\"\"\"\"
") + + "net/track/imp/2\" alt=\"\" width=\"0\" height=\"0\"/>"; + + final Bid expectedBid = Bid.builder() + .impid("123") + .adm(expectedAdm) + .ext(mapper.valueToTree(ExtPrebid.of(ExtBidPrebid.builder().build(), null))) + .exp(300) .build(); + assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()) - .containsOnly(BidderBid.of(expectedBid, banner, "USD")); + assertThat(result.getValue()).containsExactly(BidderBid.of(expectedBid, banner, "USD")); } @Test - public void makeBidsShouldReturnCorrectBidIfAdMarkTypeIsImg() throws JsonProcessingException { + public void makeBidsShouldReturnErrorIfAdMarkTypeIsReachmediaAndAdmIsEmpty() + throws JsonProcessingException { // given - final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", "Img"); - final HttpCall httpCall = givenHttpCall(BidRequest.builder() - .imp(singletonList(Imp.builder().id("123").build())) - .build(), + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm("{}"))), + MultiMap.caseInsensitiveMultiMap().set("X-Smt-Adtype", "Richmedia")); + + // when + final Result> result = smaatoBidder.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("bid.adm.richmedia is empty")); + } + + @Test + public void makeBidsShouldReturnCorrectBidIfAdMarkTypeIsVideoAndAdTypeHeaderIsAbsent() + throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), mapper.writeValueAsString( - givenBidResponse(bidBuilder -> bidBuilder.impid("123").adm("{\"image\":{\"img\":{\"url\":\"" - + "//prebid-test.smaatolabs.net/img/320x50.jpg\",\"w\":350,\"h\":50,\"ctaurl\":\"" - + "//prebid-test.smaatolabs.net/track/ctaurl/1\"},\"impressiontrackers\":[\"" - + "//prebid-test.smaatolabs.net/track/imp/1\",\"//prebid-test.smaatolabs.net/track/" - + "imp/2\"],\"clicktrackers\":[\"//prebid-test.smaatolabs.net/track/click/1\",\"" - + "//prebid-test.smaatolabs.net/track/click/2\"]}}"))), headers); + givenBidResponse(bidBuilder -> bidBuilder.adm("> result = smaatoBidder.makeBids(httpCall, null); @@ -393,65 +789,107 @@ public void makeBidsShouldReturnCorrectBidIfAdMarkTypeIsImg() throws JsonProcess // then final Bid expectedBid = Bid.builder() .impid("123") - .adm("
httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm(adm))), + MultiMap.caseInsensitiveMultiMap().set("X-Smt-Adtype", "Img")); + + // when + final Result> result = smaatoBidder.makeBids(httpCall, null); + + // then + final String expectedAdm = + "
\"\"\"\"
") + + "track/imp/2\" alt=\"\" width=\"0\" height=\"0\"/>
"; + + final Bid expectedBid = Bid.builder() + .impid("123") + .adm(expectedAdm) + .ext(mapper.valueToTree(ExtPrebid.of(ExtBidPrebid.builder().build(), null))) + .exp(300) .build(); + assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()) - .containsOnly(BidderBid.of(expectedBid, banner, "USD")); + assertThat(result.getValue()).containsExactly(BidderBid.of(expectedBid, banner, "USD")); } @Test public void makeBidsShouldReturnCorrectBidIfAdMarkTypeIsImgAndParametersAreEmpty() throws JsonProcessingException { // given - final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", "Img"); - final HttpCall httpCall = givenHttpCall(BidRequest.builder() - .imp(singletonList(Imp.builder().id("123").build())) - .build(), - mapper.writeValueAsString( - givenBidResponse(bidBuilder -> bidBuilder.impid("123").adm("{\"image\":{\"img\":{\"url\":\"" - + "//prebid-test.smaatolabs.net/img/320x50.jpg\",\"ctaurl\":\"" - + "//prebid-test.smaatolabs.net/track/ctaurl/1\"},\"impressiontrackers\":[\"" - + "//prebid-test.smaatolabs.net/track/imp/1\",\"//prebid-test.smaatolabs.net/track/" - + "imp/2\"],\"clicktrackers\":[\"//prebid-test.smaatolabs.net/track/click/1\",\"" - + "//prebid-test.smaatolabs.net/track/click/2\"]}}"))), headers); + final String adm = "{\"image\":{\"img\":{\"url\":\"" + + "//prebid-test.smaatolabs.net/img/320x50.jpg\",\"ctaurl\":\"" + + "//prebid-test.smaatolabs.net/track/ctaurl/1\"},\"impressiontrackers\":[\"" + + "//prebid-test.smaatolabs.net/track/imp/1\",\"//prebid-test.smaatolabs.net/track/" + + "imp/2\"],\"clicktrackers\":[\"//prebid-test.smaatolabs.net/track/click/1\",\"" + + "//prebid-test.smaatolabs.net/track/click/2\"]}}"; + + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.adm(adm))), + MultiMap.caseInsensitiveMultiMap().set("X-Smt-Adtype", "Img")); // when final Result> result = smaatoBidder.makeBids(httpCall, null); // then - final Bid expectedBid = Bid.builder() - .impid("123") - .adm("
\"\"\"\"
") + + "track/imp/2\" alt=\"\" width=\"0\" height=\"0\"/>"; + + final Bid expectedBid = Bid.builder() + .impid("123") + .adm(expectedAdm) + .ext(mapper.valueToTree(ExtPrebid.of(ExtBidPrebid.builder().build(), null))) + .exp(300) .build(); + assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()) - .containsOnly(BidderBid.of(expectedBid, banner, "USD")); + assertThat(result.getValue()).containsExactly(BidderBid.of(expectedBid, banner, "USD")); } @Test public void makeBidsShouldReturnCorrectBidIfAdMarkTypeIsVideo() throws JsonProcessingException { // given - final MultiMap headers = MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", "Video"); - final HttpCall httpCall = givenHttpCall(BidRequest.builder() - .imp(singletonList(Imp.builder().id("123").build())) - .build(), - mapper.writeValueAsString( - givenBidResponse(bidBuilder -> bidBuilder.impid("123").adm(""))), headers); + final HttpCall httpCall = givenHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> + bidBuilder + .adm("") + .cat(singletonList("Category1")) + .ext(mapper.valueToTree(SmaatoBidExt.of(100))))), + MultiMap.caseInsensitiveMultiMap().set("X-SMT-ADTYPE", "Video")); // when final Result> result = smaatoBidder.makeBids(httpCall, null); @@ -460,10 +898,14 @@ public void makeBidsShouldReturnCorrectBidIfAdMarkTypeIsVideo() throws JsonProce final Bid expectedBid = Bid.builder() .impid("123") .adm("Video") + .cat(singletonList("Category1")) + .ext(mapper.valueToTree(ExtPrebid.of(ExtBidPrebid.builder() + .video(ExtBidPrebidVideo.of(100, "Category1")).build(), null))) + .exp(300) .build(); + assertThat(result.getErrors()).isEmpty(); - assertThat(result.getValue()) - .containsOnly(BidderBid.of(expectedBid, video, "USD")); + assertThat(result.getValue()).containsExactly(BidderBid.of(expectedBid, video, "USD")); } @Test @@ -480,12 +922,38 @@ public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws Jso assertThat(result.getValue()).isEmpty(); } - private static BidRequest givenBidRequest( + private static BidRequest givenVideoBidRequest(Function impCustomizer) { + return givenVideoBidRequest(identity(), impCustomizer); + } + + private static BidRequest givenVideoBidRequest( Function bidRequestCustomizer, - Function impCustomizer) { + Function... impCustomizers) { + return bidRequestCustomizer.apply(BidRequest.builder() + .site(Site.builder().build()) + .app(App.builder().build()) + .imp(Arrays.stream(impCustomizers) + .map(SmaatoBidderTest::givenVideoImp) + .collect(Collectors.toList()))) + .ext(ExtRequest.of(ExtRequestPrebid.builder() + .pbs(ExtRequestPrebidPbs.of(Endpoint.openrtb2_video.value())) + .build())) + .build(); + } + private static BidRequest givenBidRequest() { + return givenBidRequest(identity()); + } + + private static BidRequest givenBidRequest( + Function bidRequestCustomizer, + Function... impCustomizers) { return bidRequestCustomizer.apply(BidRequest.builder() - .imp(singletonList(givenImp(impCustomizer)))) + .site(Site.builder().build()) + .app(App.builder().build()) + .imp(Arrays.stream(impCustomizers) + .map(SmaatoBidderTest::givenImp) + .collect(Collectors.toList()))) .build(); } @@ -493,22 +961,30 @@ private static BidRequest givenBidRequest(Function impCustomizer) { + return impCustomizer.apply(givenImp(identity()).toBuilder() + .video(Video.builder().build())) + .build(); + } + private static Imp givenImp(Function impCustomizer) { return impCustomizer.apply(Imp.builder() - .id("123") - .banner(Banner.builder() - .id("banner_id") - .w(300) - .h(500) - .build()) - .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpSmaato.of("publisherId", "adspaceId"))))) + .id("123") + .banner(Banner.builder() + .id("banner_id") + .w(300) + .h(500) + .build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpSmaato.of("publisherId", "adspaceId", "adbreakId"))))) .build(); } - private static BidResponse givenBidResponse(Function bidCustomizer) { + private static BidResponse givenBidResponse(UnaryOperator bidCustomizer) { return BidResponse.builder() .cur("USD") - .seatbid(singletonList(SeatBid.builder().bid(singletonList(bidCustomizer.apply(Bid.builder()).build())) + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(bidCustomizer.apply(Bid.builder().impid("123")).build())) .build())) .build(); } diff --git a/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-auction-smaato-response.json b/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-auction-smaato-response.json index b296d1d15da..886a9d8a04d 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-auction-smaato-response.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-auction-smaato-response.json @@ -7,16 +7,15 @@ "adm": "
\"\"\"\"
", "cid": "cid", "crid": "crid", + "id": "bid_id", + "impid": "imp_id", + "price": 0.01, "ext": { - "format": "BANNER", "prebid": { "type": "banner" }, "origbidcpm": 0.01 - }, - "id": "bid_id", - "impid": "imp_id", - "price": 0.01 + } } ], "group": 0, diff --git a/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-smaato-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-smaato-bid-request.json index 9cac449e820..e69898c274e 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-smaato-bid-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/smaato/test-smaato-bid-request.json @@ -11,8 +11,6 @@ } ], "site": { - "domain": "www.example.com", - "page": "http://www.example.com", "publisher": { "id": "11000" } @@ -32,6 +30,6 @@ } }, "ext": { - "client": "prebid_server_0.2" + "client": "prebid_server_0.4" } } \ No newline at end of file