From bcc42640398854db52fe532445ea4a68e37b7df0 Mon Sep 17 00:00:00 2001 From: Viktor Zavala Date: Fri, 13 Mar 2026 19:11:58 +0100 Subject: [PATCH 1/2] [REM-3040] Add functions to emit tracking events for display ads media impressions view and click --- README.md | 15 ++ .../java/io/constructor/core/ConstructorIo.kt | 59 +++++++ .../constructor/core/ConstructorIoConfig.kt | 3 +- .../java/io/constructor/data/DataManager.kt | 23 +++ .../data/interceptor/RequestInterceptor.kt | 2 +- .../data/local/PreferencesHelper.kt | 5 + .../MediaImpressionClickRequestBody.kt | 19 +++ .../MediaImpressionViewRequestBody.kt | 19 +++ .../io/constructor/data/remote/ApiPaths.kt | 3 + .../constructor/data/remote/ConstructorApi.kt | 10 +- ...nstructorIoIntegrationMediaTrackingTest.kt | 95 +++++++++++ .../core/ConstructorIoMediaTrackingTest.kt | 148 ++++++++++++++++++ 12 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 library/src/main/java/io/constructor/data/model/tracking/MediaImpressionClickRequestBody.kt create mode 100644 library/src/main/java/io/constructor/data/model/tracking/MediaImpressionViewRequestBody.kt create mode 100644 library/src/test/java/io/constructor/core/ConstructorIoIntegrationMediaTrackingTest.kt create mode 100644 library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt diff --git a/README.md b/README.md index 69ea44bf..575beb0a 100755 --- a/README.md +++ b/README.md @@ -634,3 +634,18 @@ ConstructorIo.trackItemDetailLoaded("Pencil", "123", "234") // Track when a product is clicked. Should be used when a clicked product is not part of search/browse/recommendation experiences. (itemName, customerId, variationId?, sectionName?) ConstructorIo.trackGenericResultClick("Pencil", "123", "234") ``` + +### Media Impression Events + +```kotlin +// Track when a media placement is viewed +ConstructorIo.trackMediaImpressionView( + bannerAdId = "abc123", + placementId = "home" +) + +// Track when a media placement is clicked +ConstructorIo.trackMediaImpressionClick( + bannerAdId = "abc123", + placementId = "home" +) diff --git a/library/src/main/java/io/constructor/core/ConstructorIo.kt b/library/src/main/java/io/constructor/core/ConstructorIo.kt index fe19e68e..11392813 100755 --- a/library/src/main/java/io/constructor/core/ConstructorIo.kt +++ b/library/src/main/java/io/constructor/core/ConstructorIo.kt @@ -24,6 +24,8 @@ import io.constructor.data.model.recommendations.RecommendationsResponse import io.constructor.data.model.search.* import io.constructor.data.model.tracking.GenericResultClickRequestBody import io.constructor.data.model.tracking.ItemDetailLoadRequestBody +import io.constructor.data.model.tracking.MediaImpressionClickRequestBody +import io.constructor.data.model.tracking.MediaImpressionViewRequestBody import io.constructor.injection.component.AppComponent import io.constructor.injection.component.DaggerAppComponent import io.constructor.injection.module.AppModule @@ -120,6 +122,7 @@ object ConstructorIo { preferenceHelper.apiKey = constructorIoConfig.apiKey preferenceHelper.serviceUrl = constructorIoConfig.serviceUrl preferenceHelper.quizzesServiceUrl = constructorIoConfig.quizzesServiceUrl + preferenceHelper.mediaServiceUrl = constructorIoConfig.mediaServiceUrl preferenceHelper.port = constructorIoConfig.servicePort preferenceHelper.scheme = constructorIoConfig.serviceScheme preferenceHelper.defaultItemSection = constructorIoConfig.defaultItemSection @@ -1894,6 +1897,26 @@ object ConstructorIo { })) } + /** + * Tracks media impression view events. + */ + fun trackMediaImpressionView(bannerAdId: String, placementId: String) { + val completable = trackMediaImpressionViewInternal(bannerAdId, placementId) + disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, { t -> + e("Media Impression View error: ${t.message}") + })) + } + + /** + * Tracks media impression click events. + */ + fun trackMediaImpressionClick(bannerAdId: String, placementId: String) { + val completable = trackMediaImpressionClickInternal(bannerAdId, placementId) + disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, { t -> + e("Media Impression Click error: ${t.message}") + })) + } + internal fun trackItemDetailLoadedInternal(itemName: String, customerId: String, variationId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map? = null): Completable { preferenceHelper.getSessionId(sessionIncrementHandler) val section = sectionName ?: preferenceHelper.defaultItemSection @@ -1920,6 +1943,42 @@ object ConstructorIo { ) } + internal fun trackMediaImpressionViewInternal(bannerAdId: String, placementId: String): Completable { + preferenceHelper.getSessionId(sessionIncrementHandler) + val requestBody = MediaImpressionViewRequestBody( + bannerAdId, + placementId, + true, + BuildConfig.CLIENT_VERSION, + preferenceHelper.id, + preferenceHelper.getSessionId(), + preferenceHelper.apiKey, + configMemoryHolder.userId, + configMemoryHolder.segments, + System.currentTimeMillis() + ) + + return dataManager.trackMediaImpressionView(preferenceHelper, requestBody) + } + + internal fun trackMediaImpressionClickInternal(bannerAdId: String, placementId: String): Completable { + preferenceHelper.getSessionId(sessionIncrementHandler) + val requestBody = MediaImpressionClickRequestBody( + bannerAdId, + placementId, + true, + BuildConfig.CLIENT_VERSION, + preferenceHelper.id, + preferenceHelper.getSessionId(), + preferenceHelper.apiKey, + configMemoryHolder.userId, + configMemoryHolder.segments, + System.currentTimeMillis() + ) + + return dataManager.trackMediaImpressionClick(preferenceHelper, requestBody) + } + /** * Tracks generic result click events. * diff --git a/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt b/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt index f9467541..5fc7a199 100644 --- a/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt +++ b/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt @@ -24,5 +24,6 @@ data class ConstructorIoConfig( val defaultItemSection: String = BuildConfig.DEFAULT_ITEM_SECTION, val servicePort: Int = BuildConfig.SERVICE_PORT, val serviceScheme: String = BuildConfig.SERVICE_SCHEME, - val defaultAnalyticsTags: Map = emptyMap() + val defaultAnalyticsTags: Map = emptyMap(), + val mediaServiceUrl: String = "behavior.media-cnstrc.com" ) \ No newline at end of file diff --git a/library/src/main/java/io/constructor/data/DataManager.kt b/library/src/main/java/io/constructor/data/DataManager.kt index d6ec16d0..10936889 100755 --- a/library/src/main/java/io/constructor/data/DataManager.kt +++ b/library/src/main/java/io/constructor/data/DataManager.kt @@ -13,11 +13,14 @@ import io.constructor.data.model.recommendations.RecommendationResultViewRequest import io.constructor.data.model.recommendations.RecommendationsResponse import io.constructor.data.model.search.* import io.constructor.data.model.tracking.GenericResultClickRequestBody +import io.constructor.data.model.tracking.MediaImpressionClickRequestBody +import io.constructor.data.model.tracking.MediaImpressionViewRequestBody import io.constructor.data.remote.ApiPaths import io.constructor.data.remote.ConstructorApi import io.constructor.injection.ConstructorSdk import io.reactivex.Completable import io.reactivex.Observable +import okhttp3.HttpUrl import javax.inject.Inject import javax.inject.Singleton @@ -312,6 +315,26 @@ constructor(private val constructorApi: ConstructorApi, @ConstructorSdk private return constructorApi.trackQuizConversion(quizConversionRequestBody, params.toMap()) } + fun trackMediaImpressionView(preferencesHelper: PreferencesHelper, mediaImpressionViewRequestBody: MediaImpressionViewRequestBody): Completable { + val url = buildMediaUrl(preferencesHelper, ApiPaths.URL_MEDIA_IMPRESSION_VIEW_EVENT) + return constructorApi.trackMediaImpressionView(url, mediaImpressionViewRequestBody) + } + + fun trackMediaImpressionClick(preferencesHelper: PreferencesHelper, mediaImpressionClickRequestBody: MediaImpressionClickRequestBody): Completable { + val url = buildMediaUrl(preferencesHelper, ApiPaths.URL_MEDIA_IMPRESSION_CLICK_EVENT) + return constructorApi.trackMediaImpressionClick(url, mediaImpressionClickRequestBody) + } + + private fun buildMediaUrl(preferencesHelper: PreferencesHelper, path: String): String { + val cleanedPathSegments = path.split("/").filter { it.isNotBlank() } + val builder = HttpUrl.Builder() + .scheme(preferencesHelper.scheme ?: "https") + .host(preferencesHelper.mediaServiceUrl ?: preferencesHelper.serviceUrl ?: "") + .port(preferencesHelper.port) + cleanedPathSegments.forEach { builder.addPathSegment(it) } + return builder.build().toString() + } + fun getQuizNextQuestion(quizId: String, encodedParams: Array> = arrayOf(), preferencesHelper: PreferencesHelper): Observable> { val url = "${preferencesHelper.scheme}://${preferencesHelper.quizzesServiceUrl}/${ApiPaths.URL_QUIZ_NEXT_QUESTION.format(quizId)}${getAdditionalParamsQueryString(encodedParams)}" return constructorApi.getQuizNextQuestion(url).map { diff --git a/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt b/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt index de04bdb2..cae7b8c7 100755 --- a/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt +++ b/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt @@ -46,7 +46,7 @@ class RequestInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val ignoreDtPaths = listOf(ApiPaths.URL_BROWSE_GROUPS, ApiPaths.URL_BROWSE_FACETS, ApiPaths.URL_BROWSE_FACET_OPTIONS); - val behavioralEndpointPaths = listOf(ApiPaths.URL_BEHAVIORAL_V1_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_V2_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_SEARCH_REGEX.toRegex() ) + val behavioralEndpointPaths = listOf(ApiPaths.URL_BEHAVIORAL_V1_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_V2_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_AD_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_SEARCH_REGEX.toRegex() ) val request = chain.request() var builder = request.url.newBuilder(); val newRequestBuilder = request.newBuilder() diff --git a/library/src/main/java/io/constructor/data/local/PreferencesHelper.kt b/library/src/main/java/io/constructor/data/local/PreferencesHelper.kt index fb836adb..1d95fb93 100755 --- a/library/src/main/java/io/constructor/data/local/PreferencesHelper.kt +++ b/library/src/main/java/io/constructor/data/local/PreferencesHelper.kt @@ -39,6 +39,10 @@ constructor(@ConstructorSdk val preferences: SharedPreferences) { get() = preferences.getString(PREF_QUIZZES_SERVICE_URL, "") set(value) = preferences.edit().putString(PREF_QUIZZES_SERVICE_URL, value).apply() + var mediaServiceUrl: String? + get() = preferences.getString(PREF_MEDIA_SERVICE_URL, "") + set(value) = preferences.edit().putString(PREF_MEDIA_SERVICE_URL, value).apply() + var port: Int get() = preferences.getInt(PREF_SERVICE_PORT, 443) set(value) = preferences.edit().putInt(PREF_SERVICE_PORT, value).apply() @@ -87,6 +91,7 @@ constructor(@ConstructorSdk val preferences: SharedPreferences) { const val SESSION_TIME_THRESHOLD = 1000 * 60 * 30 const val PREF_SERVICE_URL = "service_url" const val PREF_QUIZZES_SERVICE_URL = "quizzes_service_url" + const val PREF_MEDIA_SERVICE_URL = "media_service_url" const val PREF_SERVICE_PORT = "service_port" const val PREF_SERVICE_SCHEME = "service_scheme" } diff --git a/library/src/main/java/io/constructor/data/model/tracking/MediaImpressionClickRequestBody.kt b/library/src/main/java/io/constructor/data/model/tracking/MediaImpressionClickRequestBody.kt new file mode 100644 index 00000000..b851aad5 --- /dev/null +++ b/library/src/main/java/io/constructor/data/model/tracking/MediaImpressionClickRequestBody.kt @@ -0,0 +1,19 @@ +package io.constructor.data.model.tracking + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.io.Serializable + +@JsonClass(generateAdapter = true) +data class MediaImpressionClickRequestBody( + @Json(name = "banner_ad_id") val bannerAdId: String, + @Json(name = "placement_id") val placementId: String, + @Json(name = "beacon") val beacon: Boolean = true, + @Json(name = "c") val c: String, + @Json(name = "i") val i: String, + @Json(name = "s") val s: Int, + @Json(name = "key") val key: String, + @Json(name = "ui") val ui: String?, + @Json(name = "us") val us: List, + @Json(name = "_dt") val dt: Long +) : Serializable diff --git a/library/src/main/java/io/constructor/data/model/tracking/MediaImpressionViewRequestBody.kt b/library/src/main/java/io/constructor/data/model/tracking/MediaImpressionViewRequestBody.kt new file mode 100644 index 00000000..0fb00802 --- /dev/null +++ b/library/src/main/java/io/constructor/data/model/tracking/MediaImpressionViewRequestBody.kt @@ -0,0 +1,19 @@ +package io.constructor.data.model.tracking + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.io.Serializable + +@JsonClass(generateAdapter = true) +data class MediaImpressionViewRequestBody( + @Json(name = "banner_ad_id") val bannerAdId: String, + @Json(name = "placement_id") val placementId: String, + @Json(name = "beacon") val beacon: Boolean = true, + @Json(name = "c") val c: String, + @Json(name = "i") val i: String, + @Json(name = "s") val s: Int, + @Json(name = "key") val key: String, + @Json(name = "ui") val ui: String?, + @Json(name = "us") val us: List, + @Json(name = "_dt") val dt: Long +) : Serializable diff --git a/library/src/main/java/io/constructor/data/remote/ApiPaths.kt b/library/src/main/java/io/constructor/data/remote/ApiPaths.kt index 392b9c54..d757f8bd 100755 --- a/library/src/main/java/io/constructor/data/remote/ApiPaths.kt +++ b/library/src/main/java/io/constructor/data/remote/ApiPaths.kt @@ -6,6 +6,7 @@ package io.constructor.data.remote object ApiPaths { const val URL_BEHAVIORAL_V1_PREFIX = "/behavior" const val URL_BEHAVIORAL_V2_PREFIX = "/v2/behavioral_action" + const val URL_BEHAVIORAL_AD_PREFIX = "/v2/ad_behavioral_action" const val URL_BEHAVIORAL_SEARCH_REGEX = "/autocomplete/.*/(search|select|click_through)$" const val URL_AUTOCOMPLETE = "autocomplete/%s" const val URL_AUTOCOMPLETE_SELECT_EVENT = "autocomplete/{term}/select" @@ -34,4 +35,6 @@ object ApiPaths { const val URL_QUIZ_CONVERSION_EVENT = "v2/behavioral_action/quiz_conversion" const val URL_QUIZ_NEXT_QUESTION = "v1/quizzes/%s/next" const val URL_QUIZ_RESULTS = "v1/quizzes/%s/results" + const val URL_MEDIA_IMPRESSION_VIEW_EVENT = "v2/ad_behavioral_action/display_ad_view" + const val URL_MEDIA_IMPRESSION_CLICK_EVENT = "v2/ad_behavioral_action/display_ad_click" } diff --git a/library/src/main/java/io/constructor/data/remote/ConstructorApi.kt b/library/src/main/java/io/constructor/data/remote/ConstructorApi.kt index 38789552..4e933404 100755 --- a/library/src/main/java/io/constructor/data/remote/ConstructorApi.kt +++ b/library/src/main/java/io/constructor/data/remote/ConstructorApi.kt @@ -2,7 +2,10 @@ package io.constructor.data.remote import io.constructor.data.model.autocomplete.AutocompleteResponse import io.constructor.data.model.browse.* +import io.constructor.data.model.tracking.GenericResultClickRequestBody import io.constructor.data.model.tracking.ItemDetailLoadRequestBody +import io.constructor.data.model.tracking.MediaImpressionClickRequestBody +import io.constructor.data.model.tracking.MediaImpressionViewRequestBody import io.constructor.data.model.conversion.ConversionRequestBody import io.constructor.data.model.purchase.PurchaseRequestBody import io.constructor.data.model.quiz.* @@ -10,7 +13,6 @@ import io.constructor.data.model.recommendations.RecommendationResultClickReques import io.constructor.data.model.recommendations.RecommendationResultViewRequestBody import io.constructor.data.model.recommendations.RecommendationsResponse import io.constructor.data.model.search.* -import io.constructor.data.model.tracking.GenericResultClickRequestBody import io.reactivex.Completable import io.reactivex.Single import okhttp3.ResponseBody @@ -137,6 +139,12 @@ interface ConstructorApi { fun trackQuizConversion(@Body quizConversionRequestBody: QuizConversionRequestBody, @QueryMap params: Map): Completable + @POST + fun trackMediaImpressionView(@Url url: String, @Body mediaImpressionViewRequestBody: MediaImpressionViewRequestBody): Completable + + @POST + fun trackMediaImpressionClick(@Url url: String, @Body mediaImpressionClickRequestBody: MediaImpressionClickRequestBody): Completable + @GET fun getQuizNextQuestion(@Url quizUrl: String): Single> diff --git a/library/src/test/java/io/constructor/core/ConstructorIoIntegrationMediaTrackingTest.kt b/library/src/test/java/io/constructor/core/ConstructorIoIntegrationMediaTrackingTest.kt new file mode 100644 index 00000000..c90c1a0a --- /dev/null +++ b/library/src/test/java/io/constructor/core/ConstructorIoIntegrationMediaTrackingTest.kt @@ -0,0 +1,95 @@ +package io.constructor.core + +import android.content.Context +import io.constructor.data.local.PreferencesHelper +import io.constructor.data.memory.ConfigMemoryHolder +import io.constructor.test.createTestDataManager +import io.constructor.util.RxSchedulersOverrideRule +import io.mockk.every +import io.mockk.mockk +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ConstructorIoIntegrationMediaTrackingTest { + + @Rule + @JvmField + val overrideSchedulersRule = RxSchedulersOverrideRule() + + private var constructorIo = ConstructorIo + private val ctx = mockk() + private val preferencesHelper = mockk() + private val configMemoryHolder = mockk() + private val timeBetweenTests = 2000L + + private val testKey = "key_x6UnCVRZaJgIHFQD" + private val testPlacementId = "home" + private lateinit var bannerAdId: String + + @Before + fun setup() { + every { ctx.applicationContext } returns ctx + + every { preferencesHelper.apiKey } returns testKey + every { preferencesHelper.id } returns "wacko-the-guid" + every { preferencesHelper.scheme } returns "https" + every { preferencesHelper.serviceUrl } returns "ac.cnstrc.com" + every { preferencesHelper.mediaServiceUrl } returns "behavior.media-cnstrc.com" + every { preferencesHelper.port } returns 443 + every { preferencesHelper.defaultItemSection } returns "Products" + every { preferencesHelper.getSessionId(any(), any()) } returns 67 + + every { configMemoryHolder.autocompleteResultCount } returns null + every { configMemoryHolder.userId } returns "player-three" + every { configMemoryHolder.defaultAnalyticsTags } returns mapOf("appVersion" to "123", "appPlatform" to "Android") + every { configMemoryHolder.testCellParams } returns emptyList() + every { configMemoryHolder.segments } returns emptyList() + + val config = ConstructorIoConfig(testKey) + val dataManager = createTestDataManager(preferencesHelper, configMemoryHolder) + + constructorIo.testInit(ctx, config, dataManager, preferencesHelper, configMemoryHolder) + + bannerAdId = fetchBannerAdId() + } + + private fun fetchBannerAdId(): String { + val client = OkHttpClient() + val url = "https://display.media-cnstrc.com/display-ads?key=$testKey&placement_ids=$testPlacementId" + val request = Request.Builder().url(url).build() + client.newCall(request).execute().use { response -> + val body = response.body?.string() + val json = JSONObject(body ?: "{}") + val displayAds = json.getJSONObject("display_ads") + val placementArray = displayAds.optJSONArray(testPlacementId) + if (placementArray != null && placementArray.length() > 0) { + return placementArray.getJSONObject(0).getString("banner_ad_id") + } + val placementObject = displayAds.optJSONObject(testPlacementId) + if (placementObject != null) { + return placementObject.getString("banner_ad_id") + } + } + throw IllegalStateException("Unable to fetch banner ad id for placement $testPlacementId") + } + + @Test + fun trackMediaImpressionViewAgainstRealResponse() { + val observer = ConstructorIo.trackMediaImpressionViewInternal(bannerAdId, testPlacementId).test() + observer.assertComplete() + observer.assertNoErrors() + Thread.sleep(timeBetweenTests) + } + + @Test + fun trackMediaImpressionClickAgainstRealResponse() { + val observer = ConstructorIo.trackMediaImpressionClickInternal(bannerAdId, testPlacementId).test() + observer.assertComplete() + observer.assertNoErrors() + Thread.sleep(timeBetweenTests) + } +} diff --git a/library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt b/library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt new file mode 100644 index 00000000..64c9f4ad --- /dev/null +++ b/library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt @@ -0,0 +1,148 @@ +package io.constructor.core + +import android.content.Context +import io.constructor.data.local.PreferencesHelper +import io.constructor.data.memory.ConfigMemoryHolder +import io.constructor.test.createTestDataManager +import io.constructor.util.RxSchedulersOverrideRule +import io.mockk.every +import io.mockk.mockk +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.net.SocketTimeoutException +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals + +class ConstructorIoMediaTrackingTest { + + @Rule + @JvmField + val overrideSchedulersRule = RxSchedulersOverrideRule() + + private lateinit var mockServer: MockWebServer + private var constructorIo = ConstructorIo + private val ctx = mockk() + private val preferencesHelper = mockk() + private val configMemoryHolder = mockk() + + @Before + fun setup() { + mockServer = MockWebServer() + mockServer.start() + + every { ctx.applicationContext } returns ctx + + every { preferencesHelper.apiKey } returns "copper-key" + every { preferencesHelper.id } returns "wacko-the-guid" + every { preferencesHelper.serviceUrl } returns mockServer.hostName + every { preferencesHelper.mediaServiceUrl } returns mockServer.hostName + every { preferencesHelper.port } returns mockServer.port + every { preferencesHelper.scheme } returns "http" + every { preferencesHelper.defaultItemSection } returns "Products" + every { preferencesHelper.getSessionId(any(), any()) } returns 67 + + every { configMemoryHolder.autocompleteResultCount } returns null + every { configMemoryHolder.defaultAnalyticsTags } returns mapOf("appVersion" to "123", "appPlatform" to "Android") + every { configMemoryHolder.userId } returns "player-three" + every { configMemoryHolder.testCellParams } returns emptyList() + every { configMemoryHolder.segments } returns emptyList() + + val config = ConstructorIoConfig("dummyKey", mediaServiceUrl = mockServer.hostName) + val dataManager = createTestDataManager(preferencesHelper, configMemoryHolder) + + constructorIo.testInit(ctx, config, dataManager, preferencesHelper, configMemoryHolder) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun trackMediaImpressionView() { + val mockResponse = MockResponse().setResponseCode(204) + mockServer.enqueue(mockResponse) + + val observer = ConstructorIo.trackMediaImpressionViewInternal("test-banner", "home").test() + observer.assertComplete() + val request = awaitRequest() + assertEquals("POST", request.method) + assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_view")) + val requestBody = getRequestBody(request) + assertEquals("test-banner", requestBody["banner_ad_id"]) + assertEquals("home", requestBody["placement_id"]) + assertEquals("true", requestBody["beacon"]) + } + + @Test + fun trackMediaImpressionClick() { + val mockResponse = MockResponse().setResponseCode(204) + mockServer.enqueue(mockResponse) + + val observer = ConstructorIo.trackMediaImpressionClickInternal("test-banner", "home").test() + observer.assertComplete() + val request = awaitRequest() + assertEquals("POST", request.method) + assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_click")) + val requestBody = getRequestBody(request) + assertEquals("test-banner", requestBody["banner_ad_id"]) + assertEquals("home", requestBody["placement_id"]) + assertEquals("true", requestBody["beacon"]) + } + + @Test + fun trackMediaImpressionView500() { + val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error") + mockServer.enqueue(mockResponse) + + val observer = ConstructorIo.trackMediaImpressionViewInternal("test-banner", "home").test() + observer.assertError { true } + val request = awaitRequest() + assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_view")) + } + + @Test + fun trackMediaImpressionClick500() { + val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error") + mockServer.enqueue(mockResponse) + + val observer = ConstructorIo.trackMediaImpressionClickInternal("test-banner", "home").test() + observer.assertError { true } + val request = awaitRequest() + assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_click")) + } + + @Test + fun trackMediaImpressionViewTimeout() { + val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error") + mockResponse.throttleBody(0, 5, TimeUnit.SECONDS) + mockServer.enqueue(mockResponse) + + val observer = ConstructorIo.trackMediaImpressionViewInternal("test-banner", "home").test() + observer.assertError(SocketTimeoutException::class.java) + val request = mockServer.takeRequest() + assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_view")) + } + + @Test + fun trackMediaImpressionClickTimeout() { + val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error") + mockResponse.throttleBody(0, 5, TimeUnit.SECONDS) + mockServer.enqueue(mockResponse) + + val observer = ConstructorIo.trackMediaImpressionClickInternal("test-banner", "home").test() + observer.assertError(SocketTimeoutException::class.java) + val request = awaitRequest() + assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_click")) + } + + private fun awaitRequest(): RecordedRequest { + return mockServer.takeRequest(1, TimeUnit.SECONDS) + ?: throw AssertionError("Expected request but timed out") + } +} From 1b1ba46d6919be7a2e151e8bf7dfc3eb0f442a87 Mon Sep 17 00:00:00 2001 From: Viktor Zavala Date: Tue, 17 Mar 2026 15:45:39 +0100 Subject: [PATCH 2/2] [REM-3040] Address comments --- .../io/constructor/core/ConstructorIoConfig.kt | 1 + .../main/java/io/constructor/data/DataManager.kt | 8 ++++++-- .../data/interceptor/RequestInterceptor.kt | 7 +++++-- .../core/ConstructorIoMediaTrackingTest.kt | 16 +++++++++++----- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt b/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt index 5fc7a199..c6358b3e 100644 --- a/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt +++ b/library/src/main/java/io/constructor/core/ConstructorIoConfig.kt @@ -13,6 +13,7 @@ import io.constructor.BuildConfig * @property servicePort The port to use (for testing purposes only, defaults to 443) * @property serviceScheme The scheme to use (for testing purposes only, defaults to HTTPS) * @property defaultAnalyticsTags Additional analytics tags to pass. Will be merged with analytics tags passed on the request level + * @property mediaServiceUrl Constructor.io media service URL (defaults to "behavior.media-cnstrc.com") */ data class ConstructorIoConfig( val apiKey: String, diff --git a/library/src/main/java/io/constructor/data/DataManager.kt b/library/src/main/java/io/constructor/data/DataManager.kt index 10936889..043b0042 100755 --- a/library/src/main/java/io/constructor/data/DataManager.kt +++ b/library/src/main/java/io/constructor/data/DataManager.kt @@ -327,9 +327,13 @@ constructor(private val constructorApi: ConstructorApi, @ConstructorSdk private private fun buildMediaUrl(preferencesHelper: PreferencesHelper, path: String): String { val cleanedPathSegments = path.split("/").filter { it.isNotBlank() } + val scheme = preferencesHelper.scheme?.takeIf { it.isNotBlank() } ?: "https" + val host = preferencesHelper.mediaServiceUrl?.takeIf { it.isNotBlank() } + ?: preferencesHelper.serviceUrl?.takeIf { it.isNotBlank() } + ?: throw IllegalStateException("Media service host is not configured") val builder = HttpUrl.Builder() - .scheme(preferencesHelper.scheme ?: "https") - .host(preferencesHelper.mediaServiceUrl ?: preferencesHelper.serviceUrl ?: "") + .scheme(scheme) + .host(host) .port(preferencesHelper.port) cleanedPathSegments.forEach { builder.addPathSegment(it) } return builder.build().toString() diff --git a/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt b/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt index cae7b8c7..6a344c8a 100755 --- a/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt +++ b/library/src/main/java/io/constructor/data/interceptor/RequestInterceptor.kt @@ -46,13 +46,16 @@ class RequestInterceptor( override fun intercept(chain: Interceptor.Chain): Response { val ignoreDtPaths = listOf(ApiPaths.URL_BROWSE_GROUPS, ApiPaths.URL_BROWSE_FACETS, ApiPaths.URL_BROWSE_FACET_OPTIONS); - val behavioralEndpointPaths = listOf(ApiPaths.URL_BEHAVIORAL_V1_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_V2_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_AD_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_SEARCH_REGEX.toRegex() ) + val behavioralEndpointPrefixes = listOf(ApiPaths.URL_BEHAVIORAL_V1_PREFIX, ApiPaths.URL_BEHAVIORAL_V2_PREFIX, ApiPaths.URL_BEHAVIORAL_AD_PREFIX) + val behavioralSearchRegex = ApiPaths.URL_BEHAVIORAL_SEARCH_REGEX.toRegex() val request = chain.request() var builder = request.url.newBuilder(); val newRequestBuilder = request.newBuilder() /* Re-add, Redact url query parameters for /behavior, /v2/behavioral_action */ - if (behavioralEndpointPaths.any{request.url.encodedPath.matches(it)} ) { + val encodedPath = request.url.encodedPath + val isBehavioralEndpoint = behavioralEndpointPrefixes.any { encodedPath.startsWith(it) } || behavioralSearchRegex.matches(encodedPath) + if (isBehavioralEndpoint ) { builder = HttpUrl.Builder() .scheme(request.url.scheme) .port(request.url.port) diff --git a/library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt b/library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt index 64c9f4ad..347da300 100644 --- a/library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt +++ b/library/src/test/java/io/constructor/core/ConstructorIoMediaTrackingTest.kt @@ -119,23 +119,29 @@ class ConstructorIoMediaTrackingTest { @Test fun trackMediaImpressionViewTimeout() { - val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error") - mockResponse.throttleBody(0, 5, TimeUnit.SECONDS) + val mockResponse = MockResponse() + .setResponseCode(500) + .setBody("Internal server error") + .setBodyDelay(5, TimeUnit.SECONDS) mockServer.enqueue(mockResponse) val observer = ConstructorIo.trackMediaImpressionViewInternal("test-banner", "home").test() + observer.awaitDone(6, TimeUnit.SECONDS) observer.assertError(SocketTimeoutException::class.java) - val request = mockServer.takeRequest() + val request = awaitRequest(); assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_view")) } @Test fun trackMediaImpressionClickTimeout() { - val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error") - mockResponse.throttleBody(0, 5, TimeUnit.SECONDS) + val mockResponse = MockResponse() + .setResponseCode(500) + .setBody("Internal server error") + .setBodyDelay(5, TimeUnit.SECONDS) mockServer.enqueue(mockResponse) val observer = ConstructorIo.trackMediaImpressionClickInternal("test-banner", "home").test() + observer.awaitDone(6, TimeUnit.SECONDS) observer.assertError(SocketTimeoutException::class.java) val request = awaitRequest() assert(request.path!!.startsWith("/v2/ad_behavioral_action/display_ad_click"))