diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFileBasedSyncAction.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFileBasedSyncAction.kt new file mode 100644 index 00000000000..8b807b81177 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFileBasedSyncAction.kt @@ -0,0 +1,113 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import kotlinx.coroutines.delay +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogResult +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogState +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore +import javax.inject.Inject +import kotlin.math.pow + +class WooPosFileBasedSyncAction @Inject constructor( + private val posLocalCatalogStore: WooPosLocalCatalogStore, + private val logger: WooPosLogWrapper, +) { + companion object { + private const val INITIAL_POLL_INTERVAL_MS = 3000L + private const val MAX_POLL_ATTEMPTS = 20 + private const val MAX_CONSECUTIVE_FAILED_ATTEMPTS = 3 + + private const val MAX_POLL_INTERVAL_MS = 30_000L + private const val BACKOFF_MULTIPLIER = 1.3 + } + + @Suppress("ReturnCount") + suspend fun syncCatalog( + site: SiteModel + ): Result { + logger.d("Starting file-based catalog generation for site ${site.id}") + + var attemptCount = 0 + var failedConsecutiveAttempts = 0 + + repeat(MAX_POLL_ATTEMPTS) { attemptCount -> + if (attemptCount > 0) { + val delayMs = computeBackoffDelay(attemptCount) + logger.d("Waiting ${delayMs}ms before poll attempt $attemptCount") + delay(delayMs) + } + + val response = posLocalCatalogStore.generateCatalog(site) + + if (response.isFailure) { + if (++failedConsecutiveAttempts >= MAX_CONSECUTIVE_FAILED_ATTEMPTS) { + return Result.failure(response.exceptionOrNull() ?: Exception("Unknown error")) + } else { + logger.w("Poll attempt $attemptCount failed: ${response.exceptionOrNull()?.message}") + return@repeat + } + } + failedConsecutiveAttempts = 0 + + val result = response.getOrThrow() + logger.d( + "Poll attempt $attemptCount" + ) + + val processedResult = processPollingResult(result) + if (processedResult != null) { + return processedResult + } + } + + logger.e("Catalog generation timed out after $MAX_POLL_ATTEMPTS attempts") + return Result.failure(Exception("Catalog generation timed out")) + } + + data class FileBasedSyncResult( + val fileUrl: String, + val productFields: List?, + val variationFields: List?, + val totalProducts: Int?, + val completedAt: String? + ) + + @Suppress("ReturnCount") + private fun processPollingResult(result: WooPosGenerateCatalogResult): Result? { + return when (result.state) { + WooPosGenerateCatalogState.COMPLETED -> { + val url = result.url + if (url != null) { + logger.d("Catalog available.") + // TBD Download the file or scheduled bg download job + Result.success(createFileBasedSyncResult(result, url)) + } else { + logger.e("Catalog generation completed but URL is missing") + Result.failure(Exception("Catalog generation completed but URL is missing")) + } + } + else -> null.also { logger.d("State: ${result.state}, Progress: ${result.progress}/${result.total}") } + } + } + + private fun createFileBasedSyncResult( + result: WooPosGenerateCatalogResult, + fileUrl: String + ): FileBasedSyncResult { + return FileBasedSyncResult( + fileUrl = fileUrl, + productFields = result.productFields, + variationFields = result.variationFields, + totalProducts = result.total, + completedAt = result.completedAt + ) + } + + private fun computeBackoffDelay(attemptCount: Int): Long { + val exponent = (attemptCount - 2).coerceAtLeast(0) + val raw = INITIAL_POLL_INTERVAL_MS * BACKOFF_MULTIPLIER.pow(exponent.toDouble()) + val finalDelay = raw.coerceAtMost(MAX_POLL_INTERVAL_MS.toDouble()) + return finalDelay.toLong() + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepository.kt index 55a45fd5b6f..eee7c868144 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepository.kt @@ -127,6 +127,7 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( is PosLocalCatalogSyncResult.Failure.DatabaseError -> SyncErrorType.DATABASE_ERROR is PosLocalCatalogSyncResult.Failure.InvalidResponse -> SyncErrorType.INVALID_RESPONSE is PosLocalCatalogSyncResult.Failure.UnexpectedError -> SyncErrorType.UNEXPECTED_ERROR + is PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout -> SyncErrorType.CATALOG_GENERATION_TIMEOUT } analyticsTracker.track( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt index 555acbbf631..ef26d0a36dd 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt @@ -83,7 +83,8 @@ constructor( is PosLocalCatalogSyncResult.Failure.NetworkError, is PosLocalCatalogSyncResult.Failure.DatabaseError, is PosLocalCatalogSyncResult.Failure.InvalidResponse, - is PosLocalCatalogSyncResult.Failure.UnexpectedError -> { + is PosLocalCatalogSyncResult.Failure.UnexpectedError, + is PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout -> { logger.e("Local catalog FULL sync failed: ${fullSyncResult.error}. Retrying ...") Result.retry() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncResult.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncResult.kt index 7fdda1b84dd..41123889ae0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncResult.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncResult.kt @@ -16,6 +16,7 @@ sealed class PosLocalCatalogSyncResult { class DatabaseError(error: String) : Failure(error) class InvalidResponse(error: String) : Failure(error) class UnexpectedError(error: String) : Failure(error) + class CatalogGenerationTimeout(error: String) : Failure(error) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEventConstant.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEventConstant.kt index e63c579b108..d295f0d0913 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEventConstant.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEventConstant.kt @@ -102,7 +102,8 @@ object WooPosAnalyticsEventConstant { NETWORK_ERROR("network_error"), DATABASE_ERROR("database_error"), INVALID_RESPONSE("invalid_response"), - UNEXPECTED_ERROR("unexpected_error"); + UNEXPECTED_ERROR("unexpected_error"), + CATALOG_GENERATION_TIMEOUT("catalog_generation_timeout"); override fun toString(): String { return value diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt index 8e2ca2ec9f2..5d4ab1489aa 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/FeatureFlag.kt @@ -11,7 +11,8 @@ enum class FeatureFlag { BETTER_CUSTOMER_SEARCH_M2, ORDER_CREATION_AUTO_TAX_RATE, BOOKINGS_MVP, - POS_REFUNDS; + POS_REFUNDS, + WOO_POS_LOCAL_CATALOG_FILE_APPROACH; fun isEnabled(context: Context? = null): Boolean { return when (this) { @@ -24,6 +25,8 @@ enum class FeatureFlag { ORDER_CREATION_AUTO_TAX_RATE, BOOKINGS_MVP, POS_REFUNDS -> PackageUtils.isDebugBuild() + + WOO_POS_LOCAL_CATALOG_FILE_APPROACH -> false } } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFileBasedSyncActionTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFileBasedSyncActionTest.kt new file mode 100644 index 00000000000..3e5943f8b24 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFileBasedSyncActionTest.kt @@ -0,0 +1,186 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.viewmodel.BaseUnitTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogResult +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogState +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore + +@ExperimentalCoroutinesApi +class WooPosFileBasedSyncActionTest : BaseUnitTest() { + private val posLocalCatalogStore: WooPosLocalCatalogStore = mock() + private val logger: WooPosLogWrapper = mock() + + private lateinit var sut: WooPosFileBasedSyncAction + private val site = SiteModel().apply { id = 123 } + + @Before + fun setup() { + sut = WooPosFileBasedSyncAction( + posLocalCatalogStore = posLocalCatalogStore, + logger = logger + ) + } + + @Test + fun `given catalog is already generated, when syncCatalog, then returns success immediately`() = + runTest { + // GIVEN + val initialResult = WooPosGenerateCatalogResult( + url = "url", + state = WooPosGenerateCatalogState.COMPLETED, + ) + whenever(posLocalCatalogStore.generateCatalog(site)).thenReturn(Result.success(initialResult)) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + assertThat(result.isSuccess).isTrue() + verify(posLocalCatalogStore, times(1)).generateCatalog(site) + } + + @Test + fun `when polling completes, then returns success`() = runTest { + // GIVEN + val initialResult = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.SCHEDULED, + ) + val progressResult = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.IN_PROGRESS, + ) + val completedResult = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.COMPLETED, + url = "url", + ) + + whenever(posLocalCatalogStore.generateCatalog(site)) + .thenReturn(Result.success(initialResult)) + .thenReturn(Result.success(progressResult)) + .thenReturn(Result.success(completedResult)) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + assertThat(result.isSuccess).isTrue() + verify(posLocalCatalogStore, times(3)).generateCatalog(site) + } + + @Test + fun `when polling completes without URL, then returns failure`() = runTest { + // GIVEN + val initialResult = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.SCHEDULED + ) + val completedWithoutUrl = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.COMPLETED, + url = null + ) + + whenever(posLocalCatalogStore.generateCatalog(site)) + .thenReturn(Result.success(initialResult)) + .thenReturn(Result.success(completedWithoutUrl)) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + assertThat(result.isFailure).isTrue() + } + + @Test + fun `given initial request fails, when syncCatalog, then retries and returns success`() = runTest { + // GIVEN + val error = Exception("Network error") + val completed = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.COMPLETED, + url = "url" + ) + whenever(posLocalCatalogStore.generateCatalog(site)) + .thenReturn(Result.failure(error)) + .thenReturn(Result.success(completed)) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `given two requests fails, when syncCatalog, then continues until success`() = + runTest { + // GIVEN + val initialResult = WooPosGenerateCatalogResult(state = WooPosGenerateCatalogState.SCHEDULED) + val networkError = Exception("Network error") + val completedResult = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.COMPLETED, + url = "https://example.com/catalog.json" + ) + + whenever(posLocalCatalogStore.generateCatalog(site)) + .thenReturn(Result.success(initialResult)) + .thenReturn(Result.failure(networkError)) + .thenReturn(Result.failure(networkError)) + .thenReturn(Result.success(initialResult)) + .thenReturn(Result.success(completedResult)) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + assertThat(result.isSuccess).isTrue() + verify(posLocalCatalogStore, times(5)).generateCatalog(site) + } + + @Test + fun `given 3 consecutive requests fail, when syncCatalog, then returns failure`() = runTest { + // GIVEN + val error = Exception("Network error") + val completed = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.COMPLETED, + url = "url" + ) + whenever(posLocalCatalogStore.generateCatalog(site)) + .thenReturn(Result.failure(error)) + .thenReturn(Result.failure(error)) + .thenReturn(Result.failure(error)) + .thenReturn(Result.success(completed)) // unreachable + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + assertThat(result.isFailure).isTrue + } + + @Test + fun `when polling max attempts reached, then returns timeout failure`() = runTest { + // GIVEN + val inProgressResult = WooPosGenerateCatalogResult( + state = WooPosGenerateCatalogState.IN_PROGRESS, + progress = 50, + total = 100 + ) + whenever(posLocalCatalogStore.generateCatalog(site)) + .thenReturn(Result.success(inProgressResult)) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + assertThat(result.isFailure).isTrue() + verify(posLocalCatalogStore, times(20)).generateCatalog(site) + } +} diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosGenerateCatalogResult.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosGenerateCatalogResult.kt index d714aee47d0..3609593c482 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosGenerateCatalogResult.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosGenerateCatalogResult.kt @@ -3,7 +3,7 @@ package org.wordpress.android.fluxc.store.pos.localcatalog data class WooPosGenerateCatalogResult( val scheduledAt: String? = null, val completedAt: String? = null, - val state: String? = null, + val state: WooPosGenerateCatalogState, val progress: Int? = null, val processed: Int? = null, val total: Int? = null, @@ -11,3 +11,19 @@ data class WooPosGenerateCatalogResult( val productFields: List? = null, val variationFields: List? = null, ) + +enum class WooPosGenerateCatalogState(val rawValue: String) { + SCHEDULED("scheduled"), + IN_PROGRESS("in_progress"), + COMPLETED("completed"), + UNKNOWN(""); + + companion object { + fun from(value: String?): WooPosGenerateCatalogState = when (value) { + "scheduled" -> SCHEDULED + "in_progress" -> IN_PROGRESS + "completed" -> COMPLETED + else -> UNKNOWN + } + } +} diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt index a63d46eb7ef..995d6189a12 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt @@ -516,7 +516,7 @@ class WooPosLocalCatalogStore @Inject constructor( WooPosGenerateCatalogResult( scheduledAt = response.model.scheduledAt, completedAt = response.model.completedAt, - state = response.model.state, + state = WooPosGenerateCatalogState.from(response.model.state), progress = response.model.progress, processed = response.model.processed, total = response.model.total, diff --git a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/pos/WooPosLocalCatalogStoreTest.kt b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/pos/WooPosLocalCatalogStoreTest.kt index a1a2a7a1a07..8361649583b 100644 --- a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/pos/WooPosLocalCatalogStoreTest.kt +++ b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/pos/WooPosLocalCatalogStoreTest.kt @@ -28,6 +28,7 @@ import org.wordpress.android.fluxc.persistence.dao.pos.WooPosProductsDao import org.wordpress.android.fluxc.persistence.dao.pos.WooPosVariationsDao import org.wordpress.android.fluxc.persistence.entity.pos.WooPosProductEntity import org.wordpress.android.fluxc.persistence.entity.pos.WooPosVariationEntity +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogState import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogError import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore import org.wordpress.android.fluxc.utils.HeadersParser @@ -448,7 +449,7 @@ class WooPosLocalCatalogStoreTest { // THEN assertThat(result.scheduledAt).isEqualTo("2025-12-10T11:21:48") assertThat(result.completedAt).isEqualTo("2025-12-10T11:21:55") - assertThat(result.state).isEqualTo("completed") + assertThat(result.state).isEqualTo(WooPosGenerateCatalogState.COMPLETED) assertThat(result.progress).isEqualTo(100) assertThat(result.processed).isEqualTo(881) assertThat(result.total).isEqualTo(881) @@ -508,7 +509,7 @@ class WooPosLocalCatalogStoreTest { // THEN assertThat(result.scheduledAt).isEqualTo("2025-12-10T11:21:48") - assertThat(result.state).isEqualTo("scheduled") + assertThat(result.state).isEqualTo(WooPosGenerateCatalogState.SCHEDULED) assertThat(result.completedAt).isNull() assertThat(result.progress).isNull() assertThat(result.url).isNull()