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..ed207a56bcb
--- /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,17 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.model;
+
+import com.iab.openrtb.request.Eid;
+
+import java.util.List;
+
+public interface Id5UserId {
+
+ List toEIDs();
+
+ Id5UserId EMPTY = List::of;
+
+ static Id5UserId empty() {
+ return EMPTY;
+ }
+
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleAccountFilterIT.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleAccountFilterIT.java
new file mode 100644
index 00000000000..707a834260c
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleAccountFilterIT.java
@@ -0,0 +1,51 @@
+package org.prebid.server.hooks.modules.id5.userid;
+
+import io.restassured.response.Response;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+
+@DirtiesContext
+@TestPropertySource(properties = {
+ // Override ID5 Module Configuration with account filter (blocklist)
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=173",
+ "hooks.id5-user-id.inserter-name=prebid-server",
+ "hooks.id5-user-id.fetch-endpoint=http://localhost:8090/id5-fetch",
+ "hooks.id5-user-id.account-filter.exclude=true",
+ "hooks.id5-user-id.account-filter.values=blocked-account",
+ // Settings Configuration
+ "settings.filesystem.settings-filename=src/test/resources/test-app-settings.yaml",
+ "settings.filesystem.stored-requests-dir=",
+ "settings.filesystem.stored-imps-dir=",
+ "settings.filesystem.profiles-dir=",
+ "settings.filesystem.stored-responses-dir=",
+ "settings.filesystem.categories-dir="
+})
+public class Id5UserIdModuleAccountFilterIT extends Id5UserIdModuleITBase {
+
+ @Test
+ public void shouldFetchId5ForNonBlockedAccount() throws Exception {
+ // given: ID5 module enabled with account filter blocklist ("blocked-account" excluded)
+ // given: Request uses "test-account-id5" which is NOT blocked
+ final Response response = sendAuctionRequest(createAuctionRequestWithMultipleBidders());
+
+ Assertions.assertThat(response.statusCode()).isEqualTo(200);
+
+ verifyId5FetchCalled(1);
+ verifyBidderReceivedRequestWithId5Eid(GENERIC_EXCHANGE_PATH, TEST_ID5_VALUE);
+ verifyBidderReceivedRequestWithId5Eid(APPNEXUS_EXCHANGE_PATH, TEST_ID5_VALUE);
+ }
+
+ @Test
+ public void shouldSkipFetchForBlockedAccount() throws Exception {
+ // given: ID5 module enabled with account filter blocklist ("blocked-account" excluded)
+ // given: Request uses "blocked-account" which IS blocked
+ final Response response = sendAuctionRequest(createBlockedAccountRequest());
+
+ Assertions.assertThat(response.statusCode()).isEqualTo(200);
+ verifyId5FetchCalled(0);
+ verifyBidderReceivedRequestWithoutId5Eid(GENERIC_EXCHANGE_PATH);
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleBidderFilterIT.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleBidderFilterIT.java
new file mode 100644
index 00000000000..72cdd325a4d
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleBidderFilterIT.java
@@ -0,0 +1,41 @@
+package org.prebid.server.hooks.modules.id5.userid;
+
+import io.restassured.response.Response;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+
+@DirtiesContext
+@TestPropertySource(properties = {
+ // Override ID5 Module Configuration with bidder filter (allowlist)
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=173",
+ "hooks.id5-user-id.inserter-name=prebid-server",
+ "hooks.id5-user-id.fetch-endpoint=http://localhost:8090/id5-fetch",
+ "hooks.id5-user-id.bidder-filter.exclude=false",
+ "hooks.id5-user-id.bidder-filter.values=generic",
+ // Settings Configuration
+ "settings.filesystem.settings-filename=src/test/resources/test-app-settings.yaml",
+ "settings.filesystem.stored-requests-dir=",
+ "settings.filesystem.stored-imps-dir=",
+ "settings.filesystem.profiles-dir=",
+ "settings.filesystem.stored-responses-dir=",
+ "settings.filesystem.categories-dir="
+})
+public class Id5UserIdModuleBidderFilterIT extends Id5UserIdModuleITBase {
+
+ @Test
+ public void shouldOnlyInjectEidsIntoAllowlistedBidder() throws Exception {
+ // given: ID5 module enabled with bidder filter allowlist (only "generic" bidder allowed)
+ final Response response = sendAuctionRequest(createAuctionRequestWithMultipleBidders());
+
+ Assertions.assertThat(response.statusCode()).isEqualTo(200);
+ Assertions.assertThat(response.jsonPath().getString("id")).isEqualTo("test-request-id");
+
+ verifyId5FetchCalled(1);
+ verifyBidderReceivedRequestWithId5Eid(GENERIC_EXCHANGE_PATH, TEST_ID5_VALUE);
+ verifyBidderReceivedRequestWithoutId5Eid(APPNEXUS_EXCHANGE_PATH);
+ }
+
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleIT.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleIT.java
new file mode 100644
index 00000000000..5cd1f2e7c76
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleIT.java
@@ -0,0 +1,31 @@
+package org.prebid.server.hooks.modules.id5.userid;
+
+import io.restassured.response.Response;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class Id5UserIdModuleIT extends Id5UserIdModuleITBase {
+
+ @Test
+ public void shouldFetchId5AndInjectEIDsIntoAllBidderRequests() throws Exception {
+ // given: ID5 module enabled with no filters (all accounts and bidders allowed)
+ final Response response = sendAuctionRequest(createAuctionRequestWithMultipleBidders());
+
+ Assertions.assertThat(response.statusCode()).isEqualTo(200);
+ Assertions.assertThat(response.jsonPath().getString("id")).isEqualTo("test-request-id");
+
+ verifyId5FetchCalled(1);
+ verifyBidderReceivedRequestWithId5Eid(GENERIC_EXCHANGE_PATH, TEST_ID5_VALUE);
+ verifyBidderReceivedRequestWithId5Eid(APPNEXUS_EXCHANGE_PATH, TEST_ID5_VALUE);
+ }
+
+ @Test
+ public void shouldSkipFetchWhenId5AlreadyPresent() throws Exception {
+ // given: ID5 module enabled, request already contains ID5 EID in user.eids
+ final Response response = sendAuctionRequest(createAuctionRequestWithExistingId5("existing-id5"));
+
+ Assertions.assertThat(response.statusCode()).isEqualTo(200);
+ verifyId5FetchCalled(0);
+ verifyBidderReceivedRequestWithExistingId5(GENERIC_EXCHANGE_PATH, "existing-id5");
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleITBase.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleITBase.java
new file mode 100644
index 00000000000..7fa87890399
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleITBase.java
@@ -0,0 +1,300 @@
+package org.prebid.server.hooks.modules.id5.userid;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.iab.openrtb.request.Banner;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Format;
+import com.iab.openrtb.request.Imp;
+import com.iab.openrtb.request.Publisher;
+import com.iab.openrtb.request.Regs;
+import com.iab.openrtb.request.Site;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.prebid.server.it.IntegrationTest;
+import org.prebid.server.proto.openrtb.ext.request.ExtRegs;
+import org.prebid.server.proto.openrtb.ext.request.ExtRequest;
+import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid;
+import org.springframework.test.context.TestPropertySource;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * Base class for ID5 User ID Module integration tests.
+ *
+ * Provides common WireMock setup and helper methods for testing the ID5 module
+ * in a full Prebid Server environment.
+ */
+@TestPropertySource(properties = {
+ // ID5 Module Configuration
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=173",
+ "hooks.id5-user-id.provider-name=prebid-server",
+ "hooks.id5-user-id.inserter-name=prebid-server",
+ "hooks.id5-user-id.fetch-endpoint=http://localhost:8090/id5-fetch",
+ // Settings Configuration - use custom test-app-settings.yaml for ID5 hooks
+ "settings.filesystem.settings-filename=src/test/resources/test-app-settings.yaml",
+ "settings.filesystem.stored-requests-dir=",
+ "settings.filesystem.stored-imps-dir=",
+ "settings.filesystem.profiles-dir=",
+ "settings.filesystem.stored-responses-dir=",
+ "settings.filesystem.categories-dir="
+ // Note: Generic adapter is already configured in base test-application.properties
+})
+public abstract class Id5UserIdModuleITBase extends IntegrationTest {
+
+ protected static final String ID5_FETCH_PATH = "/id5-fetch/173.json";
+ protected static final String GENERIC_EXCHANGE_PATH = "/generic-exchange";
+ protected static final String APPNEXUS_EXCHANGE_PATH = "/appnexus-exchange";
+ protected static final String TEST_ACCOUNT_ID = "test-account-id5";
+ protected static final String TEST_ID5_VALUE = "ID5*test-e2e-id5-user-id";
+
+ @BeforeEach
+ public void setUpId5Mocks() {
+ // Mock successful ID5 API response
+ WIRE_MOCK_RULE.stubFor(WireMock.post(WireMock.urlPathEqualTo(ID5_FETCH_PATH))
+ .willReturn(WireMock.aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("""
+ {
+ "ids": {
+ "id5": {
+ "eid": {
+ "source": "id5-sync.com",
+ "uids": [{
+ "id": "ID5*test-e2e-id5-user-id",
+ "atype": 1
+ }]
+ }
+ }
+ }
+ }
+ """)));
+
+ // Mock generic bidder response
+ WIRE_MOCK_RULE.stubFor(WireMock.post(WireMock.urlPathEqualTo(GENERIC_EXCHANGE_PATH))
+ .willReturn(WireMock.aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("""
+ {
+ "id": "test-request-id",
+ "seatbid": [{
+ "seat": "generic",
+ "bid": [{
+ "id": "bid-id-1",
+ "impid": "imp-id-1",
+ "price": 5.00,
+ "adm": "",
+ "crid": "creative-1",
+ "w": 300,
+ "h": 250
+ }]
+ }],
+ "cur": "USD"
+ }
+ """)));
+
+ // Mock appnexus bidder response
+ WIRE_MOCK_RULE.stubFor(WireMock.post(WireMock.urlPathEqualTo(APPNEXUS_EXCHANGE_PATH))
+ .willReturn(WireMock.aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("""
+ {
+ "id": "test-request-id",
+ "seatbid": [{
+ "seat": "appnexus",
+ "bid": [{
+ "id": "bid-id-2",
+ "impid": "imp-id-1",
+ "price": 3.50,
+ "adm": "",
+ "crid": "creative-2",
+ "w": 300,
+ "h": 250
+ }]
+ }],
+ "cur": "USD"
+ }
+ """)));
+ }
+
+ protected String createAuctionRequestWithMultipleBidders() throws JsonProcessingException {
+ final BidRequest bidRequest = BidRequest.builder()
+ .id("test-request-id")
+ .imp(Collections.singletonList(Imp.builder()
+ .id("imp-id-1")
+ .banner(Banner.builder()
+ .format(Collections.singletonList(Format.builder().w(300).h(250).build()))
+ .build())
+ .ext(mapper.valueToTree(Map.of(
+ "prebid", Map.of(
+ "bidder", Map.of(
+ "generic", Map.of("exampleProperty", "test-value"),
+ "appnexus", Map.of("placementId", 12345)
+ )
+ )
+ )))
+ .build()))
+ .site(Site.builder()
+ .page("http://example.com")
+ .publisher(Publisher.builder().id(TEST_ACCOUNT_ID).build())
+ .build())
+ .regs(Regs.builder()
+ .ext(ExtRegs.of(0, null, null, null))
+ .build())
+ .ext(ExtRequest.of(ExtRequestPrebid.builder()
+ .debug(1)
+ .build()))
+ .build();
+
+ return mapper.writeValueAsString(bidRequest);
+ }
+
+ protected String createAuctionRequestWithExistingId5(String existingId5Value) throws JsonProcessingException {
+ final BidRequest bidRequest = BidRequest.builder()
+ .id("test-request-id")
+ .imp(Collections.singletonList(Imp.builder()
+ .id("imp-id-1")
+ .banner(Banner.builder()
+ .format(Collections.singletonList(Format.builder().w(300).h(250).build()))
+ .build())
+ .ext(mapper.valueToTree(Map.of(
+ "prebid", Map.of(
+ "bidder", Map.of(
+ "generic", Map.of("exampleProperty", "test-value"),
+ "appnexus", Map.of("placementId", 12345)
+ )
+ )
+ )))
+ .build()))
+ .site(Site.builder()
+ .page("http://example.com")
+ .publisher(Publisher.builder().id(TEST_ACCOUNT_ID).build())
+ .build())
+ .user(User.builder()
+ .eids(Collections.singletonList(Eid.builder()
+ .source("id5-sync.com")
+ .uids(Collections.singletonList(Uid.builder()
+ .id(existingId5Value)
+ .atype(1)
+ .build()))
+ .build()))
+ .build())
+ .regs(Regs.builder()
+ .ext(ExtRegs.of(0, null, null, null))
+ .build())
+ .ext(ExtRequest.of(ExtRequestPrebid.builder()
+ .debug(1)
+ .build()))
+ .build();
+
+ return mapper.writeValueAsString(bidRequest);
+ }
+
+ protected String createExpectedEidsJson(String id5Value) throws JsonProcessingException {
+ final Map expected = Map.of(
+ "user", Map.of(
+ "ext", Map.of(
+ "eids", Collections.singletonList(Eid.builder()
+ .source("id5-sync.com")
+ .uids(Collections.singletonList(Uid.builder()
+ .id(id5Value)
+ .atype(1)
+ .build()))
+ .inserter("prebid-server")
+ .build())
+ )
+ )
+ );
+
+ return mapper.writeValueAsString(expected);
+ }
+
+ protected String createExpectedEidsJsonWithoutInserter(String id5Value) throws JsonProcessingException {
+ final Map expected = Map.of(
+ "user", Map.of(
+ "ext", Map.of(
+ "eids", Collections.singletonList(Eid.builder()
+ .source("id5-sync.com")
+ .uids(Collections.singletonList(Uid.builder()
+ .id(id5Value)
+ .build()))
+ .build())
+ )
+ )
+ );
+
+ return mapper.writeValueAsString(expected);
+ }
+
+ protected String createBlockedAccountRequest() throws JsonProcessingException {
+ final BidRequest bidRequest = BidRequest.builder()
+ .id("test-request-id")
+ .imp(Collections.singletonList(Imp.builder()
+ .id("imp-id-1")
+ .banner(Banner.builder()
+ .format(Collections.singletonList(Format.builder().w(300).h(250).build()))
+ .build())
+ .ext(mapper.valueToTree(Map.of(
+ "prebid", Map.of(
+ "bidder", Map.of(
+ "generic", Map.of("exampleProperty", "test-value"),
+ "appnexus", Map.of("placementId", 12345)
+ )
+ )
+ )))
+ .build()))
+ .site(Site.builder()
+ .page("http://example.com")
+ .publisher(Publisher.builder().id("blocked-account").build())
+ .build())
+ .regs(Regs.builder()
+ .ext(ExtRegs.of(0, null, null, null))
+ .build())
+ .ext(ExtRequest.of(ExtRequestPrebid.builder()
+ .debug(1)
+ .build()))
+ .build();
+
+ return mapper.writeValueAsString(bidRequest);
+ }
+
+ protected io.restassured.response.Response sendAuctionRequest(String requestBody) {
+ return io.restassured.RestAssured.given(SPEC)
+ .header("Content-Type", "application/json")
+ .body(requestBody)
+ .post("/openrtb2/auction");
+ }
+
+ protected void verifyId5FetchCalled(int expectedTimes) {
+ WIRE_MOCK_RULE.verify(expectedTimes, WireMock.postRequestedFor(WireMock.urlPathEqualTo(ID5_FETCH_PATH)));
+ }
+
+ protected void verifyBidderReceivedRequestWithId5Eid(String exchangePath, String id5Value)
+ throws JsonProcessingException {
+ WIRE_MOCK_RULE.verify(1, WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath))
+ .withRequestBody(WireMock.equalToJson(createExpectedEidsJson(id5Value), false, true)));
+ }
+
+ protected void verifyBidderReceivedRequestWithoutId5Eid(String exchangePath) {
+ WIRE_MOCK_RULE.verify(1, WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath)));
+ final int callsWithId5 = WIRE_MOCK_RULE.findAll(
+ WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath))
+ .withRequestBody(WireMock.matchingJsonPath("$.user.ext.eids[?(@.source == 'id5-sync.com')]"))
+ ).size();
+ org.assertj.core.api.Assertions.assertThat(callsWithId5).isEqualTo(0);
+ }
+
+ protected void verifyBidderReceivedRequestWithExistingId5(String exchangePath, String id5Value)
+ throws JsonProcessingException {
+ WIRE_MOCK_RULE.verify(1, WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath))
+ .withRequestBody(WireMock.equalToJson(
+ createExpectedEidsJsonWithoutInserter(id5Value), false, true)));
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java
new file mode 100644
index 00000000000..a615473827f
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java
@@ -0,0 +1,138 @@
+package org.prebid.server.hooks.modules.id5.userid.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdFetchHook;
+import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdInjectHook;
+import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.AccountFetchFilter;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.CountryFetchFilter;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.SamplingFetchFilter;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.SelectedBidderFilter;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.util.VersionInfo;
+import org.prebid.server.vertx.httpclient.HttpClient;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import java.time.Clock;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class Id5UserIdModuleConfigurationTest {
+
+ private ApplicationContextRunner contextRunner() {
+ final VersionInfo versionInfo = Mockito.mock(VersionInfo.class);
+ Mockito.when(versionInfo.getVersion()).thenReturn("1.2.3");
+
+ final HttpClient httpClient = Mockito.mock(HttpClient.class);
+ final JacksonMapper jacksonMapper = new JacksonMapper(new ObjectMapper());
+
+ return new ApplicationContextRunner()
+ .withBean(VersionInfo.class, () -> versionInfo)
+ .withBean(HttpClient.class, () -> httpClient)
+ .withBean(JacksonMapper.class, () -> jacksonMapper)
+ .withBean(Clock.class, Clock::systemUTC)
+ .withUserConfiguration(Id5UserIdModuleConfiguration.class);
+ }
+
+ @Test
+ void shouldNotLoadConfigurationWhenModuleDisabled() {
+ contextRunner()
+ .withPropertyValues(
+ "hooks.id5-user-id.enabled=false",
+ "hooks.id5-user-id.partner=1",
+ "hooks.id5-user-id.provider-name=test-provider")
+ .run(context -> {
+ assertThat(context).doesNotHaveBean(Id5IdModule.class);
+ assertThat(context).doesNotHaveBean(Id5IdFetchHook.class);
+ assertThat(context).doesNotHaveBean(Id5IdInjectHook.class);
+ assertThat(context).doesNotHaveBean(SamplingFetchFilter.class);
+ assertThat(context).doesNotHaveBean(SelectedBidderFilter.class);
+ assertThat(context).doesNotHaveBean(AccountFetchFilter.class);
+ assertThat(context).doesNotHaveBean(CountryFetchFilter.class);
+ });
+ }
+
+ @Test
+ void shouldCreateMainBeansWhenEnabledWithoutFilters() {
+ contextRunner()
+ .withPropertyValues(
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=1",
+ "hooks.id5-user-id.provider-name=test-provider")
+ .run(context -> {
+ assertThat(context).hasSingleBean(Id5IdModule.class);
+ assertThat(context).hasSingleBean(Id5IdFetchHook.class);
+ assertThat(context).hasSingleBean(Id5IdInjectHook.class);
+
+ assertThat(context).doesNotHaveBean(SamplingFetchFilter.class);
+ assertThat(context).doesNotHaveBean(SelectedBidderFilter.class);
+ assertThat(context).doesNotHaveBean(AccountFetchFilter.class);
+ assertThat(context).doesNotHaveBean(CountryFetchFilter.class);
+ });
+ }
+
+ @Test
+ void shouldCreateSamplingFetchFilterWhenSamplingRatePropertyPresent() {
+ contextRunner()
+ .withPropertyValues(
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=1",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.fetch-sampling-rate=0.5")
+ .run(context -> assertThat(context).hasSingleBean(SamplingFetchFilter.class));
+ }
+
+ @Test
+ void shouldCreateSelectedBidderFilterWhenBidderFilterValuesPresent() {
+ contextRunner()
+ .withPropertyValues(
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=1",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.bidder-filter.values=appnexus,rubicon")
+ .run(context -> assertThat(context).hasSingleBean(SelectedBidderFilter.class));
+ }
+
+ @Test
+ void shouldCreateAccountFetchFilterWhenAccountFilterValuesPresent() {
+ contextRunner()
+ .withPropertyValues(
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=1",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.account-filter.values=acc-1,acc-2")
+ .run(context -> assertThat(context).hasSingleBean(AccountFetchFilter.class));
+ }
+
+ @Test
+ void shouldCreateCountryFetchFilterWhenCountryFilterValuesPresent() {
+ contextRunner()
+ .withPropertyValues(
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=1",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.country-filter.values=US,PL")
+ .run(context -> assertThat(context).hasSingleBean(CountryFetchFilter.class));
+ }
+
+ @Test
+ void shouldCreateAllFiltersWhenAllPropertiesPresent() {
+ contextRunner()
+ .withPropertyValues(
+ "hooks.id5-user-id.enabled=true",
+ "hooks.id5-user-id.partner=1",
+ "hooks.id5-user-id.fetch-sampling-rate=1.0",
+ "hooks.id5-user-id.bidder-filter.values=appnexus",
+ "hooks.id5-user-id.account-filter.values=acc-1",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.country-filter.values=US")
+ .run(context -> {
+ assertThat(context).hasSingleBean(SamplingFetchFilter.class);
+ assertThat(context).hasSingleBean(SelectedBidderFilter.class);
+ assertThat(context).hasSingleBean(AccountFetchFilter.class);
+ assertThat(context).hasSingleBean(CountryFetchFilter.class);
+ });
+ }
+}
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..dee2ab9b7ab
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java
@@ -0,0 +1,435 @@
+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.BidRequest.BidRequestBuilder;
+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 io.vertx.core.Future;
+import io.vertx.core.MultiMap;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+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.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.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 java.util.function.BiConsumer;
+import java.util.stream.Stream;
+
+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;
+
+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;
+
+ @BeforeEach
+ 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");
+ }
+
+ @Test
+ 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, mapper, fixedClock, versionInfo, props);
+
+ 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.builder().account(Account.builder().id("acc").build()).build(), false);
+
+ // when
+ final Id5UserId result = client.fetch(partnerId, payload, invocation).result();
+
+ // then
+ assertThat(result.toEIDs()).isEmpty();
+ }
+
+ @Test
+ void shouldReturnEmptyOnException() {
+ // given
+ final long partnerId = 123L;
+ final String expectedUrl = URL + "/" + partnerId + ".json";
+ when(httpClient.post(eq(expectedUrl), any(MultiMap.class), anyString(), anyLong()))
+ .thenReturn(Future.failedFuture(new RuntimeException("boom")));
+
+ final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, fixedClock, versionInfo, props);
+
+ 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.builder().account(Account.builder().id("acc").build()).build(), false);
+
+ // when
+ final Id5UserId result = client.fetch(partnerId, payload, invocation).result();
+
+ // then
+ assertThat(result.toEIDs()).isEmpty();
+ }
+
+ @Test
+ 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, mapper,
+ fixedClock, versionInfo, props);
+
+ 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.builder().account(Account.builder().id("acc").build()).build(), false);
+
+ // when
+ final Id5UserId result = client.fetch(123L, payload, invocation).result();
+
+ // then
+ assertThat(result.toEIDs()).hasSize(1);
+ assertThat(result.toEIDs().getFirst().getSource()).isEqualTo("id5-sync.com");
+ }
+
+ @Test
+ 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, mapper,
+ fixedClock, versionInfo, props);
+
+ 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, org.prebid.server.privacy.gdpr.model.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
+ 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, mapper, fixedClock, versionInfo, props);
+
+ final AuctionInvocationContext invocation = auctionInvocationContext(timeout,
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(), 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
+ 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, mapper,
+ fixedClock, versionInfo, props);
+
+ 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, org.prebid.server.privacy.gdpr.model.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();
+ }
+
+ public static Stream publisherSources() {
+
+ return Stream.of(
+ Arguments.of("site",
+ (BiConsumer) (rq, p) ->
+ rq.site(Site.builder()
+ .publisher(p)
+ .build())),
+ Arguments.of("app",
+ (BiConsumer) (rq1, p1) ->
+ rq1.app(App.builder()
+ .publisher(p1)
+ .build()))
+ );
+ }
+
+ @MethodSource("publisherSources")
+ @ParameterizedTest(name = "from {0}")
+ @SuppressWarnings("unchecked")
+ void shouldIncludePublisher(String ignore, BiConsumer publisherSetter) {
+ // 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, mapper,
+ fixedClock, versionInfo, props);
+
+ final BidRequestBuilder bidRequestBuilder = BidRequest.builder();
+ publisherSetter.accept(bidRequestBuilder, Publisher.builder()
+ .id("pub-123")
+ .domain("pub.domain")
+ .name("Test Publisher")
+ .build());
+ final BidRequest bidRequest = bidRequestBuilder.build();
+ final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ final AuctionContext auctionContext = AuctionContext.builder()
+ .account(Account.builder().id("acc-1").build())
+ .build();
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext, 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");
+
+ 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");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ 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, mapper,
+ fixedClock, versionInfo, moduleProps);
+
+ 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 AuctionContext auctionContext = AuctionContext.builder()
+ .account(Account.builder().id("acc-1").build())
+ .build();
+ final AuctionInvocationContext invocation = auctionInvocationContext(
+ new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext, 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");
+ }
+
+ private static AuctionInvocationContextImpl auctionInvocationContext(Timeout timeout,
+ AuctionContext auctionContext,
+ boolean debugEnabled) {
+ return AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout,
+ org.prebid.server.model.Endpoint.openrtb2_auction),
+ auctionContext, debugEnabled, null, null);
+ }
+
+}
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..33e619a8dfa
--- /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;
+
+class BidRequestUtilsTest {
+
+ @Test
+ void shouldReturnFalseWhenUserIsNull() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder().build();
+
+ // when & then
+ assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse();
+ }
+
+ @Test
+ 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
+ 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
+ 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
+ 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..9bcd6578f58
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java
@@ -0,0 +1,225 @@
+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.FetchClient;
+import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchActionFilter;
+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;
+
+class Id5IdFetchHookTest {
+
+ @Test
+ void shouldReturnNoInvocationAndSetModuleContextWithFutureWhenSampled() {
+ // given
+ final FetchClient fetchClient = Mockito.mock(FetchClient.class);
+ final FetchActionFilter filter = Mockito.mock(FetchActionFilter.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
+ void shouldReturnNoInvocationWhenId5IdAlreadyPresent() {
+ // given
+ final FetchClient fetchClient = Mockito.mock(FetchClient.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
+ void shouldReturnNoInvocationWhenSamplerRejects() {
+ // given
+ final FetchClient fetchClient = Mockito.mock(FetchClient.class);
+ final FetchActionFilter filter = Mockito.mock(FetchActionFilter.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
+ void shouldReturnNoInvocationWhenAnyFetchFilterRejectsMultipleFilters() {
+ // given
+ final FetchClient fetchClient = Mockito.mock(FetchClient.class);
+ final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class);
+ final FetchActionFilter accept1 = Mockito.mock(FetchActionFilter.class);
+ final FetchActionFilter reject = Mockito.mock(FetchActionFilter.class);
+ final FetchActionFilter accept2 = Mockito.mock(FetchActionFilter.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
+ void shouldReturnNoInvocationWhenPartnerIdNotConfigured() {
+ // given
+ final FetchClient fetchClient = Mockito.mock(FetchClient.class);
+ final FetchActionFilter filter = Mockito.mock(FetchActionFilter.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);
+ }
+
+ @Test
+ void shouldReturnFailureWhenExceptionOccurs() {
+ // given
+ final FetchClient fetchClient = Mockito.mock(FetchClient.class);
+ final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class);
+ final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class);
+
+ when(filter.shouldInvoke(any(AuctionRequestPayload.class), any()))
+ .thenReturn(FilterResult.accepted());
+ when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.of(123L));
+ when(fetchClient.fetch(anyLong(), any(AuctionRequestPayload.class), any()))
+ .thenThrow(new RuntimeException("Fetch client error"));
+
+ final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider);
+
+ final BidRequest bidRequest = BidRequest.builder().id("req-4").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.failure);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.errors()).isNotEmpty();
+ assertThat(result.errors().getFirst()).contains("Fetch client error");
+ }
+
+ private static AuctionInvocationContextImpl createAuctionContext() {
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ return AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, 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..baa769c6731
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java
@@ -0,0 +1,281 @@
+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.InjectActionFilter;
+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;
+
+class Id5IdInjectHookTest {
+
+ private BidderInvocationContextImpl bidderCtxWithEmptyIds() {
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ // provide module context with empty Ids future to ensure fetch path would continue if not filtered
+ new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty())));
+ return BidderInvocationContextImpl.of(auctionCtx, "appnexus");
+ }
+
+ @Test
+ void shouldSkipWhenId5EidAlreadyPresent() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX");
+
+ 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 Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty()));
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ expectedContext);
+ final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus");
+
+ // 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
+ void shouldSkipWhenNoTimeLeft() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX");
+
+ final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build();
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1).minus(10_000);
+ final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty()));
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ expectedContext);
+ final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus");
+
+ // 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
+ void shouldSkipWhenFetcherReturnsEmpty() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX");
+
+ final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build();
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty()));
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ expectedContext);
+ final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus");
+
+ // 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
+ void shouldInjectEidsWhenFetcherReturnsIds() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX");
+
+ final BidRequest bidRequest = BidRequest.builder().user(User.builder().eids(List.of()).build()).build();
+
+ final Id5UserId id5 = () -> List.of(
+ Eid.builder()
+ .source("id5-sync.com")
+ .uids(List.of(Uid.builder().id("id5-123").build()))
+ .build());
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(id5));
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ expectedContext);
+ final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus");
+
+ // when
+ final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest),
+ bidderCtx)
+ .toCompletionStage().toCompletableFuture().join();
+
+ // 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
+ void shouldReturnNoActionWhenNoModuleContextPresent() {
+ // given
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX");
+
+ final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build();
+
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ null // no Id5IdModuleContext provided
+ );
+ final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus");
+
+ // 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(); // nothing to propagate
+ }
+
+ @Test
+ void shouldReturnNoInvocationWhenInjectFilterRejectsSingleFilter() {
+ // given
+ final InjectActionFilter filter = Mockito.mock(InjectActionFilter.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 = bidderCtxWithEmptyIds();
+
+ // 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()).isNotNull();
+ assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class);
+ assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext());
+ }
+
+ @Test
+ void shouldReturnNoInvocationWhenAnyInjectFilterRejectsMultipleFilters() {
+ // given
+ final InjectActionFilter accept1 = Mockito.mock(InjectActionFilter.class);
+ final InjectActionFilter reject = Mockito.mock(InjectActionFilter.class);
+ final InjectActionFilter accept2 = Mockito.mock(InjectActionFilter.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 = bidderCtxWithEmptyIds();
+
+ // 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()).isNotNull();
+ assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class);
+ assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext());
+ }
+
+ @Test
+ void shouldReturnFailureWhenExceptionOccurs() {
+ // given
+ final InjectActionFilter filter = Mockito.mock(InjectActionFilter.class);
+ Mockito.when(filter.shouldInvoke(any(), any()))
+ .thenThrow(new RuntimeException("Filter processing error"));
+
+ final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(filter));
+
+ final BidRequest bidRequest = BidRequest.builder().build();
+ final BidderInvocationContextImpl invocationContext = bidderCtxWithEmptyIds();
+
+ // when
+ final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest),
+ invocationContext).result();
+
+ // then
+ assertThat(result.status()).isEqualTo(InvocationStatus.failure);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_invocation);
+ assertThat(result.moduleContext()).isEqualTo(invocationContext.moduleContext());
+ assertThat(result.errors()).isNotEmpty();
+ assertThat(result.errors().getFirst()).contains("Filter processing error");
+ }
+}
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..207b57624ea
--- /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;
+
+class Id5IdModuleContextTest {
+
+ @Test
+ 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, 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
+ void shouldReturnEmptyModuleContextWhenAbsent() {
+ // given
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, 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/Id5IdModulePropertiesTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java
new file mode 100644
index 00000000000..d679e50b2a7
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java
@@ -0,0 +1,95 @@
+package org.prebid.server.hooks.modules.id5.userid.v1.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class Id5IdModulePropertiesTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(TestPropsConfig.class);
+
+ @EnableConfigurationProperties(Id5IdModuleProperties.class)
+ static class TestPropsConfig { }
+
+ @Test
+ void shouldHaveDefaultFetchEndpoint() {
+ contextRunner
+ .withPropertyValues(
+ "hooks.id5-user-id.partner=123",
+ "hooks.id5-user-id.provider-name=test-provider")
+ .run(ctx -> {
+ final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class);
+ assertThat(props.getFetchEndpoint()).isEqualTo("https://api.id5-sync.com/gs/v2");
+ });
+ }
+
+ @Test
+ void shouldBindBidderFilterValuesWithDefaultExcludeFalse() {
+ contextRunner
+ .withPropertyValues(
+ "hooks.id5-user-id.partner=123",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.bidder-filter.values=appnexus,rubicon")
+ .run(ctx -> {
+ final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class);
+ final ValuesFilter filter = props.getBidderFilter();
+
+ assertThat(filter).isNotNull();
+ assertThat(filter.isExclude()).isFalse(); // default
+ assertThat(filter.getValues()).containsExactlyInAnyOrder("appnexus", "rubicon");
+ });
+ }
+
+ @Test
+ void shouldBindAccountFilterWithExcludeTrue() {
+ contextRunner
+ .withPropertyValues(
+ "hooks.id5-user-id.partner=123",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.account-filter.exclude=true",
+ "hooks.id5-user-id.account-filter.values=acc-1,acc-2")
+ .run(ctx -> {
+ final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class);
+ final ValuesFilter filter = props.getAccountFilter();
+
+ assertThat(filter).isNotNull();
+ assertThat(filter.isExclude()).isTrue();
+ assertThat(filter.getValues()).containsExactlyInAnyOrder("acc-1", "acc-2");
+ });
+ }
+
+ @Test
+ void shouldBindCountryFilterWhenOnlyExcludeProvidedValuesRemainNull() {
+ contextRunner
+ .withPropertyValues(
+ "hooks.id5-user-id.partner=123",
+ "hooks.id5-user-id.provider-name=test-provider",
+ "hooks.id5-user-id.country-filter.exclude=true")
+ .run(ctx -> {
+ final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class);
+ final ValuesFilter filter = props.getCountryFilter();
+
+ assertThat(filter).isNotNull();
+ assertThat(filter.isExclude()).isTrue();
+ assertThat(filter.getValues()).isNull();
+ });
+ }
+
+ @Test
+ void shouldNotCreateFiltersWhenNotProvided() {
+ contextRunner
+ .withPropertyValues(
+ "hooks.id5-user-id.partner=123",
+ "hooks.id5-user-id.provider-name=test-provider")
+ .run(ctx -> {
+ final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class);
+
+ assertThat(props.getBidderFilter()).isNull();
+ assertThat(props.getAccountFilter()).isNull();
+ assertThat(props.getCountryFilter()).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..c2fdf5f07bd
--- /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;
+
+class ValuesFilterTest {
+
+ @Test
+ 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
+ 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
+ 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
+ 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..22455bcf7e6
--- /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,83 @@
+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")
+class AccountFetchFilterTest {
+
+ @Test
+ 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, 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
+ 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, 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");
+ }
+
+ @Test
+ void shouldRejectWhenAccountMissing() {
+ final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class);
+ 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, Endpoint.openrtb2_auction),
+ AuctionContext.builder().build(),
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, ctx);
+ assertThat(result.isAccepted()).isFalse();
+ assertThat(result.reason()).contains("missing account id");
+ }
+}
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..1f756d2bb3f
--- /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,132 @@
+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.geolocation.model.GeoInfo;
+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")
+class CountryFetchFilterTest {
+
+ @Test
+ void shouldUseGeoInfoCountryFirst() {
+ final ValuesFilter vf = Mockito.mock(ValuesFilter.class);
+ when(vf.isValueAllowed("PL")).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())
+ .geoInfo(GeoInfo.builder().vendor("test").country("PL").build())
+ .build();
+ final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ auctionContext,
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, invocation);
+ assertThat(result.isAccepted()).isTrue();
+ }
+
+ @Test
+ void shouldFallbackToDeviceGeoCountry() {
+ 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, Endpoint.openrtb2_auction),
+ auctionContext,
+ false,
+ null,
+ null);
+
+ final FilterResult result = filter.shouldInvoke(payload, invocation);
+ assertThat(result.isAccepted()).isTrue();
+ }
+
+ @Test
+ 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, 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
+ 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, 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..e739f81ef3a
--- /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,76 @@
+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.Random;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SamplingFetchFilterTest {
+
+ @Test
+ void shouldAcceptWhenRandomBelowRate() {
+ // given
+ final Random random = new Random() {
+ @Override
+ public double nextDouble() {
+ return 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, 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
+ void shouldRejectWhenRandomAboveRate() {
+ // given
+ final Random random = new Random() {
+ @Override
+ public double nextDouble() {
+ return 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, 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..be922e50d33
--- /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")
+class SelectedBidderFilterTest {
+
+ private BidderInvocationContextImpl bidderCtx(String bidder) {
+ final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000);
+ final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of(
+ InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction),
+ AuctionContext.builder().account(Account.builder().id("acc").build()).build(),
+ false,
+ null,
+ null);
+ return BidderInvocationContextImpl.of(auctionCtx, bidder);
+ }
+
+ @Test
+ 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
+ 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
+ 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();
+ }
+}
diff --git a/extra/modules/id5-user-id/src/test/resources/test-app-settings.yaml b/extra/modules/id5-user-id/src/test/resources/test-app-settings.yaml
new file mode 100644
index 00000000000..f65476d9af5
--- /dev/null
+++ b/extra/modules/id5-user-id/src/test/resources/test-app-settings.yaml
@@ -0,0 +1,21 @@
+# Minimal app settings for ID5 E2E test
+---
+accounts:
+ - id: test-account-id5
+ hooks:
+ execution-plan:
+ endpoints:
+ /openrtb2/auction:
+ stages:
+ processed-auction-request:
+ groups:
+ - timeout: 5000
+ hook-sequence:
+ - module-code: id5-user-id
+ hook-impl-code: id5-user-id-fetch-hook
+ bidder-request:
+ groups:
+ - timeout: 5000
+ hook-sequence:
+ - module-code: id5-user-id
+ hook-impl-code: id5-user-id-inject-hook
diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml
index 6c4e70d62ab..380b6511d33 100644
--- a/extra/modules/pom.xml
+++ b/extra/modules/pom.xml
@@ -28,6 +28,7 @@
wurfl-devicedetection
live-intent-omni-channel-identity
pb-rule-engine
+ id5-user-id
diff --git a/pom.xml b/pom.xml
index 0c79bae025d..84c11847f75 100644
--- a/pom.xml
+++ b/pom.xml
@@ -667,6 +667,18 @@
${java.version}
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.3.0
+
+
+
+ test-jar
+
+
+
+
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/sample/configs/prebid-config-with-id5.yaml b/sample/configs/prebid-config-with-id5.yaml
new file mode 100644
index 00000000000..2737fd1362f
--- /dev/null
+++ b/sample/configs/prebid-config-with-id5.yaml
@@ -0,0 +1,45 @@
+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
+ generate-storedrequest-bidrequest-id: true
+ filesystem:
+ settings-filename: sample/configs/sample-app-settings-id5.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
+hooks:
+ id5-user-id:
+ enabled: true
+ partner: 173
+ fetchEndpoint: http://localhost:8091/id5-fetch
+ inserterName: local-pbs.example.com
+ providerName: local-pbs
+
+logging:
+ level:
+ org.prebid.server.hooks.modules.id5: DEBUG
+
+admin-endpoints:
+ logging-changelevel:
+ enabled: true
+ path: /logging/changelevel
+ on-application-port: true
+ protected: false
diff --git a/sample/configs/sample-app-settings-id5.yaml b/sample/configs/sample-app-settings-id5.yaml
new file mode 100644
index 00000000000..bfcd72a1698
--- /dev/null
+++ b/sample/configs/sample-app-settings-id5.yaml
@@ -0,0 +1,33 @@
+accounts:
+ - id: "1001"
+ status: active
+ hooks:
+ execution-plan:
+ {
+ "endpoints": {
+ "/openrtb2/auction": {
+ "stages": {
+ "processed-auction-request": {
+ "groups": [
+ {
+ "timeout": 500,
+ "hook-sequence": [
+ { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-fetch-hook" }
+ ]
+ }
+ ]
+ },
+ "bidder-request": {
+ "groups": [
+ {
+ "timeout": 500,
+ "hook-sequence": [
+ { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-inject-hook" }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
diff --git a/sample/wiremock/README.md b/sample/wiremock/README.md
new file mode 100644
index 00000000000..e01981ad960
--- /dev/null
+++ b/sample/wiremock/README.md
@@ -0,0 +1,91 @@
+# Sample WireMock for Prebid Server Java
+
+This directory contains a minimal WireMock setup you can use for local integration testing with Prebid Server (PBS-Java).
+
+Structure:
+- `mappings/generic-exchange.json` — maps requests to the `/generic-exchange` endpoint.
+- `__files/generic-bid.json` — sample OpenRTB BidResponse returned by the mapping.
+- `docker-compose.wiremock.yml` — ready-to-use Docker Compose definition for running WireMock in a container.
+
+Target mock endpoint: `POST /generic-exchange`.
+
+---
+
+## Run with Docker Compose
+Requirements: Docker Desktop (or Docker Engine) + Docker Compose.
+
+1. Go to the sample directory:
+ ```bash
+ cd sample/wiremock
+ ```
+2. Start WireMock in the background:
+ ```bash
+ docker compose -f docker-compose.wiremock.yml up -d
+ ```
+ - The container runs image `wiremock/wiremock:3.13.2`.
+ - WireMock listens inside the container on port `8080` and is mapped on the host as `http://localhost:8090`.
+ - A volume mounts the current directory as `/home/wiremock` (read-only), so any changes in `mappings`/`__files` are visible without rebuilding the image.
+
+3. Verify the endpoint works (example call):
+ ```bash
+ curl -s -X POST http://localhost:8090/generic-exchange -H 'Content-Type: application/json' -d '{}'
+ ```
+ You should receive the content from `__files/generic-bid.json`.
+
+4. Tail logs:
+ ```bash
+ docker logs -f wiremock-prebid-server
+ ```
+
+5. Stop and remove the container:
+ ```bash
+ docker compose -f docker-compose.wiremock.yml down
+ ```
+
+Notes:
+- If port `8090` is taken, change the port mapping in `docker-compose.wiremock.yml` (the line `ports: - "8090:8080"`) to something else, e.g., `9090:8080`, and remember to use the new port in your calls.
+
+---
+
+## Run using the IntelliJ WireMock Plugin
+Requirements: IntelliJ IDEA with the “WireMock” plugin installed.
+
+1. Install the plugin:
+ - File → Settings → Plugins → Marketplace → search for “WireMock” → Install → Restart IDE.
+
+2. Create a WireMock Run Configuration:
+ - Run → Edit Configurations… → `+` → select “WireMock”.
+ - Set the fields:
+ - Files root (or Root dir): point to the `sample/wiremock` directory in the repo.
+ - Port: set to `8090` (important: see the port note below).
+ - Optionally enable `Verbose` logs.
+
+3. Run the configuration and test:
+ - Click Run on the new configuration.
+ - Check the endpoint:
+ ```bash
+ curl -s -X POST http://localhost:8090/generic-exchange -H 'Content-Type: application/json' -d '{}'
+ ```
+
+### Important: set the port in the IntelliJ Run Configuration
+- Make sure the WireMock configuration uses port `8090` to stay consistent with this sample and with any local PBS config that points to `http://localhost:8090/...`.
+- If needed, you can choose a different port (e.g., 9090), but then update the addresses in your testing tools and/or PBS configuration accordingly.
+
+---
+
+## Integration with Prebid Server (locally)
+- In your PBS adapter/bidder configuration, set the endpoint to the WireMock address, e.g., `http://localhost:8090/generic-exchange`.
+- This sample does not enforce a specific request body — any `POST` to `/generic-exchange` returns the fixed response from `__files/generic-bid.json`.
+
+---
+
+## Customizing mappings
+- To change the response payload, edit `__files/generic-bid.json`.
+- To refine match conditions (e.g., headers, body patterns), update `mappings/generic-exchange.json` according to WireMock 3.x documentation.
+
+---
+
+## Troubleshooting
+- Port in use: change the port in Docker Compose or in the IntelliJ Run Configuration.
+- No response/404: ensure `Files root` points to `sample/wiremock` and that files under `mappings` and `__files` are visible.
+- Changes not reflected in Docker: remember the volume is mounted as `:ro` (read-only) in the container — make edits on the host; the container reads them live.
diff --git a/sample/wiremock/__files/generic-bid-response.json b/sample/wiremock/__files/generic-bid-response.json
new file mode 100644
index 00000000000..21aaf45cabd
--- /dev/null
+++ b/sample/wiremock/__files/generic-bid-response.json
@@ -0,0 +1,18 @@
+{
+ "id": "test-bid-request-id",
+ "seatbid": [
+ {
+ "bid": [
+ {
+ "id": "bid1",
+ "impid": "test-imp-id",
+ "price": 1.23,
+ "crid": "crid001",
+ "adm": "adm001",
+ "w": 300,
+ "h": 250
+ }
+ ]
+ }
+ ]
+}
diff --git a/sample/wiremock/__files/id5-fetch-response.json b/sample/wiremock/__files/id5-fetch-response.json
new file mode 100644
index 00000000000..c0b8c115648
--- /dev/null
+++ b/sample/wiremock/__files/id5-fetch-response.json
@@ -0,0 +1,31 @@
+{
+ "created_at": "2025-11-23T12:52:34+09:00",
+ "original_uid": "ID5*YsvxY",
+ "universal_uid": "ID5*YsvxY",
+ "privacy": {
+ "jurisdiction": "gdpr",
+ "id5_consent": true
+ },
+ "signature": "signature",
+ "ext": {
+ "linkType": 0,
+ "pba": "pba-value"
+ },
+ "ids": {
+ "id5id": {
+ "eid": {
+ "source": "id5-sync.com",
+ "uids": [
+ {
+ "id": "ID5*YsvxY",
+ "atype": 1,
+ "ext": {
+ "linkType": 2,
+ "pba": "jWwv+"
+ }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/sample/wiremock/docker-compose.wiremock.yml b/sample/wiremock/docker-compose.wiremock.yml
new file mode 100644
index 00000000000..2844ba2315e
--- /dev/null
+++ b/sample/wiremock/docker-compose.wiremock.yml
@@ -0,0 +1,10 @@
+services:
+ wiremock:
+ image: wiremock/wiremock:3.13.2
+ container_name: wiremock-prebid-server
+ command: ["--port", "8080", "--root-dir", "/home/wiremock", "--verbose"]
+ ports:
+ - "8091:8080"
+ volumes:
+ - ./:/home/wiremock:ro
+ restart: unless-stopped
diff --git a/sample/wiremock/mappings/generic-exchange.json b/sample/wiremock/mappings/generic-exchange.json
new file mode 100644
index 00000000000..1211343e651
--- /dev/null
+++ b/sample/wiremock/mappings/generic-exchange.json
@@ -0,0 +1,13 @@
+{
+ "request": {
+ "method": "POST",
+ "urlPath": "/generic-exchange"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "bodyFileName": "generic-bid-response.json"
+ }
+}
diff --git a/sample/wiremock/mappings/id5-fetch.json b/sample/wiremock/mappings/id5-fetch.json
new file mode 100644
index 00000000000..b2233df45b7
--- /dev/null
+++ b/sample/wiremock/mappings/id5-fetch.json
@@ -0,0 +1,13 @@
+{
+ "request": {
+ "method": "POST",
+ "urlPathPattern": "/id5-fetch/[0-9]+\\.json"
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "bodyFileName": "id5-fetch-response.json"
+ }
+}