Bug 1853311 - Add review checker reanalysis logic

fenix/119.0
rahulsainani 8 months ago committed by mergify[bot]
parent 18ef60af4e
commit eb106f2a0f

@ -35,7 +35,7 @@ class ReviewQualityCheckFragment : BottomSheetDialogFragment() {
middleware = ReviewQualityCheckMiddlewareProvider.provideMiddleware(
settings = requireComponents.settings,
browserStore = requireComponents.core.store,
context = requireContext(),
context = requireContext().applicationContext,
openLink = { link, shouldOpenInNewTab ->
(requireActivity() as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = link,

@ -27,7 +27,11 @@ fun ProductAnalysis?.toProductReviewState(): ProductReviewState =
private fun GeckoProductAnalysis.toProductReview(): ProductReviewState =
if (productId == null) {
ProductReviewState.NoAnalysisPresent()
if (needsAnalysis) {
ProductReviewState.NoAnalysisPresent()
} else {
ProductReviewState.Error.GenericError
}
} else {
val mappedRating = adjustedRating.toFloatOrNull()
val mappedGrade = grade?.toGrade()

@ -0,0 +1,45 @@
/* 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.delay
private const val MAX_RETRIES = 10
private const val INITIAL_DELAY_MS = 2000L
private const val MAX_DELAY_MS = 20000L
private const val FACTOR = 2.0
/**
* Retry a suspend function until it returns a value that satisfies the predicate.
*
* @param maxRetries The maximum number of retries.
* @param initialDelayMs The initial delay in milliseconds.
* @param maxDelayMs The maximum delay in milliseconds.
* @param factor The factor to increase the delay by.
* @param predicate The predicate to check the result against.
* @param block The function to retry.
*/
suspend fun <T> retry(
maxRetries: Int = MAX_RETRIES,
initialDelayMs: Long = INITIAL_DELAY_MS,
maxDelayMs: Long = MAX_DELAY_MS,
factor: Double = FACTOR,
predicate: (T) -> Boolean,
block: suspend () -> T,
): T {
var delayTime = initialDelayMs
var data: T = block()
repeat(maxRetries - 1) {
if (predicate(data)) {
delay(delayTime)
data = block()
delayTime = (delayTime * factor).toLong().coerceAtMost(maxDelayMs)
} else {
return data
}
}
return data
}

@ -6,14 +6,15 @@ 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.ReviewQualityCheckMiddleware
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
/**
* Middleware that handles network requests for the review quality check feature.
@ -26,7 +27,7 @@ class ReviewQualityCheckNetworkMiddleware(
private val reviewQualityCheckService: ReviewQualityCheckService,
private val networkChecker: NetworkChecker,
private val scope: CoroutineScope,
) : Middleware<ReviewQualityCheckState, ReviewQualityCheckAction> {
) : ReviewQualityCheckMiddleware {
override fun invoke(
context: MiddlewareContext<ReviewQualityCheckState, ReviewQualityCheckAction>,
@ -46,21 +47,73 @@ class ReviewQualityCheckNetworkMiddleware(
store: Store<ReviewQualityCheckState, ReviewQualityCheckAction>,
action: ReviewQualityCheckAction.NetworkAction,
) {
when (action) {
FetchProductAnalysis, RetryProductAnalysis -> {
scope.launch {
val productReviewState = if (networkChecker.isConnected()) {
reviewQualityCheckService.fetchProductReview().toProductReviewState()
if (!networkChecker.isConnected()) {
store.updateProductReviewState(ProductReviewState.Error.NetworkError)
return
}
scope.launch {
when (action) {
FetchProductAnalysis, RetryProductAnalysis -> {
val productReviewState = fetchAnalysis()
store.updateProductReviewState(productReviewState)
}
ReviewQualityCheckAction.ReanalyzeProduct -> {
val reanalysis = reviewQualityCheckService.reanalyzeProduct()
if (reanalysis == null) {
store.updateProductReviewState(ProductReviewState.Error.GenericError)
return@launch
}
val status = pollForAnalysisStatus()
if (status == null ||
status == AnalysisStatusDto.PENDING ||
status == AnalysisStatusDto.IN_PROGRESS
) {
// poll failed, reset to previous state
val state = store.state
if (state is ReviewQualityCheckState.OptedIn) {
if (state.productReviewState is ProductReviewState.NoAnalysisPresent) {
store.updateProductReviewState(ProductReviewState.NoAnalysisPresent())
} else if (state.productReviewState is ProductReviewState.AnalysisPresent) {
store.updateProductReviewState(
state.productReviewState.copy(
analysisStatus = AnalysisStatus.NEEDS_ANALYSIS,
),
)
}
}
} else {
ProductReviewState.Error.NetworkError
// poll succeeded, update state
store.updateProductReviewState(status.toProductReviewState())
}
store.dispatch(ReviewQualityCheckAction.UpdateProductReview(productReviewState))
}
}
}
}
ReviewQualityCheckAction.ReanalyzeProduct -> {
// Bug 1853311 - Integrate analyze and analysis_status
}
private suspend fun fetchAnalysis(): ProductReviewState =
reviewQualityCheckService.fetchProductReview().toProductReviewState()
private suspend fun pollForAnalysisStatus(): AnalysisStatusDto? =
retry(
predicate = { it == AnalysisStatusDto.PENDING || it == AnalysisStatusDto.IN_PROGRESS },
block = { reviewQualityCheckService.analysisStatus() },
)
private suspend fun AnalysisStatusDto.toProductReviewState(): ProductReviewState =
when (this) {
AnalysisStatusDto.COMPLETED -> fetchAnalysis()
AnalysisStatusDto.NOT_ANALYZABLE -> ProductReviewState.Error.UnsupportedProductTypeError
else -> ProductReviewState.Error.GenericError
}
private fun Store<ReviewQualityCheckState, ReviewQualityCheckAction>.updateProductReviewState(
productReviewState: ProductReviewState,
) {
dispatch(ReviewQualityCheckAction.UpdateProductReview(productReviewState))
}
}

@ -9,6 +9,7 @@ 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 mozilla.components.support.base.log.logger.Logger
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@ -23,6 +24,20 @@ interface ReviewQualityCheckService {
* @return [ProductAnalysis] if the request succeeds, null otherwise.
*/
suspend fun fetchProductReview(): ProductAnalysis?
/**
* Triggers a reanalysis of the product review for the current tab.
*
* @return [AnalysisStatusDto] if the request succeeds, null otherwise.
*/
suspend fun reanalyzeProduct(): AnalysisStatusDto?
/**
* Fetches the status of the product review for the current tab.
*
* @return [AnalysisStatusDto] if the request succeeds, null otherwise.
*/
suspend fun analysisStatus(): AnalysisStatusDto?
}
/**
@ -34,15 +49,94 @@ class DefaultReviewQualityCheckService(
private val browserStore: BrowserStore,
) : ReviewQualityCheckService {
private val logger = Logger("DefaultReviewQualityCheckService")
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) },
onResult = {
continuation.resume(it)
},
onException = {
logger.error("Error fetching product review", it)
continuation.resume(null)
},
)
}
}
}
override suspend fun reanalyzeProduct(): AnalysisStatusDto? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
tab.engineState.engineSession?.reanalyzeProduct(
url = tab.content.url,
onResult = {
continuation.resume(it.asEnumOrDefault<AnalysisStatusDto>())
},
onException = {
logger.error("Error starting reanalysis", it)
continuation.resume(null)
},
)
}
}
}
override suspend fun analysisStatus(): AnalysisStatusDto? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
tab.engineState.engineSession?.requestAnalysisStatus(
url = tab.content.url,
onResult = {
continuation.resume(it.asEnumOrDefault<AnalysisStatusDto>())
},
onException = {
logger.error("Error fetching analysis status", it)
continuation.resume(null)
},
)
}
}
}
private inline fun <reified T : Enum<T>> String.asEnumOrDefault(defaultValue: T? = null): T? =
enumValues<T>().firstOrNull { it.name.equals(this, ignoreCase = true) } ?: defaultValue
}
/**
* Enum that represents the status of the product review analysis.
*/
enum class AnalysisStatusDto {
/**
* Analysis is waiting to be picked up.
*/
PENDING,
/**
* Analysis is in progress.
*/
IN_PROGRESS,
/**
* Analysis is completed.
*/
COMPLETED,
/**
* Product can not be analyzed.
*/
NOT_ANALYZABLE,
/**
* Current analysis status with provided params not found.
*/
NOT_FOUND,
/**
* Wrong product params provided.
*/
UNPROCESSABLE,
}

@ -0,0 +1,13 @@
/* 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.NetworkChecker
class FakeNetworkChecker(
private val isConnected: Boolean,
) : NetworkChecker {
override fun isConnected(): Boolean = isConnected
}

@ -0,0 +1,27 @@
/* 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.ReviewQualityCheckPreferences
class FakeReviewQualityCheckPreferences(
private val isEnabled: Boolean = false,
private val isProductRecommendationsEnabled: Boolean? = false,
private val updateCFRCallback: () -> Unit = { },
) : ReviewQualityCheckPreferences {
override suspend fun enabled(): Boolean = isEnabled
override suspend fun productRecommendationsEnabled(): Boolean? = isProductRecommendationsEnabled
override suspend fun setEnabled(isEnabled: Boolean) {
}
override suspend fun setProductRecommendationsEnabled(isEnabled: Boolean) {
}
override suspend fun updateCFRCondition(time: Long) {
updateCFRCallback()
}
}

@ -0,0 +1,28 @@
/* 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 mozilla.components.concept.engine.shopping.ProductAnalysis
import org.mozilla.fenix.shopping.middleware.AnalysisStatusDto
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckService
class FakeReviewQualityCheckService(
private val productAnalysis: (Int) -> ProductAnalysis? = { null },
private val reanalysis: AnalysisStatusDto? = null,
private val status: AnalysisStatusDto? = null,
) : ReviewQualityCheckService {
private var analysisCount = 0
override suspend fun fetchProductReview(): ProductAnalysis? {
return productAnalysis(analysisCount).also {
analysisCount++
}
}
override suspend fun reanalyzeProduct(): AnalysisStatusDto? = reanalysis
override suspend fun analysisStatus(): AnalysisStatusDto? = status
}

@ -113,14 +113,29 @@ class ProductAnalysisMapperTest {
}
@Test
fun `WHEN product id is null THEN it is mapped to no analysis present`() {
fun `WHEN product id is null and needs analysis is true THEN it is mapped to no analysis present`() {
val actual =
ProductAnalysisTestData.productAnalysis(productId = null).toProductReviewState()
ProductAnalysisTestData.productAnalysis(
productId = null,
needsAnalysis = true,
).toProductReviewState()
val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.NoAnalysisPresent()
assertEquals(expected, actual)
}
@Test
fun `WHEN product id is null and needs analysis is false THEN it is mapped to no generic error`() {
val actual =
ProductAnalysisTestData.productAnalysis(
productId = null,
needsAnalysis = false,
).toProductReviewState()
val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError
assertEquals(expected, actual)
}
@Test
fun `WHEN grade, rating and highlights are all null THEN it is mapped to no analysis present`() {
val actual =

@ -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 junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RetryKtTest {
@Test
fun `WHEN predicate is false THEN return data on first attempt`() = runTest {
var count = 0
val actual = retry(predicate = { false }) {
count += 1
count
}
val expected = 1
assertEquals(expected, actual)
}
@Test
fun `WHEN predicate is true THEN retry max times`() = runTest {
var count = 0
val actual = retry(
maxRetries = 10,
predicate = { true },
) {
count += 1
count
}
val expected = 10
assertEquals(expected, actual)
}
@Test
fun `WHEN predicate changes to false from true THEN return data on that attempt`() = runTest {
var count = 0
val actual = retry(
maxRetries = 10,
predicate = { it < 5 },
) {
count += 1
count
}
val expected = 5
assertEquals(expected, actual)
}
}

@ -8,17 +8,20 @@ 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.fake.FakeNetworkChecker
import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckPreferences
import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckService
import org.mozilla.fenix.shopping.middleware.AnalysisStatusDto
import org.mozilla.fenix.shopping.middleware.NetworkChecker
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
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
class ReviewQualityCheckStoreTest {
@ -158,16 +161,14 @@ class ReviewQualityCheckStoreTest {
}
@Test
fun `GIVEN the user has opted in the feature WHEN the a product analysis is fetched successfully THEN state should reflect that`() =
fun `GIVEN the user has opted in the feature WHEN 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,
reviewQualityCheckService = FakeReviewQualityCheckService(
productAnalysis = { ProductAnalysisTestData.productAnalysis() },
),
networkChecker = FakeNetworkChecker(isConnected = true),
),
)
@ -186,15 +187,12 @@ class ReviewQualityCheckStoreTest {
}
@Test
fun `GIVEN the user has opted in the feature WHEN the a product analysis returns an error THEN state should reflect that`() =
fun `GIVEN the user has opted in the feature WHEN a product analysis returns an error 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,
reviewQualityCheckService = FakeReviewQualityCheckService(),
networkChecker = FakeNetworkChecker(isConnected = true),
),
)
@ -218,7 +216,7 @@ class ReviewQualityCheckStoreTest {
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
reviewQualityCheckService = mock(),
reviewQualityCheckService = FakeReviewQualityCheckService(),
networkChecker = FakeNetworkChecker(isConnected = false),
),
)
@ -236,6 +234,102 @@ class ReviewQualityCheckStoreTest {
assertEquals(expected, tested.state)
}
@Test
fun `WHEN reanalysis api call fails THEN state should reflect that`() =
runTest {
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
reviewQualityCheckService = FakeReviewQualityCheckService(
reanalysis = null,
),
networkChecker = FakeNetworkChecker(isConnected = true),
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
val expected = ReviewQualityCheckState.OptedIn(
productReviewState = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError,
productRecommendationsPreference = false,
)
assertEquals(expected, tested.state)
}
@Test
fun `GIVEN a product analysis WHEN reanalysis call succeeds and status fails THEN state should reflect that`() =
runTest {
val productAnalysisList = listOf(
ProductAnalysisTestData.productAnalysis(
needsAnalysis = true,
grade = "B",
),
ProductAnalysisTestData.productAnalysis(),
)
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
reviewQualityCheckService = FakeReviewQualityCheckService(
reanalysis = AnalysisStatusDto.PENDING,
status = null,
productAnalysis = { productAnalysisList[it] },
),
networkChecker = FakeNetworkChecker(isConnected = true),
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
val expected = ReviewQualityCheckState.OptedIn(
productReviewState = ProductAnalysisTestData.analysisPresent(
reviewGrade = ReviewQualityCheckState.Grade.B,
analysisStatus = AnalysisStatus.NEEDS_ANALYSIS,
),
productRecommendationsPreference = false,
)
assertEquals(expected, tested.state)
}
@Test
fun `WHEN reanalysis and status api call succeeds THEN analysis should be fetched and displayed`() =
runTest {
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(isEnabled = true),
reviewQualityCheckService = FakeReviewQualityCheckService(
productAnalysis = { ProductAnalysisTestData.productAnalysis() },
reanalysis = AnalysisStatusDto.PENDING,
status = AnalysisStatusDto.COMPLETED,
),
networkChecker = FakeNetworkChecker(isConnected = true),
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
val expected = ReviewQualityCheckState.OptedIn(
productReviewState = ProductAnalysisTestData.analysisPresent(),
productRecommendationsPreference = false,
)
assertEquals(expected, tested.state)
}
private fun provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences: ReviewQualityCheckPreferences,
reviewQualityCheckService: ReviewQualityCheckService? = null,
@ -263,29 +357,3 @@ class ReviewQualityCheckStoreTest {
}
}
}
private class FakeReviewQualityCheckPreferences(
private val isEnabled: Boolean = false,
private val isProductRecommendationsEnabled: Boolean? = false,
private val updateCFRCallback: () -> Unit = { },
) : ReviewQualityCheckPreferences {
override suspend fun enabled(): Boolean = isEnabled
override suspend fun productRecommendationsEnabled(): Boolean? = isProductRecommendationsEnabled
override suspend fun setEnabled(isEnabled: Boolean) {
}
override suspend fun setProductRecommendationsEnabled(isEnabled: Boolean) {
}
override suspend fun updateCFRCondition(time: Long) {
updateCFRCallback()
}
}
private class FakeNetworkChecker(
private val isConnected: Boolean,
) : NetworkChecker {
override fun isConnected(): Boolean = isConnected
}

Loading…
Cancel
Save