Bug 1847923 - Part 2: Add ReviewQualityCheckNetworkMiddleware

fenix/118.0
rahulsainani 11 months ago committed by mergify[bot]
parent 5510dc1101
commit 4c52f74add

@ -32,6 +32,7 @@ class ReviewQualityCheckFragment : BottomSheetDialogFragment() {
ReviewQualityCheckStore(
middleware = ReviewQualityCheckMiddlewareProvider.provideMiddleware(
settings = requireComponents.settings,
browserStore = requireComponents.core.store,
scope = lifecycleScope,
),
)

@ -5,8 +5,11 @@
package org.mozilla.fenix.shopping.di
import kotlinx.coroutines.CoroutineScope
import mozilla.components.browser.state.store.BrowserStore
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckNetworkMiddleware
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferencesImpl
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferencesMiddleware
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckServiceImpl
import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware
import org.mozilla.fenix.utils.Settings
@ -19,21 +22,32 @@ object ReviewQualityCheckMiddlewareProvider {
* Provides middlewares for review quality check feature.
*
* @param settings The [Settings] instance to use.
* @param browserStore The [BrowserStore] instance to access state.
* @param scope The [CoroutineScope] to use for launching coroutines.
*/
fun provideMiddleware(
settings: Settings,
browserStore: BrowserStore,
scope: CoroutineScope,
): List<ReviewQualityCheckMiddleware> =
listOf(providePreferencesMiddleware(settings, scope))
listOf(
providePreferencesMiddleware(settings, scope),
provideNetworkMiddleware(browserStore, scope),
)
private fun providePreferencesMiddleware(
settings: Settings,
scope: CoroutineScope,
) = ReviewQualityCheckPreferencesMiddleware(
reviewQualityCheckPreferences = ReviewQualityCheckPreferencesImpl(
settings,
),
reviewQualityCheckPreferences = ReviewQualityCheckPreferencesImpl(settings),
scope = scope,
)
private fun provideNetworkMiddleware(
browserStore: BrowserStore,
scope: CoroutineScope,
) = ReviewQualityCheckNetworkMiddleware(
reviewQualityCheckService = ReviewQualityCheckServiceImpl(browserStore),
scope = scope,
)
}

@ -8,6 +8,7 @@ import mozilla.components.browser.engine.gecko.shopping.GeckoProductAnalysis
import mozilla.components.browser.engine.gecko.shopping.Highlight
import mozilla.components.concept.engine.shopping.ProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.HighlightType
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
/**
@ -52,18 +53,27 @@ private fun String.toGrade(): ReviewQualityCheckState.Grade? =
null
}
private fun Highlight.toHighlights(): Map<ReviewQualityCheckState.HighlightType, List<String>>? {
val highlights: Map<ReviewQualityCheckState.HighlightType, List<String>?> = mapOf(
ReviewQualityCheckState.HighlightType.QUALITY to quality,
ReviewQualityCheckState.HighlightType.PRICE to price,
ReviewQualityCheckState.HighlightType.SHIPPING to shipping,
ReviewQualityCheckState.HighlightType.PACKAGING_AND_APPEARANCE to appearance,
ReviewQualityCheckState.HighlightType.COMPETITIVENESS to competitiveness,
)
private fun Highlight.toHighlights(): Map<HighlightType, List<String>>? =
HighlightType.values()
.associateWith { highlightsForType(it) }
.filterValues { it != null }
.mapValues { it.value!! }
.ifEmpty { null }
return highlights.filterValues { it != null }.mapValues { it.value!! }.ifEmpty { null }
}
private fun Highlight.highlightsForType(highlightType: HighlightType) =
when (highlightType) {
HighlightType.QUALITY -> quality
HighlightType.PRICE -> price
HighlightType.SHIPPING -> shipping
HighlightType.PACKAGING_AND_APPEARANCE -> appearance
HighlightType.COMPETITIVENESS -> competitiveness
}
/**
* GeckoView sets 0.0 as default instead of null for adjusted rating. This maps 0.0 to null making
* it easier for the UI layer to decide whether to display a UI element based on the presence of
* value.
*/
private fun Double.toFloatOrNull(): Float? =
if (this == 0.0) {
null

@ -0,0 +1,59 @@
/* 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.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.lib.state.Store
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.FetchProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.RetryProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
/**
* Middleware that handles network requests for the review quality check feature.
*
* @property reviewQualityCheckService The service that handles the network requests.
* @property scope The [CoroutineScope] that will be used to launch coroutines.
*/
class ReviewQualityCheckNetworkMiddleware(
private val reviewQualityCheckService: ReviewQualityCheckService,
private val scope: CoroutineScope,
) : Middleware<ReviewQualityCheckState, ReviewQualityCheckAction> {
override fun invoke(
context: MiddlewareContext<ReviewQualityCheckState, ReviewQualityCheckAction>,
next: (ReviewQualityCheckAction) -> Unit,
action: ReviewQualityCheckAction,
) {
when (action) {
is ReviewQualityCheckAction.NetworkAction -> processAction(context.store, action)
else -> {
// no-op
}
}
next(action)
}
private fun processAction(
store: Store<ReviewQualityCheckState, ReviewQualityCheckAction>,
action: ReviewQualityCheckAction.NetworkAction,
) {
when (action) {
FetchProductAnalysis, RetryProductAnalysis -> {
store.dispatch(ReviewQualityCheckAction.UpdateProductReview(ProductReviewState.Loading))
scope.launch {
val analysis = reviewQualityCheckService.fetchProductReview()
val productReviewState = analysis.toProductReviewState()
store.dispatch(ReviewQualityCheckAction.UpdateProductReview(productReviewState))
}
}
}
}
}

@ -0,0 +1,48 @@
/* 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.concept.engine.shopping.ProductAnalysis
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Service that handles the network requests for the review quality check feature.
*/
interface ReviewQualityCheckService {
/**
* Fetches the product review for the current tab.
*
* @return [ProductAnalysis] if the request succeeds, null otherwise.
*/
suspend fun fetchProductReview(): ProductAnalysis?
}
/**
* Service that handles the network requests for the review quality check feature.
*
* @property browserStore Reference to the application's [BrowserStore] to access state.
*/
class ReviewQualityCheckServiceImpl(
private val browserStore: BrowserStore,
) : ReviewQualityCheckService {
override suspend fun fetchProductReview(): ProductAnalysis? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
tab.engineState.engineSession?.requestProductAnalysis(
url = tab.content.url,
onResult = { continuation.resume(it) },
onException = { continuation.resume(null) },
)
}
}
}
}

