diff --git a/.github/workflows/scripts/codepath-notification b/.github/workflows/scripts/codepath-notification index 7884219ef5b..7fc4dbe2eba 100644 --- a/.github/workflows/scripts/codepath-notification +++ b/.github/workflows/scripts/codepath-notification @@ -25,3 +25,4 @@ thetradedesk|TheTradeDesk: Prebid-Maintainers@thetradedesk.com gumgum|GumGum: prebid@gumgum.com kargo|Kargo: kraken@kargo.com bidwave|Bidwave: prebid@bidwave.net +ocm|Ocm: devs-subs@orangeclickmedia.com diff --git a/src/main/java/org/prebid/server/bidder/ocm/OcmBidder.java b/src/main/java/org/prebid/server/bidder/ocm/OcmBidder.java new file mode 100644 index 00000000000..e7cc4a8fd2e --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/ocm/OcmBidder.java @@ -0,0 +1,437 @@ +package org.prebid.server.bidder.ocm; + +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.ObjectNode; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Dooh; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.User; +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.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtPublisher; +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.ocm.ExtImpOcm; +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.BidderUtil; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.ObjectUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Routes bid requests to the Orange Click Media exchange, which is itself a Prebid Server holding + * stored impression configurations. The placementId param therefore names an impression-level + * stored request rather than a plain slot, and publisherId selects the account that resolves it. + */ +public class OcmBidder implements Bidder { + + private static final TypeReference> OCM_EXT_TYPE_REFERENCE = new TypeReference<>() { + }; + + private static final String BIDDER_FIELD = "bidder"; + private static final String PREBID_FIELD = "prebid"; + private static final String STORED_REQUEST_FIELD = "storedrequest"; + private static final String ID_FIELD = "id"; + private static final String DEFAULT_CURRENCY = "USD"; + + /** + * Headroom deducted from tmax when no impression sets tmaxBufferMs. It covers the host's own + * processing, the round trip to the OCM endpoint and that endpoint's response preparation. + */ + private static final long DEFAULT_TMAX_BUFFER_MS = 250L; + + private static final Set SUPPORTED_BID_TYPES = Set.of(BidType.banner, BidType.video, BidType.xNative); + + private final String endpointUrl; + private final JacksonMapper mapper; + + public OcmBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + /** + * Packs every impression into a single request. The publisherId param becomes the request-level + * publisher id, so all impressions must agree on it; a divergent value is a misconfiguration + * that rejects the whole request rather than a single impression. + *

+ * The OCM endpoint is another Prebid Server, so tmax is reduced by a buffer before forwarding; + * see {@link #reduceTmax(Long, long)}. + */ + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List modifiedImps = new ArrayList<>(); + String publisherId = null; + Integer tmaxBufferMs = null; + + for (Imp imp : request.getImp()) { + final ExtImpOcm extImpOcm; + try { + extImpOcm = parseImpExt(imp); + validateImpExt(extImpOcm, imp.getId()); + publisherId = resolvePublisherId(publisherId, extImpOcm.getPublisherId(), imp.getId()); + } catch (PreBidException e) { + return Result.withError(BidderError.badInput(e.getMessage())); + } + + tmaxBufferMs = resolveTmaxBufferMs(tmaxBufferMs, extImpOcm.getTmaxBufferMs()); + modifiedImps.add(modifyImp(imp, extImpOcm.getPlacementId())); + } + + final long bufferMs = tmaxBufferMs != null ? tmaxBufferMs : DEFAULT_TMAX_BUFFER_MS; + final BidRequest modifiedRequest = modifyBidRequest(request, publisherId, bufferMs, modifiedImps); + return Result.withValue(BidderUtil.defaultRequest(modifiedRequest, endpointUrl, mapper)); + } + + private ExtImpOcm parseImpExt(Imp imp) { + final ObjectNode impExt = imp.getExt(); + final ExtImpOcm extImpOcm; + try { + extImpOcm = impExt != null + ? mapper.mapper().convertValue(impExt, OCM_EXT_TYPE_REFERENCE).getBidder() + : null; + } catch (IllegalArgumentException e) { + throw new PreBidException("Error parsing imp.ext for impression " + imp.getId()); + } + + if (extImpOcm == null) { + throw new PreBidException("Error parsing imp.ext for impression " + imp.getId()); + } + return extImpOcm; + } + + /** + * The params schema already enforces presence of both params; the blank checks here cover + * values the schema accepts but the OCM endpoint cannot resolve. + */ + private static void validateImpExt(ExtImpOcm extImpOcm, String impId) { + if (StringUtils.isBlank(extImpOcm.getPublisherId())) { + throw new PreBidException("imp %s: publisherId is required and must not be blank".formatted(impId)); + } + if (StringUtils.isBlank(extImpOcm.getPlacementId())) { + throw new PreBidException("imp %s: placementId is required and must not be blank".formatted(impId)); + } + } + + private static String resolvePublisherId(String publisherId, String impPublisherId, String impId) { + if (publisherId == null) { + return impPublisherId; + } + if (!publisherId.equals(impPublisherId)) { + throw new PreBidException( + "imp %s: all impressions must share the same publisherId, found \"%s\" and \"%s\"" + .formatted(impId, impPublisherId, publisherId)); + } + return publisherId; + } + + /** + * tmax is request-level, so the largest buffer any impression asks for wins: it errs toward + * answering before the host's deadline. + */ + private static Integer resolveTmaxBufferMs(Integer tmaxBufferMs, Integer impTmaxBufferMs) { + if (impTmaxBufferMs == null || impTmaxBufferMs < 0) { + return tmaxBufferMs; + } + return tmaxBufferMs == null ? impTmaxBufferMs : Math.max(tmaxBufferMs, impTmaxBufferMs); + } + + /** + * Consumes imp.ext.bidder and records placementId as the impression's stored-request id, where + * the OCM endpoint looks for it. The surrounding ext is merged rather than replaced so that + * members this adapter knows nothing about survive the round trip. + */ + private static Imp modifyImp(Imp imp, String placementId) { + final ObjectNode modifiedImpExt = imp.getExt().deepCopy(); + modifiedImpExt.remove(BIDDER_FIELD); + getOrCreateObject(modifiedImpExt, PREBID_FIELD) + .putObject(STORED_REQUEST_FIELD) + .put(ID_FIELD, placementId); + + return imp.toBuilder().ext(modifiedImpExt).build(); + } + + private static ObjectNode getOrCreateObject(ObjectNode parent, String field) { + final JsonNode child = parent.get(field); + return child != null && child.isObject() ? (ObjectNode) child : parent.putObject(field); + } + + /** + * Writes publisherId to every distribution channel present rather than to just one: the OCM + * endpoint resolves its account from whichever channel it ranks first, so each one must name the + * OCM publisher. + */ + private static BidRequest modifyBidRequest(BidRequest request, + String publisherId, + long tmaxBufferMs, + List modifiedImps) { + + return request.toBuilder() + .imp(modifiedImps) + .user(modifyUser(request.getUser())) + .tmax(reduceTmax(request.getTmax(), tmaxBufferMs)) + .ext(withoutHostOnlyData(request.getExt())) + .site(modifySite(request.getSite(), publisherId)) + .app(modifyApp(request.getApp(), publisherId)) + .dooh(modifyDooh(request.getDooh(), publisherId)) + .build(); + } + + /** + * Moves the OCM buyer UID into user.id. The host resolved user.buyeruid for OCM, so it is the OCM + * exchange's own identifier for the user. Left in buyeruid, the OCM endpoint would hand it to every + * bidder in the stored placement as that bidder's own identifier, since a set buyeruid takes + * precedence there over every other UID source. + */ + private static User modifyUser(User user) { + final String buyeruid = ObjectUtil.getIfNotNull(user, User::getBuyeruid); + if (StringUtils.isBlank(buyeruid)) { + return user; + } + + return user.toBuilder().id(buyeruid).buyeruid(null).build(); + } + + /** + * Reduces tmax by bufferMs. The receiving Prebid Server gives its own bidders the full tmax it is + * sent, so forwarding the host's value unchanged would land the response after the host's deadline + * by the round trip plus the downstream overhead. The result never exceeds the original tmax and, + * as long as the original allows it, never drops below the buffer, so a tight budget is not cut to + * nothing. + *

+ * An absent or non-positive tmax means the request set none; it is forwarded unchanged so the + * receiving Prebid Server applies its own default. + */ + private static Long reduceTmax(Long tmax, long bufferMs) { + if (tmax == null || tmax <= 0) { + return tmax; + } + return Math.min(Math.max(tmax - bufferMs, bufferMs), tmax); + } + + /** + * Drops request-level data that only the host can act on, so the OCM endpoint, itself a Prebid + * Server, does not act on it a second time: + *

    + *
  • storedrequest and profiles were resolved and merged into this request by the host. Forwarded, + * the OCM endpoint would look the host's IDs up under the OCM account instead, and an ID that does + * not resolve there rejects the whole downstream auction — before any impression placement can be + * used.
  • + *
  • bidderparams holds only this adapter's global params, already merged into the impressions. + * Forwarded, the OCM endpoint would merge them into every stored impression, re-adding the OCM + * bidder this adapter has just removed.
  • + *
  • bidadjustmentfactors and bidadjustments hold only this adapter's adjustments, which the host + * applies to the bids it gets back. They are keyed by the requested bidder name, which may be a + * request-defined alias the OCM endpoint does not know, and an unknown bidder in + * bidadjustmentfactors rejects the whole downstream auction.
  • + *
+ * Everything else in the extension is left untouched. + */ + private static ExtRequest withoutHostOnlyData(ExtRequest requestExt) { + final ExtRequestPrebid prebid = ObjectUtil.getIfNotNull(requestExt, ExtRequest::getPrebid); + if (prebid == null || !hasHostOnlyData(prebid)) { + return requestExt; + } + + final ExtRequest modifiedRequestExt = ExtRequest.of(prebid.toBuilder() + .storedrequest(null) + .profiles(null) + .bidderparams(null) + .bidadjustmentfactors(null) + .bidadjustments(null) + .build()); + modifiedRequestExt.addProperties(requestExt.getProperties()); + return modifiedRequestExt; + } + + private static boolean hasHostOnlyData(ExtRequestPrebid prebid) { + return prebid.getStoredrequest() != null + || prebid.getProfiles() != null + || prebid.getBidderparams() != null + || prebid.getBidadjustmentfactors() != null + || prebid.getBidadjustments() != null; + } + + private static Site modifySite(Site site, String publisherId) { + return site != null + ? site.toBuilder().publisher(modifyPublisher(site.getPublisher(), publisherId)).build() + : null; + } + + private static App modifyApp(App app, String publisherId) { + return app != null + ? app.toBuilder().publisher(modifyPublisher(app.getPublisher(), publisherId)).build() + : null; + } + + private static Dooh modifyDooh(Dooh dooh, String publisherId) { + return dooh != null + ? dooh.toBuilder().publisher(modifyPublisher(dooh.getPublisher(), publisherId)).build() + : null; + } + + private static Publisher modifyPublisher(Publisher publisher, String publisherId) { + return Optional.ofNullable(publisher) + .map(Publisher::toBuilder) + .orElseGet(Publisher::builder) + .id(publisherId) + .ext(withoutParentAccount(ObjectUtil.getIfNotNull(publisher, Publisher::getExt))) + .build(); + } + + /** + * Drops the originating host's parent account, which is meaningless to the OCM endpoint, while + * retaining every other publisher extension member. + */ + private static ExtPublisher withoutParentAccount(ExtPublisher publisherExt) { + if (publisherExt == null || publisherExt.getPrebid() == null) { + return publisherExt; + } + + final Map properties = publisherExt.getProperties(); + if (properties.isEmpty()) { + return null; + } + + final ExtPublisher modifiedPublisherExt = ExtPublisher.empty(); + modifiedPublisherExt.addProperties(properties); + return modifiedPublisherExt; + } + + /** + * Bids whose media type cannot be resolved are skipped and reported, so one malformed bid does + * not discard the rest of the seat. + */ + @Override + public final Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + final String body = httpCall.getResponse().getBody(); + if (StringUtils.isBlank(body)) { + return Result.empty(); + } + + try { + final BidResponse bidResponse = mapper.decodeValue(body, BidResponse.class); + final List errors = new ArrayList<>(); + final List bids = extractBids(bidResponse, errors); + return Result.of(bids, errors); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private List extractBids(BidResponse bidResponse, List errors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + + final String currency = StringUtils.defaultIfBlank(bidResponse.getCur(), DEFAULT_CURRENCY); + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .map(bid -> makeBidderBid(bid, currency, errors)) + .filter(Objects::nonNull) + .toList(); + } + + private BidderBid makeBidderBid(Bid bid, String currency, List errors) { + final ExtBidPrebid extBidPrebid = parseBidExtPrebid(bid); + + final BidType bidType; + try { + bidType = resolveBidType(bid, extBidPrebid); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + return null; + } + + return BidderBid.builder() + .bid(bid) + .type(bidType) + .bidCurrency(currency) + .videoInfo(videoInfo(bidType, extBidPrebid)) + .build(); + } + + private ExtBidPrebid parseBidExtPrebid(Bid bid) { + final ObjectNode bidExt = bid.getExt(); + final JsonNode prebidNode = bidExt != null ? bidExt.get(PREBID_FIELD) : null; + if (prebidNode == null || !prebidNode.isObject()) { + return null; + } + + try { + return mapper.mapper().treeToValue(prebidNode, ExtBidPrebid.class); + } catch (JsonProcessingException e) { + return null; + } + } + + /** + * Resolves the media type from bid.mtype, which is authoritative whenever it is set. + *

+ * Not every upstream populates it: a Prebid Server passes through whatever mtype its winning + * adapter set and reports the type it resolved in bid.ext.prebid.type instead, so treating an + * absent mtype as fatal would discard perfectly good bids. A set-but-unsupported mtype is + * rejected outright and never allowed to fall through to the extension, which would otherwise + * let an ext of "banner" reclassify an audio bid. + */ + private static BidType resolveBidType(Bid bid, ExtBidPrebid extBidPrebid) { + final Integer mtype = bid.getMtype(); + if (mtype == null || mtype == 0) { + return Optional.ofNullable(extBidPrebid) + .map(ExtBidPrebid::getType) + .filter(SUPPORTED_BID_TYPES::contains) + .orElseThrow(() -> new PreBidException( + "unable to determine media type for bid \"%s\": no mtype and no ext.prebid.type" + .formatted(bid.getId()))); + } + + return switch (mtype) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + case 4 -> BidType.xNative; + default -> throw new PreBidException( + "unsupported mtype %d for bid \"%s\"".formatted(mtype, bid.getId())); + }; + } + + /** + * Returns the targeting metadata supplied by the receiving Prebid Server for video bids. + * Invalid, unrelated and non-video bid extensions are ignored. + */ + private static ExtBidPrebidVideo videoInfo(BidType bidType, ExtBidPrebid extBidPrebid) { + return bidType == BidType.video + ? ObjectUtil.getIfNotNull(extBidPrebid, ExtBidPrebid::getVideo) + : null; + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ocm/ExtImpOcm.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ocm/ExtImpOcm.java new file mode 100644 index 00000000000..0975c58d3f2 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ocm/ExtImpOcm.java @@ -0,0 +1,30 @@ +package org.prebid.server.proto.openrtb.ext.request.ocm; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpOcm { + + /** + * Orange Click Media publisher identifier. It is propagated to the request-level publisher id, + * so every impression in a single request must carry the same value. + */ + @JsonProperty("publisherId") + String publisherId; + + /** + * Names the impression's stored request on the OCM endpoint, which expands it into the + * impression's real configuration. + */ + @JsonProperty("placementId") + String placementId; + + /** + * Deducted from the request tmax before it is forwarded. The OCM endpoint is itself a Prebid Server + * that would otherwise spend the whole budget on its own bidders, leaving no room for the round trip + * back. Null selects the adapter default; an explicit 0 disables the buffer. + */ + @JsonProperty("tmaxBufferMs") + Integer tmaxBufferMs; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/OcmConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/OcmConfiguration.java new file mode 100644 index 00000000000..33b5d2661ea --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/OcmConfiguration.java @@ -0,0 +1,35 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.ocm.OcmBidder; +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.env.YamlPropertySourceFactory; +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; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/ocm.yaml", factory = YamlPropertySourceFactory.class) +public class OcmConfiguration { + + private static final String BIDDER_NAME = "ocm"; + + @Bean("ocmConfigurationProperties") + @ConfigurationProperties("adapters.ocm") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps ocmBidderDeps(BidderConfigurationProperties ocmConfigurationProperties, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(ocmConfigurationProperties) + .bidderCreator(config -> new OcmBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/ocm.yaml b/src/main/resources/bidder-config/ocm.yaml new file mode 100644 index 00000000000..721fcd5063f --- /dev/null +++ b/src/main/resources/bidder-config/ocm.yaml @@ -0,0 +1,34 @@ +# The endpoint is itself a Prebid Server, so this bidder is disabled by default: +# enabled on that server, a stored request naming ocm would call itself. +# To use it, set `adapters.ocm.enabled: true` in the application config +# (or ADAPTERS_OCM_ENABLED=true). +adapters: + ocm: + enabled: false + ortb-version: "2.6" + endpoint: https://pbam.orangeclickmedia.com/openrtb2/auction + endpoint-compression: gzip + geoscope: + - global + ortb: + multiformat-supported: true + meta-info: + maintainer-email: devs-subs@orangeclickmedia.com + app-media-types: + - banner + - video + - native + site-media-types: + - banner + - video + - native + supported-vendors: + vendor-id: 1148 + usersync: + cookie-family-name: ocm + redirect: + url: https://pbam.orangeclickmedia.com/usersync/redirect?gdpr={gdpr}&gdpr_consent={gdpr_consent}&us_privacy={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: '$UID' + iframe: + url: https://pbam.orangeclickmedia.com/usersync/iframe?gdpr={gdpr}&gdpr_consent={gdpr_consent}&us_privacy={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: '$UID' diff --git a/src/main/resources/static/bidder-params/ocm.json b/src/main/resources/static/bidder-params/ocm.json new file mode 100644 index 00000000000..22bd0d33559 --- /dev/null +++ b/src/main/resources/static/bidder-params/ocm.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "OCM Adapter Params", + "description": "A schema which validates params accepted by the OCM adapter", + "type": "object", + "properties": { + "publisherId": { + "type": "string", + "minLength": 1, + "description": "Orange Click Media publisher identifier" + }, + "placementId": { + "type": "string", + "minLength": 1, + "description": "Placement identifier, resolved as the impression's stored request" + }, + "tmaxBufferMs": { + "type": "integer", + "minimum": 0, + "description": "Milliseconds deducted from tmax before forwarding, to cover network and processing overhead; default 250" + } + }, + "required": [ + "publisherId", + "placementId" + ] +} diff --git a/src/test/java/org/prebid/server/bidder/ocm/OcmBidderTest.java b/src/test/java/org/prebid/server/bidder/ocm/OcmBidderTest.java new file mode 100644 index 00000000000..e70fe4239a6 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/ocm/OcmBidderTest.java @@ -0,0 +1,1319 @@ +package org.prebid.server.bidder.ocm; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Dooh; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.User; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtPublisher; +import org.prebid.server.proto.openrtb.ext.request.ExtPublisherPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestBidAdjustmentFactors; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtStoredRequest; +import org.prebid.server.proto.openrtb.ext.request.ocm.ExtImpOcm; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidVideo; + +import java.math.BigDecimal; +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; + +public class OcmBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://test-endpoint.url/openrtb2/auction"; + + private final OcmBidder target = new OcmBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new OcmBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenImpExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp( + imp -> imp.id("imp1").ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).isEqualTo("Error parsing imp.ext for impression imp1"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenPublisherIdIsBlank() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt(" ", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()) + .isEqualTo("imp imp1: publisherId is required and must not be blank"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenPlacementIdIsBlank() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", " ")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()) + .isEqualTo("imp imp1: placementId is required and must not be blank"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReportPublisherIdFirstWhenBothParamsAreBlank() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").ext(givenImpExt(null, null)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .containsExactly("imp imp1: publisherId is required and must not be blank"); + } + + @Test + public void makeHttpRequestsShouldReturnErrorAndNoRequestsWhenPublisherIdsDiffer() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder().domain("example.gr").build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1"))), + givenImp(imp -> imp.id("imp2").ext(givenImpExt("publisher-9", "placement-2")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).isEqualTo("imp imp2: all impressions must share the same" + + " publisherId, found \"publisher-9\" and \"publisher-1\""); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldSetStoredRequestIdAndRemoveBidderFromImpExt() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .allSatisfy(ext -> { + assertThat(ext.has("bidder")).isFalse(); + assertThat(ext.get("prebid").get("storedrequest").get("id").textValue()) + .isEqualTo("placement-1"); + }); + } + + @Test + public void makeHttpRequestsShouldPreserveUnknownImpExtMembers() { + // given + final ObjectNode impExt = givenImpExt("publisher-1", "placement-1"); + impExt.put("gpid", "/1234/homepage#top"); + impExt.put("tid", "b4c9a1de-0000-4000-8000-000000000001"); + impExt.putObject("data").put("pbadslot", "/1234/homepage#top"); + impExt.putObject("prebid").put("is_rewarded_inventory", 1); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").ext(impExt))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .allSatisfy(ext -> { + assertThat(ext.get("gpid").textValue()).isEqualTo("/1234/homepage#top"); + assertThat(ext.get("tid").textValue()).isEqualTo("b4c9a1de-0000-4000-8000-000000000001"); + assertThat(ext.get("data").get("pbadslot").textValue()).isEqualTo("/1234/homepage#top"); + assertThat(ext.get("prebid").get("is_rewarded_inventory").intValue()).isEqualTo(1); + assertThat(ext.get("prebid").get("storedrequest").get("id").textValue()) + .isEqualTo("placement-1"); + }); + } + + @Test + public void makeHttpRequestsShouldOverwriteExistingStoredRequest() { + // given + final ObjectNode impExt = givenImpExt("publisher-1", "placement-1"); + impExt.putObject("prebid").putObject("storedrequest").put("id", "stale"); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").ext(impExt))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(imp -> imp.getExt().get("prebid").get("storedrequest").get("id").textValue()) + .containsExactly("placement-1"); + } + + @Test + public void makeHttpRequestsShouldSetSitePublisherIdAndKeepOtherPublisherFields() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder() + .domain("example.gr") + .publisher(Publisher.builder().id("publisher-from-request").name("Example").build()) + .build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getSite) + .extracting(Site::getPublisher) + .extracting(Publisher::getId, Publisher::getName) + .containsExactly(tuple("publisher-1", "Example")); + } + + @Test + public void makeHttpRequestsShouldCreatePublisherWhenSiteHasNone() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder().domain("example.gr").build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getSite().getPublisher()) + .extracting(Publisher::getId, Publisher::getExt) + .containsExactly(tuple("publisher-1", null)); + } + + @Test + public void makeHttpRequestsShouldRemoveParentAccountAndKeepOtherPublisherExtProperties() { + // given + final ExtPublisher publisherExt = ExtPublisher.of(ExtPublisherPrebid.of("origin-host")); + publisherExt.addProperty("unrelated", mapper.createObjectNode().put("value", 1)); + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder() + .publisher(Publisher.builder().id("publisher-from-request").ext(publisherExt).build()) + .build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getSite().getPublisher().getExt()) + .allSatisfy(ext -> { + assertThat(ext.getPrebid()).isNull(); + assertThat(ext.getProperty("unrelated").get("value").intValue()).isEqualTo(1); + }); + } + + @Test + public void makeHttpRequestsShouldDropPublisherExtWhenOnlyParentAccountPresent() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder() + .publisher(Publisher.builder() + .ext(ExtPublisher.of(ExtPublisherPrebid.of("origin-host"))) + .build()) + .build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getSite().getPublisher().getExt()) + .containsOnlyNulls(); + } + + @Test + public void makeHttpRequestsShouldSetAppPublisherIdWhenSiteIsAbsent() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.app(App.builder().bundle("com.example.app").build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getApp().getPublisher().getId()) + .containsExactly("publisher-1"); + } + + @Test + public void makeHttpRequestsShouldSetDoohPublisherIdAndRemoveParentAccount() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.dooh(Dooh.builder() + .name("billboard") + .publisher(Publisher.builder() + .id("host-publisher") + .ext(ExtPublisher.of(ExtPublisherPrebid.of("host-account"))) + .build()) + .build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getDooh) + .containsExactly(Dooh.builder() + .name("billboard") + .publisher(Publisher.builder().id("publisher-1").build()) + .build()); + } + + @Test + public void makeHttpRequestsShouldSetPublisherIdOnEveryChannelPresent() { + // given + final Publisher hostPublisher = Publisher.builder() + .id("host-publisher") + .ext(ExtPublisher.of(ExtPublisherPrebid.of("host-account"))) + .build(); + final BidRequest bidRequest = givenBidRequest( + request -> request + .site(Site.builder().domain("example.gr").publisher(hostPublisher).build()) + .app(App.builder().bundle("com.example.app").publisher(hostPublisher).build()) + .dooh(Dooh.builder().name("billboard").publisher(hostPublisher).build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + final Publisher expectedPublisher = Publisher.builder().id("publisher-1").build(); + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting( + payload -> payload.getSite().getPublisher(), + payload -> payload.getApp().getPublisher(), + payload -> payload.getDooh().getPublisher()) + .containsExactly(tuple(expectedPublisher, expectedPublisher, expectedPublisher)); + } + + @Test + public void makeHttpRequestsShouldRemoveRequestLevelStoredRequest() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.ext(ExtRequest.of(ExtRequestPrebid.builder() + .storedrequest(ExtStoredRequest.of("host-stored-request")) + .debug(1) + .build())), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getExt().getPrebid()) + .extracting(ExtRequestPrebid::getStoredrequest, ExtRequestPrebid::getDebug) + .containsExactly(tuple(null, 1)); + } + + @Test + public void makeHttpRequestsShouldPreserveRequestExtPropertiesWhenRemovingStoredRequest() { + // given + final ExtRequest requestExt = ExtRequest.of(ExtRequestPrebid.builder() + .storedrequest(ExtStoredRequest.of("host-stored-request")) + .build()); + requestExt.addProperty("unrelated", mapper.createObjectNode().put("value", 1)); + final BidRequest bidRequest = givenBidRequest( + request -> request.ext(requestExt), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .allSatisfy(ext -> { + assertThat(ext.getPrebid().getStoredrequest()).isNull(); + assertThat(ext.getProperty("unrelated").get("value").intValue()).isEqualTo(1); + }); + } + + @Test + public void makeHttpRequestsShouldRemoveRequestLevelProfiles() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.ext(ExtRequest.of(ExtRequestPrebid.builder() + .profiles(asList("host-profile-1", "host-profile-2")) + .debug(1) + .build())), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getExt().getPrebid()) + .extracting(ExtRequestPrebid::getProfiles, ExtRequestPrebid::getDebug) + .containsExactly(tuple(null, 1)); + } + + @Test + public void makeHttpRequestsShouldRemoveGlobalBidderParams() { + // given + final ObjectNode bidderParams = mapper.createObjectNode(); + bidderParams.putObject("ocm").put("publisherId", "publisher-1"); + final BidRequest bidRequest = givenBidRequest( + request -> request.ext(ExtRequest.of(ExtRequestPrebid.builder() + .bidderparams(bidderParams) + .debug(1) + .build())), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getExt().getPrebid()) + .extracting(ExtRequestPrebid::getBidderparams, ExtRequestPrebid::getDebug) + .containsExactly(tuple(null, 1)); + } + + @Test + public void makeHttpRequestsShouldRemoveBidAdjustmentFactors() { + // given + final ExtRequestBidAdjustmentFactors bidAdjustmentFactors = ExtRequestBidAdjustmentFactors.builder().build(); + bidAdjustmentFactors.addFactor("myocm", BigDecimal.valueOf(0.8)); + final BidRequest bidRequest = givenBidRequest( + request -> request.ext(ExtRequest.of(ExtRequestPrebid.builder() + .bidadjustmentfactors(bidAdjustmentFactors) + .debug(1) + .build())), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getExt().getPrebid()) + .extracting(ExtRequestPrebid::getBidadjustmentfactors, ExtRequestPrebid::getDebug) + .containsExactly(tuple(null, 1)); + } + + @Test + public void makeHttpRequestsShouldRemoveBidAdjustments() { + // given + final ObjectNode bidAdjustments = mapper.createObjectNode(); + bidAdjustments.putObject("mediatype").putObject("banner").putArray("myocm"); + final BidRequest bidRequest = givenBidRequest( + request -> request.ext(ExtRequest.of(ExtRequestPrebid.builder() + .bidadjustments(bidAdjustments) + .debug(1) + .build())), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getExt().getPrebid()) + .extracting(ExtRequestPrebid::getBidadjustments, ExtRequestPrebid::getDebug) + .containsExactly(tuple(null, 1)); + } + + @Test + public void makeHttpRequestsShouldKeepRequestExtWhenItHasNoHostOnlyData() { + // given + final ExtRequest requestExt = ExtRequest.of(ExtRequestPrebid.builder().debug(1).build()); + final BidRequest bidRequest = givenBidRequest( + request -> request.ext(requestExt), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .containsExactly(requestExt); + } + + @Test + public void makeHttpRequestsShouldTolerateAbsentRequestExt() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .containsOnlyNulls(); + } + + @Test + public void makeHttpRequestsShouldMoveBuyeruidToUserIdAndKeepOtherUserFields() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.user(User.builder() + .id("host-user-id") + .buyeruid("ocm-uid") + .keywords("sports") + .build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getUser) + .containsExactly(User.builder().id("ocm-uid").keywords("sports").build()); + } + + @Test + public void makeHttpRequestsShouldKeepUserWhenBuyeruidIsBlank() { + // given + final User user = User.builder().id("host-user-id").buyeruid(" ").build(); + final BidRequest bidRequest = givenBidRequest( + request -> request.user(user), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getUser) + .containsExactly(user); + } + + @Test + public void makeHttpRequestsShouldTolerateAbsentUser() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getUser) + .containsOnlyNulls(); + } + + @Test + public void makeHttpRequestsShouldNotMutateIncomingRequest() { + // given + final Publisher publisher = Publisher.builder().id("publisher-from-request").build(); + final ObjectNode impExt = givenImpExt("publisher-1", "placement-1"); + final ExtRequest requestExt = ExtRequest.of(ExtRequestPrebid.builder() + .storedrequest(ExtStoredRequest.of("host-stored-request")) + .build()); + final User user = User.builder().id("host-user-id").buyeruid("ocm-uid").build(); + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder().publisher(publisher).build()) + .user(user) + .ext(requestExt) + .tmax(1000L), + givenImp(imp -> imp.id("imp1").ext(impExt))); + + // when + target.makeHttpRequests(bidRequest); + + // then + assertThat(bidRequest.getTmax()).isEqualTo(1000L); + assertThat(bidRequest.getUser()).isSameAs(user); + assertThat(user.getId()).isEqualTo("host-user-id"); + assertThat(user.getBuyeruid()).isEqualTo("ocm-uid"); + assertThat(bidRequest.getSite().getPublisher().getId()).isEqualTo("publisher-from-request"); + assertThat(publisher.getId()).isEqualTo("publisher-from-request"); + assertThat(bidRequest.getImp().getFirst().getExt().has("bidder")).isTrue(); + assertThat(bidRequest.getImp().getFirst().getExt().has("prebid")).isFalse(); + assertThat(requestExt.getPrebid().getStoredrequest()).isEqualTo(ExtStoredRequest.of("host-stored-request")); + } + + @Test + public void makeHttpRequestsShouldReturnSingleRequestWithAllImpIds() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder().domain("example.gr").build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1"))), + givenImp(imp -> imp.id("imp2").ext(givenImpExt("publisher-1", "placement-2")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + assertThat(result.getValue()) + .flatExtracting(HttpRequest::getImpIds) + .containsExactlyInAnyOrder("imp1", "imp2"); + assertThat(result.getValue()) + .extracting(HttpRequest::getUri) + .containsExactly(ENDPOINT_URL); + } + + @Test + public void makeHttpRequestsShouldDeductDefaultBufferFromTmax() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(1000L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(750L); + } + + @Test + public void makeHttpRequestsShouldDeductCustomBufferFromTmax() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(1000L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1", 400)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(600L); + } + + @Test + public void makeHttpRequestsShouldNotChangeTmaxWhenBufferIsZero() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(1000L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1", 0)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(1000L); + } + + @Test + public void makeHttpRequestsShouldUseLargestBufferAcrossImps() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(1000L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1", 100))), + givenImp(imp -> imp.id("imp2").ext(givenImpExt("publisher-1", "placement-2"))), + givenImp(imp -> imp.id("imp3").ext(givenImpExt("publisher-1", "placement-3", 300)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(700L); + } + + @Test + public void makeHttpRequestsShouldIgnoreNegativeBuffer() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(1000L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1", -100)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(750L); + } + + @Test + public void makeHttpRequestsShouldNotReduceTmaxBelowBuffer() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(300L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(250L); + } + + @Test + public void makeHttpRequestsShouldKeepTmaxWhenSmallerThanBuffer() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(200L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(200L); + } + + @Test + public void makeHttpRequestsShouldKeepTmaxWhenAbsent() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsNull(); + } + + @Test + public void makeHttpRequestsShouldKeepTmaxWhenZero() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.tmax(0L), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getTmax) + .containsExactly(0L); + } + + @Test + public void makeBidsShouldReturnEmptyResultForEmptyBody() { + // given + final BidderCall httpCall = givenHttpCallWithBody(""); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyResultForEmptyJsonObjectBody() { + // given + final BidderCall httpCall = givenHttpCallWithBody("{}"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCallWithBody("invalid_json"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response)); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyResultWhenSeatBidIsEmpty() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + BidResponse.builder().cur("USD").seatbid(List.of()).build()); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldUseResponseCurrency() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("EUR", givenBid(bid -> bid.mtype(1))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBidCurrency) + .containsExactly("EUR"); + } + + @Test + public void makeBidsShouldDefaultCurrencyToUsd() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, givenBid(bid -> bid.mtype(1))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBidCurrency) + .containsExactly("USD"); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenMtypeIsOne() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.mtype(1))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.banner); + } + + @Test + public void makeBidsShouldReturnVideoBidWhenMtypeIsTwo() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.mtype(2))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.video); + } + + @Test + public void makeBidsShouldReturnNativeBidWhenMtypeIsFour() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.mtype(4))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.xNative); + } + + @Test + public void makeBidsShouldPreferMtypeOverExtType() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.mtype(1).ext(givenBidExtWithType("video")))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.banner); + } + + @Test + public void makeBidsShouldReturnErrorWhenMtypeIsUnsupportedEvenIfExtTypeIsSupported() throws + JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.mtype(3).ext(givenBidExtWithType("banner")))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).isEqualTo("unsupported mtype 3 for bid \"bid-1\""); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldResolveBannerTypeFromExtWhenMtypeIsAbsent() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.ext(givenBidExtWithType("banner")))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.banner); + } + + @Test + public void makeBidsShouldResolveVideoTypeFromExtWhenMtypeIsAbsent() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.ext(givenBidExtWithType("video")))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.video); + } + + @Test + public void makeBidsShouldResolveNativeTypeFromExtWhenMtypeIsAbsent() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.ext(givenBidExtWithType("native")))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.xNative); + } + + @Test + public void makeBidsShouldReturnErrorWhenMtypeIsAbsentAndExtTypeIsAudio() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.ext(givenBidExtWithType("audio")))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).isEqualTo( + "unable to determine media type for bid \"bid-1\": no mtype and no ext.prebid.type"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorWhenMtypeIsAbsentAndBidExtIsMissing() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", givenBid(identity())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .containsExactly( + "unable to determine media type for bid \"bid-1\": no mtype and no ext.prebid.type"); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldSkipOnlyBidsWithUnresolvableMediaType() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.id("bid-1").mtype(1)), + givenBid(bid -> bid.id("bid-2"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .containsExactly( + "unable to determine media type for bid \"bid-2\": no mtype and no ext.prebid.type"); + assertThat(result.getValue()) + .extracting(bidderBid -> bidderBid.getBid().getId()) + .containsExactly("bid-1"); + } + + @Test + public void makeBidsShouldSkipOnlyBidsWithUnsupportedMtype() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.id("bid-banner-1").mtype(1)), + givenBid(bid -> bid.id("bid-audio-1").mtype(3))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()) + .extracting(BidderError::getType, BidderError::getMessage) + .containsExactly(tuple( + BidderError.Type.bad_server_response, + "unsupported mtype 3 for bid \"bid-audio-1\"")); + assertThat(result.getValue()) + .extracting(bidderBid -> bidderBid.getBid().getId(), BidderBid::getType) + .containsExactly(tuple("bid-banner-1", BidType.banner)); + } + + @Test + public void makeBidsShouldPopulateVideoInfoFromBidExt() throws JsonProcessingException { + // given + final ObjectNode bidExt = givenBidExtWithType("video"); + ((ObjectNode) bidExt.get("prebid")).putObject("video") + .put("duration", 30) + .put("primary_category", "IAB1-2"); + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.mtype(2).ext(bidExt))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getVideoInfo) + .containsExactly(ExtBidPrebidVideo.of(30, "IAB1-2")); + } + + @Test + public void makeBidsShouldPopulateVideoInfoWhenTypeResolvesToVideoFromExt() throws JsonProcessingException { + // given + final ObjectNode bidExt = givenBidExtWithType("video"); + ((ObjectNode) bidExt.get("prebid")).putObject("video") + .put("duration", 15) + .put("primary_category", "IAB1-7"); + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.ext(bidExt))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getVideoInfo) + .containsExactly(ExtBidPrebidVideo.of(15, "IAB1-7")); + } + + @Test + public void makeBidsShouldNotPopulateVideoInfoForNonVideoBid() throws JsonProcessingException { + // given + final ObjectNode bidExt = givenBidExtWithType("video"); + ((ObjectNode) bidExt.get("prebid")).putObject("video") + .put("duration", 30) + .put("primary_category", "IAB1-2"); + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.mtype(1).ext(bidExt))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType, BidderBid::getVideoInfo) + .containsExactly(tuple(BidType.banner, null)); + } + + @Test + public void makeBidsShouldNotPopulateVideoInfoWhenVideoBidExtHasNone() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.mtype(2))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getVideoInfo) + .containsOnlyNulls(); + } + + @Test + public void makeBidsShouldResolveTypeFromExtWhenMtypeIsZero() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall("USD", + givenBid(bid -> bid.mtype(0).ext(givenBidExtWithType("banner")))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.banner); + } + + @Test + public void makeBidsShouldReturnErrorWhenBidExtPrebidIsNotAnObject() throws JsonProcessingException { + // given + final ObjectNode bidExt = mapper.createObjectNode(); + bidExt.put("prebid", "not-an-object"); + final BidderCall httpCall = givenHttpCall("USD", givenBid(bid -> bid.ext(bidExt))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .containsExactly( + "unable to determine media type for bid \"bid-1\": no mtype and no ext.prebid.type"); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenImpExtIsAbsent() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).isEqualTo("Error parsing imp.ext for impression imp1"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldKeepPublisherExtWhenItHasNoPrebid() { + // given + final ExtPublisher publisherExt = ExtPublisher.empty(); + publisherExt.addProperty("unrelated", mapper.createObjectNode().put("value", 1)); + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder() + .publisher(Publisher.builder().ext(publisherExt).build()) + .build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("publisher-1", "placement-1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getSite().getPublisher().getExt()) + .containsExactly(publisherExt); + } + + private static BidRequest givenBidRequest(UnaryOperator bidRequestCustomizer, + Imp... imps) { + + return bidRequestCustomizer.apply(BidRequest.builder().imp(asList(imps))).build(); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return givenBidRequest(identity(), imps); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder()).build(); + } + + private static ObjectNode givenImpExt(String publisherId, String placementId) { + return givenImpExt(publisherId, placementId, null); + } + + private static ObjectNode givenImpExt(String publisherId, String placementId, Integer tmaxBufferMs) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpOcm.of(publisherId, placementId, tmaxBufferMs))); + } + + private static Bid givenBid(UnaryOperator bidCustomizer) { + return bidCustomizer.apply(Bid.builder() + .id("bid-1") + .impid("imp1") + .price(BigDecimal.valueOf(1.85))) + .build(); + } + + private static ObjectNode givenBidExtWithType(String type) { + final ObjectNode bidExt = mapper.createObjectNode(); + bidExt.putObject("prebid").put("type", type); + return bidExt; + } + + private static BidderCall givenHttpCallWithBody(String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().build(), + HttpResponse.of(200, null, body), + null); + } + + private static BidderCall givenHttpCall(BidResponse bidResponse) throws JsonProcessingException { + return givenHttpCallWithBody(mapper.writeValueAsString(bidResponse)); + } + + private static BidderCall givenHttpCall(String currency, Bid... bids) + throws JsonProcessingException { + + return givenHttpCall(BidResponse.builder() + .cur(currency) + .seatbid(singletonList(SeatBid.builder().seat("ocm").bid(asList(bids)).build())) + .build()); + } +} diff --git a/src/test/java/org/prebid/server/it/OcmTest.java b/src/test/java/org/prebid/server/it/OcmTest.java new file mode 100644 index 00000000000..7ea3ab1e119 --- /dev/null +++ b/src/test/java/org/prebid/server/it/OcmTest.java @@ -0,0 +1,32 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Collections.singletonList; + +public class OcmTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromOcm() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/ocm-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/ocm/test-ocm-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/ocm/test-ocm-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/ocm/test-auction-ocm-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/ocm/test-auction-ocm-response.json", response, singletonList("ocm")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-auction-ocm-request.json b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-auction-ocm-request.json new file mode 100644 index 00000000000..c5ed7b1d39e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-auction-ocm-request.json @@ -0,0 +1,24 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "ocm": { + "publisherId": "ocm-test-publisher", + "placementId": "ocm-test-placement" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-auction-ocm-response.json b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-auction-ocm-response.json new file mode 100644 index 00000000000..1b7a6793c66 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-auction-ocm-response.json @@ -0,0 +1,42 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 1.85, + "adm": "

banner
", + "crid": "crid1", + "w": 300, + "h": 250, + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "ocm" + } + }, + "origbidcpm": 1.85, + "origbidcur": "USD" + } + } + ], + "seat": "ocm", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "ocm": "{{ ocm.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-ocm-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-ocm-bid-request.json new file mode 100644 index 00000000000..78c26e11a19 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-ocm-bid-request.json @@ -0,0 +1,58 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "secure": 1, + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "tid": "${json-unit.any-string}", + "prebid": { + "storedrequest": { + "id": "ocm-test-placement" + } + } + } + } + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "id": "ocm-test-publisher", + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "regs": { + "gdpr": 0 + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method": "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-ocm-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-ocm-bid-response.json new file mode 100644 index 00000000000..2d247ea530e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/ocm/test-ocm-bid-response.json @@ -0,0 +1,21 @@ +{ + "id": "request_id", + "cur": "USD", + "seatbid": [ + { + "seat": "ocm", + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 1.85, + "adm": "
banner
", + "crid": "crid1", + "w": 300, + "h": 250, + "mtype": 1 + } + ] + } + ] +} 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 cc1a749d4ba..6222d688682 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -448,6 +448,8 @@ adapters.nexx360.aliases.ybidder.enabled=true adapters.nexx360.aliases.ybidder.endpoint=http://localhost:8090/ybidder-exchange adapters.nobid.enabled=true adapters.nobid.endpoint=http://localhost:8090/nobid-exchange +adapters.ocm.enabled=true +adapters.ocm.endpoint=http://localhost:8090/ocm-exchange adapters.ogury.enabled=true adapters.ogury.endpoint=http://localhost:8090/ogury-exchange adapters.oms.enabled=true