toEids() {
+ return Optional.ofNullable(ids)
+ .map(userIds -> userIds.values().stream().map(UserId::eid).toList())
+ .orElse(Collections.emptyList());
+ }
+}
diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java
new file mode 100644
index 00000000000..adefde62023
--- /dev/null
+++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java
@@ -0,0 +1,30 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.model;
+
+import org.prebid.server.auction.model.AuctionContext;
+
+import javax.validation.constraints.NotNull;
+import java.util.Optional;
+
+/**
+ * Provider for ID5 Partner ID that can return different values based on the auction context.
+ *
+ * Implement this interface to provide dynamic Partner IDs based on account ID, channel, or other criteria.
+ * Register your implementation as a Spring bean with @Component or in a @Configuration class.
+ *
+ * The default implementation {@link ConstantId5PartnerId} returns a constant value from configuration.
+ *
+ * If {@link #getPartnerId(AuctionContext)} returns {@link Optional#empty()}, the ID5 fetch will be skipped
+ * for that request.
+ */
+public interface Id5PartnerIdProvider {
+
+ /**
+ * Returns the ID5 Partner ID for the given auction context.
+ *
+ * @param auctionContext the auction context containing account, request, and privacy information
+ * @return Optional containing the Partner ID, or empty to skip the ID5 fetch for this request
+ */
+ @NotNull
+ Optional getPartnerId(AuctionContext auctionContext);
+
+}
diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java
new file mode 100644
index 00000000000..d405c134977
--- /dev/null
+++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java
@@ -0,0 +1,15 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.model;
+
+import com.iab.openrtb.request.Eid;
+
+import java.util.Collections;
+import java.util.List;
+
+public record Id5UserId(List eids) {
+
+ private static final Id5UserId EMPTY = new Id5UserId(Collections.emptyList());
+
+ public static Id5UserId empty() {
+ return EMPTY;
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java
new file mode 100644
index 00000000000..93e7dcca3ce
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java
@@ -0,0 +1,539 @@
+package org.prebid.server.hooks.modules.id5.userid.fetch;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.iab.openrtb.request.App;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Publisher;
+import com.iab.openrtb.request.Site;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import io.vertx.core.Future;
+import io.vertx.core.MultiMap;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.prebid.server.activity.Activity;
+import org.prebid.server.activity.ComponentType;
+import org.prebid.server.activity.infrastructure.ActivityInfrastructure;
+import org.prebid.server.activity.infrastructure.payload.ActivityInvocationPayload;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule;
+import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties;
+import org.prebid.server.hooks.modules.id5.userid.v1.fetch.HttpFetchClient;
+import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchResponse;
+import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.privacy.ccpa.Ccpa;
+import org.prebid.server.privacy.gdpr.model.TcfContext;
+import org.prebid.server.privacy.model.Privacy;
+import org.prebid.server.privacy.model.PrivacyContext;
+import org.prebid.server.proto.openrtb.ext.request.ExtDevice;
+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.ExtRequestPrebidChannel;
+import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidData;
+import org.prebid.server.settings.model.Account;
+import org.prebid.server.util.VersionInfo;
+import org.prebid.server.vertx.httpclient.HttpClient;
+import org.prebid.server.vertx.httpclient.model.HttpClientResponse;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class HttpFetchClientTest {
+
+ private static final String URL = "http://example.test/fetch";
+
+ private JacksonMapper mapper;
+ private HttpClient httpClient;
+ private VersionInfo versionInfo;
+ private Clock fixedClock;
+ private Id5IdModuleProperties props;
+ private UserFpdActivityMask userFpdActivityMask;
+ private ActivityInfrastructure activityInfrastructure;
+
+ @BeforeEach
+ public void setUp() {
+ mapper = new JacksonMapper(new ObjectMapper());
+ httpClient = Mockito.mock(HttpClient.class);
+ versionInfo = Mockito.mock(VersionInfo.class);
+ when(versionInfo.getVersion()).thenReturn("1.2.3");
+ fixedClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC);
+ props = new Id5IdModuleProperties();
+ props.setProviderName("pbs");
+ activityInfrastructure = Mockito.mock(ActivityInfrastructure.class);
+ when(activityInfrastructure.isAllowed(any(), any())).thenReturn(true);
+ userFpdActivityMask = Mockito.mock(UserFpdActivityMask.class);
+ when(userFpdActivityMask.maskDevice(any(), any(Boolean.class), any(Boolean.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ when(userFpdActivityMask.maskUser(any(), any(Boolean.class), any(Boolean.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ }
+
+ @Test
+ public void shouldReturnEmptyOnNon200Response() {
+ // given
+ final long partnerId = 123L;
+ final String expectedUrl = URL + "/" + partnerId + ".json";
+ when(httpClient.post(eq(expectedUrl), any(MultiMap.class), anyString(), anyLong()))
+ .thenReturn(Future.succeededFuture(
+ HttpClientResponse.of(503, MultiMap.caseInsensitiveMultiMap(), "oops")
+ ));
+
+ final HttpFetchClient client = new HttpFetchClient(
+ URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build());
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), false);
+
+ // when
+ final Id5UserId result = client.fetch(partnerId, payload, invocation).result();
+
+ // then
+ assertThat(result.eids()).isEmpty();
+ }
+
+ @Test
+ public void shouldReturnEmptyOnException() {
+ // given
+ final long partnerId = 123L;
+ final String expectedUrl = URL + "/" + partnerId + ".json";
+ final String unparsableBody = "invalid response";
+ when(httpClient.post(eq(expectedUrl), any(MultiMap.class), anyString(), anyLong()))
+ .thenReturn(Future.succeededFuture(
+ HttpClientResponse.of(200, MultiMap.caseInsensitiveMultiMap(), unparsableBody)
+ ));
+
+ final HttpFetchClient client = new HttpFetchClient(
+ URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build());
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), false);
+
+ // when
+ final Id5UserId result = client.fetch(partnerId, payload, invocation).result();
+
+ // then
+ assertThat(result.eids()).isEmpty();
+ }
+
+ @Test
+ public void shouldParseSuccessfulResponse() {
+ // given
+ final Eid eid = Eid.builder()
+ .source("id5-sync.com")
+ .uids(List.of(Uid.builder().id("id5-xyz").build()))
+ .build();
+ final FetchResponse response = new FetchResponse(java.util.Map.of("id5", new FetchResponse.UserId(eid)));
+ final String body = mapper.encodeToString(response);
+ final String expectedUrl123b = URL + "/123.json";
+ when(httpClient.post(eq(expectedUrl123b), any(MultiMap.class), anyString(), anyLong()))
+ .thenReturn(Future.succeededFuture(
+ HttpClientResponse.of(200, MultiMap.caseInsensitiveMultiMap(), body)
+ ));
+
+ final HttpFetchClient client = new HttpFetchClient(URL, httpClient,
+ fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build());
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), false);
+
+ // when
+ final Id5UserId result = client.fetch(123L, payload, invocation).result();
+
+ // then
+ assertThat(result.eids()).hasSize(1);
+ assertThat(result.eids().getFirst().getSource()).isEqualTo("id5-sync.com");
+ }
+
+ @Test
+ public void shouldBuildRequestWithExpectedFieldsAndUseTimeout() {
+ // given
+ final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ final ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class);
+ final long remainingTime = 100L;
+ final Timeout timeout = mock(Timeout.class);
+ when(timeout.remaining()).thenReturn(remainingTime);
+ when(httpClient.post(anyString(), headersCaptor.capture(), bodyCaptor.capture(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200,
+ MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null)))));
+
+ final HttpFetchClient client = new HttpFetchClient(URL, httpClient,
+ fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final BidRequest bidRequest = BidRequest.builder()
+ .app(App.builder().bundle("com.example.app").build())
+ .site(Site.builder().domain("example.com").ref("https://ref.example").build())
+ .device(Device.builder()
+ .ifa("ifa-123")
+ .ua("UA/1.0")
+ .ip("203.0.113.10")
+ .ipv6("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
+ .ext(ExtDevice.of(3, null))
+ .build())
+ .build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final Privacy privacy = Privacy.builder()
+ .gdpr("1")
+ .consentString("CONSENT_STRING")
+ .ccpa(Ccpa.of("1YNN"))
+ .coppa(1)
+ .gpp("GPP_STRING")
+ .gppSid(List.of(7, 8))
+ .build();
+ final AuctionContext auctionContext = AuctionContext.builder()
+ .account(Account.builder().id("acc-1").build())
+ .privacyContext(PrivacyContext.of(privacy, TcfContext.empty()))
+ .build();
+
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ timeout,
+ auctionContext,
+ false);
+
+ // when
+ client.fetch(999L, payload, invocation).result();
+
+ // then
+ final MultiMap headers = headersCaptor.getValue();
+ final String contentType = headers.get("Content-Type");
+ assertThat(contentType).isEqualTo("application/json;charset=utf-8");
+
+ // then: request body
+ final String captured = bodyCaptor.getValue();
+ final Map json = mapper.decodeValue(captured, new TypeReference<>() {
+ });
+ assertThat(((Number) json.get("partner")).longValue()).isEqualTo(999L);
+ assertThat(json.get("version")).isEqualTo("1.2.3");
+ assertThat(json.get("bundle")).isEqualTo("com.example.app");
+ assertThat(json.get("domain")).isEqualTo("example.com");
+ assertThat(json.get("maid")).isEqualTo("ifa-123");
+ assertThat(json.get("ua")).isEqualTo("UA/1.0");
+ assertThat(json.get("ref")).isEqualTo("https://ref.example");
+ assertThat(json.get("ipv4")).isEqualTo("203.0.113.10");
+ assertThat(json.get("ipv6")).isEqualTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
+ assertThat(json.get("att")).isEqualTo("3");
+ assertThat(json.get("gdpr")).isEqualTo("1");
+ assertThat(json.get("gdpr_consent")).isEqualTo("CONSENT_STRING");
+ assertThat(json.get("us_privacy")).isEqualTo("1YNN");
+ assertThat(json.get("coppa")).isEqualTo("1");
+ assertThat(json.get("gpp_string")).isEqualTo("GPP_STRING");
+ assertThat(json.get("gpp_sid")).isEqualTo("7,8");
+ assertThat(json.get("origin")).isEqualTo("pbs-java");
+ assertThat(json.get("provider")).isEqualTo("pbs");
+ assertThat(json.get("ts")).isEqualTo("2025-01-01T00:00:00Z");
+ assertThat(json.get("_trace")).isEqualTo(false);
+
+ verify(httpClient, times(1)).post(eq(URL + "/999.json"),
+ any(MultiMap.class), anyString(), eq(remainingTime));
+ }
+
+ @Test
+ public void shouldSetTraceWhenDebugEnabled() {
+ // given
+ final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ final Timeout timeout = mock(Timeout.class);
+ when(timeout.remaining()).thenReturn(100L);
+ when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200,
+ MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null)))));
+
+ final HttpFetchClient client = new HttpFetchClient(
+ URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), true);
+
+ // when
+ client.fetch(999L,
+ AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()), invocation).result();
+
+ // then: __trace should be true
+ final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() {
+ });
+ assertThat(json.get("_trace")).isEqualTo(true);
+ }
+
+ @Test
+ public void shouldHandleEmptyGppSidList() {
+ // given
+ final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200,
+ MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null)))));
+
+ final HttpFetchClient client = new HttpFetchClient(URL, httpClient,
+ fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final Privacy privacy = Privacy.builder()
+ .gpp("GPP_STRING")
+ .gppSid(List.of()) // Empty list should result in null gpp_sid
+ .build();
+ final AuctionContext auctionContext = AuctionContext.builder()
+ .account(Account.builder().id("acc-1").build())
+ .privacyContext(PrivacyContext.of(privacy, TcfContext.empty()))
+ .build();
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext, false);
+
+ // when
+ client.fetch(999L,
+ AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()), invocation).result();
+
+ // then: gpp_sid should be null when list is empty (not empty string "")
+ final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() {
+ });
+ assertThat(json.get("gpp_string")).isEqualTo("GPP_STRING");
+ assertThat(json.get("gpp_sid")).isNull();
+ }
+
+ @Test
+ public void shouldIncludePublisherFromSite() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .site(Site.builder().publisher(testPublisher()).build())
+ .build();
+
+ // when
+ final Map json = fetchAndCaptureBody(bidRequest);
+
+ // then
+ assertPublisherIncluded(json);
+ }
+
+ @Test
+ public void shouldIncludePublisherFromApp() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .app(App.builder().publisher(testPublisher()).build())
+ .build();
+
+ // when
+ final Map json = fetchAndCaptureBody(bidRequest);
+
+ // then
+ assertPublisherIncluded(json);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void shouldIncludeAllProviderMetadataFieldsWhenAllPresent() {
+ // given
+ final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200,
+ MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null)))));
+
+ final Id5IdModuleProperties moduleProps = new Id5IdModuleProperties();
+ moduleProps.setProviderName("comprehensive-provider");
+ moduleProps.setPartner(789L);
+
+ final HttpFetchClient client = new HttpFetchClient(URL, httpClient,
+ fixedClock, versionInfo, moduleProps, userFpdActivityMask);
+
+ final BidRequest bidRequest = BidRequest.builder()
+ .app(App.builder()
+ .publisher(Publisher.builder()
+ .id("pub-789")
+ .name("Comprehensive Publisher")
+ .build())
+ .build())
+ .ext(ExtRequest.of(ExtRequestPrebid.builder()
+ .channel(ExtRequestPrebidChannel.of("mobile-app", "3.5"))
+ .data(ExtRequestPrebidData.of(List.of("bidder1", "bidder2"), null))
+ .build()))
+ .build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext("acc-1"), false);
+
+ // when
+ client.fetch(999L, payload, invocation).result();
+
+ // then
+ final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() {
+ });
+ final Map metadata = (Map) json.get("providerMetadata");
+
+ // Verify all fields are present
+ assertThat(metadata.get("id5ModuleConfig")).isNotNull();
+ assertThat(metadata.get("publisher")).isNotNull();
+ assertThat(metadata.get("channel")).isEqualTo("mobile-app");
+ assertThat(metadata.get("channelVersion")).isEqualTo("3.5");
+ assertThat(metadata.get("bidders")).isNotNull();
+
+ final Map config = (Map) metadata.get("id5ModuleConfig");
+ assertThat(config.get("providerName")).isEqualTo("comprehensive-provider");
+ assertThat(((Number) config.get("partner")).longValue()).isEqualTo(789L);
+
+ final Map publisher = (Map) metadata.get("publisher");
+ assertThat(publisher.get("id")).isEqualTo("pub-789");
+
+ final List bidders = (List) metadata.get("bidders");
+ assertThat(bidders).containsExactly("bidder1", "bidder2");
+ }
+
+ @Test
+ public void shouldUseMaskedPersonalDataWhenDisallowed() {
+ // given
+ final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200,
+ MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null)))));
+
+ final Device originalDevice = Device.builder()
+ .ip("203.0.113.10")
+ .ipv6("2001:0db8:85a3::8a2e:0370:7334")
+ .build();
+
+ final Device maskedDevice = Device.builder()
+ .ip("192.0.2.1")
+ .ipv6("2001:db8::1")
+ .build();
+
+ Mockito.reset(userFpdActivityMask);
+ when(userFpdActivityMask.maskDevice(any(), any(Boolean.class), any(Boolean.class)))
+ .thenReturn(maskedDevice);
+
+ final HttpFetchClient client = new HttpFetchClient(
+ URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final BidRequest bidRequest = BidRequest.builder().device(originalDevice).build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext("acc"), false);
+
+ // when
+ client.fetch(999L, payload, invocation).result();
+
+ // then
+ final ArgumentCaptor payloadCaptor =
+ ArgumentCaptor.forClass(ActivityInvocationPayload.class);
+ verify(activityInfrastructure).isAllowed(eq(Activity.TRANSMIT_UFPD), payloadCaptor.capture());
+ verify(activityInfrastructure).isAllowed(eq(Activity.TRANSMIT_GEO), payloadCaptor.capture());
+
+ final ActivityInvocationPayload capturedPayload = payloadCaptor.getValue();
+ assertThat(capturedPayload.componentType()).isEqualTo(ComponentType.RTD_MODULE);
+ assertThat(capturedPayload.componentName()).isEqualTo(Id5IdModule.CODE);
+
+ // verify request uses masked data
+ final String captured = bodyCaptor.getValue();
+ final Map json = mapper.decodeValue(captured, new TypeReference<>() {
+ });
+ assertThat(json.get("ipv4")).isEqualTo(maskedDevice.getIp());
+ assertThat(json.get("ipv6")).isEqualTo(maskedDevice.getIpv6());
+ }
+
+ @Test
+ public void shouldMaskUserAccordingToActivityInfra() {
+ // given
+ when(httpClient.post(anyString(), any(MultiMap.class), anyString(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200,
+ MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null)))));
+
+ when(activityInfrastructure.isAllowed(eq(Activity.TRANSMIT_UFPD), any())).thenReturn(false);
+ when(activityInfrastructure.isAllowed(eq(Activity.TRANSMIT_EIDS), any())).thenReturn(false);
+
+ final HttpFetchClient client = new HttpFetchClient(
+ URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask);
+
+ final User originalUser = User.builder().id("u1").build();
+ final BidRequest bidRequest = BidRequest.builder().user(originalUser).build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext("acc"), false);
+
+ // when
+ client.fetch(999L, payload, invocation).result();
+
+ // then
+ verify(activityInfrastructure).isAllowed(eq(Activity.TRANSMIT_EIDS), any());
+ verify(userFpdActivityMask).maskUser(eq(originalUser), eq(true), eq(true));
+ }
+
+ private static AuctionContext auctionContext(String accountId) {
+ return AuctionContext.builder()
+ .account(Account.builder().id(accountId).build())
+ .privacyContext(PrivacyContext.of(Privacy.builder().build(), TcfContext.empty()))
+ .build();
+ }
+
+ private AuctionInvocationContextImpl auctionInvocationContext(Timeout timeout,
+ AuctionContext auctionContext,
+ boolean debugEnabled) {
+
+ final AuctionContext contextWithActivity = auctionContext.toBuilder()
+ .activityInfrastructure(activityInfrastructure)
+ .build();
+
+ return AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null,
+ org.prebid.server.model.Endpoint.openrtb2_auction),
+ contextWithActivity, debugEnabled, null, null);
+ }
+
+ private Map fetchAndCaptureBody(BidRequest bidRequest) {
+ final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class);
+ when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong()))
+ .thenReturn(Future.succeededFuture(HttpClientResponse.of(200,
+ MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null)))));
+
+ final HttpFetchClient client = new HttpFetchClient(URL, httpClient,
+ fixedClock, versionInfo, props, userFpdActivityMask);
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext("acc-1"), false);
+
+ client.fetch(999L, AuctionRequestPayloadImpl.of(bidRequest), invocation).result();
+
+ return mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() {
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void assertPublisherIncluded(Map json) {
+ final Map metadata = (Map) json.get("providerMetadata");
+ assertThat(metadata.get("publisher")).isNotNull();
+ final Map publisher = (Map) metadata.get("publisher");
+ assertThat(publisher.get("id")).isEqualTo("pub-123");
+ assertThat(publisher.get("name")).isEqualTo("Test Publisher");
+ assertThat(publisher.get("domain")).isEqualTo("pub.domain");
+ }
+
+ private static Publisher testPublisher() {
+ return Publisher.builder()
+ .id("pub-123")
+ .domain("pub.domain")
+ .name("Test Publisher")
+ .build();
+ }
+
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java
new file mode 100644
index 00000000000..9c0a95b83d2
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java
@@ -0,0 +1,73 @@
+package org.prebid.server.hooks.modules.id5.userid.v1;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class BidRequestUtilsTest {
+
+ @Test
+ public void shouldReturnFalseWhenUserIsNull() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder().build();
+
+ // when & then
+ assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse();
+ }
+
+ @Test
+ public void shouldReturnFalseWhenEidsIsNull() {
+ // given
+ final User user = User.builder().build(); // eids null
+ final BidRequest bidRequest = BidRequest.builder().user(user).build();
+
+ // when & then
+ assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse();
+ }
+
+ @Test
+ public void shouldReturnFalseWhenOnlyOtherSourcesPresent() {
+ // given
+ final User user = User.builder()
+ .eids(List.of(
+ Eid.builder().source("other-source").uids(List.of(Uid.builder().id("x").build())).build()))
+ .build();
+ final BidRequest bidRequest = BidRequest.builder().user(user).build();
+
+ // when & then
+ assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse();
+ }
+
+ @Test
+ public void shouldReturnTrueWhenId5SourcePresentAmongOthers() {
+ // given
+ final User user = User.builder()
+ .eids(List.of(
+ Eid.builder().source("other-source").uids(List.of(Uid.builder().id("x").build())).build(),
+ Eid.builder().source(BidRequestUtils.ID5_ID_SOURCE)
+ .uids(List.of(Uid.builder().id("id5-1").build())).build()))
+ .build();
+ final BidRequest bidRequest = BidRequest.builder().user(user).build();
+
+ // when & then
+ assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isTrue();
+ }
+
+ @Test
+ public void shouldReturnFalseWhenEidSourceIsNull() {
+ // given
+ final User user = User.builder()
+ .eids(List.of(Eid.builder().source(null).uids(List.of(Uid.builder().id("x").build())).build()))
+ .build();
+ final BidRequest bidRequest = BidRequest.builder().user(user).build();
+
+ // when & then
+ assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse();
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java
new file mode 100644
index 00000000000..34ff763b988
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java
@@ -0,0 +1,196 @@
+package org.prebid.server.hooks.modules.id5.userid.v1;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import io.vertx.core.Future;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.id5.userid.v1.fetch.HttpFetchClient;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchFilter;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult;
+import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId;
+import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5PartnerIdProvider;
+import org.prebid.server.hooks.v1.InvocationAction;
+import org.prebid.server.hooks.v1.InvocationResult;
+import org.prebid.server.hooks.v1.InvocationStatus;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.model.Endpoint;
+import org.prebid.server.settings.model.Account;
+
+import java.time.Clock;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+public class Id5IdFetchHookTest {
+
+ @Test
+ public void shouldReturnNoInvocationAndSetModuleContextWithFutureWhenSampled() {
+ // given
+ final HttpFetchClient fetchClient = Mockito.mock(HttpFetchClient.class);
+ final FetchFilter filter = Mockito.mock(FetchFilter.class);
+ final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class);
+ final Future future = Future.succeededFuture(Id5UserId.empty());
+ when(fetchClient.fetch(anyLong(), any(AuctionRequestPayload.class), any())).thenReturn(future);
+ when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())).thenReturn(FilterResult.accepted());
+ when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.of(123L));
+
+ final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider);
+
+ final BidRequest bidRequest = BidRequest.builder().id("req-1").build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final AuctionInvocationContext invocation = createAuctionContext();
+
+ // when
+ final Future> resultFuture = hook.call(payload, invocation);
+ final InvocationResult result = resultFuture.result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class);
+ final Id5IdModuleContext ctx = (Id5IdModuleContext) result.moduleContext();
+ assertThat(ctx.getId5UserIdFuture()).isSameAs(future);
+ }
+
+ @Test
+ public void shouldReturnNoInvocationWhenId5IdAlreadyPresent() {
+ // given
+ final HttpFetchClient fetchClient = Mockito.mock(HttpFetchClient.class);
+ final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class);
+ final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(), partnerIdProvider);
+
+ final User userWithId5 = User.builder()
+ .eids(List.of(Eid.builder()
+ .source("id5-sync.com")
+ .uids(List.of(Uid.builder().id("id5-xyz").build()))
+ .build()))
+ .build();
+ final BidRequest bidRequest = BidRequest.builder().user(userWithId5).build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final AuctionInvocationContext invocation = createAuctionContext();
+
+ // when
+ final InvocationResult result = hook.call(payload, invocation).result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.moduleContext()).isNull();
+ }
+
+ @Test
+ public void shouldReturnNoInvocationWhenSamplerRejects() {
+ // given
+ final HttpFetchClient fetchClient = Mockito.mock(HttpFetchClient.class);
+ final FetchFilter filter = Mockito.mock(FetchFilter.class);
+ final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class);
+ when(filter.shouldInvoke(any(AuctionRequestPayload.class), any()))
+ .thenReturn(FilterResult.rejected("rejected by sampling"));
+
+ final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider);
+
+ final BidRequest bidRequest = BidRequest.builder().id("req-2").build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final AuctionInvocationContext invocation = createAuctionContext();
+
+ // when
+ final InvocationResult result = hook.call(payload, invocation).result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.moduleContext()).isNull();
+ }
+
+ @Test
+ public void shouldReturnNoInvocationWhenAnyFetchFilterRejectsMultipleFilters() {
+ // given
+ final HttpFetchClient fetchClient = Mockito.mock(HttpFetchClient.class);
+ final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class);
+ final FetchFilter accept1 = Mockito.mock(FetchFilter.class);
+ final FetchFilter reject = Mockito.mock(FetchFilter.class);
+ final FetchFilter accept2 = Mockito.mock(FetchFilter.class);
+
+ when(accept1.shouldInvoke(any(AuctionRequestPayload.class), any()))
+ .thenReturn(FilterResult.accepted());
+ when(reject.shouldInvoke(any(AuctionRequestPayload.class), any()))
+ .thenReturn(FilterResult.rejected("block-by-second"));
+ when(accept2.shouldInvoke(any(AuctionRequestPayload.class), any()))
+ .thenReturn(FilterResult.accepted());
+
+ final Id5IdFetchHook hook = new Id5IdFetchHook(
+ fetchClient, List.of(accept1, reject, accept2), partnerIdProvider);
+
+ final BidRequest bidRequest = BidRequest.builder().id("req-3").build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final AuctionInvocationContext invocation = createAuctionContext();
+
+ // when
+ final InvocationResult result = hook.call(payload, invocation).result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.moduleContext()).isNull();
+ // ensure fetch client was not called after filter rejection
+ Mockito.verifyNoInteractions(fetchClient);
+ // reason from rejecting filter should be present in debug messages
+ assertThat(result.debugMessages()).anyMatch(m -> m.contains("block-by-second"));
+ }
+
+ @Test
+ public void shouldReturnNoInvocationWhenPartnerIdNotConfigured() {
+ // given
+ final HttpFetchClient fetchClient = Mockito.mock(HttpFetchClient.class);
+ final FetchFilter filter = Mockito.mock(FetchFilter.class);
+ final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class);
+ when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())).thenReturn(FilterResult.accepted());
+ when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.empty());
+
+ final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider);
+
+ final BidRequest bidRequest = BidRequest.builder().id("req-5").build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final AuctionInvocationContext invocation = createAuctionContext();
+
+ // when
+ final InvocationResult result = hook.call(payload, invocation).result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.moduleContext()).isNull();
+ assertThat(result.debugMessages()).anyMatch(m -> m.contains("partner id not configured"));
+ Mockito.verifyNoInteractions(fetchClient);
+ }
+
+ private static AuctionInvocationContextImpl createAuctionContext() {
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ return AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ null
+ );
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java
new file mode 100644
index 00000000000..21ae5587d53
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java
@@ -0,0 +1,253 @@
+package org.prebid.server.hooks.modules.id5.userid.v1;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import io.vertx.core.Future;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.bidder.BidderInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.InjectFilter;
+import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId;
+import org.prebid.server.hooks.v1.InvocationAction;
+import org.prebid.server.hooks.v1.InvocationResult;
+import org.prebid.server.hooks.v1.InvocationStatus;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.bidder.BidderInvocationContext;
+import org.prebid.server.hooks.v1.bidder.BidderRequestPayload;
+import org.prebid.server.model.Endpoint;
+import org.prebid.server.settings.model.Account;
+
+import java.time.Clock;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+
+public class Id5IdInjectHookTest {
+
+ @Test
+ public void shouldSkipWhenId5EidAlreadyPresent() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of());
+
+ final User userWithId5 = User.builder()
+ .eids(List.of(Eid.builder()
+ .source("id5-sync.com")
+ .uids(List.of(Uid.builder().id("abc").build()))
+ .build()))
+ .build();
+ final BidRequest bidRequest = BidRequest.builder().user(userWithId5).build();
+
+ final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty()));
+ final BidderInvocationContext bidderCtx = bidderCtx(expectedContext);
+
+ // when
+ final InvocationResult result = hook
+ .call(BidderRequestPayloadImpl.of(bidRequest), bidderCtx)
+ .result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.moduleContext()).isEqualTo(expectedContext);
+ }
+
+ @Test
+ public void shouldSkipWhenFetcherReturnsEmpty() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of());
+
+ final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build();
+
+ final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty()));
+ final BidderInvocationContext bidderCtx = bidderCtx(expectedContext);
+
+ // when
+ final InvocationResult result = hook
+ .call(BidderRequestPayloadImpl.of(bidRequest), bidderCtx)
+ .result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ assertThat(result.moduleContext()).isEqualTo(expectedContext);
+ }
+
+ @Test
+ public void shouldInjectEidsWhenFetcherReturnsIds() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of());
+
+ final BidRequest bidRequest = BidRequest.builder().user(User.builder().eids(List.of()).build()).build();
+
+ final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(singleId5()));
+ final BidderInvocationContext bidderCtx = bidderCtx(expectedContext);
+
+ // when
+ final InvocationResult result = hook
+ .call(BidderRequestPayloadImpl.of(bidRequest), bidderCtx)
+ .result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ assertThat(result.moduleContext()).isEqualTo(expectedContext);
+
+ final BidderRequestPayload updated = result.payloadUpdate().apply(BidderRequestPayloadImpl.of(bidRequest));
+ assertThat(updated.bidRequest().getUser().getEids()).hasSize(1);
+ final Eid eid = updated.bidRequest().getUser().getEids().getFirst();
+ assertThat(eid.getSource()).isEqualTo("id5-sync.com");
+ assertThat(eid.getInserter()).isEqualTo("inserterX");
+ }
+
+ @Test
+ public void shouldInjectEidsWhenUserIsNull() {
+ // when
+ final List sources = injectedEidSources(null);
+
+ // then
+ assertThat(sources).containsExactlyInAnyOrder("id5-sync.com");
+ }
+
+ @Test
+ public void shouldInjectEidsWhenUserEidsAreNull() {
+ // when
+ final List sources = injectedEidSources(User.builder().build());
+
+ // then
+ assertThat(sources).containsExactlyInAnyOrder("id5-sync.com");
+ }
+
+ @Test
+ public void shouldMergeEidsWithExistingNonId5Eids() {
+ // given
+ final User user = User.builder()
+ .eids(List.of(Eid.builder()
+ .source("other-sync.com")
+ .uids(List.of(Uid.builder().id("other-123").build()))
+ .build()))
+ .build();
+
+ // when
+ final List sources = injectedEidSources(user);
+
+ // then
+ assertThat(sources).containsExactlyInAnyOrder("other-sync.com", "id5-sync.com");
+ }
+
+ @Test
+ public void shouldReturnNoActionWhenNoModuleContextPresent() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of());
+
+ final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build();
+ final BidderInvocationContext bidderCtx = bidderCtx(null);
+
+ // when
+ final InvocationResult result = hook
+ .call(BidderRequestPayloadImpl.of(bidRequest), bidderCtx)
+ .result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ assertThat(result.payloadUpdate()).isNull();
+ assertThat(result.moduleContext()).isNull();
+ }
+
+ @Test
+ public void shouldReturnNoInvocationWhenInjectFilterRejectsSingleFilter() {
+ // given
+ final InjectFilter filter = Mockito.mock(InjectFilter.class);
+ Mockito.when(filter.shouldInvoke(any(), any())).thenReturn(FilterResult.rejected("reject-by-filter"));
+
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(filter));
+
+ final BidRequest bidRequest = BidRequest.builder().build();
+ final BidderInvocationContext bidderCtx = bidderCtx(new Id5IdModuleContext(
+ Future.succeededFuture(Id5UserId.empty())));
+
+ // when
+ final InvocationResult result = hook
+ .call(BidderRequestPayloadImpl.of(bidRequest), bidderCtx)
+ .result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.payloadUpdate()).isNull();
+ assertThat(result.debugMessages()).anyMatch(m -> m.contains("reject-by-filter"));
+ assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class);
+ assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext());
+ }
+
+ @Test
+ public void shouldReturnNoInvocationWhenAnyInjectFilterRejectsMultipleFilters() {
+ // given
+ final InjectFilter accept1 = Mockito.mock(InjectFilter.class);
+ final InjectFilter reject = Mockito.mock(InjectFilter.class);
+ final InjectFilter accept2 = Mockito.mock(InjectFilter.class);
+ Mockito.when(accept1.shouldInvoke(any(), any())).thenReturn(FilterResult.accepted());
+ Mockito.when(reject.shouldInvoke(any(), any())).thenReturn(FilterResult.rejected("block-by-second"));
+ Mockito.when(accept2.shouldInvoke(any(), any())).thenReturn(FilterResult.accepted());
+
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(accept1, reject, accept2));
+
+ final BidRequest bidRequest = BidRequest.builder().build();
+ final BidderInvocationContext bidderCtx = bidderCtx(new Id5IdModuleContext(
+ Future.succeededFuture(Id5UserId.empty())));
+
+ // when
+ final InvocationResult result = hook
+ .call(BidderRequestPayloadImpl.of(bidRequest), bidderCtx)
+ .result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.payloadUpdate()).isNull();
+ assertThat(result.debugMessages()).anyMatch(m -> m.contains("block-by-second"));
+ assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class);
+ assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext());
+ }
+
+ private static List injectedEidSources(User user) {
+ final Id5IdInjectHook hook = new Id5IdInjectHook(null, List.of());
+ final BidRequest bidRequest = BidRequest.builder().user(user).build();
+
+ final InvocationResult result = hook
+ .call(BidderRequestPayloadImpl.of(bidRequest), bidderCtx(
+ new Id5IdModuleContext(Future.succeededFuture(singleId5()))))
+ .result();
+
+ final BidderRequestPayload updated = result.payloadUpdate().apply(BidderRequestPayloadImpl.of(bidRequest));
+ return updated.bidRequest().getUser().getEids().stream().map(Eid::getSource).toList();
+ }
+
+ private static Id5UserId singleId5() {
+ return new Id5UserId(List.of(Eid.builder()
+ .source("id5-sync.com")
+ .uids(List.of(Uid.builder().id("id5-123").build()))
+ .build()));
+ }
+
+ private static BidderInvocationContext bidderCtx(Id5IdModuleContext moduleContext) {
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ moduleContext);
+ return BidderInvocationContextImpl.of(auctionCtx, "bidder");
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java
new file mode 100644
index 00000000000..82d0dc819ef
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java
@@ -0,0 +1,63 @@
+package org.prebid.server.hooks.modules.id5.userid.v1;
+
+import io.vertx.core.Future;
+import org.junit.jupiter.api.Test;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.model.Endpoint;
+import org.prebid.server.settings.model.Account;
+
+import java.time.Clock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class Id5IdModuleContextTest {
+
+ @Test
+ public void shouldReturnProvidedModuleContextWhenPresent() {
+ // given
+ final Future future = Future.succeededFuture();
+ final Id5IdModuleContext moduleContext = new Id5IdModuleContext(future);
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ moduleContext);
+
+ // when
+ final Id5IdModuleContext result = Id5IdModuleContext.from(invocation);
+
+ // then
+ assertThat(result).isSameAs(moduleContext);
+ assertThat(result.getId5UserIdFuture()).isSameAs(future);
+ }
+
+ @Test
+ public void shouldReturnEmptyModuleContextWhenAbsent() {
+ // given
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ null);
+
+ // when
+ final Id5IdModuleContext result = Id5IdModuleContext.from(invocation);
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(result.getId5UserIdFuture()).isNotNull();
+ assertThat(result.getId5UserIdFuture().succeeded()).isTrue();
+ assertThat(result.getId5UserIdFuture().result()).isNull();
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java
new file mode 100644
index 00000000000..d468e66f51a
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java
@@ -0,0 +1,62 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.config;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ValuesFilterTest {
+
+ @Test
+ public void shouldAllowAllWhenValuesNull() {
+ // given
+ final ValuesFilter filter = new ValuesFilter<>();
+ filter.setValues(null);
+ filter.setExclude(false); // default include mode
+
+ // expect
+ assertThat(filter.isValueAllowed("anything")).isTrue();
+ assertThat(filter.isValueAllowed(null)).isTrue();
+ }
+
+ @Test
+ public void shouldAllowAllWhenValuesEmpty() {
+ // given
+ final ValuesFilter filter = new ValuesFilter<>();
+ filter.setValues(Set.of());
+ filter.setExclude(false);
+
+ // expect
+ assertThat(filter.isValueAllowed("x")).isTrue();
+ assertThat(filter.isValueAllowed("y")).isTrue();
+ }
+
+ @Test
+ public void shouldWhitelistAllowOnlyListedWhenExcludeFalse() {
+ // given
+ final ValuesFilter filter = new ValuesFilter<>();
+ filter.setExclude(false); // allowlist semantics
+ filter.setValues(Set.of("a", "b"));
+
+ // expect
+ assertThat(filter.isValueAllowed("a")).isTrue();
+ assertThat(filter.isValueAllowed("b")).isTrue();
+ assertThat(filter.isValueAllowed("c")).isFalse();
+ assertThat(filter.isValueAllowed(null)).isFalse();
+ }
+
+ @Test
+ public void shouldBlacklistRejectListedWhenExcludeTrue() {
+ // given
+ final ValuesFilter filter = new ValuesFilter<>();
+ filter.setExclude(true); // blocklist semantics
+ filter.setValues(Set.of("blocked", "forbidden"));
+
+ // expect
+ assertThat(filter.isValueAllowed("blocked")).isFalse();
+ assertThat(filter.isValueAllowed("forbidden")).isFalse();
+ assertThat(filter.isValueAllowed("other")).isTrue();
+ assertThat(filter.isValueAllowed(null)).isFalse();
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java
new file mode 100644
index 00000000000..8f9a5885e57
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java
@@ -0,0 +1,64 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.filter;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.model.Endpoint;
+import org.prebid.server.settings.model.Account;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+
+import java.time.Clock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@SuppressWarnings("unchecked")
+public class AccountFetchFilterTest {
+
+ @Test
+ public void shouldAcceptWhenAccountAllowed() {
+ final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class);
+ when(valuesFilter.isValueAllowed(eq("acc-2"))).thenReturn(true);
+ final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter);
+
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null);
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc-2").build()).build(),
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, ctx);
+ assertThat(result.isAccepted()).isTrue();
+ }
+
+ @Test
+ public void shouldRejectWhenAccountNotAllowed() {
+ final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class);
+ when(valuesFilter.isValueAllowed(eq("acc-3"))).thenReturn(false);
+ final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter);
+
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null);
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc-3").build()).build(),
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, ctx);
+ assertThat(result.isAccepted()).isFalse();
+ assertThat(result.reason()).contains("account acc-3 rejected");
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java
new file mode 100644
index 00000000000..e04acae6f12
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java
@@ -0,0 +1,104 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.filter;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Geo;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.model.Endpoint;
+import org.prebid.server.settings.model.Account;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+
+import java.time.Clock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@SuppressWarnings("unchecked")
+public class CountryFetchFilterTest {
+
+ @Test
+ public void shouldUseDeviceGeoCountry() {
+ final ValuesFilter vf = Mockito.mock(ValuesFilter.class);
+ when(vf.isValueAllowed("US")).thenReturn(true);
+ final CountryFetchFilter filter = new CountryFetchFilter(vf);
+
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().geo(Geo.builder().country("US").build()).build())
+ .build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionContext auctionContext = AuctionContext.builder()
+ .account(Account.builder().id("acc").build())
+ .build();
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ auctionContext,
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, invocation);
+ assertThat(result.isAccepted()).isTrue();
+ }
+
+ @Test
+ public void shouldRejectWhenCountryMissing() {
+ final ValuesFilter vf = Mockito.mock(ValuesFilter.class);
+ final CountryFetchFilter filter = new CountryFetchFilter(vf);
+
+ final BidRequest bidRequest = BidRequest.builder().build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionContext auctionContext = AuctionContext.builder()
+ .account(Account.builder().id("acc").build())
+ .build();
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ auctionContext,
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, invocation);
+ assertThat(result.isAccepted()).isFalse();
+ assertThat(result.reason()).contains("missing country");
+ }
+
+ @Test
+ public void shouldRejectWhenCountryNotAllowed() {
+ final ValuesFilter vf = Mockito.mock(ValuesFilter.class);
+ when(vf.isValueAllowed("US")).thenReturn(false);
+ final CountryFetchFilter filter = new CountryFetchFilter(vf);
+
+ final BidRequest bidRequest = BidRequest.builder()
+ .device(Device.builder().geo(Geo.builder().country("US").build()).build())
+ .build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionContext auctionContext = AuctionContext.builder()
+ .account(Account.builder().id("acc").build())
+ .build();
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ auctionContext,
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, invocation);
+ assertThat(result.isAccepted()).isFalse();
+ assertThat(result.reason()).contains("country US rejected");
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java
new file mode 100644
index 00000000000..e31bd914db7
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java
@@ -0,0 +1,66 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.filter;
+
+import org.junit.jupiter.api.Test;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.model.Endpoint;
+import org.prebid.server.settings.model.Account;
+
+import java.time.Clock;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class SamplingFetchFilterTest {
+
+ @Test
+ public void shouldAcceptWhenRandomBelowRate() {
+ // given
+ final Supplier random = () -> 0.1d;
+ final SamplingFetchFilter filter = new SamplingFetchFilter(random, 0.25d);
+
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null);
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ null);
+
+ // when
+ final FilterResult result = filter.shouldInvoke(payload, invocation);
+
+ // then
+ assertThat(result.isAccepted()).isTrue();
+ }
+
+ @Test
+ public void shouldRejectWhenRandomAboveRate() {
+ // given
+ final Supplier random = () -> 0.9d;
+ final SamplingFetchFilter filter = new SamplingFetchFilter(random, 0.5d);
+
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null);
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ null);
+
+ // when
+ final FilterResult result = filter.shouldInvoke(payload, invocation);
+
+ // then
+ assertThat(result.isAccepted()).isFalse();
+ assertThat(result.reason()).contains("sampling");
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java
new file mode 100644
index 00000000000..9bb597da709
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java
@@ -0,0 +1,67 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.filter;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.execution.v1.InvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.bidder.BidderInvocationContextImpl;
+import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl;
+import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+import org.prebid.server.model.Endpoint;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.settings.model.Account;
+import org.prebid.server.execution.timeout.TimeoutFactory;
+
+import java.time.Clock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@SuppressWarnings("unchecked")
+public class SelectedBidderFilterTest {
+
+ @Test
+ public void shouldAcceptWhenBidderAllowed() {
+ final ValuesFilter vf = Mockito.mock(ValuesFilter.class);
+ when(vf.isValueAllowed(any())).thenReturn(true);
+ final SelectedBidderFilter filter = new SelectedBidderFilter(vf);
+
+ final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("rubicon"));
+ assertThat(result.isAccepted()).isTrue();
+ }
+
+ @Test
+ public void shouldRejectWhenBidderNotAllowed() {
+ final ValuesFilter vf = Mockito.mock(ValuesFilter.class);
+ when(vf.isValueAllowed(any())).thenReturn(false);
+ final SelectedBidderFilter filter = new SelectedBidderFilter(vf);
+
+ final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("pubmatic"));
+ assertThat(result.isAccepted()).isFalse();
+ assertThat(result.reason()).contains("bidder pubmatic rejected");
+ }
+
+ @Test
+ public void shouldDelegateDecisionToValuesFilter() {
+ final ValuesFilter vf = Mockito.mock(ValuesFilter.class);
+ when(vf.isValueAllowed("anything")).thenReturn(true);
+ final SelectedBidderFilter filter = new SelectedBidderFilter(vf);
+
+ final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("anything"));
+ assertThat(result.isAccepted()).isTrue();
+ }
+
+ private static BidderInvocationContextImpl bidderCtx(String bidder) {
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, null, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ null);
+ return BidderInvocationContextImpl.of(auctionCtx, bidder);
+ }
+}
diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml
index 473a433b61d..10a42ff1968 100644
--- a/extra/modules/pom.xml
+++ b/extra/modules/pom.xml
@@ -29,6 +29,7 @@
optable-targeting
wurfl-devicedetection
live-intent-omni-channel-identity
+ id5-user-id
diff --git a/sample/configs/localdev-config-wiremock.yaml b/sample/configs/localdev-config-wiremock.yaml
new file mode 100644
index 00000000000..6bef32cc64f
--- /dev/null
+++ b/sample/configs/localdev-config-wiremock.yaml
@@ -0,0 +1,26 @@
+status-response: "ok"
+adapters:
+ generic:
+ enabled: true
+ endpoint: http://localhost:8091/generic-exchange
+cache:
+ scheme: http
+ host: localhost
+ path: /cache
+ query: uuid=
+settings:
+ enforce-valid-account: false
+ filesystem:
+ settings-filename: sample/configs/sample-app-settings.yaml
+ stored-requests-dir: sample/stored
+ stored-imps-dir: sample/stored
+ profiles-dir: sample/profiles
+ stored-responses-dir: sample/stored
+ categories-dir:
+gdpr:
+ default-value: 1
+ vendorlist:
+ v2:
+ cache-dir: /var/tmp/vendor2
+ v3:
+ cache-dir: /var/tmp/vendor3
diff --git a/src/test/groovy/org/prebid/server/functional/model/config/ModuleName.groovy b/src/test/groovy/org/prebid/server/functional/model/config/ModuleName.groovy
index cc4f9710d03..672eb47745b 100644
--- a/src/test/groovy/org/prebid/server/functional/model/config/ModuleName.groovy
+++ b/src/test/groovy/org/prebid/server/functional/model/config/ModuleName.groovy
@@ -9,7 +9,8 @@ enum ModuleName {
PB_ORTB2_BLOCKING("pb-ortb2-blocking"),
PB_REQUEST_CORRECTION('pb-request-correction'),
PB_RULE_ENGINE('pb-rule-engine'),
- OPTABLE_TARGETING('optable-targeting')
+ OPTABLE_TARGETING('optable-targeting'),
+ ID5_USER_ID('id5-user-id')
@JsonValue
final String code
diff --git a/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy
index da5057d472b..8929f91cf2a 100644
--- a/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy
+++ b/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy
@@ -1,15 +1,19 @@
package org.prebid.server.functional.tests.module
+import org.prebid.server.functional.model.config.EndpointExecutionPlan
import org.prebid.server.functional.model.config.ModuleName
-import org.prebid.server.functional.model.config.Endpoint
+import org.prebid.server.functional.model.config.ExecutionGroup
import org.prebid.server.functional.model.config.ExecutionPlan
import org.prebid.server.functional.model.config.HookHttpEndpoint
+import org.prebid.server.functional.model.config.HookId
import org.prebid.server.functional.model.config.Stage
+import org.prebid.server.functional.model.config.StageExecutionPlan
import org.prebid.server.functional.model.response.auction.AnalyticResult
import org.prebid.server.functional.model.response.auction.BidResponse
import org.prebid.server.functional.tests.BaseSpec
import org.prebid.server.functional.util.PBSUtils
+import static org.prebid.server.functional.model.config.ModuleName.ID5_USER_ID
import static org.prebid.server.functional.model.config.ModuleName.OPTABLE_TARGETING
import static org.prebid.server.functional.model.config.ModuleName.PB_ORTB2_BLOCKING
import static org.prebid.server.functional.model.config.ModuleName.PB_REQUEST_CORRECTION
@@ -18,6 +22,7 @@ import static org.prebid.server.functional.model.config.ModuleName.PB_RICHMEDIA_
import static org.prebid.server.functional.model.config.ModuleName.PB_RULE_ENGINE
import static org.prebid.server.functional.model.config.HookHttpEndpoint.AUCTION
import static org.prebid.server.functional.model.config.Stage.ALL_PROCESSED_BID_RESPONSES
+import static org.prebid.server.functional.model.config.Stage.BIDDER_REQUEST
import static org.prebid.server.functional.model.config.Stage.PROCESSED_AUCTION_REQUEST
import static org.prebid.server.functional.testcontainers.Dependencies.getNetworkServiceContainer
@@ -105,6 +110,32 @@ class ModuleBaseSpec extends BaseSpec {
"hooks.host-execution-plan" : encode(ExecutionPlan.getSingleEndpointExecutionPlan(endpoint, PB_RULE_ENGINE, [stage]))]
}
+ protected static Map getId5UserIdSettings(Map extraConfig = [:],
+ HookHttpEndpoint endpoint = AUCTION) {
+ def executionPlan = new ExecutionPlan(endpoints: [
+ (endpoint): new EndpointExecutionPlan(stages: [
+ (PROCESSED_AUCTION_REQUEST): new StageExecutionPlan(groups: [
+ new ExecutionGroup(timeout: 5000, hookSequence: [
+ new HookId(moduleCode: ID5_USER_ID.code, hookImplCode: "${ID5_USER_ID.code}-fetch-processed-auction-request-hook" as String)
+ ])
+ ]),
+ (BIDDER_REQUEST): new StageExecutionPlan(groups: [
+ new ExecutionGroup(timeout: 5000, hookSequence: [
+ new HookId(moduleCode: ID5_USER_ID.code, hookImplCode: "${ID5_USER_ID.code}-inject-bidder-request-hook" as String)
+ ])
+ ])
+ ])
+ ])
+ def config = ["hooks.${ID5_USER_ID.code}.enabled" : "true",
+ "hooks.${ID5_USER_ID.code}.partner" : "173",
+ "hooks.${ID5_USER_ID.code}.provider-name" : "prebid-server",
+ "hooks.${ID5_USER_ID.code}.inserter-name" : "prebid-server",
+ "hooks.${ID5_USER_ID.code}.fetch-endpoint": "$networkServiceContainer.rootUri/id5-fetch".toString(),
+ "hooks.host-execution-plan" : encode(executionPlan)]
+ .collectEntries { key, value -> [(key.toString()): value.toString()] } as Map
+ config + extraConfig
+ }
+
protected static List getAnalyticResults(BidResponse response) {
response.ext.prebid.modules?.trace?.stages?.first()
?.outcomes?.first()?.groups?.first()
diff --git a/src/test/groovy/org/prebid/server/functional/tests/module/id5userid/Id5UserIdModuleSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/module/id5userid/Id5UserIdModuleSpec.groovy
new file mode 100644
index 00000000000..cd58b9c00be
--- /dev/null
+++ b/src/test/groovy/org/prebid/server/functional/tests/module/id5userid/Id5UserIdModuleSpec.groovy
@@ -0,0 +1,210 @@
+package org.prebid.server.functional.tests.module.id5userid
+
+import com.github.tomakehurst.wiremock.matching.RequestPattern
+import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder
+import org.prebid.server.functional.model.bidder.AppNexus
+import org.prebid.server.functional.model.bidder.Generic
+import org.prebid.server.functional.model.request.auction.BidRequest
+import org.prebid.server.functional.model.request.auction.Eid
+import org.prebid.server.functional.model.request.auction.Uid
+import org.prebid.server.functional.model.request.auction.User
+import org.prebid.server.functional.service.PrebidServerService
+import org.prebid.server.functional.testcontainers.container.NetworkServiceContainer
+import org.prebid.server.functional.testcontainers.scaffolding.Bidder
+import org.prebid.server.functional.testcontainers.scaffolding.NetworkScaffolding
+import org.prebid.server.functional.tests.module.ModuleBaseSpec
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse
+import static com.github.tomakehurst.wiremock.client.WireMock.post
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching
+import static org.apache.http.HttpStatus.SC_OK
+import static org.prebid.server.functional.model.config.ModuleName.ID5_USER_ID
+import static org.prebid.server.functional.testcontainers.Dependencies.networkServiceContainer
+
+class Id5UserIdModuleSpec extends ModuleBaseSpec {
+
+ private static final String ID5_SOURCE = "id5-sync.com"
+ private static final String TEST_ID5_VALUE = "ID5*test-id5-user-id"
+ private static final String BLOCKED_ACCOUNT = "blocked-account"
+
+ private static final String ALIAS_ENDPOINT = "/alias-auction"
+ private static final String APPNEXUS_ENDPOINT = "/appnexus-auction"
+
+ private static final Id5FetchService id5FetchService = new Id5FetchService(networkServiceContainer)
+ private static final Bidder aliasBidder = new Bidder(networkServiceContainer, ALIAS_ENDPOINT)
+ private static final Bidder appnexusBidder = new Bidder(networkServiceContainer, APPNEXUS_ENDPOINT)
+
+ private static final Map CONFIG =
+ getId5UserIdSettings([
+ "hooks.${ID5_USER_ID.code}.bidder-filter.exclude" : "false",
+ "hooks.${ID5_USER_ID.code}.bidder-filter.values" : "generic,alias",
+ "hooks.${ID5_USER_ID.code}.account-filter.exclude": "true",
+ "hooks.${ID5_USER_ID.code}.account-filter.values" : BLOCKED_ACCOUNT
+ ]) + [
+ "adapters.generic.aliases.alias.enabled" : "true",
+ "adapters.generic.aliases.alias.meta-info.vendor-id": "0",
+ "adapters.generic.aliases.alias.endpoint" : "${networkServiceContainer.rootUri}${ALIAS_ENDPOINT}".toString(),
+ "adapters.appnexus.enabled" : "true",
+ "adapters.appnexus.endpoint" : "${networkServiceContainer.rootUri}${APPNEXUS_ENDPOINT}".toString()
+ ]
+
+ private static final PrebidServerService pbsService = pbsServiceFactory.getService(CONFIG)
+
+ def setupSpec() {
+ aliasBidder.setResponse()
+ appnexusBidder.setResponse()
+ }
+
+ def setup() {
+ id5FetchService.reset()
+ id5FetchService.setFetchResponse(TEST_ID5_VALUE)
+ }
+
+ def cleanupSpec() {
+ pbsServiceFactory.removeContainer(CONFIG)
+ aliasBidder.reset()
+ appnexusBidder.reset()
+ id5FetchService.reset()
+ }
+
+ def "PBS should fetch ID5 and inject EIDs into allowed bidders and skip filtered-out bidders"() {
+ given: "Bid request with all bidders"
+ def bidRequest = createBidRequest()
+
+ when: "PBS processes auction request"
+ pbsService.sendAuctionRequest(bidRequest)
+
+ then: "ID5 fetch endpoint should be called once"
+ assert id5FetchService.requestCount == 1
+
+ and: "Allowed bidders should receive request with ID5 EID"
+ def genericBidderRequest = bidder.getBidderRequest(bidRequest.id)
+ verifyId5EidPresent(genericBidderRequest.user, TEST_ID5_VALUE)
+ def aliasBidderRequest = aliasBidder.getBidderRequest(bidRequest.id)
+ verifyId5EidPresent(aliasBidderRequest.user, TEST_ID5_VALUE)
+
+ and: "Not allowed bidder should not receive ID5 EID"
+ def appnexusBidderRequest = appnexusBidder.getBidderRequest(bidRequest.id)
+ verifyId5EidAbsent(appnexusBidderRequest.user)
+ }
+
+ def "PBS should skip ID5 fetch when ID5 EID already present in request"() {
+ given: "Bid request with existing ID5 EID"
+ def existingId5Value = "existing-id5-value"
+ def bidRequest = createBidRequest().tap {
+ user = new User(eids: [new Eid(source: ID5_SOURCE, uids: [new Uid(id: existingId5Value, atype: 1)])])
+ }
+
+ when: "PBS processes auction request"
+ pbsService.sendAuctionRequest(bidRequest)
+
+ then: "ID5 fetch endpoint should not be called"
+ assert id5FetchService.requestCount == 0
+
+ and: "All bidders should receive request with original ID5 EID"
+ verifyId5EidPresent(bidder.getBidderRequest(bidRequest.id).user, existingId5Value, false)
+ verifyId5EidPresent(aliasBidder.getBidderRequest(bidRequest.id).user, existingId5Value, false)
+ verifyId5EidPresent(appnexusBidder.getBidderRequest(bidRequest.id).user, existingId5Value, false)
+ }
+
+ def "PBS should skip ID5 fetch for blocked account"() {
+ given: "Bid request with blocked account"
+ def bidRequest = createBidRequest().tap {
+ accountId = BLOCKED_ACCOUNT
+ }
+
+ when: "PBS processes auction request"
+ pbsService.sendAuctionRequest(bidRequest)
+
+ then: "ID5 fetch endpoint should not be called"
+ assert id5FetchService.requestCount == 0
+
+ and: "All bidders should receive request without ID5 EID"
+ verifyId5EidAbsent(bidder.getBidderRequest(bidRequest.id).user)
+ verifyId5EidAbsent(aliasBidder.getBidderRequest(bidRequest.id).user)
+ verifyId5EidAbsent(appnexusBidder.getBidderRequest(bidRequest.id).user)
+ }
+
+ private static BidRequest createBidRequest() {
+ BidRequest.defaultBidRequest.tap {
+ imp[0].ext.prebid.bidder.alias = new Generic()
+ imp[0].ext.prebid.bidder.appNexus = AppNexus.default
+ }
+ }
+
+ private static void verifyId5EidPresent(User user, String expectedId5Value, boolean expectInserter = true) {
+ def eids = user?.ext?.eids ?: user?.eids
+ assert eids, "Expected EIDs to be present in bidder request"
+ def id5Eid = eids.find { it.source == ID5_SOURCE }
+ assert id5Eid, "Expected ID5 EID with source '$ID5_SOURCE'"
+ assert id5Eid.uids[0].id == expectedId5Value
+ if (expectInserter) {
+ assert id5Eid.inserter == "prebid-server"
+ }
+ }
+
+ private static void verifyId5EidAbsent(User user) {
+ def eids = user?.ext?.eids ?: user?.eids
+ if (eids) {
+ assert !eids.any { it.source == ID5_SOURCE }, "Expected no ID5 EID in bidder request"
+ }
+ }
+
+ static class Id5FetchService extends NetworkScaffolding {
+
+ private static final String ID5_FETCH_ENDPOINT = "/id5-fetch"
+
+ Id5FetchService(NetworkServiceContainer networkServiceContainer) {
+ super(networkServiceContainer, ID5_FETCH_ENDPOINT)
+ }
+
+ void setFetchResponse(String id5Value) {
+ def responseBody = """
+ {
+ "ids": {
+ "id5": {
+ "eid": {
+ "source": "id5-sync.com",
+ "uids": [{
+ "id": "${id5Value}",
+ "atype": 1
+ }]
+ }
+ }
+ }
+ }
+ """
+ wireMockClient.register(post(urlPathMatching("${endpoint}/.*"))
+ .willReturn(aResponse()
+ .withStatus(SC_OK)
+ .withHeader("Content-Type", "application/json")
+ .withBody(responseBody)))
+ }
+
+ int getRequestCount() {
+ getRequestCount(postRequestedFor(urlPathMatching("${endpoint}/.*")))
+ }
+
+ @Override
+ protected RequestPatternBuilder getRequest(String value) {
+ postRequestedFor(urlPathEqualTo("${endpoint}/${value}"))
+ }
+
+ @Override
+ protected RequestPattern getRequest() {
+ postRequestedFor(urlPathMatching("${endpoint}/.*")).build()
+ }
+
+ @Override
+ void reset() {
+ wireMockClient.resetRequests()
+ }
+
+ @Override
+ void setResponse() {
+ setFetchResponse("ID5*test-id5-user-id")
+ }
+ }
+}