@ -5,17 +5,13 @@
package org.mozilla.fenix.shopping.store
import mozilla.components.lib.state.Action
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
/**
* Actions for review quality check feature.
*/
sealed interface ReviewQualityCheckAction : Action {
/**
* Actions that are observed by middlewares.
*/
sealed interface MiddlewareAction : ReviewQualityCheckAction
/**
* Actions that cause updates to state.
*/
@ -24,7 +20,12 @@ sealed interface ReviewQualityCheckAction : Action {
/**
* Actions related to preferences.
*/
sealed interface PreferencesMiddlewareAction : MiddlewareAction
sealed interface PreferencesMiddlewareAction : ReviewQualityCheckAction
/**
* Actions related to network requests.
*/
sealed interface NetworkAction : ReviewQualityCheckAction
/**
* Triggered when the store is initialized.
@ -53,4 +54,19 @@ sealed interface ReviewQualityCheckAction : Action {
val hasUserOptedIn: Boolean,
val isProductRecommendationsEnabled: Boolean,
) : UpdateAction
/**
* Triggered as a result of a [NetworkAction] to update the state.
*/
data class UpdateProductReview(val productReviewState: ProductReviewState) : UpdateAction
/**
* Triggered when the user has opted in to the review quality check feature and the UI is opened.
*/
object FetchProductAnalysis : NetworkAction
/**
* Triggered when the user retries to fetch product analysis after a failure.
*/
object RetryProductAnalysis : NetworkAction
}

@ -32,7 +32,7 @@ sealed interface ReviewQualityCheckState : State {
* recommendations. True if product recommendations should be shown.
*/
data class OptedIn(
val productReviewState: ProductReviewState = fakeAnalysis,
val productReviewState: ProductReviewState = ProductReviewState.Loading,
val productRecommendationsPreference: Boolean,
) : ReviewQualityCheckState {
@ -152,41 +152,3 @@ fun Map<HighlightType, List<String>>.forCompactMode(): Map<HighlightType, List<S
entries.first().let { entry ->
mapOf(entry.key to entry.value.take(NUMBER_OF_HIGHLIGHTS_FOR_COMPACT_MODE))
}
/**
* Fake analysis for showing the UI. To be deleted once the API is integrated.
*/
private val fakeAnalysis = ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent(
productId = "123",
reviewGrade = ReviewQualityCheckState.Grade.B,
needsAnalysis = false,
adjustedRating = 3.6f,
productUrl = "123",
highlights = sortedMapOf(
HighlightType.QUALITY to listOf(
"High quality",
"Excellent craftsmanship",
"Superior materials",
),
HighlightType.PRICE to listOf(
"Affordable prices",
"Great value for money",
"Discounted offers",
),
HighlightType.SHIPPING to listOf(
"Fast and reliable shipping",
"Free shipping options",
"Express delivery",
),
HighlightType.PACKAGING_AND_APPEARANCE to listOf(
"Elegant packaging",
"Attractive appearance",
"Beautiful design",
),
HighlightType.COMPETITIVENESS to listOf(
"Competitive pricing",
"Strong market presence",
"Unbeatable deals",
),
),
)

@ -38,9 +38,9 @@ private fun mapStateForUpdateAction(
state: ReviewQualityCheckState,
action: ReviewQualityCheckAction.UpdateAction,
): ReviewQualityCheckState {
when (action) {
return when (action) {
is ReviewQualityCheckAction.UpdateUserPreferences -> {
return if (action.hasUserOptedIn) {
if (action.hasUserOptedIn) {
if (state is ReviewQualityCheckState.OptedIn) {
state.copy(productRecommendationsPreference = action.isProductRecommendationsEnabled)
} else {
@ -54,15 +54,23 @@ private fun mapStateForUpdateAction(
}
ReviewQualityCheckAction.OptOut -> {
return ReviewQualityCheckState.NotOptedIn
ReviewQualityCheckState.NotOptedIn
}
ReviewQualityCheckAction.ToggleProductRecommendation -> {
return if (state is ReviewQualityCheckState.OptedIn) {
if (state is ReviewQualityCheckState.OptedIn) {
state.copy(productRecommendationsPreference = !state.productRecommendationsPreference)
} else {
state
}
}
is ReviewQualityCheckAction.UpdateProductReview -> {
if (state is ReviewQualityCheckState.OptedIn) {
state.copy(productReviewState = action.productReviewState)
} else {
state
}
}
}
}

@ -7,7 +7,9 @@ package org.mozilla.fenix.shopping.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import mozilla.components.lib.state.ext.observeAsState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
@ -29,6 +31,8 @@ fun ReviewQualityCheckBottomSheet(
modifier: Modifier = Modifier,
) {
val reviewQualityCheckState by store.observeAsState(ReviewQualityCheckState.Initial) { it }
val isOptedIn =
remember(reviewQualityCheckState) { reviewQualityCheckState is ReviewQualityCheckState.OptedIn }
ReviewQualityCheckScaffold(
onRequestDismiss = onRequestDismiss,
@ -63,6 +67,12 @@ fun ReviewQualityCheckBottomSheet(
is ReviewQualityCheckState.Initial -> {}
}
}
LaunchedEffect(isOptedIn) {
if (isOptedIn) {
store.dispatch(ReviewQualityCheckAction.FetchProductAnalysis)
}
}
}
@Composable

@ -0,0 +1,110 @@
/* 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.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.concept.engine.EngineSession
import mozilla.components.concept.engine.shopping.ProductAnalysis
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito.doAnswer
class ReviewQualityCheckServiceImplTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Test
fun `GIVEN fetch is called WHEN onResult is invoked THEN product analysis returns the same data`() =
runTest {
val engineSession = mock<EngineSession>()
val expected = mock<ProductAnalysis>()
doAnswer { invocation ->
val onResult: (ProductAnalysis) -> Unit = invocation.getArgument(1)
onResult(expected)
}.`when`(engineSession).requestProductAnalysis(any(), any(), any())
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckServiceImpl(BrowserStore(browserState))
val actual = tested.fetchProductReview()
assertEquals(expected, actual)
}
@Test
fun `GIVEN fetch is called WHEN onException is invoked THEN product analysis returns null`() =
runTest {
val engineSession = mock<EngineSession>()
doAnswer { invocation ->
val onException: (Throwable) -> Unit = invocation.getArgument(2)
onException(RuntimeException())
}.`when`(engineSession).requestProductAnalysis(any(), any(), any())
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckServiceImpl(BrowserStore(browserState))
assertNull(tested.fetchProductReview())
}
@Test
fun `WHEN fetch is called THEN fetch is called for the selected tab`() = runTest {
val engineSession = mock<EngineSession>()
val expected = mock<ProductAnalysis>()
doAnswer { invocation ->
val onResult: (ProductAnalysis) -> Unit = invocation.getArgument(1)
onResult(expected)
}.`when`(engineSession).requestProductAnalysis(any(), any(), any())
val tab1 = createTab(
url = "https://www.mozilla.org",
id = "1",
)
val tab2 = createTab(
url = "https://www.shopping.org/product",
id = "2",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab1, tab2),
selectedTabId = tab2.id,
)
val tested = ReviewQualityCheckServiceImpl(BrowserStore(browserState))
val actual = tested.fetchProductReview()
assertEquals(expected, actual)
}
}

@ -8,11 +8,16 @@ import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.mock
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.whenever
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.shopping.ProductAnalysisTestData
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckNetworkMiddleware
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferences
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferencesMiddleware
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckService
class ReviewQualityCheckStoreTest {
@ -124,15 +129,83 @@ class ReviewQualityCheckStoreTest {
assertEquals(expected, tested.state)
}
@Test
fun `GIVEN the user has opted in the feature WHEN the a product analysis is fetched successfully THEN state should reflect that`() =
runTest {
val reviewQualityCheckService = mock<ReviewQualityCheckService>()
whenever(reviewQualityCheckService.fetchProductReview())
.thenReturn(ProductAnalysisTestData.productAnalysis())
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
reviewQualityCheckService = reviewQualityCheckService,
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
val expected = ReviewQualityCheckState.OptedIn(
productReviewState = ProductAnalysisTestData.analysisPresent(),
productRecommendationsPreference = false,
)
assertEquals(expected, tested.state)
}
@Test
fun `GIVEN the user has opted in the feature WHEN the a product analysis fetch fails THEN state should reflect that`() =
runTest {
val reviewQualityCheckService = mock<ReviewQualityCheckService>()
whenever(reviewQualityCheckService.fetchProductReview()).thenReturn(null)
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
reviewQualityCheckService = reviewQualityCheckService,
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
val expected = ReviewQualityCheckState.OptedIn(
productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error,
productRecommendationsPreference = false,
)
assertEquals(expected, tested.state)
}
private fun provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences: ReviewQualityCheckPreferences,
): List<ReviewQualityCheckMiddleware> =
listOf(
ReviewQualityCheckPreferencesMiddleware(
reviewQualityCheckPreferences = reviewQualityCheckPreferences,
scope = this.scope,
),
)
reviewQualityCheckService: ReviewQualityCheckService? = null,
): List<ReviewQualityCheckMiddleware> {
return if (reviewQualityCheckService != null) {
listOf(
ReviewQualityCheckPreferencesMiddleware(
reviewQualityCheckPreferences = reviewQualityCheckPreferences,
scope = this.scope,
),
ReviewQualityCheckNetworkMiddleware(
reviewQualityCheckService = reviewQualityCheckService,
scope = this.scope,
),
)
} else {
listOf(
ReviewQualityCheckPreferencesMiddleware(
reviewQualityCheckPreferences = reviewQualityCheckPreferences,
scope = this.scope,
),
)
}
}
}
private class FakeReviewQualityCheckPreferences(

Loading…
Cancel
Save