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 ca1450899f26..f905754dbfd0 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 @@ -33,6 +33,7 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( private val dispatchers: CoroutineDispatchers, private val logger: WooPosLogWrapper, private val posLocalCatalogStore: WooPosLocalCatalogStore, + private val dateTimeProvider: DateTimeProvider, ) { companion object { const val PAGE_SIZE = 100 @@ -50,7 +51,7 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( maxTotalItems = MAX_TOTAL_ITEMS_FULL_SYNC ).also { if (it is PosLocalCatalogSyncResult.Success) { - syncTimestampManager.storeFullSyncLastCompletedTimestamp(System.currentTimeMillis()) + syncTimestampManager.storeFullSyncLastCompletedTimestamp(dateTimeProvider.now()) } if (it is PosLocalCatalogSyncResult.Failure.CatalogTooLarge) { preferencesRepository.disablePeriodicSyncForSite(site.siteId) @@ -79,7 +80,7 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( maxTotalItems: Int, modifiedAfterGmt: String? = null, ): PosLocalCatalogSyncResult { - val startTime = System.currentTimeMillis() + val startTime = dateTimeProvider.now() logger.d("Starting sync for items modified after $modifiedAfterGmt, max pages: $maxPages") @@ -92,63 +93,34 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( return catalogSizeCheckResult.toPosLocalCatalogSyncFailure() } - val productSyncResult = syncProducts(site, modifiedAfterGmt, pageSize, maxPages) - if (productSyncResult is WooPosSyncResult.Failed) { - return productSyncResult.toPosLocalCatalogSyncFailure() + val syncResult = posSyncAction.syncCatalog(site, modifiedAfterGmt, pageSize, maxPages) + if (syncResult is WooPosSyncResult.Failed) { + return syncResult.toPosLocalCatalogSyncFailure() } - val variationSyncResult = syncVariations(site, modifiedAfterGmt, pageSize, maxPages) - if (variationSyncResult is WooPosSyncResult.Failed) { - return variationSyncResult.toPosLocalCatalogSyncFailure() - } - - val syncDuration = System.currentTimeMillis() - startTime + val successResult = syncResult as WooPosSyncResult.Success - return PosLocalCatalogSyncResult.Success( - productsSynced = (productSyncResult as WooPosSyncResult.Success).syncedCount, - variationsSynced = (variationSyncResult as WooPosSyncResult.Success).syncedCount, - syncDurationMs = syncDuration - ) - } - - private suspend fun syncProducts( - site: SiteModel, - modifiedAfterGmt: String?, - pageSize: Int, - maxPages: Int - ): WooPosSyncResult { - val result = posSyncAction.syncProducts(site, modifiedAfterGmt, pageSize, maxPages) - - if (result is WooPosSyncResult.Success) { - result.serverDate?.let { serverDate -> - syncTimestampManager.parseTimestampFromApi(serverDate)?.let { timestamp -> - syncTimestampManager.storeProductsLastSyncTimestamp(timestamp) - logger.d("Stored products sync timestamp: $serverDate") - } + successResult.productsServerDate?.let { serverDate -> + syncTimestampManager.parseTimestampFromApi(serverDate)?.let { timestamp -> + syncTimestampManager.storeProductsLastSyncTimestamp(timestamp) + logger.d("Stored products sync timestamp: $serverDate") } } - return result - } - - private suspend fun syncVariations( - site: SiteModel, - modifiedAfterGmt: String?, - pageSize: Int, - maxPages: Int - ): WooPosSyncResult { - val result = posSyncAction.syncVariations(site, modifiedAfterGmt, pageSize, maxPages) - - if (result is WooPosSyncResult.Success) { - result.serverDate?.let { serverDate -> - syncTimestampManager.parseTimestampFromApi(serverDate)?.let { timestamp -> - syncTimestampManager.storeVariationsLastSyncTimestamp(timestamp) - logger.d("Stored variations sync timestamp: $serverDate") - } + successResult.variationsServerDate?.let { serverDate -> + syncTimestampManager.parseTimestampFromApi(serverDate)?.let { timestamp -> + syncTimestampManager.storeVariationsLastSyncTimestamp(timestamp) + logger.d("Stored variations sync timestamp: $serverDate") } } - return result + val syncDuration = dateTimeProvider.now() - startTime + + return PosLocalCatalogSyncResult.Success( + productsSynced = successResult.productsSynced, + variationsSynced = successResult.variationsSynced, + syncDurationMs = syncDuration + ) } suspend fun getProductCount(site: SiteModel): Int = diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncAction.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncAction.kt index a30d85590abb..8f74a372a44d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncAction.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncAction.kt @@ -12,131 +12,107 @@ import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosPaginatedFetchRe import javax.inject.Inject class WooPosSyncAction @Inject constructor( - private val productAction: WooPosSyncProductsAction, - private val variationsAction: WooPosSyncVariationsAction + private val posLocalCatalogStore: WooPosLocalCatalogStore, + private val logger: WooPosLogWrapper, ) { - suspend fun syncProducts( + suspend fun syncCatalog( site: SiteModel, modifiedAfterGmt: String? = null, pageSize: Int, maxPages: Int - ) = productAction.execute( - site = site, - modifiedAfterGmt = modifiedAfterGmt, - pageSize = pageSize, - maxPages = maxPages - ) + ): WooPosSyncResult { + return runCatching { + val isFullSync = modifiedAfterGmt == null + val fetchResults = fetchAllCatalogData(site, modifiedAfterGmt, pageSize, maxPages, isFullSync) + + updateDatabaseWithFetchedItems(site, fetchResults, isFullSync) + }.fold( + onSuccess = { result -> result }, + onFailure = { error -> handleSyncError(error) } + ) + } - suspend fun syncVariations( + private suspend fun fetchAllCatalogData( site: SiteModel, - modifiedAfterGmt: String? = null, + modifiedAfterGmt: String?, pageSize: Int, - maxPages: Int - ) = variationsAction.execute( - site = site, - modifiedAfterGmt = modifiedAfterGmt, - pageSize = pageSize, - maxPages = maxPages - ) -} - -private typealias ServerDate = String - -sealed interface WooPosSyncResult { - val syncedCount: Int - val serverDate: String? - - data class Success( - override val syncedCount: Int, - override val serverDate: String? - ) : WooPosSyncResult - - sealed class Failed( - val error: String - ) : WooPosSyncResult { - override val syncedCount: Int = 0 - override val serverDate: String? = null + maxPages: Int, + isFullSync: Boolean + ): FetchResults = coroutineScope { + val regularProductsDeferred = async { + fetchAllProductPages(site, modifiedAfterGmt, pageSize, maxPages) + } + val trashProductsDeferred = async { + if (isFullSync) { + // We run incremental sync right after completing full sync -> no need to fetch trash products + emptyList() + } else { + fetchAllTrashProducts(site, pageSize) + } + } + val variationsDeferred = async { + fetchAllVariationPages(site, modifiedAfterGmt, pageSize, maxPages) + } - data class CatalogTooLarge( - val totalPages: Int, - val maxPages: Int - ) : Failed("Local Catalog too large: $totalPages pages exceed maximum of $maxPages pages") + val (products, productsServerDate) = regularProductsDeferred.await() + val trashProducts = trashProductsDeferred.await() + val (variations, variationsServerDate) = variationsDeferred.await() - data class UnexpectedError( - val errorMessage: String - ) : Failed(errorMessage) + FetchResults(products, trashProducts, productsServerDate, variations, variationsServerDate) } -} -class WooPosSyncProductsAction @Inject constructor( - private val posLocalCatalogStore: WooPosLocalCatalogStore, - private val logger: WooPosLogWrapper, -) { - suspend fun execute( + private suspend fun updateDatabaseWithFetchedItems( site: SiteModel, - modifiedAfterGmt: String? = null, - pageSize: Int, - maxPages: Int + fetchResults: FetchResults, + isFullSync: Boolean ): WooPosSyncResult { - return runCatching { - val isFullSync = modifiedAfterGmt == null + val allProducts = fetchResults.products + fetchResults.trashProducts - val (products, trashProducts, serverDate) = coroutineScope { - val regularProductsDeferred = async { - fetchAllPages(site, modifiedAfterGmt, pageSize, maxPages) - } - val trashProductsDeferred = async { - if (isFullSync) { - // We run incremental sync right after completing full sync -> no need to fetch trash products - emptyList() - } else { - fetchAllTrashProducts(site, pageSize) - } - } - - val (products, serverDate) = regularProductsDeferred.await() - val trashProducts = trashProductsDeferred.await() - Triple(products, trashProducts, serverDate) + return posLocalCatalogStore.executeInTransaction { + if (isFullSync) { + posLocalCatalogStore.deleteAllProducts(site.localId()).getOrThrow() + posLocalCatalogStore.deleteAllVariations(site.localId()).getOrThrow() } - val allProducts = products + trashProducts - - posLocalCatalogStore.executeInTransaction { - if (isFullSync) { - posLocalCatalogStore.deleteAllProducts( - siteId = site.localId() - ).getOrThrow() - } + posLocalCatalogStore.upsertProducts(allProducts).getOrThrow() + posLocalCatalogStore.upsertVariations(fetchResults.variations).getOrThrow() + }.fold( + onSuccess = { + logger.d("Local Catalog transaction committed successfully") + WooPosSyncResult.Success( + productsSynced = allProducts.size, + variationsSynced = fetchResults.variations.size, + productsServerDate = fetchResults.productsServerDate, + variationsServerDate = fetchResults.variationsServerDate + ) + }, + onFailure = { error -> handleTransactionError(error) } + ) + } - posLocalCatalogStore.upsertProducts(allProducts).getOrThrow() - }.fold( - onSuccess = { - logger.d("Local Catalog transaction committed successfully") - WooPosSyncResult.Success(allProducts.size, serverDate) - }, - onFailure = { error -> - handleTransactionError(error) - } + private fun handleSyncError(error: Throwable): WooPosSyncResult { + logger.e("Failed to sync catalog: ${error.message}") + return when (error) { + is CatalogTooLargeException -> WooPosSyncResult.Failed.CatalogTooLarge( + error.totalPages, + error.maxPages ) - }.fold( - onSuccess = { result -> result }, - onFailure = { error -> - logger.e("Failed to sync products: ${error.message}") - when (error) { - is CatalogTooLargeException -> WooPosSyncResult.Failed.CatalogTooLarge( - error.totalPages, - error.maxPages - ) - else -> WooPosSyncResult.Failed.UnexpectedError( - error.message ?: "Failed to sync products" - ) - } - } - ) + else -> WooPosSyncResult.Failed.UnexpectedError( + error.message ?: "Failed to sync catalog" + ) + } } - private suspend fun fetchAllPages( + private data class FetchResults( + val products: List, + val trashProducts: List, + val productsServerDate: String, + val variations: List, + val variationsServerDate: String + ) + + private suspend fun fetchAllProductPages( site: SiteModel, modifiedAfterGmt: String?, pageSize: Int, @@ -158,20 +134,26 @@ class WooPosSyncProductsAction @Inject constructor( return helper.fetchAllPages(maxPages) } - private fun handleTransactionError(error: Throwable): WooPosSyncResult { - return when (error) { - is CatalogTooLargeException -> { - logger.e("Local Catalog too large, transaction rolled back") - WooPosSyncResult.Failed.CatalogTooLarge(error.totalPages, error.maxPages) - } - - else -> { - logger.e("Local Catalog Transaction failed and was rolled back: ${error.message}") - WooPosSyncResult.Failed.UnexpectedError( - error.message ?: "Local Catalog Transaction failed and was rolled back" + private suspend fun fetchAllVariationPages( + site: SiteModel, + modifiedAfterGmt: String?, + pageSize: Int, + maxPages: Int + ): Pair, String> { + val helper = PaginatedSyncHelper( + logger = logger, + entityType = "variations", + fetchPage = { page -> + posLocalCatalogStore.fetchRecentlyModifiedVariations( + site = site, + modifiedAfterGmt = modifiedAfterGmt, + page = page, + pageSize = pageSize, ) } - } + ) + + return helper.fetchAllPages(maxPages) } private suspend fun fetchAllTrashProducts( @@ -197,96 +179,58 @@ class WooPosSyncProductsAction @Inject constructor( val (trashProducts, _) = helper.fetchAllPages(Int.MAX_VALUE) return trashProducts } -} - -class WooPosSyncVariationsAction @Inject constructor( - private val posLocalCatalogStore: WooPosLocalCatalogStore, - private val logger: WooPosLogWrapper, -) { - suspend fun execute( - site: SiteModel, - modifiedAfterGmt: String? = null, - pageSize: Int, - maxPages: Int - ): WooPosSyncResult { - return runCatching { - val (variations, serverDate) = fetchAllPages(site, modifiedAfterGmt, pageSize, maxPages) - - posLocalCatalogStore.executeInTransaction { - val isFullSync = modifiedAfterGmt == null - if (isFullSync) { - posLocalCatalogStore.deleteAllVariations( - siteId = site.localId() - ).getOrThrow() - } - - posLocalCatalogStore.upsertVariations(variations).getOrThrow() - }.fold( - onSuccess = { - logger.d("Local Catalog variations transaction committed successfully") - WooPosSyncResult.Success(variations.size, serverDate) - }, - onFailure = { error -> - handleTransactionError(error) - } - ) - }.fold( - onSuccess = { result -> result }, - onFailure = { error -> - logger.e("Failed to sync variations: ${error.message}") - when (error) { - is CatalogTooLargeException -> WooPosSyncResult.Failed.CatalogTooLarge( - error.totalPages, - error.maxPages - ) - - else -> WooPosSyncResult.Failed.UnexpectedError( - error.message ?: "Failed to sync variations" - ) - } - } - ) - } - - private suspend fun fetchAllPages( - site: SiteModel, - modifiedAfterGmt: String?, - pageSize: Int, - maxPages: Int - ): Pair, String> { - val helper = PaginatedSyncHelper( - logger = logger, - entityType = "variations", - fetchPage = { page -> - posLocalCatalogStore.fetchRecentlyModifiedVariations( - site = site, - modifiedAfterGmt = modifiedAfterGmt, - page = page, - pageSize = pageSize, - ) - } - ) - - return helper.fetchAllPages(maxPages) - } private fun handleTransactionError(error: Throwable): WooPosSyncResult { return when (error) { is CatalogTooLargeException -> { - logger.e("Local Catalog variations too large, transaction rolled back") + logger.e("Local Catalog too large, transaction rolled back") WooPosSyncResult.Failed.CatalogTooLarge(error.totalPages, error.maxPages) } else -> { - logger.e("Local Catalog Variations Transaction failed and was rolled back: ${error.message}") + logger.e("Local Catalog Transaction failed and was rolled back: ${error.message}") WooPosSyncResult.Failed.UnexpectedError( - error.message ?: "Local Catalog Variations Transaction failed and was rolled back" + error.message ?: "Local Catalog Transaction failed and was rolled back" ) } } } } +private typealias ServerDate = String + +sealed interface WooPosSyncResult { + val productsSynced: Int + val variationsSynced: Int + val productsServerDate: String? + val variationsServerDate: String? + + data class Success( + override val productsSynced: Int, + override val variationsSynced: Int, + override val productsServerDate: String?, + override val variationsServerDate: String? + ) : WooPosSyncResult + + sealed class Failed( + val error: String + ) : WooPosSyncResult { + override val productsSynced: Int = 0 + override val variationsSynced: Int = 0 + override val productsServerDate: String? = null + override val variationsServerDate: String? = null + + data class CatalogTooLarge( + val totalPages: Int, + val maxPages: Int + ) : Failed("Local Catalog too large: $totalPages pages exceed maximum of $maxPages pages") + + data class UnexpectedError( + val errorMessage: String + ) : Failed(errorMessage) + } +} + private class CatalogTooLargeException( val totalPages: Int, val maxPages: Int diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepositoryTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepositoryTest.kt index b3bc0ff5bc86..e6c1f28707f4 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepositoryTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncRepositoryTest.kt @@ -30,6 +30,7 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { private lateinit var dispatchers: CoroutineDispatchers private lateinit var site: SiteModel private var logger: WooPosLogWrapper = mock() + private var dateTimeProvider: DateTimeProvider = mock() private var posLocalCatalogStore: WooPosLocalCatalogStore = mock() @Before @@ -48,6 +49,7 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { logger = logger, preferencesRepository = preferencesRepository, posLocalCatalogStore = posLocalCatalogStore, + dateTimeProvider = dateTimeProvider, ) site = SiteModel().apply { @@ -63,12 +65,15 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `when full sync succeeds, then returns success`() = testBlocking { // GIVEN val productsSynced = 150 - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn( - WooPosSyncResult.Success(productsSynced, "2024-01-01T12:00:00Z") + WooPosSyncResult.Success( + productsSynced = productsSynced, + variationsSynced = 50, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) ) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(50, "2024-01-01T12:00:00Z")) // WHEN val result = sut.syncLocalCatalogFull(site) @@ -81,18 +86,22 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `when full sync succeeds, then stores both last sync and last full sync timestamps`() = testBlocking { // GIVEN val productsSynced = 150 - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn( - WooPosSyncResult.Success(productsSynced, "2024-01-01T12:00:00Z") + WooPosSyncResult.Success( + productsSynced = productsSynced, + variationsSynced = 50, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) ) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(50, "2024-01-01T12:00:00Z")) // WHEN sut.syncLocalCatalogFull(site) // THEN verify(syncTimestampManager).storeProductsLastSyncTimestamp(any()) + verify(syncTimestampManager).storeVariationsLastSyncTimestamp(any()) verify(syncTimestampManager).storeFullSyncLastCompletedTimestamp(any()) } @@ -100,12 +109,15 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `when full sync succeeds, then does not disable periodic sync`() = testBlocking { // GIVEN val productsSynced = 150 - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn( - WooPosSyncResult.Success(productsSynced, "2024-01-01T12:00:00Z") + WooPosSyncResult.Success( + productsSynced = productsSynced, + variationsSynced = 50, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) ) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(50, "2024-01-01T12:00:00Z")) // WHEN sut.syncLocalCatalogFull(site) @@ -119,7 +131,7 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { // GIVEN val totalPages = 15 val maxPages = WooPosLocalCatalogSyncRepository.MAX_PAGES_PER_FULL_SYNC - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn(WooPosSyncResult.Failed.CatalogTooLarge(totalPages, maxPages)) // WHEN @@ -134,7 +146,7 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { // GIVEN val totalPages = 15 val maxPages = WooPosLocalCatalogSyncRepository.MAX_PAGES_PER_FULL_SYNC - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn(WooPosSyncResult.Failed.CatalogTooLarge(totalPages, maxPages)) // WHEN @@ -148,7 +160,7 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `when full sync fails with unexpected error, then returns UnexpectedError failure`() = testBlocking { // GIVEN val errorMessage = "Network timeout" - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn(WooPosSyncResult.Failed.UnexpectedError(errorMessage)) // WHEN @@ -162,12 +174,15 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `when incremental sync succeeds, then returns success`() = testBlocking { // GIVEN val productsSynced = 150 - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn( - WooPosSyncResult.Success(productsSynced, "2024-01-01T12:00:00Z") + WooPosSyncResult.Success( + productsSynced = productsSynced, + variationsSynced = 50, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) ) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(50, "2024-01-01T12:00:00Z")) // WHEN val result = sut.syncLocalCatalogIncremental(site) @@ -180,18 +195,22 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `when incremental sync succeeds, then stores timestamp`() = testBlocking { // GIVEN val productsSynced = 150 - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn( - WooPosSyncResult.Success(productsSynced, "2024-01-01T12:00:00Z") + WooPosSyncResult.Success( + productsSynced = productsSynced, + variationsSynced = 50, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) ) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(50, "2024-01-01T12:00:00Z")) // WHEN sut.syncLocalCatalogIncremental(site) // THEN verify(syncTimestampManager).storeProductsLastSyncTimestamp(any()) + verify(syncTimestampManager).storeVariationsLastSyncTimestamp(any()) } @Test @@ -199,7 +218,7 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { // GIVEN val totalPages = 15 val maxPages = WooPosLocalCatalogSyncRepository.MAX_PAGES_PER_FULL_SYNC - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn(WooPosSyncResult.Failed.CatalogTooLarge(totalPages, maxPages)) // WHEN @@ -213,7 +232,7 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `when incremental sync fails with unexpected error, then returns UnexpectedError failure`() = testBlocking { // GIVEN val errorMessage = "Network timeout" - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) .thenReturn(WooPosSyncResult.Failed.UnexpectedError(errorMessage)) // WHEN @@ -240,10 +259,15 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { @Test fun `given catalog size is exactly at limit, when sync starts, then proceeds with sync`() = testBlocking { // GIVEN - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(600, "2024-01-01T12:00:00Z")) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(400, "2024-01-01T12:00:00Z")) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) + .thenReturn( + WooPosSyncResult.Success( + productsSynced = 600, + variationsSynced = 400, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) + ) // WHEN val result = sut.syncLocalCatalogFull(site) @@ -256,10 +280,15 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `given products count fetch fails, when sync starts, then proceeds with sync anyway`() = testBlocking { // GIVEN givenCatalogSizeUnknown() - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(100, "2024-01-01T12:00:00Z")) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(50, "2024-01-01T12:00:00Z")) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) + .thenReturn( + WooPosSyncResult.Success( + productsSynced = 100, + variationsSynced = 50, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) + ) // WHEN val result = sut.syncLocalCatalogFull(site) @@ -272,10 +301,15 @@ class WooPosLocalCatalogSyncRepositoryTest : BaseUnitTest() { fun `given variations count fetch fails, when sync starts, then proceeds with sync anyway`() = testBlocking { // GIVEN givenCatalogSizeUnknown() - whenever(posSyncAction.syncProducts(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(500, "2024-01-01T12:00:00Z")) - whenever(posSyncAction.syncVariations(any(), anyOrNull(), any(), any())) - .thenReturn(WooPosSyncResult.Success(200, "2024-01-01T12:00:00Z")) + whenever(posSyncAction.syncCatalog(any(), anyOrNull(), any(), any())) + .thenReturn( + WooPosSyncResult.Success( + productsSynced = 500, + variationsSynced = 200, + productsServerDate = "2024-01-01T12:00:00Z", + variationsServerDate = "2024-01-01T12:00:00Z" + ) + ) // WHEN val result = sut.syncLocalCatalogFull(site) diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncActionTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncActionTest.kt new file mode 100644 index 000000000000..1b8ec464cd5e --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncActionTest.kt @@ -0,0 +1,863 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncRepository.Companion.PAGE_SIZE +import kotlinx.coroutines.runBlocking +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 +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.LocalOrRemoteId +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.CoreProductStatus +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.WooPosLocalCatalogStore +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosPaginatedFetchResult +import kotlin.Result as KotlinResult + +class WooPosSyncActionTest { + + private lateinit var sut: WooPosSyncAction + private var posLocalCatalogStore: WooPosLocalCatalogStore = mock() + private lateinit var site: SiteModel + private var logger: WooPosLogWrapper = mock() + + @Before + fun setup() { + sut = WooPosSyncAction(posLocalCatalogStore, logger) + site = SiteModel().apply { + id = 1 + siteId = 123L + } + runBlocking { + givenTransactionSuccess() + } + } + + // === PRODUCT SYNC TESTS === + + @Test + fun `when products have single page, then syncs correct product count`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(50), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(50) + } + + @Test + fun `when products have multiple pages, then syncs all product pages`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(50, 30, 20), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(100) + verify(posLocalCatalogStore, times(3)).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(null) + ) + } + + @Test + fun `when products have empty catalog, then returns zero product count`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(0) + } + + @Test + fun `when product fetch fails on first page, then returns error`() = runTest { + // GIVEN + givenProductFetchFails(page = 1, "Network error") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when product fetch fails on middle page, then returns error`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(50, 30), serverDate = "2024-01-01T12:00:00Z") + givenProductFetchFails(page = 2, "Network timeout") + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when products exceed page limit, then returns CatalogTooLarge`() = runTest { + // GIVEN + val totalPages = 15 + val maxPages = 10 + givenProductCatalogTooLarge(totalPages) + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, maxPages) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.CatalogTooLarge::class.java) + val failure = result as WooPosSyncResult.Failed.CatalogTooLarge + assertThat(failure.totalPages).isEqualTo(totalPages) + assertThat(failure.maxPages).isEqualTo(maxPages) + } + + @Test + fun `when products have zero items but hasMore true, then stops fetching`() = runTest { + // GIVEN + givenProductPageWithZeroItemsButHasMore() + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(0) + verify(posLocalCatalogStore, times(1)).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + eq(1), + any(), + eq(null) + ) + } + + // === VARIATIONS SYNC TESTS === + + @Test + fun `when variations have single page, then syncs correct variation count`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(25), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).variationsSynced).isEqualTo(25) + } + + @Test + fun `when variations have multiple pages, then syncs all variation pages`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(25, 15, 10), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).variationsSynced).isEqualTo(50) + verify(posLocalCatalogStore, times(3)).fetchRecentlyModifiedVariations(eq(site), anyOrNull(), any(), any()) + } + + @Test + fun `when variations have empty catalog, then returns zero variation count`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).variationsSynced).isEqualTo(0) + } + + @Test + fun `when variation fetch fails on first page, then returns error`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + givenVariationFetchFails(page = 1, "Network error") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when variation fetch fails on middle page, then returns error`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(25, 15), serverDate = "2024-01-01T12:00:00Z") + givenVariationFetchFails(page = 2, "Database error") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when variations exceed page limit, then returns CatalogTooLarge`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + givenVariationCatalogTooLarge(totalPages = 12) + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.CatalogTooLarge::class.java) + } + + @Test + fun `when variations have zero items but hasMore true, then stops fetching`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + givenVariationPageWithZeroItemsButHasMore() + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).variationsSynced).isEqualTo(0) + verify(posLocalCatalogStore, times(1)).fetchRecentlyModifiedVariations(eq(site), anyOrNull(), eq(1), any()) + } + + // === TRASH PRODUCTS SYNC TESTS === + + @Test + fun `when incremental sync, then fetches and includes trash products`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + givenTrashProducts(count = 3) + + // WHEN + val result = sut.syncCatalog(site, "2024-01-01T12:00:00Z", PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(13) // 10 + 3 trash + verify(posLocalCatalogStore).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) + } + + @Test + fun `when full sync, then does not fetch trash products`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + verify(posLocalCatalogStore, never()).fetchRecentlyModifiedProducts( + any(), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) + } + + @Test + fun `when trash product fetch has multiple pages, then fetches all trash pages`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + givenMultiPageTrashProducts(pages = listOf(5, 3)) + + // WHEN + val result = sut.syncCatalog(site, "2024-01-01T12:00:00Z", PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(18) // 10 + 5 + 3 trash + verify(posLocalCatalogStore, times(2)).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) + } + + @Test + fun `when trash product fetch fails, then returns error`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + givenTrashProductFetchFails("Trash fetch error") + + // WHEN + val result = sut.syncCatalog(site, "2024-01-01T12:00:00Z", PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when trash products are empty, then includes zero trash products`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + givenTrashProducts(count = 0) + + // WHEN + val result = sut.syncCatalog(site, "2024-01-01T12:00:00Z", PAGE_SIZE, 10) + + // THEN + assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(10) // no trash products + } + + // === TRANSACTION TESTS === + + @Test + fun `when syncing, then executes all operations in single transaction`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + verify(posLocalCatalogStore).executeInTransaction(any KotlinResult>()) + } + + @Test + fun `when transaction fails, then returns UnexpectedError`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + givenTransactionFailure("Database error") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when full sync, then deletes both products and variations before insert`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + verify(posLocalCatalogStore).deleteAllProducts(LocalOrRemoteId.LocalId(site.id)) + verify(posLocalCatalogStore).deleteAllVariations(LocalOrRemoteId.LocalId(site.id)) + } + + @Test + fun `when incremental sync, then does not delete any data`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + sut.syncCatalog(site, "2024-01-01T12:00:00Z", PAGE_SIZE, 10) + + // THEN + verify(posLocalCatalogStore, never()).deleteAllProducts(any()) + verify(posLocalCatalogStore, never()).deleteAllVariations(any()) + } + + @Test + fun `when transaction succeeds, then commits all changes atomically`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) + verify(posLocalCatalogStore).upsertProducts(any()) + verify(posLocalCatalogStore).upsertVariations(any()) + } + + // === SERVER DATE TESTS === + + @Test + fun `when sync succeeds, then returns both products and variations server dates`() = runTest { + // GIVEN + val productsDate = "2024-01-01T12:00:00Z" + val variationsDate = "2024-01-01T13:00:00Z" + mockFetchProductsSuccess(pages = listOf(10), serverDate = productsDate) + mockFetchVariationsSuccess(pages = listOf(5), serverDate = variationsDate) + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + val success = result as WooPosSyncResult.Success + assertThat(success.productsServerDate).isEqualTo(productsDate) + assertThat(success.variationsServerDate).isEqualTo(variationsDate) + } + + @Test + fun `when products and variations have different dates, then returns both dates correctly`() = runTest { + // GIVEN + val productsDate = "2024-01-01T12:00:00Z" + val variationsDate = "2024-01-02T15:30:00Z" + mockFetchProductsSuccess(pages = listOf(5), serverDate = productsDate) + mockFetchVariationsSuccess(pages = listOf(3), serverDate = variationsDate) + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + val success = result as WooPosSyncResult.Success + assertThat(success.productsServerDate).isEqualTo(productsDate) + assertThat(success.variationsServerDate).isEqualTo(variationsDate) + } + + @Test + fun `when multiple pages synced, then returns first page server date`() = runTest { + // GIVEN + val firstPageDate = "2024-01-01T12:00:00Z" + mockFetchProductsSuccess(pages = listOf(50, 30), serverDate = firstPageDate) + mockFetchVariationsSuccess(pages = listOf(25, 15), serverDate = firstPageDate) + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + val success = result as WooPosSyncResult.Success + assertThat(success.productsServerDate).isEqualTo(firstPageDate) + assertThat(success.variationsServerDate).isEqualTo(firstPageDate) + } + + // === COMBINED INTEGRATION TESTS === + + @Test + fun `when catalog has both products and variations, then returns success with correct counts`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(50), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(30), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + val success = result as WooPosSyncResult.Success + assertThat(success.productsSynced).isEqualTo(50) + assertThat(success.variationsSynced).isEqualTo(30) + } + + @Test + fun `when catalog has multiple pages of both types, then syncs all pages correctly`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(50, 30, 20), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(25, 15, 10), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + val success = result as WooPosSyncResult.Success + assertThat(success.productsSynced).isEqualTo(100) + assertThat(success.variationsSynced).isEqualTo(50) + } + + @Test + fun `when empty catalog for both types, then returns success with zero counts`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + val success = result as WooPosSyncResult.Success + assertThat(success.productsSynced).isEqualTo(0) + assertThat(success.variationsSynced).isEqualTo(0) + } + + @Test + fun `when sync with incremental mode includes trash products, then combines all correctly`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(20), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + givenTrashProducts(count = 5) + + // WHEN + val result = sut.syncCatalog(site, "2024-01-01T12:00:00Z", PAGE_SIZE, 10) + + // THEN + val success = result as WooPosSyncResult.Success + assertThat(success.productsSynced).isEqualTo(25) // 20 + 5 trash + assertThat(success.variationsSynced).isEqualTo(10) + } + + // === ERROR HANDLING TESTS === + + @Test + fun `when API returns null error message, then returns generic error`() = runTest { + // GIVEN + givenProductFetchFails(page = 1, null) + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when network timeout occurs, then returns UnexpectedError`() = runTest { + // GIVEN + givenProductFetchFails(page = 1, "Network timeout") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when catalog size check fails before sync, then returns appropriate error`() = runTest { + // GIVEN + givenProductCatalogTooLarge(totalPages = 20) + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.CatalogTooLarge::class.java) + } + + // === EDGE CASE TESTS === + + @Test + fun `when products succeed but variations fail, then returns error`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(50), serverDate = "2024-01-01T12:00:00Z") + givenVariationFetchFails(page = 1, "Variation error") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when variations succeed but products fail, then returns error`() = runTest { + // GIVEN + givenProductFetchFails(page = 1, "Product error") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when both fetches succeed but transaction fails, then returns transaction error`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(5), serverDate = "2024-01-01T12:00:00Z") + givenTransactionFailure("Transaction rollback") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) + } + + @Test + fun `when page has zero items and hasMore false, then completes successfully`() = runTest { + // GIVEN + mockFetchProductsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + mockFetchVariationsSuccess(pages = listOf(0), serverDate = "2024-01-01T12:00:00Z") + + // WHEN + val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) + + // THEN + assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) + val success = result as WooPosSyncResult.Success + assertThat(success.productsSynced).isEqualTo(0) + assertThat(success.variationsSynced).isEqualTo(0) + } + + // === HELPER METHODS === + + private fun mockFetchProductsSuccess(pages: List, serverDate: String) = runBlocking { + pages.forEachIndexed { index, count -> + val pageNumber = index + 1 + whenever( + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + eq(pageNumber), + any(), + eq(null) + ) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = generateProducts(count), + totalPages = pages.size, + hasMore = pageNumber < pages.size, + nextPage = pageNumber + 1, + syncedCount = count, + serverDate = serverDate + ) + ) + ) + } + } + + private fun mockFetchVariationsSuccess(pages: List, serverDate: String) = runBlocking { + pages.forEachIndexed { index, count -> + val pageNumber = index + 1 + whenever( + posLocalCatalogStore.fetchRecentlyModifiedVariations(eq(site), anyOrNull(), eq(pageNumber), any()) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = generateVariations(count), + totalPages = pages.size, + hasMore = pageNumber < pages.size, + nextPage = pageNumber + 1, + syncedCount = count, + serverDate = serverDate + ) + ) + ) + } + } + + private fun givenTrashProducts(count: Int) = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = generateProducts(count), + totalPages = 1, + hasMore = false, + nextPage = 1, + syncedCount = count, + serverDate = "2024-01-01T12:00:00Z" + ) + ) + ) + } + + private fun givenMultiPageTrashProducts(pages: List) = runBlocking { + pages.forEachIndexed { index, count -> + val pageNumber = index + 1 + whenever( + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + eq(pageNumber), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = generateProducts(count), + totalPages = pages.size, + hasMore = pageNumber < pages.size, + nextPage = pageNumber + 1, + syncedCount = count, + serverDate = "2024-01-01T12:00:00Z" + ) + ) + ) + } + } + + private fun givenTrashProductFetchFails(errorMessage: String) = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) + ).thenReturn(KotlinResult.failure(Exception(errorMessage))) + } + + private fun givenProductFetchFails(page: Int, errorMessage: String?) = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), eq(page), any(), eq(null)) + ).thenReturn(KotlinResult.failure(Exception(errorMessage ?: "Generic error"))) + } + + private fun givenVariationFetchFails(page: Int, errorMessage: String?) = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedVariations(eq(site), anyOrNull(), eq(page), any()) + ).thenReturn(KotlinResult.failure(Exception(errorMessage ?: "Generic error"))) + } + + private fun givenProductCatalogTooLarge(totalPages: Int) = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), eq(1), any(), eq(null)) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = generateProducts(50), + totalPages = totalPages, + hasMore = true, + nextPage = 2, + syncedCount = 50, + serverDate = "2024-01-01T12:00:00Z" + ) + ) + ) + } + + private fun givenVariationCatalogTooLarge(totalPages: Int) = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedVariations(eq(site), anyOrNull(), eq(1), any()) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = generateVariations(50), + totalPages = totalPages, + hasMore = true, + nextPage = 2, + syncedCount = 50, + serverDate = "2024-01-01T12:00:00Z" + ) + ) + ) + } + + private fun givenProductPageWithZeroItemsButHasMore() = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), eq(1), any(), eq(null)) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = emptyList(), + totalPages = 1, + hasMore = true, + nextPage = 2, + syncedCount = 0, + serverDate = "2024-01-01T12:00:00Z" + ) + ) + ) + } + + private fun givenVariationPageWithZeroItemsButHasMore() = runBlocking { + whenever( + posLocalCatalogStore.fetchRecentlyModifiedVariations(eq(site), anyOrNull(), eq(1), any()) + ).thenReturn( + KotlinResult.success( + WooPosPaginatedFetchResult( + items = emptyList(), + totalPages = 1, + hasMore = true, + nextPage = 2, + syncedCount = 0, + serverDate = "2024-01-01T12:00:00Z" + ) + ) + ) + } + + @Suppress("UNCHECKED_CAST") + private fun givenTransactionSuccess() = runBlocking { + whenever( + posLocalCatalogStore.executeInTransaction(any KotlinResult>()) + ).thenAnswer { invocation -> + val block = invocation.arguments[0] as suspend () -> KotlinResult + runBlocking { block.invoke() } + } + whenever(posLocalCatalogStore.deleteAllProducts(any())).thenReturn(KotlinResult.success(Unit)) + whenever(posLocalCatalogStore.deleteAllVariations(any())).thenReturn(KotlinResult.success(Unit)) + whenever(posLocalCatalogStore.upsertProducts(any())).thenReturn(KotlinResult.success(Unit)) + whenever(posLocalCatalogStore.upsertVariations(any())).thenReturn(KotlinResult.success(Unit)) + } + + @Suppress("UNCHECKED_CAST") + private fun givenTransactionFailure(errorMessage: String) = runBlocking { + whenever( + posLocalCatalogStore.executeInTransaction(any KotlinResult>()) + ).thenReturn(KotlinResult.failure(Exception(errorMessage))) + } + + private fun generateProducts(count: Int): List { + return (1..count).map { index -> + WooPosProductEntity( + remoteId = LocalOrRemoteId.RemoteId(index.toLong()), + name = "Product $index" + ) + } + } + + private fun generateVariations(count: Int): List { + return (1..count).map { index -> + WooPosVariationEntity( + localSiteId = LocalOrRemoteId.LocalId(site.id), + remoteProductId = LocalOrRemoteId.RemoteId(100), + remoteVariationId = LocalOrRemoteId.RemoteId(index.toLong()), + dateModified = "2024-01-15T10:00:00Z", + sku = "VAR-$index", + globalUniqueId = "var-$index", + variationName = "Variation $index", + price = "10.00", + regularPrice = "10.00", + salePrice = "", + description = "Test variation $index", + stockQuantity = 1.0 + ) + } + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncProductsActionTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncProductsActionTest.kt deleted file mode 100644 index 242b658765d8..000000000000 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncProductsActionTest.kt +++ /dev/null @@ -1,613 +0,0 @@ -package com.woocommerce.android.ui.woopos.localcatalog - -import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper -import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncRepository.Companion.PAGE_SIZE -import kotlinx.coroutines.runBlocking -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.argThat -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.wordpress.android.fluxc.model.LocalOrRemoteId -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.CoreProductStatus -import org.wordpress.android.fluxc.persistence.entity.pos.WooPosProductEntity -import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore -import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosPaginatedFetchResult -import kotlin.Result as KotlinResult - -class WooPosSyncProductsActionTest { - - private lateinit var sut: WooPosSyncProductsAction - private var posLocalCatalogStore: WooPosLocalCatalogStore = mock() - private lateinit var site: SiteModel - private var logger: WooPosLogWrapper = mock() - - @Before - fun setup() = runBlocking { - sut = WooPosSyncProductsAction(posLocalCatalogStore, logger) - site = SiteModel().apply { - id = 1 - siteId = 123L - } - givenSinglePageCatalog(productsCount = 10) - givenTransactionSuccess() - } - - @Test - fun `when catalog has single page, then returns success with correct count`() = runTest { - // GIVEN - givenSinglePageCatalog(productsCount = 50) - - // WHEN - val result = sut.execute(site, pageSize = 100, maxPages = 2) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(50) - } - - @Test - fun `when catalog has multiple pages within limit, then returns success with total count`() = runTest { - // GIVEN - val maxPages = 10 - givenMultiPageCatalog( - page1Count = 100, - page2Count = 100, - page3Count = 50 - ) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(250) - } - - @Test - fun `when empty catalog, then returns success with zero count`() = runTest { - // GIVEN - val maxPages = 10 - givenEmptyCatalog() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(0) - } - - @Test - fun `when incremental sync with modifiedAfterGmt, then passes filter correctly`() = runTest { - // GIVEN - val modifiedAfter = "2024-01-01T00:00:00Z" - val maxPages = 10 - givenSinglePageCatalog(productsCount = 25) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = modifiedAfter, pageSize = 100, maxPages = maxPages) - - // THEN - verify(posLocalCatalogStore).executeInTransaction(any()) - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - } - - @Test - fun `when catalog has exactly max pages, then returns success`() = runTest { - // GIVEN - val maxPages = 3 - givenMultiPageCatalog( - page1Count = PAGE_SIZE, - page2Count = PAGE_SIZE, - page3Count = PAGE_SIZE - ) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(300) - } - - @Test - fun `when catalog has one page more than limit, then returns CatalogTooLarge`() = runTest { - // GIVEN - val maxPages = 2 - givenCatalogTooLarge(totalPages = 3) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.CatalogTooLarge::class.java) - } - - @Test - fun `when first page fetch fails, then returns UnexpectedError`() = runTest { - // GIVEN - val errorMessage = "Network error" - givenFirstPageFails(errorMessage) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 10) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) - assertThat((result as WooPosSyncResult.Failed).error).contains(errorMessage) - } - - @Test - fun `when middle page fetch fails, then returns UnexpectedError`() = runTest { - // GIVEN - val maxPages = 10 - val errorMessage = "API error on page 2" - givenSecondPageFails(errorMessage) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) - assertThat((result as WooPosSyncResult.Failed).error).contains(errorMessage) - } - - @Test - fun `when API returns null error message, then returns generic error`() = runTest { - // GIVEN - val maxPages = 10 - givenFirstPageFailsWithNullMessage() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) - } - - @Test - fun `when page has zero products but hasMore is true, then returns success`() = runTest { - // GIVEN - val maxPages = 10 - givenPageWithZeroProductsButHasMore() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - verify(posLocalCatalogStore, times(1)) - .executeInTransaction(any()) - } - - @Test - fun `when hasMore is false, then stops fetching pages`() = runTest { - // GIVEN - val maxPages = 10 - givenSinglePageCatalog(productsCount = 10) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - verify(posLocalCatalogStore, times(1)) - .executeInTransaction(any()) - } - - @Test - fun `when syncing multiple pages, then executes all operations in single transaction`() = runTest { - // GIVEN - givenMultiPageCatalog( - page1Count = 100, - page2Count = 100, - page3Count = 50 - ) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 10) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(250) - - verify(posLocalCatalogStore, times(1)) - .executeInTransaction(any()) - } - - @Test - fun `when sync with no modifiedAfterGmt, then deletes and reinserts products`() = runTest { - // GIVEN - givenSinglePageCatalog(productsCount = 50) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 10) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - verify(posLocalCatalogStore).deleteAllProducts(eq(site.localId())) - verify(posLocalCatalogStore).upsertProducts(any()) - } - - @Test - fun `when sync with modifiedAfterGmt, then does not delete products`() = runTest { - // GIVEN - val modifiedAfter = "2024-01-01T00:00:00Z" - givenSinglePageCatalog(productsCount = 25) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = modifiedAfter, pageSize = 100, maxPages = 10) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - verify(posLocalCatalogStore, times(0)).deleteAllProducts(any()) - verify(posLocalCatalogStore).upsertProducts(any()) - } - - @Test - fun `when transaction fails, then returns UnexpectedError`() = runTest { - // GIVEN - val errorMessage = "Database transaction failed" - givenSinglePageCatalog(productsCount = 50) - whenever(posLocalCatalogStore.upsertProducts(any())) - .thenReturn(KotlinResult.failure(Exception(errorMessage))) - whenever(posLocalCatalogStore.executeInTransaction(any())) - .thenReturn(KotlinResult.failure(Exception(errorMessage))) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 10) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) - assertThat((result as WooPosSyncResult.Failed).error).contains(errorMessage) - } - - @Test - fun `given incremental sync, when sync products called, then fetches trash products`() = runTest { - // GIVEN - val modifiedAfter = "2024-01-01T00:00:00Z" - givenSinglePageCatalog(productsCount = 10) - givenSinglePageTrashCatalog(productsCount = 5) - - // WHEN - sut.execute(site, modifiedAfterGmt = modifiedAfter, pageSize = 100, maxPages = 2) - - // THEN - verify(posLocalCatalogStore, times(1)).fetchRecentlyModifiedProducts( - any(), - anyOrNull(), - any(), - any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - } - - @Test - fun `given incremental sync with multiple trash pages, when sync products called, then fetches all trash pages`() = - runTest { - // GIVEN - val modifiedAfter = "2024-01-01T00:00:00Z" - givenSinglePageCatalog(productsCount = 10) - givenMultiPageTrashCatalog() - - // WHEN - sut.execute(site, modifiedAfterGmt = modifiedAfter, pageSize = 100, maxPages = 2) - - // THEN - verify(posLocalCatalogStore, times(3)).fetchRecentlyModifiedProducts( - any(), - anyOrNull(), - any(), - any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - } - - @Test - fun `given full sync, when sync products called, then does not fetch trash products`() = runTest { - // GIVEN - givenSinglePageCatalog(productsCount = 50) - - // WHEN - sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 2) - - // THEN - verify(posLocalCatalogStore, never()).fetchRecentlyModifiedProducts( - any(), - anyOrNull(), - any(), - any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - } - - private suspend fun givenSinglePageTrashCatalog(productsCount: Int) { - val trashProducts = createMockProducts(101, 101 + productsCount - 1) - whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts( - site = any(), - modifiedAfterGmt = eq(null), - page = eq(1), - pageSize = any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - ).thenReturn( - KotlinResult.success( - WooPosPaginatedFetchResult( - items = trashProducts, - syncedCount = productsCount, - hasMore = false, - nextPage = 1, - totalPages = 1, - serverDate = "" - ) - ) - ) - } - - @Suppress("LongMethod") - private suspend fun givenMultiPageTrashCatalog() { - val trashPage1 = createMockProducts(101, 110) - val trashPage2 = createMockProducts(111, 120) - val trashPage3 = createMockProducts(121, 125) - - whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts( - site = any(), - modifiedAfterGmt = eq(null), - page = eq(1), - pageSize = any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - ).thenReturn( - KotlinResult.success( - WooPosPaginatedFetchResult( - items = trashPage1, - syncedCount = 10, - hasMore = true, - nextPage = 2, - totalPages = 3, - serverDate = "" - ) - ) - ) - - whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts( - site = any(), - modifiedAfterGmt = eq(null), - page = eq(2), - pageSize = any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - ).thenReturn( - KotlinResult.success( - WooPosPaginatedFetchResult( - items = trashPage2, - syncedCount = 10, - hasMore = true, - nextPage = 3, - totalPages = 3, - serverDate = "" - ) - ) - ) - - whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts( - site = any(), - modifiedAfterGmt = eq(null), - page = eq(3), - pageSize = any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - ).thenReturn( - KotlinResult.success( - WooPosPaginatedFetchResult( - items = trashPage3, - syncedCount = 5, - hasMore = false, - nextPage = 3, - totalPages = 3, - serverDate = "" - ) - ) - ) - } - - private suspend fun givenSinglePageCatalog(productsCount: Int = PAGE_SIZE / 2) { - val mockProducts = createMockProducts(1, productsCount) - - mockFetchRecentlyModifiedProductsSuccess( - page = 1, - mockProducts = mockProducts, - syncedCount = productsCount, - hasMore = false, - totalPages = 1, - ) - } - - private suspend fun givenMultiPageCatalog(page1Count: Int, page2Count: Int, page3Count: Int, totalPages: Int = 3) { - val mockPage1Products = createMockProducts(1, page1Count) - val mockPage2Products = createMockProducts(page1Count + 1, page1Count + page2Count) - val mockPage3Products = createMockProducts( - page1Count + page2Count + 1, - page1Count + page2Count + page3Count - ) - - mockFetchRecentlyModifiedProductsSuccess( - page = 1, - mockProducts = mockPage1Products, - syncedCount = page1Count, - hasMore = true, - totalPages = totalPages, - ) - - mockFetchRecentlyModifiedProductsSuccess( - page = 2, - mockProducts = mockPage2Products, - syncedCount = page2Count, - hasMore = true, - totalPages = totalPages, - ) - - mockFetchRecentlyModifiedProductsSuccess( - page = 3, - mockProducts = mockPage3Products, - syncedCount = page3Count, - hasMore = false, - totalPages = totalPages, - ) - } - - private suspend fun givenEmptyCatalog() { - mockFetchRecentlyModifiedProductsSuccess( - page = 1, - mockProducts = emptyList(), - syncedCount = 0, - hasMore = false, - totalPages = 1, - ) - } - - private suspend fun givenFirstPageFails(errorMessage: String) { - whenever(posLocalCatalogStore.fetchRecentlyModifiedProducts(any(), anyOrNull(), any(), any(), eq(null))) - .thenReturn(KotlinResult.failure(Exception(errorMessage))) - } - - private suspend fun givenFirstPageFailsWithNullMessage() { - whenever(posLocalCatalogStore.fetchRecentlyModifiedProducts(any(), anyOrNull(), any(), any(), eq(null))) - .thenReturn(KotlinResult.failure(Exception())) - } - - private suspend fun givenSecondPageFails(errorMessage: String) { - val mockPage1Products = createMockProducts(1, 100) - - mockFetchRecentlyModifiedProductsSuccess( - page = 1, - mockProducts = mockPage1Products, - syncedCount = 100, - hasMore = true, - totalPages = 2, - ) - - whenever(posLocalCatalogStore.fetchRecentlyModifiedProducts(any(), anyOrNull(), eq(2), any(), eq(null))) - .thenReturn(KotlinResult.failure(Exception(errorMessage))) - } - - private suspend fun givenPageWithZeroProductsButHasMore() { - val mockPage2Products = createMockProducts(1, 50) - - mockFetchRecentlyModifiedProductsSuccess( - page = 1, - mockProducts = emptyList(), - syncedCount = 0, - hasMore = true, - totalPages = 2, - ) - - mockFetchRecentlyModifiedProductsSuccess( - page = 2, - mockProducts = mockPage2Products, - syncedCount = 50, - hasMore = true, - totalPages = 2, - ) - } - - private suspend fun givenCatalogTooLarge(totalPages: Int) { - val mockProducts = createMockProducts(1, PAGE_SIZE) - - mockFetchRecentlyModifiedProductsSuccess( - page = 1, - mockProducts, - totalPages, - syncedCount = PAGE_SIZE, - hasMore = true - ) - } - - @Suppress("LongParameterList") - private suspend fun mockFetchRecentlyModifiedProductsSuccess( - page: Int, - mockProducts: List, - totalPages: Int, - syncedCount: Int, - hasMore: Boolean - ) { - whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts( - site = any(), - modifiedAfterGmt = anyOrNull(), - page = eq(page), - pageSize = any(), - includeStatus = eq(null) - ) - ) - .thenReturn( - KotlinResult.success( - WooPosPaginatedFetchResult( - items = mockProducts, - syncedCount = syncedCount, - hasMore = hasMore, - nextPage = if (hasMore) page + 1 else page, - totalPages = totalPages, - serverDate = "" - ) - ) - ) - - whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts( - site = any(), - modifiedAfterGmt = anyOrNull(), - page = eq(page), - pageSize = any(), - includeStatus = argThat { this.contains(CoreProductStatus.TRASH) } - ) - ).thenReturn( - KotlinResult.success( - WooPosPaginatedFetchResult( - items = mockProducts, - syncedCount = syncedCount, - hasMore = hasMore, - nextPage = if (hasMore) page + 1 else page, - totalPages = totalPages, - serverDate = "" - ) - ) - ) - } - - private fun createMockProducts(start: Int = 1, end: Int): List { - return (start..end).map { - WooPosProductEntity( - remoteId = LocalOrRemoteId.RemoteId(it.toLong()), - name = "Product $it" - ) - } - } - - @Suppress("UNCHECKED_CAST") - private suspend fun givenTransactionSuccess() { - whenever( - posLocalCatalogStore - .executeInTransaction(any KotlinResult>()) - ).thenAnswer { invocation -> - val block = invocation.arguments[0] as suspend () -> KotlinResult - runBlocking { block.invoke() } - } - whenever(posLocalCatalogStore.upsertProducts(any())).thenReturn(KotlinResult.success(Unit)) - whenever(posLocalCatalogStore.deleteAllProducts(any())).thenReturn(KotlinResult.success(Unit)) - } -} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncVariationsActionTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncVariationsActionTest.kt deleted file mode 100644 index 55407ba4232f..000000000000 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncVariationsActionTest.kt +++ /dev/null @@ -1,588 +0,0 @@ -package com.woocommerce.android.ui.woopos.localcatalog - -import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper -import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncRepository.Companion.PAGE_SIZE -import com.woocommerce.android.util.InlineClassesAnswer -import kotlinx.coroutines.runBlocking -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.doAnswer -import org.mockito.kotlin.eq -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.LocalOrRemoteId.LocalId -import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.persistence.entity.pos.WooPosVariationEntity -import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore -import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosPaginatedFetchResult -import kotlin.Result as KotlinResult - -class WooPosSyncVariationsActionTest { - - private lateinit var sut: WooPosSyncVariationsAction - private var posLocalCatalogStore: WooPosLocalCatalogStore = mock() - private lateinit var site: SiteModel - private var logger: WooPosLogWrapper = mock() - - @Before - fun setup() = runBlocking { - sut = WooPosSyncVariationsAction(posLocalCatalogStore, logger) - site = SiteModel().apply { - id = 1 - siteId = 123L - } - givenTransactionSuccess() - } - - @Test - fun `given single page catalog, when sync variations called, then returns success with correct count`() = runTest { - // GIVEN - givenSinglePageCatalog(variationsCount = 50) - - // WHEN - val result = sut.execute(site, pageSize = 100, maxPages = 2) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(50) - } - - @Test - fun `given single page catalog, when sync variations called, then returns server date`() = runTest { - // GIVEN - val expectedServerDate = "2024-01-15T10:30:00Z" - givenSinglePageCatalog(variationsCount = 25, serverDate = expectedServerDate) - - // WHEN - val result = sut.execute(site, pageSize = 100, maxPages = 2) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).serverDate).isEqualTo(expectedServerDate) - } - - @Test - fun `given multiple pages within limit, when sync variations called, then returns success with total count`() = - runTest { - // GIVEN - val maxPages = 10 - givenMultiPageCatalog( - page1Count = 100, - page2Count = 100, - page3Count = 50 - ) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(250) - } - - @Test - fun `given empty catalog, when sync variations called, then returns success with zero count`() = runTest { - // GIVEN - val maxPages = 10 - givenEmptyCatalog() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(0) - } - - @Test - fun `given incremental sync with modifiedAfterGmt, when sync variations called, then passes filter correctly`() = - runTest { - // GIVEN - val modifiedAfter = "2024-01-01T00:00:00Z" - val maxPages = 10 - givenSinglePageCatalog(variationsCount = 25) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = modifiedAfter, pageSize = 100, maxPages = maxPages) - - // THEN - verify(posLocalCatalogStore).fetchRecentlyModifiedVariations( - site = eq(site), - modifiedAfterGmt = eq(modifiedAfter), - page = eq(1), - pageSize = eq(100) - ) - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - } - - @Test - fun `given null modifiedAfterGmt, when sync variations called, then passes null as filter`() = runTest { - // GIVEN - val maxPages = 10 - givenSinglePageCatalog(variationsCount = 30) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - verify(posLocalCatalogStore).fetchRecentlyModifiedVariations( - site = eq(site), - modifiedAfterGmt = eq(null), - page = eq(1), - pageSize = eq(100) - ) - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - } - - @Test - fun `given catalog has exactly max pages, when sync variations called, then returns success`() = runTest { - // GIVEN - val maxPages = 3 - givenMultiPageCatalog( - page1Count = PAGE_SIZE, - page2Count = PAGE_SIZE, - page3Count = PAGE_SIZE - ) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).syncedCount).isEqualTo(300) - } - - @Test - fun `given catalog has more pages than limit, when sync variations called, then returns CatalogTooLarge`() = - runTest { - // GIVEN - val maxPages = 2 - givenMultiPageCatalog( - page1Count = PAGE_SIZE, - page2Count = PAGE_SIZE, - page3Count = PAGE_SIZE, - totalPages = 3 - ) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.CatalogTooLarge::class.java) - val failureResult = result as WooPosSyncResult.Failed.CatalogTooLarge - assertThat(failureResult.totalPages).isEqualTo(3) - assertThat(failureResult.maxPages).isEqualTo(maxPages) - } - - @Test - fun `given first page fetch fails, when sync variations called, then returns UnexpectedError`() = runTest { - // GIVEN - val errorMessage = "Network error" - givenFirstPageFails(errorMessage) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 10) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) - assertThat((result as WooPosSyncResult.Failed).error).isEqualTo(errorMessage) - } - - @Test - fun `given middle page fetch fails, when sync variations called, then returns UnexpectedError`() = runTest { - // GIVEN - val maxPages = 10 - val errorMessage = "API error on page 2" - givenSecondPageFails(errorMessage) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) - assertThat((result as WooPosSyncResult.Failed).error).isEqualTo(errorMessage) - } - - @Test - fun `given API returns null error message, when sync variations called, then returns generic error`() = runTest { - // GIVEN - val maxPages = 10 - givenFirstPageFailsWithNullMessage() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Failed.UnexpectedError::class.java) - assertThat((result as WooPosSyncResult.Failed).error).isEqualTo("Failed to sync variations") - } - - @Test - fun `given page has zero variations but hasMore is true, when sync variations called, then stops fetching`() = - runTest { - // GIVEN - val maxPages = 10 - givenPageWithZeroVariationsButHasMore() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - verify(posLocalCatalogStore, times(1)) - .fetchRecentlyModifiedVariations(any(), anyOrNull(), any(), any()) - } - - @Test - fun `given hasMore is false, when sync variations called, then stops fetching pages`() = runTest { - // GIVEN - val maxPages = 10 - givenSinglePageCatalog(variationsCount = 10) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - verify(posLocalCatalogStore, times(1)) - .fetchRecentlyModifiedVariations(any(), anyOrNull(), any(), any()) - } - - @Test - fun `given multiple pages synced, when sync variations called, then returns first server date`() = runTest { - // GIVEN - val maxPages = 10 - val firstServerDate = "2024-01-15T15:45:00Z" - givenMultiPageCatalog( - page1Count = 100, - page2Count = 50, - page3Count = 0, - serverDate = firstServerDate - ) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - assertThat((result as WooPosSyncResult.Success).serverDate).isEqualTo(firstServerDate) - } - - @Test - fun `given pagination uses offsets, when sync variations called, then increments offset correctly`() = runTest { - // GIVEN - val maxPages = 10 - givenThreePageCatalogWithOffsets() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = maxPages) - - // THEN - verify(posLocalCatalogStore).fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(1), any()) - verify(posLocalCatalogStore).fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(2), any()) - verify(posLocalCatalogStore).fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(3), any()) - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - } - - @Test - fun `given full sync with null modifiedAfterGmt, when sync variations called, then deletes variations not in list`() = runTest { - // GIVEN - createMockVariations(1, 50) - givenSinglePageCatalog(variationsCount = 50) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 2) - - // THEN - verify(posLocalCatalogStore).deleteAllVariations(siteId = eq(site.localId())) - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - } - - @Test - fun `given incremental sync with modifiedAfterGmt, when sync variations called, then does not delete variations`() = runTest { - // GIVEN - val modifiedAfter = "2024-01-01T00:00:00Z" - givenSinglePageCatalog(variationsCount = 25) - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = modifiedAfter, pageSize = 100, maxPages = 2) - - // THEN - verify(posLocalCatalogStore, times(0)).deleteAllVariations(any()) - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - } - - @Test - fun `given empty catalog full sync, when sync variations called, then deletes all variations`() = runTest { - // GIVEN - givenEmptyCatalog() - - // WHEN - val result = sut.execute(site, modifiedAfterGmt = null, pageSize = 100, maxPages = 2) - - // THEN - verify(posLocalCatalogStore).deleteAllVariations( - siteId = eq(site.localId()) - ) - assertThat(result).isInstanceOf(WooPosSyncResult.Success::class.java) - } - - // Helper functions - private suspend fun givenSinglePageCatalog( - variationsCount: Int = PAGE_SIZE / 2, - serverDate: String = "2024-01-15T10:00:00Z" - ) { - val variations = createMockVariations(1, variationsCount) - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(1), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations, - syncedCount = variationsCount, - hasMore = false, - nextPage = 2, - totalPages = 1, - serverDate = serverDate - ) - ) - } - ) - } - - private suspend fun givenMultiPageCatalog( - page1Count: Int, - page2Count: Int, - page3Count: Int, - totalPages: Int = 3, - serverDate: String = "2024-01-15T12:00:00Z" - ) { - val variations1 = createMockVariations(1, page1Count) - val variations2 = createMockVariations(page1Count + 1, page2Count) - val variations3 = createMockVariations(page1Count + page2Count + 1, page3Count) - - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(1), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations1, - syncedCount = page1Count, - hasMore = true, - nextPage = 2, - totalPages = totalPages, - serverDate = serverDate - ) - ) - } - ) - - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(2), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations2, - syncedCount = page2Count, - hasMore = page3Count > 0, - nextPage = 3, - totalPages = totalPages, - serverDate = "2024-01-15T11:00:00Z", - ) - ) - } - ) - - whenever( - posLocalCatalogStore.fetchRecentlyModifiedVariations( - any(), - anyOrNull(), - eq(3), - any() - ) - ) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations3, - syncedCount = page3Count, - hasMore = false, - nextPage = 4, - totalPages = totalPages, - serverDate = "2024-01-15T11:00:00Z", - ) - ) - } - ) - } - - private suspend fun givenEmptyCatalog() { - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), any(), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = emptyList(), - syncedCount = 0, - hasMore = false, - nextPage = 1, - totalPages = 0, - serverDate = "2024-01-15T10:00:00Z" - ) - ) - } - ) - } - - private suspend fun givenFirstPageFails(errorMessage: String) { - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), any(), any())) - .thenReturn(KotlinResult.failure(Exception(errorMessage))) - } - - private suspend fun givenFirstPageFailsWithNullMessage() { - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), any(), any())) - .thenReturn(KotlinResult.failure(Exception())) - } - - private suspend fun givenSecondPageFails(errorMessage: String) { - val variations1 = createMockVariations(1, 100) - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(1), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations1, - syncedCount = 100, - hasMore = true, - nextPage = 2, - totalPages = 2, - serverDate = "2024-01-15T10:00:00Z" - ) - ) - } - ) - - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(2), any())) - .thenReturn(KotlinResult.failure(Exception(errorMessage))) - } - - private suspend fun givenPageWithZeroVariationsButHasMore() { - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(1), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = emptyList(), - syncedCount = 0, - hasMore = false, // Changed to false so it stops fetching after zero items - nextPage = 1, - totalPages = 1, - serverDate = "2024-01-15T10:00:00Z" - ) - ) - } - ) - } - - private suspend fun givenThreePageCatalogWithOffsets() { - val variations1 = createMockVariations(1, 100) - val variations2 = createMockVariations(101, 100) - val variations3 = createMockVariations(201, 50) - - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(1), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations1, - syncedCount = 100, - hasMore = true, - nextPage = 2, - totalPages = 3, - serverDate = "2024-01-15T10:00:00Z" - ) - ) - } - ) - - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(2), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations2, - syncedCount = 100, - hasMore = true, - nextPage = 3, - totalPages = 3, - serverDate = "2024-01-15T11:00:00Z" - ) - ) - } - ) - - whenever(posLocalCatalogStore.fetchRecentlyModifiedVariations(any(), anyOrNull(), eq(3), any())) - .doAnswer( - InlineClassesAnswer { - KotlinResult.success( - WooPosPaginatedFetchResult( - items = variations3, - syncedCount = 50, - hasMore = false, - nextPage = 4, - totalPages = 3, - serverDate = "2024-01-15T12:00:00Z" - ) - ) - } - ) - } - - private fun createMockVariations(startId: Int, count: Int): List { - return (startId until startId + count).map { id -> - WooPosVariationEntity( - localSiteId = LocalId(1), - remoteProductId = RemoteId(100), - remoteVariationId = RemoteId(id.toLong()), - dateModified = "2024-01-15T10:00:00Z", - sku = "VAR-$id", - globalUniqueId = "var-$id", - variationName = "Variation $id", - price = "10.00", - regularPrice = "10.00", - salePrice = "", - description = "Test variation $id", - stockQuantity = 1.0, - stockStatus = "instock", - manageStock = false, - backordered = false, - attributesJson = "{}", - imageUrl = "", - status = "publish", - lastUpdated = "2024-01-15T10:00:00Z", - downloadable = false - ) - } - } - - @Suppress("UNCHECKED_CAST") - private suspend fun givenTransactionSuccess() { - whenever( - posLocalCatalogStore - .executeInTransaction(any KotlinResult>()) - ).thenAnswer { invocation -> - val block = invocation.arguments[0] as suspend () -> KotlinResult - runBlocking { block.invoke() } - } - whenever(posLocalCatalogStore.upsertVariations(any())).thenReturn(KotlinResult.success(Unit)) - whenever(posLocalCatalogStore.deleteAllVariations(any())).thenReturn(KotlinResult.success(Unit)) - } -}