Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions library/src/main/java/io/constructor/core/ConstructorIo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2191,14 +2191,16 @@ object ConstructorIo {
* @param resultCount The total number of recommendation results
* @param resultPositionOnPage The position of the recommendation result that was clicked on
* @param analyticsTags Additional analytics tags to pass
* @param seedItemIds The seed item ID(s) used to generate the recommendation results
*/
fun trackRecommendationResultClick(podId: String, strategyId: String, customerId: String, variationId: String? = null, sectionName: String? = null, resultId: String? = null, numResultsPerPage: Int? = null, resultPage: Int? = null, resultCount: Int? = null, resultPositionOnPage: Int? = null, analyticsTags: Map<String, String>? = null) {
var completable = trackRecommendationResultClickInternal(podId, strategyId, customerId, variationId, sectionName, resultId, numResultsPerPage, resultPage, resultCount, resultPositionOnPage, analyticsTags)
fun trackRecommendationResultClick(podId: String, strategyId: String, customerId: String, variationId: String? = null, sectionName: String? = null, resultId: String? = null, numResultsPerPage: Int? = null, resultPage: Int? = null, resultCount: Int? = null, resultPositionOnPage: Int? = null, analyticsTags: Map<String, String>? = null, seedItemIds: List<String>? = null) {
var completable = trackRecommendationResultClickInternal(podId, strategyId, customerId, variationId, sectionName, resultId, numResultsPerPage, resultPage, resultCount, resultPositionOnPage, analyticsTags, seedItemIds)
Comment on lines +2196 to +2197
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding seedItemIds as a new parameter to the existing trackRecommendationResultClick function changes the JVM method signature and will break binary compatibility for apps compiled against older versions of this library (NoSuchMethodError at runtime) and source compatibility for Java callers. To avoid a breaking change, keep the old signature as an overload (without seedItemIds) delegating to the new implementation (e.g., passing null), and optionally deprecate the old overload.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Copilot has a point here. What do you think about leaving the original function as is and adding an overload?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The completable variable is declared with var but never reassigned — it should be val. This is a pre-existing pattern in the file, but since this PR is touching these lines, it would be a good opportunity to fix it here. Same applies to the two trackRecommendationResultsView overloads below.

// Change:
var completable = trackRecommendationResultClickInternal(...)
// To:
val completable = trackRecommendationResultClickInternal(...)

disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, {
t -> e("Recommendation Result Click error: ${t.message}")
}))
}
internal fun trackRecommendationResultClickInternal(podId: String, strategyId: String, customerId: String, variationId: String? = null, sectionName: String? = null, resultId: String? = null, numResultsPerPage: Int? = null, resultPage: Int? = null, resultCount: Int? = null, resultPositionOnPage: Int? = null, analyticsTags: Map<String, String>? = null): Completable {

internal fun trackRecommendationResultClickInternal(podId: String, strategyId: String, customerId: String, variationId: String? = null, sectionName: String? = null, resultId: String? = null, numResultsPerPage: Int? = null, resultPage: Int? = null, resultCount: Int? = null, resultPositionOnPage: Int? = null, analyticsTags: Map<String, String>? = null, seedItemIds: List<String>? = null): Completable {
preferenceHelper.getSessionId(sessionIncrementHandler)
val section = sectionName ?: preferenceHelper.defaultItemSection
val recommendationsResultClickRequestBody = RecommendationResultClickRequestBody(
Expand All @@ -2220,7 +2222,8 @@ object ConstructorIo {
mergeAnalyticsTags(configMemoryHolder.defaultAnalyticsTags, analyticsTags),
true,
section,
System.currentTimeMillis()
System.currentTimeMillis(),
seedItemIds?.takeIf { it.isNotEmpty() }
)

return dataManager.trackRecommendationResultClick(
Expand All @@ -2244,9 +2247,10 @@ object ConstructorIo {
* @param resultId The result ID of the recommendation response that the selection came from
* @param sectionName The section that the results came from, i.e. "Products"
* @param analyticsTags Additional analytics tags to pass
* @param seedItemIds The seed item ID(s) used to generate the recommendation results
*/
fun trackRecommendationResultsView(podId: String, itemIds: Array<String>, numResultsViewed: Int, resultPage: Int? = null, resultCount: Int? = null, resultId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null) {
var completable = trackRecommendationResultsViewInternal(podId, itemIds, numResultsViewed, resultPage, resultCount, resultId, sectionName, url, analyticsTags)
fun trackRecommendationResultsView(podId: String, itemIds: Array<String>, numResultsViewed: Int, resultPage: Int? = null, resultCount: Int? = null, resultId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null, seedItemIds: List<String>? = null) {
var completable = trackRecommendationResultsViewInternal(podId, itemIds, numResultsViewed, resultPage, resultCount, resultId, sectionName, url, analyticsTags, seedItemIds)
Comment on lines +2252 to +2253
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trackRecommendationResultsView(podId, itemIds, ...) gained a new trailing seedItemIds parameter, which changes the JVM method signature and is a binary-breaking change for existing consumers. Consider restoring the previous signature as an overload delegating to this new one (passing null), and optionally deprecate the old overload to guide migration.

Copilot uses AI. Check for mistakes.
disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, {
t -> e("Recommendation Results View error: ${t.message}")
}))
Expand All @@ -2266,14 +2270,16 @@ object ConstructorIo {
* @param resultId The result ID of the recommendation response that the selection came from
* @param sectionName The section that the results came from, i.e. "Products"
* @param analyticsTags Additional analytics tags to pass
* @param seedItemIds The seed item ID(s) used to generate the recommendation results
*/
fun trackRecommendationResultsView(podId: String, numResultsViewed: Int, resultPage: Int? = null, resultCount: Int? = null, resultId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null) {
var completable = trackRecommendationResultsViewInternal(podId, null, numResultsViewed, resultPage, resultCount, resultId, sectionName, url, analyticsTags)
fun trackRecommendationResultsView(podId: String, numResultsViewed: Int, resultPage: Int? = null, resultCount: Int? = null, resultId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null, seedItemIds: List<String>? = null) {
var completable = trackRecommendationResultsViewInternal(podId, null, numResultsViewed, resultPage, resultCount, resultId, sectionName, url, analyticsTags, seedItemIds)
Comment on lines +2275 to +2276
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trackRecommendationResultsView(podId, numResultsViewed, ...) gained a new trailing seedItemIds parameter, which changes the JVM method signature and will break binary compatibility for clients compiled against the prior version. To keep this non-breaking, reintroduce the previous signature as an overload delegating to the new one (with seedItemIds = null), and optionally deprecate it.

Copilot uses AI. Check for mistakes.
disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, {
t -> e("Recommendation Results View error: ${t.message}")
}))
}
internal fun trackRecommendationResultsViewInternal(podId: String, itemIds: Array<String>? = null, numResultsViewed: Int, resultPage: Int? = null, resultCount: Int? = null, resultId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null): Completable {

internal fun trackRecommendationResultsViewInternal(podId: String, itemIds: Array<String>? = null, numResultsViewed: Int, resultPage: Int? = null, resultCount: Int? = null, resultId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null, seedItemIds: List<String>? = null): Completable {
preferenceHelper.getSessionId(sessionIncrementHandler)
val section = sectionName ?: preferenceHelper.defaultItemSection
val items = itemIds?.map{ item -> TrackingItem(item, null, null, null)}
Expand All @@ -2294,7 +2300,8 @@ object ConstructorIo {
mergeAnalyticsTags(configMemoryHolder.defaultAnalyticsTags, analyticsTags),
true,
section,
System.currentTimeMillis()
System.currentTimeMillis(),
seedItemIds?.takeIf { it.isNotEmpty() }
)

return dataManager.trackRecommendationResultsView(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ data class RecommendationResultClickRequestBody(
@Json(name = "analytics_tags") val analyticsTags: Map<String, String>?,
@Json(name= "beacon") val beacon: Boolean?,
@Json(name= "section") val section: String?,
@Json(name= "_dt") val _dt: Long?
@Json(name= "_dt") val _dt: Long?,
@Json(name = "seed_item_ids") val seedItemIds: List<String>?
) : Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ data class RecommendationResultViewRequestBody(
@Json(name = "analytics_tags") val analyticsTags: Map<String, String>?,
@Json(name= "beacon") val beacon: Boolean?,
@Json(name= "section") val section: String?,
@Json(name= "_dt") val _dt: Long?
@Json(name= "_dt") val _dt: Long?,
@Json(name = "seed_item_ids") val seedItemIds: List<String>?
) : Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import java.net.URLEncoder
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertNull

internal fun getRequestBody(request: RecordedRequest): Map<String, String> {
val requestBodyString = request.body.readUtf8().drop(1).dropLast(1).replace("\"", "")
Expand Down Expand Up @@ -1133,6 +1134,7 @@ class ConstructorIoTrackingTest {
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("User Featured", requestBody["strategy_id"])
assertEquals("TIT-REP-1997", requestBody["item_id"])
assertNull(requestBody["seed_item_ids"])
assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}
Expand Down Expand Up @@ -1170,6 +1172,57 @@ class ConstructorIoTrackingTest {
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultClickWithSingleSeedItemId() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The new tests for seedItemIds only exercise the *Internal methods directly. There are no tests calling the public trackRecommendationResultClick or trackRecommendationResultsView overloads (with itemIds: Array<String> and without) with seedItemIds populated. Since the public methods simply delegate to the internal ones the risk is low, but adding at least one smoke-test for a public overload with seedItemIds would make the test suite consistent with how other parameters are tested in this file.

val mockResponse = MockResponse().setResponseCode(204)
mockServer.enqueue(mockResponse)
val observer = ConstructorIo.trackRecommendationResultClickInternal("pdp5", "User Featured","TIT-REP-1997", seedItemIds = listOf("seed-item-123")).test()
observer.assertComplete()
val request = mockServer.takeRequest()
val requestBody = getRequestBody(request)
val path = "/v2/behavioral_action/recommendation_result_click?section=Products&key=copper-key&i=wacko-the-guid&ui=player-three&s=67&c=cioand-2.39.0&_dt="
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("User Featured", requestBody["strategy_id"])
assertEquals("TIT-REP-1997", requestBody["item_id"])
assertEquals("[seed-item-123]", requestBody["seed_item_ids"])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important Issue: The test asserts "[seed-item-123]" (with brackets) as the serialized value of seed_item_ids. This format is the JSON array representation from Moshi's serialization, stripped of quotes by the getRequestBody helper. While this likely works today due to the parser implementation, the assertion is coupling the test to a specific serialization artefact of getRequestBody.

It would be clearer and more robust to verify this field is present and non-null, and separately test the actual JSON body format. At minimum, a comment explaining why the expected value includes brackets would help future readers understand the intent:

// Moshi serializes List<String> as a JSON array; getRequestBody strips quotes
// so "[\"seed-item-123\"]" becomes "[seed-item-123]"
assertEquals("[seed-item-123]", requestBody["seed_item_ids"])

assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultClickWithMultipleSeedItemIds() {
val mockResponse = MockResponse().setResponseCode(204)
mockServer.enqueue(mockResponse)
val observer = ConstructorIo.trackRecommendationResultClickInternal("pdp5", "User Featured","TIT-REP-1997", seedItemIds = listOf("seed-item-1", "seed-item-2", "seed-item-3")).test()
observer.assertComplete()
val request = mockServer.takeRequest()
val requestBody = getRequestBody(request)
val path = "/v2/behavioral_action/recommendation_result_click?section=Products&key=copper-key&i=wacko-the-guid&ui=player-three&s=67&c=cioand-2.39.0&_dt="
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("User Featured", requestBody["strategy_id"])
assertEquals("TIT-REP-1997", requestBody["item_id"])
assertEquals("[seed-item-1,seed-item-2,seed-item-3]", requestBody["seed_item_ids"])
assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultClickWithEmptySeedItemIds() {
val mockResponse = MockResponse().setResponseCode(204)
mockServer.enqueue(mockResponse)
val observer = ConstructorIo.trackRecommendationResultClickInternal("pdp5", "User Featured", "TIT-REP-1997", seedItemIds = listOf()).test()
observer.assertComplete()
val request = mockServer.takeRequest()
val requestBody = getRequestBody(request)
val path = "/v2/behavioral_action/recommendation_result_click?section=Products&key=copper-key&i=wacko-the-guid&ui=player-three&s=67&c=cioand-2.39.0&_dt="
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("User Featured", requestBody["strategy_id"])
assertEquals("TIT-REP-1997", requestBody["item_id"])
assertNull(requestBody["seed_item_ids"])
assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultClick500() {
val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error")
Expand Down Expand Up @@ -1208,6 +1261,7 @@ class ConstructorIoTrackingTest {
val path = "/v2/behavioral_action/recommendation_result_view?section=Products&key=copper-key&i=wacko-the-guid&ui=player-three&s=67&c=cioand-2.39.0&_dt="
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("4", requestBody["num_results_viewed"])
assertNull(requestBody["seed_item_ids"])
assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}
Expand Down Expand Up @@ -1264,6 +1318,54 @@ class ConstructorIoTrackingTest {
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultsViewWithSingleSeedItemId() {
val mockResponse = MockResponse().setResponseCode(204)
mockServer.enqueue(mockResponse)
val observer = ConstructorIo.trackRecommendationResultsViewInternal("pdp5", null, 4, seedItemIds = listOf("seed-item-123")).test()
observer.assertComplete()
val request = mockServer.takeRequest()
val requestBody = getRequestBody(request)
val path = "/v2/behavioral_action/recommendation_result_view?section=Products&key=copper-key&i=wacko-the-guid&ui=player-three&s=67&c=cioand-2.39.0&_dt="
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("4", requestBody["num_results_viewed"])
assertEquals("[seed-item-123]", requestBody["seed_item_ids"])
assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultsViewWithMultipleSeedItemIds() {
val mockResponse = MockResponse().setResponseCode(204)
mockServer.enqueue(mockResponse)
val observer = ConstructorIo.trackRecommendationResultsViewInternal("pdp5", null, 4, seedItemIds = listOf("seed-item-1", "seed-item-2", "seed-item-3")).test()
observer.assertComplete()
val request = mockServer.takeRequest()
val requestBody = getRequestBody(request)
val path = "/v2/behavioral_action/recommendation_result_view?section=Products&key=copper-key&i=wacko-the-guid&ui=player-three&s=67&c=cioand-2.39.0&_dt="
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("4", requestBody["num_results_viewed"])
assertEquals("[seed-item-1,seed-item-2,seed-item-3]", requestBody["seed_item_ids"])
assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultsViewWithEmptySeedItemIds() {
val mockResponse = MockResponse().setResponseCode(204)
mockServer.enqueue(mockResponse)
val observer = ConstructorIo.trackRecommendationResultsViewInternal("pdp5", null, 4, seedItemIds = listOf()).test()
observer.assertComplete()
val request = mockServer.takeRequest()
val requestBody = getRequestBody(request)
val path = "/v2/behavioral_action/recommendation_result_view?section=Products&key=copper-key&i=wacko-the-guid&ui=player-three&s=67&c=cioand-2.39.0&_dt="
assertEquals("pdp5", requestBody["pod_id"])
assertEquals("4", requestBody["num_results_viewed"])
assertNull(requestBody["seed_item_ids"])
assertEquals("POST", request.method)
assert(request.path!!.startsWith(path))
}

@Test
fun trackRecommendationResultsView500() {
val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error")
Expand Down
Loading