diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifier.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifier.kt index 6725b925436..eacf313d4c1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifier.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifier.kt @@ -25,7 +25,7 @@ class WooPosSearchByIdentifier @Inject constructor( val localResult = localSearcher(identifier, syncStrategy) // When product not found in local catalog, immediately return "not found" to avoid unnecessary remote call - if (localResult.isSuccess || syncStrategy == WooPosProductsDataSource.SyncStrategy.LOCAL_CATALOG) { + if (localResult.isSuccess || syncStrategy != WooPosProductsDataSource.SyncStrategy.REMOTE) { return filterUnsupportedProductResult(localResult) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifierLocal.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifierLocal.kt index 14fcf25413f..b10994fdbb3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifierLocal.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/searchbyidentifier/WooPosSearchByIdentifierLocal.kt @@ -25,7 +25,8 @@ class WooPosSearchByIdentifierLocal @Inject constructor( val siteId = selectedSite.get().localId() return when (syncStrategy) { - SyncStrategy.LOCAL_CATALOG -> searchInLocalCatalog(identifier, siteId) + SyncStrategy.LOCAL_CATALOG, + SyncStrategy.LOCAL_CATALOG_FILE -> searchInLocalCatalog(identifier, siteId) SyncStrategy.REMOTE -> searchInMemoryCache(identifier) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSource.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSource.kt index 86e2367c9f5..a5b125b0266 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSource.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSource.kt @@ -12,6 +12,7 @@ import com.woocommerce.android.ui.woopos.common.data.models.WooPosProductModel import com.woocommerce.android.ui.woopos.common.data.models.WooPosProductModelMapper import com.woocommerce.android.ui.woopos.common.data.models.WooPosWCProductToWooPosProductModelMapper import com.woocommerce.android.ui.woopos.common.data.toWooPosVariation +import com.woocommerce.android.ui.woopos.featureflags.WooPosLocalCatalogFileApproachEnabled import com.woocommerce.android.ui.woopos.home.items.search.WooPosProductsSearchInDbDataSource import com.woocommerce.android.ui.woopos.home.items.search.WooPosSearchProductsDataSource import com.woocommerce.android.ui.woopos.home.items.variations.WooPosVariationsLRUCache @@ -56,17 +57,23 @@ class WooPosProductsDataSource @Inject constructor( private val remoteDataSource: WooPosProductsRemoteDataSource, private val localDbDataSource: WooPosProductsInDbDataSource, private val syncStatusChecker: WooPosFullSyncStatusChecker, + private val fileApproachEnabled: WooPosLocalCatalogFileApproachEnabled, ) { enum class SyncStrategy { REMOTE, LOCAL_CATALOG, + LOCAL_CATALOG_FILE, } private var activeSource: WooPosProductsDataSourceInterface? = null fun getCurrentSyncStrategy(): SyncStrategy { return when (activeSource) { - localDbDataSource -> SyncStrategy.LOCAL_CATALOG + localDbDataSource -> if (fileApproachEnabled()) { + SyncStrategy.LOCAL_CATALOG_FILE + } else { + SyncStrategy.LOCAL_CATALOG + } remoteDataSource -> SyncStrategy.REMOTE else -> error("Unknown sync strategy") } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/search/WooPosItemsSearchViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/search/WooPosItemsSearchViewModel.kt index 700024fb474..4ce5bbf852d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/search/WooPosItemsSearchViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/search/WooPosItemsSearchViewModel.kt @@ -338,7 +338,8 @@ class WooPosItemsSearchViewModel @Inject constructor( private fun determinePullToRefreshState(): WooPosPullToRefreshState { return when (dataSource.getCurrentSyncStrategy()) { - SyncStrategy.LOCAL_CATALOG -> WooPosPullToRefreshState.Enabled + SyncStrategy.LOCAL_CATALOG, + SyncStrategy.LOCAL_CATALOG_FILE -> WooPosPullToRefreshState.Enabled SyncStrategy.REMOTE -> WooPosPullToRefreshState.Disabled } } 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 index 2a121253a7a..353ec074d98 100644 --- 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 @@ -1,7 +1,10 @@ package com.woocommerce.android.ui.woopos.localcatalog import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.ui.woopos.util.datastore.WooPosPreferencesRepository +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager import kotlinx.coroutines.delay +import org.wordpress.android.fluxc.model.LocalOrRemoteId 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 @@ -14,6 +17,8 @@ class WooPosFileBasedSyncAction @Inject constructor( private val catalogFileDownloader: WooPosCatalogFileDownloader, private val catalogFileParser: WooPosCatalogFileParser, private val syncWithFts: WooPosLocalCatalogSyncWithFts, + private val preferencesRepository: WooPosPreferencesRepository, + private val syncTimestampManager: WooPosSyncTimestampManager, private val logger: WooPosLogWrapper, ) { companion object { @@ -39,12 +44,15 @@ class WooPosFileBasedSyncAction @Inject constructor( val startTime = System.currentTimeMillis() logger.d("WooPosFileBasedSyncAction: Starting file-based catalog generation for site ${site.id}") + val siteId = site.localId() + val accumulatedPollAttempts = preferencesRepository.getAndClearFileBasedSyncPollAttempts(siteId) + var lastGenerationState: WooPosGenerateCatalogState? = null var failedConsecutiveAttempts = 0 - repeat(MAX_POLL_ATTEMPTS) { attemptCount -> - if (attemptCount > 0) { - val delayMs = computeBackoffDelay(attemptCount) - logger.d("WooPosFileBasedSyncAction: Waiting ${delayMs}ms before poll attempt $attemptCount") + repeat(MAX_POLL_ATTEMPTS) { attemptIndex -> + if (attemptIndex > 0) { + val delayMs = computeBackoffDelay(attemptIndex) + logger.d("WooPosFileBasedSyncAction: Waiting ${delayMs}ms before poll attempt $attemptIndex") delay(delayMs) } @@ -63,25 +71,46 @@ class WooPosFileBasedSyncAction @Inject constructor( ) ) } else { - logger.w("Poll attempt $attemptCount failed: ${response.exceptionOrNull()?.message}") + logger.w("Poll attempt $attemptIndex failed: ${response.exceptionOrNull()?.message}") return@repeat } } failedConsecutiveAttempts = 0 val result = response.getOrThrow() - logger.d("WooPosFileBasedSyncAction: Poll attempt $attemptCount") + lastGenerationState = result.state + logger.d("WooPosFileBasedSyncAction: Poll attempt $attemptIndex, state: ${result.state}") - val processedResult = processPollingResult(result, site, startTime = startTime) + val totalPollAttempts = accumulatedPollAttempts + attemptIndex + 1 + val processedResult = processPollingResult(result, site, startTime, totalPollAttempts) if (processedResult != null) { return processedResult } } - logger.e("WooPosFileBasedSyncAction: Catalog generation timed out after $MAX_POLL_ATTEMPTS attempts") + return handleTimeout( + siteId = siteId, + totalPollAttempts = accumulatedPollAttempts + MAX_POLL_ATTEMPTS, + lastGenerationState = lastGenerationState + ) + } + + private suspend fun handleTimeout( + siteId: LocalOrRemoteId.LocalId, + totalPollAttempts: Int, + lastGenerationState: WooPosGenerateCatalogState? + ): WooPosFileBasedSyncResult.Failure { + preferencesRepository.setFileBasedSyncPollAttempts(siteId, totalPollAttempts) + + logger.e( + "WooPosFileBasedSyncAction: Catalog generation timed out after $totalPollAttempts total attempts. " + + "Last state: $lastGenerationState" + ) return WooPosFileBasedSyncResult.Failure( PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout( - "Catalog generation is taking longer than expected." + error = "Catalog generation is taking longer than expected.", + lastGenerationState = lastGenerationState?.rawValue, + pollAttempts = totalPollAttempts ) ) } @@ -89,13 +118,14 @@ class WooPosFileBasedSyncAction @Inject constructor( private suspend fun processPollingResult( result: WooPosGenerateCatalogResult, site: SiteModel, - startTime: Long + startTime: Long, + pollAttempts: Int ): WooPosFileBasedSyncResult? { return when (result.state) { WooPosGenerateCatalogState.COMPLETED -> { if (result.url != null) { logger.d("WooPosFileBasedSyncAction: Catalog available, starting download.") - processDownloadAndStore(result, site, startTime) + processDownloadAndStore(result, site, startTime, pollAttempts) } else { logger.e("WooPosFileBasedSyncAction: Catalog generation completed but URL is missing") WooPosFileBasedSyncResult.Failure( @@ -118,7 +148,8 @@ class WooPosFileBasedSyncAction @Inject constructor( private suspend fun processDownloadAndStore( result: WooPosGenerateCatalogResult, site: SiteModel, - startTime: Long + startTime: Long, + pollAttempts: Int ): WooPosFileBasedSyncResult { val downloadedFile = catalogFileDownloader.downloadCatalogFile(result.url!!, site.localId()) .onFailureLog("Failed to download catalog file") @@ -162,17 +193,24 @@ class WooPosFileBasedSyncAction @Inject constructor( catalogFileDownloader.cleanupOldCatalogFiles(keepLatest = downloadedFile) val syncDuration = System.currentTimeMillis() - startTime + val generationDuration = syncTimestampManager.calculateGenerationDuration( + scheduledAt = result.scheduledAt, + completedAt = result.completedAt + ) + logger.d( "WooPosFileBasedSyncAction: File-based sync completed successfully. " + "Products: ${parsedData.products.size}, Variations: ${parsedData.variations.size}. " + - "Duration: ${syncDuration}ms." + "Duration: ${syncDuration}ms. Generation: ${generationDuration}ms. Poll attempts: $pollAttempts." ) return WooPosFileBasedSyncResult.Success( PosLocalCatalogSyncResult.Success( productsSynced = parsedData.products.size, variationsSynced = parsedData.variations.size, - syncDurationMs = syncDuration + syncDurationMs = syncDuration, + generationDurationMs = generationDuration, + pollAttempts = pollAttempts ), // Using scheduledAt (not completedAt) to not miss changes made during generation lastModifiedDate = result.scheduledAt 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 7eb31c64882..7e5d4c0466e 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 @@ -134,7 +134,9 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( variationsSynced = result.variationsSynced, totalProducts = totalProducts, totalVariations = totalVariations, - syncDurationMs = result.syncDurationMs + syncDurationMs = result.syncDurationMs, + generationDurationMs = result.generationDurationMs, + pollAttempts = result.pollAttempts ) ) } @@ -152,12 +154,16 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( is PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout -> SyncErrorType.CATALOG_GENERATION_TIMEOUT } + val timeoutResult = result as? PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout + analyticsTracker.track( LocalCatalogSyncFailed( syncType = syncType, errorContext = "WooPosLocalCatalogSyncRepository", errorType = errorType, - errorDescription = result.error + errorDescription = result.error, + lastGenerationState = timeoutResult?.lastGenerationState, + pollAttempts = timeoutResult?.pollAttempts ) ) } 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 41123889ae0..c6c24d51ab5 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 @@ -7,7 +7,9 @@ sealed class PosLocalCatalogSyncResult { data class Success( val productsSynced: Int, val variationsSynced: Int, - val syncDurationMs: Long + val syncDurationMs: Long, + val generationDurationMs: Long? = null, + val pollAttempts: Int? = null ) : PosLocalCatalogSyncResult() sealed class Failure(val error: String) : PosLocalCatalogSyncResult() { @@ -16,7 +18,11 @@ 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) + class CatalogGenerationTimeout( + error: String, + val lastGenerationState: String? = null, + val pollAttempts: Int? = null + ) : Failure(error) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt index f78c033ad38..8aa2b15edb3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt @@ -18,7 +18,7 @@ class WooPosSettingsCategoriesViewModel @Inject constructor( init { val categories = WooPosSettingsCategory.entries.filter { if (it == WooPosSettingsCategory.LOCAL_CATALOG) { - productsDataSource.getCurrentSyncStrategy() == WooPosProductsDataSource.SyncStrategy.LOCAL_CATALOG + productsDataSource.getCurrentSyncStrategy() != WooPosProductsDataSource.SyncStrategy.REMOTE } else { true } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt index 63e5fd3025c..4c22cc56001 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt @@ -438,21 +438,24 @@ sealed class WooPosAnalyticsEvent : IAnalyticsEvent { val variationsSynced: Int, val totalProducts: Int, val totalVariations: Int, - val syncDurationMs: Long + val syncDurationMs: Long, + val generationDurationMs: Long? = null, + val pollAttempts: Int? = null ) : Event() { override val name: String = "local_catalog_sync_completed" init { - addProperties( - mapOf( - SyncType.SYNC_TYPE to syncType.toString(), - "products_synced" to productsSynced.toString(), - "variations_synced" to variationsSynced.toString(), - "total_products" to totalProducts.toString(), - "total_variations" to totalVariations.toString(), - "sync_duration_ms" to syncDurationMs.toString() - ) + val properties = mutableMapOf( + SyncType.SYNC_TYPE to syncType.toString(), + "products_synced" to productsSynced.toString(), + "variations_synced" to variationsSynced.toString(), + "total_products" to totalProducts.toString(), + "total_variations" to totalVariations.toString(), + "sync_duration_ms" to syncDurationMs.toString() ) + generationDurationMs?.let { properties["generation_duration_ms"] = it.toString() } + pollAttempts?.let { properties["poll_attempts"] = it.toString() } + addProperties(properties) } } @@ -460,19 +463,22 @@ sealed class WooPosAnalyticsEvent : IAnalyticsEvent { val syncType: SyncType, val errorContext: String, val errorType: SyncErrorType, - val errorDescription: String + val errorDescription: String, + val lastGenerationState: String? = null, + val pollAttempts: Int? = null ) : Event() { override val name: String = "local_catalog_sync_failed" init { - addProperties( - mapOf( - SyncType.SYNC_TYPE to syncType.toString(), - "error_context" to errorContext, - SyncErrorType.ERROR_TYPE to errorType.toString(), - "error_description" to errorDescription - ) + val properties = mutableMapOf( + SyncType.SYNC_TYPE to syncType.toString(), + "error_context" to errorContext, + SyncErrorType.ERROR_TYPE to errorType.toString(), + "error_description" to errorDescription ) + lastGenerationState?.let { properties["last_generation_state"] = it } + pollAttempts?.let { properties["poll_attempts"] = it.toString() } + addProperties(properties) } } @@ -1138,5 +1144,6 @@ internal fun SyncStrategy.toAnalyticsValue(): String { return when (this) { SyncStrategy.REMOTE -> "remote" SyncStrategy.LOCAL_CATALOG -> "local_catalog" + SyncStrategy.LOCAL_CATALOG_FILE -> "local_catalog_file" } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosPreferencesRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosPreferencesRepository.kt index 4890d061c0d..2fb35bfd325 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosPreferencesRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosPreferencesRepository.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.woocommerce.android.tools.SelectedSite @@ -128,8 +129,34 @@ class WooPosPreferencesRepository @Inject constructor( } } + suspend fun getFileBasedSyncPollAttempts(siteId: LocalOrRemoteId.LocalId): Int { + val key = buildFileBasedSyncPollAttemptsKey(siteId) + return dataStore.data.map { it[key] ?: 0 }.first() + } + + suspend fun setFileBasedSyncPollAttempts(siteId: LocalOrRemoteId.LocalId, attempts: Int) { + val key = buildFileBasedSyncPollAttemptsKey(siteId) + dataStore.edit { preferences -> + preferences[key] = attempts + } + } + + suspend fun getAndClearFileBasedSyncPollAttempts(siteId: LocalOrRemoteId.LocalId): Int { + val key = buildFileBasedSyncPollAttemptsKey(siteId) + var attempts = 0 + dataStore.edit { preferences -> + attempts = preferences[key] ?: 0 + preferences.remove(key) + } + return attempts + } + private fun buildPeriodicSyncEnabledKey(siteId: LocalOrRemoteId.LocalId): Preferences.Key = booleanPreferencesKey("pos_periodic_sync_enabled_v2_${siteId.value}") + + private fun buildFileBasedSyncPollAttemptsKey(siteId: LocalOrRemoteId.LocalId): Preferences.Key = + intPreferencesKey("pos_file_based_sync_poll_attempts_${siteId.value}") + private fun buildSiteSpecificKey(key: String): Preferences.Key = stringPreferencesKey("${selectedSite.getOrNull()?.id}_v2_$key") diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt index 594cd5c73a2..edddd7c4291 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt @@ -49,6 +49,13 @@ class WooPosSyncTimestampManager @Inject constructor( return parseGmtTimestamp(dateFromApi) } + fun calculateGenerationDuration(scheduledAt: String?, completedAt: String?): Long? { + if (scheduledAt == null || completedAt == null) return null + val scheduledTs = parseGmtTimestamp(scheduledAt) ?: return null + val completedTs = parseGmtTimestamp(completedAt) ?: return null + return completedTs - scheduledTs + } + private fun parseGmtTimestamp(dateFromApi: String): Long? { for (formatter in PARSING_FORMATTERS) { try { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSourceTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSourceTest.kt index 4e2edb5ae07..72a5a50ff8a 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSourceTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsDataSourceTest.kt @@ -1,5 +1,6 @@ package com.woocommerce.android.ui.woopos.home.items.products +import com.woocommerce.android.ui.woopos.featureflags.WooPosLocalCatalogFileApproachEnabled import com.woocommerce.android.ui.woopos.localcatalog.PosLocalCatalogProductSyncResult import com.woocommerce.android.ui.woopos.localcatalog.PosLocalCatalogSyncResult import com.woocommerce.android.ui.woopos.localcatalog.PosLocalCatalogVariationSyncResult @@ -26,6 +27,9 @@ class WooPosProductsDataSourceTest { private val remoteDataSource: WooPosProductsRemoteDataSource = mock() private val localDbDataSource: WooPosProductsInDbDataSource = mock() private val syncStatusChecker: WooPosFullSyncStatusChecker = mock() + private val fileApproachEnabled: WooPosLocalCatalogFileApproachEnabled = mock { + on { invoke() }.thenReturn(false) + } @Rule @JvmField @@ -313,6 +317,7 @@ class WooPosProductsDataSourceTest { private fun createSut() = WooPosProductsDataSource( remoteDataSource = remoteDataSource, localDbDataSource = localDbDataSource, - syncStatusChecker = syncStatusChecker + syncStatusChecker = syncStatusChecker, + fileApproachEnabled = fileApproachEnabled ) } 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 index af8602d579d..fd294f8ac10 100644 --- 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 @@ -1,11 +1,14 @@ package com.woocommerce.android.ui.woopos.localcatalog import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.ui.woopos.util.datastore.WooPosPreferencesRepository +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -28,6 +31,8 @@ class WooPosFileBasedSyncActionTest { private val catalogFileDownloader: WooPosCatalogFileDownloader = mock() private val catalogFileParser: WooPosCatalogFileParser = mock() private val syncWithFts: WooPosLocalCatalogSyncWithFts = mock() + private val preferencesRepository: WooPosPreferencesRepository = mock() + private val syncTimestampManager: WooPosSyncTimestampManager = mock() private val logger: WooPosLogWrapper = mock() private lateinit var site: SiteModel @@ -62,11 +67,15 @@ class WooPosFileBasedSyncActionTest { @Before fun setup() = runTest { + whenever(preferencesRepository.getAndClearFileBasedSyncPollAttempts(any())).thenReturn(0) + sut = WooPosFileBasedSyncAction( posLocalCatalogStore = posLocalCatalogStore, catalogFileDownloader = catalogFileDownloader, catalogFileParser = catalogFileParser, syncWithFts = syncWithFts, + preferencesRepository = preferencesRepository, + syncTimestampManager = syncTimestampManager, logger = logger ) site = SiteModel().apply { @@ -354,6 +363,75 @@ class WooPosFileBasedSyncActionTest { assertThat(result).isInstanceOf(WooPosFileBasedSyncAction.WooPosFileBasedSyncResult.Success::class.java) } + @Test + fun `given accumulated poll attempts, when sync completes, then total attempts included in result`() = runTest { + // GIVEN + whenever(preferencesRepository.getAndClearFileBasedSyncPollAttempts(any())).thenReturn(15) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN - total = 15 accumulated + 1 current = 16 + val syncResult = (result as WooPosFileBasedSyncAction.WooPosFileBasedSyncResult.Success).result + assertThat(syncResult.pollAttempts).isEqualTo(16) + } + + @Test + fun `given timeout occurs, when sync fails, then total poll attempts saved for next run`() = runTest { + // GIVEN + whenever(preferencesRepository.getAndClearFileBasedSyncPollAttempts(any())).thenReturn(10) + givenCatalogGenerationNeverCompletes() + + // WHEN + sut.syncCatalog(site) + + // THEN - 10 accumulated + 20 max attempts = 30 + verify(preferencesRepository).setFileBasedSyncPollAttempts(site.localId(), 30) + } + + @Test + fun `given generation duration available, when sync completes, then duration included in result`() = runTest { + // GIVEN + whenever(syncTimestampManager.calculateGenerationDuration(anyOrNull(), anyOrNull())).thenReturn(5000L) + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + val syncResult = (result as WooPosFileBasedSyncAction.WooPosFileBasedSyncResult.Success).result + assertThat(syncResult.generationDurationMs).isEqualTo(5000L) + } + + @Test + fun `given timeout occurs during in_progress state, when sync fails, then last state included in failure`() = runTest { + // GIVEN + givenCatalogGenerationNeverCompletes() + + // WHEN + val result = sut.syncCatalog(site) + + // THEN + val failure = (result as WooPosFileBasedSyncAction.WooPosFileBasedSyncResult.Failure).result + assertThat(failure).isInstanceOf(PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout::class.java) + val timeout = failure as PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout + assertThat(timeout.lastGenerationState).isEqualTo("in_progress") + } + + @Test + fun `given timeout occurs, when sync fails, then poll attempts included in failure result`() = runTest { + // GIVEN + whenever(preferencesRepository.getAndClearFileBasedSyncPollAttempts(any())).thenReturn(5) + givenCatalogGenerationNeverCompletes() + + // WHEN + val result = sut.syncCatalog(site) + + // THEN - 5 accumulated + 20 max = 25 + val timeout = (result as WooPosFileBasedSyncAction.WooPosFileBasedSyncResult.Failure).result + as PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout + assertThat(timeout.pollAttempts).isEqualTo(25) + } + private suspend fun givenCatalogGenerationCompleted() { whenever(posLocalCatalogStore.generateCatalogOrGetStatus(site)) .thenReturn(