diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt index 6a2bb18e0..eae237c46 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/AppAction.kt @@ -15,6 +15,7 @@ import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory import org.mozilla.fenix.browser.StandardSnackbarError import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.AppStore +import org.mozilla.fenix.components.appstate.shopping.ShoppingState import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory import org.mozilla.fenix.home.recentbookmarks.RecentBookmark @@ -250,5 +251,12 @@ sealed class AppAction : Action { val productPageUrl: String, val expanded: Boolean, ) : ShoppingAction() + + /** + * [ShoppingAction] used to update the recorded product recommendation impressions set. + */ + data class ProductRecommendationImpression( + val key: ShoppingState.ProductRecommendationImpressionKey, + ) : ShoppingAction() } } diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingState.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingState.kt index d2287bd55..a9dd7f2c8 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingState.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingState.kt @@ -10,12 +10,28 @@ package org.mozilla.fenix.components.appstate.shopping * @property shoppingSheetExpanded Boolean indicating if the shopping sheet is expanded and visible. * @property productCardState Map of product url to [CardState] that contains the state of different * cards in the shopping sheet. + * @property recordedProductRecommendationImpressions Set of [ProductRecommendationImpressionKey] + * that contains the product recommendation impressions that have been recorded. */ data class ShoppingState( val shoppingSheetExpanded: Boolean? = null, val productCardState: Map = emptyMap(), + val recordedProductRecommendationImpressions: Set = emptySet(), ) { + /** + * Key for a product recommendation impression. + * + * @property tabId The id of the tab that the product and recommendation is displayed in. + * @property productUrl The url of the product. + * @property aid The id of the recommendation. + */ + data class ProductRecommendationImpressionKey( + val tabId: String, + val productUrl: String, + val aid: String, + ) + /** * State for different cards in the shopping sheet for a product. * diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingStateReducer.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingStateReducer.kt index f8c5b2b59..4779d26ac 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingStateReducer.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/shopping/ShoppingStateReducer.kt @@ -64,6 +64,13 @@ internal object ShoppingStateReducer { ), ) } + + is ShoppingAction.ProductRecommendationImpression -> state.copy( + shoppingState = state.shoppingState.copy( + recordedProductRecommendationImpressions = + state.shoppingState.recordedProductRecommendationImpressions + action.key, + ), + ) } private fun ShoppingState.updateProductCardState(key: String, value: CardState): ShoppingState = diff --git a/app/src/main/java/org/mozilla/fenix/shopping/di/ReviewQualityCheckMiddlewareProvider.kt b/app/src/main/java/org/mozilla/fenix/shopping/di/ReviewQualityCheckMiddlewareProvider.kt index 946ff852c..b0632bc20 100644 --- a/app/src/main/java/org/mozilla/fenix/shopping/di/ReviewQualityCheckMiddlewareProvider.kt +++ b/app/src/main/java/org/mozilla/fenix/shopping/di/ReviewQualityCheckMiddlewareProvider.kt @@ -13,6 +13,7 @@ import org.mozilla.fenix.shopping.DefaultShoppingExperienceFeature import org.mozilla.fenix.shopping.middleware.DefaultNetworkChecker import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckPreferences import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckService +import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckTelemetryService import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckVendorsService import org.mozilla.fenix.shopping.middleware.GetReviewQualityCheckSumoUrl import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckNavigationMiddleware @@ -47,7 +48,7 @@ object ReviewQualityCheckMiddlewareProvider { providePreferencesMiddleware(settings, browserStore, appStore, scope), provideNetworkMiddleware(browserStore, context, scope), provideNavigationMiddleware(TabsUseCases.SelectOrAddUseCase(browserStore), context), - provideTelemetryMiddleware(), + provideTelemetryMiddleware(browserStore, appStore, scope), ) private fun providePreferencesMiddleware( @@ -81,6 +82,15 @@ object ReviewQualityCheckMiddlewareProvider { GetReviewQualityCheckSumoUrl(context), ) - private fun provideTelemetryMiddleware() = - ReviewQualityCheckTelemetryMiddleware() + private fun provideTelemetryMiddleware( + browserStore: BrowserStore, + appStore: AppStore, + scope: CoroutineScope, + ) = + ReviewQualityCheckTelemetryMiddleware( + telemetryService = DefaultReviewQualityCheckTelemetryService(browserStore), + browserStore = browserStore, + appStore = appStore, + scope = scope, + ) } diff --git a/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNetworkMiddleware.kt b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNetworkMiddleware.kt index f4d741967..d461c57f0 100644 --- a/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNetworkMiddleware.kt +++ b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckNetworkMiddleware.kt @@ -82,14 +82,6 @@ class ReviewQualityCheckNetworkMiddleware( store.updateRecommendedProductState() } } - - is ReviewQualityCheckAction.RecommendedProductClick -> { - reviewQualityCheckService.recordRecommendedProductClick(action.productAid) - } - - is ReviewQualityCheckAction.RecommendedProductImpression -> { - reviewQualityCheckService.recordRecommendedProductImpression(action.productAid) - } } } } diff --git a/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckService.kt b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckService.kt index 56dd8d927..60ec062ab 100644 --- a/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckService.kt +++ b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckService.kt @@ -48,16 +48,6 @@ interface ReviewQualityCheckService { */ suspend fun productRecommendation(shouldRecordAvailableTelemetry: Boolean): ProductRecommendation? - /** - * Sends a click attribution event for a given product aid. - */ - suspend fun recordRecommendedProductClick(productAid: String) - - /** - * Sends an impression attribution event for a given product aid. - */ - suspend fun recordRecommendedProductImpression(productAid: String) - /** * Reports that a product is back in stock. * @@ -170,39 +160,6 @@ class DefaultReviewQualityCheckService( } } - override suspend fun recordRecommendedProductClick(productAid: String) = - withContext(Dispatchers.Main) { - suspendCoroutine { continuation -> - browserStore.state.selectedTab?.engineState?.engineSession?.sendClickAttributionEvent( - aid = productAid, - onResult = { - continuation.resume(Unit) - }, - onException = { - logger.error("Error sending click attribution event", it) - continuation.resume(Unit) - }, - ) - } - } - - override suspend fun recordRecommendedProductImpression(productAid: String) { - withContext(Dispatchers.Main) { - suspendCoroutine { continuation -> - browserStore.state.selectedTab?.engineState?.engineSession?.sendImpressionAttributionEvent( - aid = productAid, - onResult = { - continuation.resume(Unit) - }, - onException = { - logger.error("Error sending impression attribution event", it) - continuation.resume(Unit) - }, - ) - } - } - } - override suspend fun reportBackInStock(): ReportBackInStockStatusDto? = withContext(Dispatchers.Main) { suspendCoroutine { continuation -> browserStore.state.selectedTab?.let { tab -> diff --git a/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddleware.kt b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddleware.kt index b043d616e..1be0137e4 100644 --- a/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddleware.kt +++ b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddleware.kt @@ -4,10 +4,17 @@ package org.mozilla.fenix.shopping.middleware +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.lib.state.MiddlewareContext import mozilla.components.lib.state.Store import org.mozilla.fenix.GleanMetrics.Shopping import org.mozilla.fenix.GleanMetrics.ShoppingSettings +import org.mozilla.fenix.components.AppStore +import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction +import org.mozilla.fenix.components.appstate.shopping.ShoppingState import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware import org.mozilla.fenix.shopping.store.ReviewQualityCheckState @@ -18,8 +25,18 @@ private const val ACTION_DISABLED = "disabled" /** * Middleware that captures telemetry events for the review quality check feature. + * + * @param telemetryService The service that handles telemetry events for review checker. + * @param browserStore The [BrowserStore] instance to access the current tab. + * @param appStore The [AppStore] instance to access [ShoppingState]. + * @param scope The [CoroutineScope] to use for launching coroutines. */ -class ReviewQualityCheckTelemetryMiddleware : ReviewQualityCheckMiddleware { +class ReviewQualityCheckTelemetryMiddleware( + private val telemetryService: ReviewQualityCheckTelemetryService, + private val browserStore: BrowserStore, + private val appStore: AppStore, + private val scope: CoroutineScope, +) : ReviewQualityCheckMiddleware { override fun invoke( context: MiddlewareContext, @@ -129,11 +146,34 @@ class ReviewQualityCheckTelemetryMiddleware : ReviewQualityCheckMiddleware { } is ReviewQualityCheckAction.RecommendedProductImpression -> { - Shopping.surfaceAdsImpression.record() + browserStore.state.selectedTab?.let { tabSessionState -> + val key = ShoppingState.ProductRecommendationImpressionKey( + tabId = tabSessionState.id, + productUrl = tabSessionState.content.url, + aid = action.productAid, + ) + + val recordedImpressions = + appStore.state.shoppingState.recordedProductRecommendationImpressions + + if (!recordedImpressions.contains(key)) { + Shopping.surfaceAdsImpression.record() + scope.launch { + val result = + telemetryService.recordRecommendedProductImpression(action.productAid) + if (result != null) { + appStore.dispatch(ShoppingAction.ProductRecommendationImpression(key)) + } + } + } + } } is ReviewQualityCheckAction.RecommendedProductClick -> { Shopping.surfaceAdsClicked.record() + scope.launch { + telemetryService.recordRecommendedProductClick(action.productAid) + } } ReviewQualityCheckAction.ToggleProductRecommendation -> { diff --git a/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryService.kt b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryService.kt new file mode 100644 index 000000000..1c94452b3 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryService.kt @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.shopping.middleware + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.support.base.log.logger.Logger +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Service that handles telemetry events for review checker. + */ +interface ReviewQualityCheckTelemetryService { + + /** + * Sends a click attribution event for a given product aid. + */ + suspend fun recordRecommendedProductClick(productAid: String): Unit? + + /** + * Sends an impression attribution event for a given product aid. + */ + suspend fun recordRecommendedProductImpression(productAid: String): Unit? +} + +/** + * Service that handles the network requests for the review quality check feature. + * + * @param browserStore Reference to the application's [BrowserStore] to access state. + */ +class DefaultReviewQualityCheckTelemetryService( + private val browserStore: BrowserStore, +) : ReviewQualityCheckTelemetryService { + + private val logger = Logger(TAG) + + override suspend fun recordRecommendedProductClick(productAid: String) = + withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + browserStore.state.selectedTab?.engineState?.engineSession?.sendClickAttributionEvent( + aid = productAid, + onResult = { + continuation.resume(Unit) + }, + onException = { + logger.error("Error sending click attribution event", it) + continuation.resume(null) + }, + ) + } + } + + override suspend fun recordRecommendedProductImpression(productAid: String) = + withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + browserStore.state.selectedTab?.engineState?.engineSession?.sendImpressionAttributionEvent( + aid = productAid, + onResult = { + continuation.resume(Unit) + }, + onException = { + logger.error("Error sending impression attribution event", it) + continuation.resume(null) + }, + ) + } + } + + companion object { + private const val TAG = "ReviewQualityCheckTelemetryService" + } +} diff --git a/app/src/main/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckAction.kt b/app/src/main/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckAction.kt index 8aed790ce..3abfc4561 100644 --- a/app/src/main/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckAction.kt +++ b/app/src/main/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckAction.kt @@ -147,7 +147,7 @@ sealed interface ReviewQualityCheckAction : Action { data class RecommendedProductClick( val productAid: String, val productUrl: String, - ) : NavigationMiddlewareAction, NetworkAction, TelemetryAction + ) : NavigationMiddlewareAction, TelemetryAction /** * Triggered when the user views the recommended product. @@ -156,7 +156,7 @@ sealed interface ReviewQualityCheckAction : Action { */ data class RecommendedProductImpression( val productAid: String, - ) : NetworkAction, TelemetryAction + ) : TelemetryAction /** * Triggered when the user clicks on learn more link on the explainer card. diff --git a/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt b/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt index 9ea044796..30f9e708f 100644 --- a/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt +++ b/app/src/test/java/org/mozilla/fenix/components/appstate/ShoppingActionTest.kt @@ -203,4 +203,48 @@ class ShoppingActionTest { assertEquals(expected, store.state.shoppingState) } + + @Test + fun `WHEN product recommendation impression is recorded THEN state should reflect that`() { + val store = AppStore( + initialState = AppState( + shoppingState = ShoppingState( + recordedProductRecommendationImpressions = setOf( + ShoppingState.ProductRecommendationImpressionKey( + productUrl = "pdp", + tabId = "1", + aid = "aid", + ), + ), + ), + ), + ) + + store.dispatch( + AppAction.ShoppingAction.ProductRecommendationImpression( + key = ShoppingState.ProductRecommendationImpressionKey( + productUrl = "pdp2", + tabId = "2", + aid = "aid2", + ), + ), + ).joinBlocking() + + val expected = ShoppingState( + recordedProductRecommendationImpressions = setOf( + ShoppingState.ProductRecommendationImpressionKey( + productUrl = "pdp", + tabId = "1", + aid = "aid", + ), + ShoppingState.ProductRecommendationImpressionKey( + productUrl = "pdp2", + tabId = "2", + aid = "aid2", + ), + ), + ) + + assertEquals(expected, store.state.shoppingState) + } } diff --git a/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt b/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt index 2965bc435..e049f88fb 100644 --- a/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt +++ b/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckService.kt @@ -16,8 +16,6 @@ class FakeReviewQualityCheckService( private val reanalysis: AnalysisStatusDto? = null, private val statusProgress: () -> AnalysisStatusProgressDto? = { null }, private val productRecommendation: () -> ProductRecommendation? = { null }, - private val recordClick: (String) -> Unit = {}, - private val recordImpression: (String) -> Unit = {}, private val report: ReportBackInStockStatusDto? = null, ) : ReviewQualityCheckService { @@ -39,13 +37,5 @@ class FakeReviewQualityCheckService( return productRecommendation.invoke() } - override suspend fun recordRecommendedProductClick(productAid: String) { - recordClick(productAid) - } - - override suspend fun recordRecommendedProductImpression(productAid: String) { - recordImpression(productAid) - } - override suspend fun reportBackInStock(): ReportBackInStockStatusDto? = report } diff --git a/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckTelemetryService.kt b/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckTelemetryService.kt new file mode 100644 index 000000000..07a377f3b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/shopping/fake/FakeReviewQualityCheckTelemetryService.kt @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.shopping.fake + +import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckTelemetryService + +class FakeReviewQualityCheckTelemetryService( + private val recordClick: (String) -> Unit = {}, + private val recordImpression: (String) -> Unit = {}, +) : ReviewQualityCheckTelemetryService { + + override suspend fun recordRecommendedProductClick(productAid: String) { + return recordClick.invoke(productAid) + } + + override suspend fun recordRecommendedProductImpression(productAid: String) { + return recordImpression.invoke(productAid) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt b/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt index e3c1f16d8..2a631516f 100644 --- a/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt +++ b/app/src/test/java/org/mozilla/fenix/shopping/middleware/ReviewQualityCheckTelemetryMiddlewareTest.kt @@ -4,10 +4,15 @@ package org.mozilla.fenix.shopping.middleware +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.service.glean.testing.GleanTestRule import mozilla.components.support.test.ext.joinBlocking import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -16,8 +21,12 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mozilla.fenix.GleanMetrics.Shopping +import org.mozilla.fenix.components.AppStore +import org.mozilla.fenix.components.appstate.AppState +import org.mozilla.fenix.components.appstate.shopping.ShoppingState import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.shopping.ProductAnalysisTestData +import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckTelemetryService import org.mozilla.fenix.shopping.store.BottomSheetDismissSource import org.mozilla.fenix.shopping.store.BottomSheetViewState import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction @@ -31,14 +40,15 @@ class ReviewQualityCheckTelemetryMiddlewareTest { @get:Rule val gleanTestRule = GleanTestRule(testContext) + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private lateinit var store: ReviewQualityCheckStore @Before fun setup() { store = ReviewQualityCheckStore( - middleware = listOf( - ReviewQualityCheckTelemetryMiddleware(), - ), + middleware = provideTelemetryMiddleware(), ) store.waitUntilIdle() } @@ -53,18 +63,23 @@ class ReviewQualityCheckTelemetryMiddlewareTest { @Test fun `WHEN the bottom sheet is closed THEN the bottom sheet closed event is recorded`() { - store.dispatch(ReviewQualityCheckAction.BottomSheetClosed(BottomSheetDismissSource.CLICK_OUTSIDE)).joinBlocking() + store.dispatch(ReviewQualityCheckAction.BottomSheetClosed(BottomSheetDismissSource.CLICK_OUTSIDE)) + .joinBlocking() store.waitUntilIdle() assertNotNull(Shopping.surfaceClosed.testGetValue()) val event = Shopping.surfaceClosed.testGetValue()!! assertEquals(1, event.size) - assertEquals(BottomSheetDismissSource.CLICK_OUTSIDE.sourceName, event.single().extra?.getValue("source")) + assertEquals( + BottomSheetDismissSource.CLICK_OUTSIDE.sourceName, + event.single().extra?.getValue("source"), + ) } @Test fun `WHEN the bottom sheet is displayed THEN the bottom sheet displayed event is recorded`() { - store.dispatch(ReviewQualityCheckAction.BottomSheetDisplayed(BottomSheetViewState.HALF_VIEW)).joinBlocking() + store.dispatch(ReviewQualityCheckAction.BottomSheetDisplayed(BottomSheetViewState.HALF_VIEW)) + .joinBlocking() store.waitUntilIdle() assertNotNull(Shopping.surfaceDisplayed.testGetValue()) @@ -122,9 +137,7 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productVendor = ReviewQualityCheckState.ProductVendor.AMAZON, isHighlightsExpanded = false, ), - middleware = listOf( - ReviewQualityCheckTelemetryMiddleware(), - ), + middleware = provideTelemetryMiddleware(), ) tested.waitUntilIdle() tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking() @@ -142,9 +155,7 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productVendor = ReviewQualityCheckState.ProductVendor.AMAZON, isHighlightsExpanded = true, ), - middleware = listOf( - ReviewQualityCheckTelemetryMiddleware(), - ), + middleware = provideTelemetryMiddleware(), ) tested.waitUntilIdle() tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking() @@ -162,9 +173,7 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productVendor = ReviewQualityCheckState.ProductVendor.AMAZON, isSettingsExpanded = false, ), - middleware = listOf( - ReviewQualityCheckTelemetryMiddleware(), - ), + middleware = provideTelemetryMiddleware(), ) tested.waitUntilIdle() tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking() @@ -182,9 +191,7 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productVendor = ReviewQualityCheckState.ProductVendor.AMAZON, isSettingsExpanded = true, ), - middleware = listOf( - ReviewQualityCheckTelemetryMiddleware(), - ), + middleware = provideTelemetryMiddleware(), ) tested.waitUntilIdle() tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking() @@ -253,7 +260,7 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productRecommendationsExposure = true, productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY, ), - middleware = listOf(ReviewQualityCheckTelemetryMiddleware()), + middleware = provideTelemetryMiddleware(), ) tested.dispatch( @@ -278,7 +285,7 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productRecommendationsExposure = true, productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY, ), - middleware = listOf(ReviewQualityCheckTelemetryMiddleware()), + middleware = provideTelemetryMiddleware(), ) tested.dispatch( @@ -302,7 +309,7 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productRecommendationsExposure = true, productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY, ), - middleware = listOf(ReviewQualityCheckTelemetryMiddleware()), + middleware = provideTelemetryMiddleware(), ) tested.dispatch( @@ -317,20 +324,136 @@ class ReviewQualityCheckTelemetryMiddlewareTest { } @Test - fun `WHEN a product recommendation is visible for more than one and a half seconds THEN ad impression telemetry probe is sent`() { - store.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("")).joinBlocking() - store.waitUntilIdle() + fun `GIVEN a recommendation impression action is dispatched WHEN app state does not contain key with tab id, product url and aid THEN ad impression telemetry probe is sent`() = + runTest { + var productViewed: String? = null + val tested = ReviewQualityCheckStore( + middleware = provideTelemetryMiddleware( + reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService( + recordImpression = { + productViewed = it + }, + ), + browserState = BrowserState( + selectedTabId = "tabId", + tabs = listOf( + createTab( + id = "tabId", + url = "pdp", + ), + ), + ), + ), + ) + tested.waitUntilIdle() + tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId")) + .joinBlocking() + tested.waitUntilIdle() + + assertNotNull(Shopping.surfaceAdsImpression.testGetValue()) + assertEquals("productId", productViewed) + } - assertNotNull(Shopping.surfaceAdsImpression.testGetValue()) - } + @Test + fun `WHEN recommendation impression action is dispatched many times and app state does not initially contain key with tab id, product url and aid THEN ad impression telemetry probe is sent only once`() = + runTest { + var productViewed: String? = null + var impressionCount = 0 + val appStore = AppStore() + val tested = ReviewQualityCheckStore( + middleware = provideTelemetryMiddleware( + reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService( + recordImpression = { + productViewed = it + impressionCount++ + }, + ), + browserState = BrowserState( + selectedTabId = "tabId", + tabs = listOf( + createTab( + id = "tabId", + url = "pdp", + ), + ), + ), + appStore = appStore, + ), + ) + tested.waitUntilIdle() + for (i in 1..100) { + tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId")) + .joinBlocking() + tested.waitUntilIdle() + appStore.waitUntilIdle() + } + + assertNotNull(Shopping.surfaceAdsImpression.testGetValue()) + assertEquals("productId", productViewed) + assertEquals(1, impressionCount) + } @Test - fun `WHEN a product recommendation is clicked THEN the ad clicked telemetry probe is sent`() { - store.dispatch(ReviewQualityCheckAction.RecommendedProductClick("", "")).joinBlocking() - store.waitUntilIdle() + fun `GIVEN a recommendation impression action is dispatched WHEN app state contains key with tab id, product url and aid THEN ad impression telemetry probe is NOT sent`() = + runTest { + var productViewed: String? = null + val tested = ReviewQualityCheckStore( + middleware = provideTelemetryMiddleware( + reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService( + recordImpression = { productViewed = it }, + ), + browserState = BrowserState( + selectedTabId = "tabId", + tabs = listOf( + createTab( + id = "tabId", + url = "pdp", + ), + ), + ), + appStore = AppStore( + AppState( + shoppingState = ShoppingState( + recordedProductRecommendationImpressions = setOf( + ShoppingState.ProductRecommendationImpressionKey( + tabId = "tabId", + productUrl = "pdp", + aid = "productId", + ), + ), + ), + ), + ), + ), + ) + tested.waitUntilIdle() + tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId")) + .joinBlocking() + tested.waitUntilIdle() + + assertNull(Shopping.surfaceAdsImpression.testGetValue()) + assertNull(productViewed) + } - assertNotNull(Shopping.surfaceAdsClicked.testGetValue()) - } + @Test + fun `WHEN a product recommendation is clicked THEN the ad clicked telemetry probe is sent`() = + runTest { + var productClicked: String? = null + val tested = ReviewQualityCheckStore( + middleware = provideTelemetryMiddleware( + reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService( + recordClick = { productClicked = it }, + ), + ), + ) + tested.waitUntilIdle() + tested.dispatch(ReviewQualityCheckAction.RecommendedProductClick("productId", "")) + .joinBlocking() + tested.waitUntilIdle() + + assertNotNull(Shopping.surfaceAdsClicked.testGetValue()) + assertEquals("productId", productClicked) + } @Test fun `GIVEN the user has opted in WHEN the user switches product recommendations on THEN send enabled product recommendations toggled telemetry probe`() { @@ -341,15 +464,16 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productVendor = ReviewQualityCheckState.ProductVendor.AMAZON, isHighlightsExpanded = false, ), - middleware = listOf( - ReviewQualityCheckTelemetryMiddleware(), - ), + middleware = provideTelemetryMiddleware(), ) tested.waitUntilIdle() tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking() tested.waitUntilIdle() - assertEquals("enabled", Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"]) + assertEquals( + "enabled", + Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"], + ) } @Test @@ -361,14 +485,28 @@ class ReviewQualityCheckTelemetryMiddlewareTest { productVendor = ReviewQualityCheckState.ProductVendor.AMAZON, isHighlightsExpanded = false, ), - middleware = listOf( - ReviewQualityCheckTelemetryMiddleware(), - ), + middleware = provideTelemetryMiddleware(), ) tested.waitUntilIdle() tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking() tested.waitUntilIdle() - assertEquals("disabled", Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"]) + assertEquals( + "disabled", + Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"], + ) } + + private fun provideTelemetryMiddleware( + reviewQualityCheckTelemetryService: FakeReviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(), + browserState: BrowserState = BrowserState(), + appStore: AppStore = AppStore(), + ) = listOf( + ReviewQualityCheckTelemetryMiddleware( + reviewQualityCheckTelemetryService, + BrowserStore(browserState), + appStore, + coroutinesTestRule.scope, + ), + ) } diff --git a/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt b/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt index 7833d84c8..254fef743 100644 --- a/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt +++ b/app/src/test/java/org/mozilla/fenix/shopping/store/ReviewQualityCheckStoreTest.kt @@ -1618,81 +1618,6 @@ class ReviewQualityCheckStoreTest { captureActionsMiddleware.assertNotDispatched(ReviewQualityCheckAction.UpdateRecommendedProduct::class) } - @Test - fun `GIVEN product recommendations are enabled WHEN recommended product is clicked THEN click event is recorded`() = - runTest { - var productClicked: String? = null - val tested = ReviewQualityCheckStore( - middleware = provideReviewQualityCheckMiddleware( - reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences( - isEnabled = true, - isProductRecommendationsEnabled = true, - ), - reviewQualityCheckService = FakeReviewQualityCheckService( - productAnalysis = { ProductAnalysisTestData.productAnalysis() }, - productRecommendation = { - ProductRecommendationTestData.productRecommendation( - aid = "342", - ) - }, - recordClick = { - productClicked = it - }, - ), - ), - ) - tested.waitUntilIdle() - dispatcher.scheduler.advanceUntilIdle() - tested.waitUntilIdle() - tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking() - tested.waitUntilIdle() - dispatcher.scheduler.advanceUntilIdle() - tested.dispatch( - ReviewQualityCheckAction.RecommendedProductClick( - productAid = "342", - productUrl = "https://test.com", - ), - ).joinBlocking() - - assertEquals("342", productClicked) - } - - @Test - fun `GIVEN product recommendations are enabled WHEN recommended product is viewed THEN impression event is recorded`() = - runTest { - var productViewed: String? = null - val tested = ReviewQualityCheckStore( - middleware = provideReviewQualityCheckMiddleware( - reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences( - isEnabled = true, - isProductRecommendationsEnabled = true, - ), - reviewQualityCheckService = FakeReviewQualityCheckService( - productAnalysis = { ProductAnalysisTestData.productAnalysis() }, - productRecommendation = { - ProductRecommendationTestData.productRecommendation( - aid = "342", - ) - }, - recordImpression = { - productViewed = it - }, - ), - ), - ) - tested.waitUntilIdle() - dispatcher.scheduler.advanceUntilIdle() - tested.waitUntilIdle() - tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking() - tested.waitUntilIdle() - dispatcher.scheduler.advanceUntilIdle() - tested.dispatch( - ReviewQualityCheckAction.RecommendedProductImpression(productAid = "342"), - ).joinBlocking() - - assertEquals("342", productViewed) - } - private fun provideReviewQualityCheckMiddleware( reviewQualityCheckPreferences: ReviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(), reviewQualityCheckVendorsService: FakeReviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(),