From 976fe96370acb46de90d8b0ea7f5d6199bc0ea5a Mon Sep 17 00:00:00 2001 From: malinajirka Date: Wed, 29 Oct 2025 16:43:43 +0100 Subject: [PATCH 1/7] Execute product and variation sync within single transaction --- .../WooPosLocalCatalogSyncRepository.kt | 66 ++--- .../woopos/localcatalog/WooPosSyncAction.kt | 242 +++++++----------- 2 files changed, 107 insertions(+), 201 deletions(-) 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..57472869f50c 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 @@ -92,64 +92,36 @@ 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 successResult = syncResult as WooPosSyncResult.Success + + successResult.productsServerDate?.let { serverDate -> + syncTimestampManager.parseTimestampFromApi(serverDate)?.let { timestamp -> + syncTimestampManager.storeProductsLastSyncTimestamp(timestamp) + logger.d("Stored products sync timestamp: $serverDate") + } + } + + successResult.variationsServerDate?.let { serverDate -> + syncTimestampManager.parseTimestampFromApi(serverDate)?.let { timestamp -> + syncTimestampManager.storeVariationsLastSyncTimestamp(timestamp) + logger.d("Stored variations sync timestamp: $serverDate") + } } val syncDuration = System.currentTimeMillis() - startTime return PosLocalCatalogSyncResult.Success( - productsSynced = (productSyncResult as WooPosSyncResult.Success).syncedCount, - variationsSynced = (variationSyncResult as WooPosSyncResult.Success).syncedCount, + productsSynced = successResult.productsSynced, + variationsSynced = successResult.variationsSynced, 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") - } - } - } - - 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") - } - } - } - - return result - } suspend fun getProductCount(site: SiteModel): Int = posLocalCatalogStore.getProductCount(LocalId(site.id)).getOrElse { 0 } 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..c8558973034b 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,67 +12,10 @@ 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 -) { - suspend fun syncProducts( - site: SiteModel, - modifiedAfterGmt: String? = null, - pageSize: Int, - maxPages: Int - ) = productAction.execute( - site = site, - modifiedAfterGmt = modifiedAfterGmt, - pageSize = pageSize, - maxPages = maxPages - ) - - suspend fun syncVariations( - site: SiteModel, - modifiedAfterGmt: String? = null, - 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 - - 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) - } -} - -class WooPosSyncProductsAction @Inject constructor( private val posLocalCatalogStore: WooPosLocalCatalogStore, private val logger: WooPosLogWrapper, ) { - suspend fun execute( + suspend fun syncCatalog( site: SiteModel, modifiedAfterGmt: String? = null, pageSize: Int, @@ -81,9 +24,9 @@ class WooPosSyncProductsAction @Inject constructor( return runCatching { val isFullSync = modifiedAfterGmt == null - val (products, trashProducts, serverDate) = coroutineScope { + val fetchResults = coroutineScope { val regularProductsDeferred = async { - fetchAllPages(site, modifiedAfterGmt, pageSize, maxPages) + fetchAllProductPages(site, modifiedAfterGmt, pageSize, maxPages) } val trashProductsDeferred = async { if (isFullSync) { @@ -93,26 +36,40 @@ class WooPosSyncProductsAction @Inject constructor( fetchAllTrashProducts(site, pageSize) } } + val variationsDeferred = async { + fetchAllVariationPages(site, modifiedAfterGmt, pageSize, maxPages) + } - val (products, serverDate) = regularProductsDeferred.await() + val (products, productsServerDate) = regularProductsDeferred.await() val trashProducts = trashProductsDeferred.await() - Triple(products, trashProducts, serverDate) + val (variations, variationsServerDate) = variationsDeferred.await() + + FetchResults(products, trashProducts, productsServerDate, variations, variationsServerDate) } - val allProducts = products + trashProducts + val allProducts = fetchResults.products + fetchResults.trashProducts posLocalCatalogStore.executeInTransaction { if (isFullSync) { posLocalCatalogStore.deleteAllProducts( siteId = site.localId() ).getOrThrow() + posLocalCatalogStore.deleteAllVariations( + siteId = site.localId() + ).getOrThrow() } posLocalCatalogStore.upsertProducts(allProducts).getOrThrow() + posLocalCatalogStore.upsertVariations(fetchResults.variations).getOrThrow() }.fold( onSuccess = { logger.d("Local Catalog transaction committed successfully") - WooPosSyncResult.Success(allProducts.size, serverDate) + WooPosSyncResult.Success( + productsSynced = allProducts.size, + variationsSynced = fetchResults.variations.size, + productsServerDate = fetchResults.productsServerDate, + variationsServerDate = fetchResults.variationsServerDate + ) }, onFailure = { error -> handleTransactionError(error) @@ -121,7 +78,7 @@ class WooPosSyncProductsAction @Inject constructor( }.fold( onSuccess = { result -> result }, onFailure = { error -> - logger.e("Failed to sync products: ${error.message}") + logger.e("Failed to sync catalog: ${error.message}") when (error) { is CatalogTooLargeException -> WooPosSyncResult.Failed.CatalogTooLarge( error.totalPages, @@ -129,14 +86,22 @@ class WooPosSyncProductsAction @Inject constructor( ) else -> WooPosSyncResult.Failed.UnexpectedError( - error.message ?: "Failed to sync products" + 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 +123,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 +168,59 @@ 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 From ab47842187ea1f555f02ddabf08b9bb720562c89 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Wed, 29 Oct 2025 16:44:06 +0100 Subject: [PATCH 2/7] Add tests for shared sync action --- .../localcatalog/WooPosSyncActionTest.kt | 809 ++++++++++++++++++ .../WooPosSyncProductsActionTest.kt | 613 ------------- .../WooPosSyncVariationsActionTest.kt | 588 ------------- 3 files changed, 809 insertions(+), 1201 deletions(-) create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncActionTest.kt delete mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncProductsActionTest.kt delete mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncVariationsActionTest.kt 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..ae35f5d15bb4 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosSyncActionTest.kt @@ -0,0 +1,809 @@ +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, maxPages) + + // 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, maxPages = 10) + + // 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 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, maxPages = 10) + + // 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, maxPages: 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, maxPages: 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 + ) + } + } +} \ No newline at end of file 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)) - } -} From 7e8db35ee876838823525c6bd87ea1ca40a00184 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Wed, 29 Oct 2025 16:44:14 +0100 Subject: [PATCH 3/7] Update repo tests --- .../WooPosLocalCatalogSyncRepositoryTest.kt | 106 ++++++++++++------ 1 file changed, 69 insertions(+), 37 deletions(-) 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..4a275ad862ee 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 @@ -63,12 +63,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 +84,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 +107,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 +129,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 +144,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 +158,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 +172,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 +193,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 +216,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 +230,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 +257,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 +278,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 +299,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) From c0ff22a36be42138a5473d70835458b3ee03df8d Mon Sep 17 00:00:00 2001 From: malinajirka Date: Wed, 29 Oct 2025 16:47:02 +0100 Subject: [PATCH 4/7] Fix detekt --- .../WooPosLocalCatalogSyncRepository.kt | 1 - .../woopos/localcatalog/WooPosSyncAction.kt | 1 - .../localcatalog/WooPosSyncActionTest.kt | 90 +++++++++++++++---- 3 files changed, 72 insertions(+), 20 deletions(-) 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 57472869f50c..2460c4cf55e9 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 @@ -122,7 +122,6 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( ) } - suspend fun getProductCount(site: SiteModel): Int = posLocalCatalogStore.getProductCount(LocalId(site.id)).getOrElse { 0 } 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 c8558973034b..ff8eb9c48f3e 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 @@ -220,7 +220,6 @@ sealed interface WooPosSyncResult { } } - private class CatalogTooLargeException( val totalPages: Int, val maxPages: Int 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 index ae35f5d15bb4..1b8ec464cd5e 100644 --- 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 @@ -44,7 +44,7 @@ class WooPosSyncActionTest { } // === PRODUCT SYNC TESTS === - + @Test fun `when products have single page, then syncs correct product count`() = runTest { // GIVEN @@ -69,7 +69,13 @@ class WooPosSyncActionTest { // THEN assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(100) - verify(posLocalCatalogStore, times(3)).fetchRecentlyModifiedProducts(eq(site), anyOrNull(), any(), any(), eq(null)) + verify(posLocalCatalogStore, times(3)).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(null) + ) } @Test @@ -116,7 +122,7 @@ class WooPosSyncActionTest { // GIVEN val totalPages = 15 val maxPages = 10 - givenProductCatalogTooLarge(totalPages, maxPages) + givenProductCatalogTooLarge(totalPages) // WHEN val result = sut.syncCatalog(site, null, PAGE_SIZE, maxPages) @@ -139,7 +145,13 @@ class WooPosSyncActionTest { // THEN assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(0) - verify(posLocalCatalogStore, times(1)).fetchRecentlyModifiedProducts(eq(site), anyOrNull(), eq(1), any(), eq(null)) + verify(posLocalCatalogStore, times(1)).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + eq(1), + any(), + eq(null) + ) } // === VARIATIONS SYNC TESTS === @@ -215,7 +227,7 @@ class WooPosSyncActionTest { fun `when variations exceed page limit, then returns CatalogTooLarge`() = runTest { // GIVEN mockFetchProductsSuccess(pages = listOf(10), serverDate = "2024-01-01T12:00:00Z") - givenVariationCatalogTooLarge(totalPages = 12, maxPages = 10) + givenVariationCatalogTooLarge(totalPages = 12) // WHEN val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) @@ -252,7 +264,13 @@ class WooPosSyncActionTest { // THEN assertThat((result as WooPosSyncResult.Success).productsSynced).isEqualTo(13) // 10 + 3 trash - verify(posLocalCatalogStore).fetchRecentlyModifiedProducts(eq(site), anyOrNull(), any(), any(), eq(listOf(CoreProductStatus.TRASH))) + verify(posLocalCatalogStore).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) } @Test @@ -265,7 +283,13 @@ class WooPosSyncActionTest { sut.syncCatalog(site, null, PAGE_SIZE, 10) // THEN - verify(posLocalCatalogStore, never()).fetchRecentlyModifiedProducts(any(), anyOrNull(), any(), any(), eq(listOf(CoreProductStatus.TRASH))) + verify(posLocalCatalogStore, never()).fetchRecentlyModifiedProducts( + any(), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) } @Test @@ -280,7 +304,13 @@ class WooPosSyncActionTest { // 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))) + verify(posLocalCatalogStore, times(2)).fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) } @Test @@ -314,7 +344,7 @@ class WooPosSyncActionTest { // === TRANSACTION TESTS === @Test - fun `when syncing executes all operations in single transaction`() = runTest { + 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") @@ -527,7 +557,7 @@ class WooPosSyncActionTest { @Test fun `when catalog size check fails before sync, then returns appropriate error`() = runTest { // GIVEN - givenProductCatalogTooLarge(totalPages = 20, maxPages = 10) + givenProductCatalogTooLarge(totalPages = 20) // WHEN val result = sut.syncCatalog(site, null, PAGE_SIZE, 10) @@ -599,7 +629,13 @@ class WooPosSyncActionTest { pages.forEachIndexed { index, count -> val pageNumber = index + 1 whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), eq(pageNumber), any(), eq(null)) + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + eq(pageNumber), + any(), + eq(null) + ) ).thenReturn( KotlinResult.success( WooPosPaginatedFetchResult( @@ -637,7 +673,13 @@ class WooPosSyncActionTest { private fun givenTrashProducts(count: Int) = runBlocking { whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), any(), any(), eq(listOf(CoreProductStatus.TRASH))) + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) ).thenReturn( KotlinResult.success( WooPosPaginatedFetchResult( @@ -656,7 +698,13 @@ class WooPosSyncActionTest { pages.forEachIndexed { index, count -> val pageNumber = index + 1 whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), eq(pageNumber), any(), eq(listOf(CoreProductStatus.TRASH))) + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + eq(pageNumber), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) ).thenReturn( KotlinResult.success( WooPosPaginatedFetchResult( @@ -674,7 +722,13 @@ class WooPosSyncActionTest { private fun givenTrashProductFetchFails(errorMessage: String) = runBlocking { whenever( - posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), any(), any(), eq(listOf(CoreProductStatus.TRASH))) + posLocalCatalogStore.fetchRecentlyModifiedProducts( + eq(site), + anyOrNull(), + any(), + any(), + eq(listOf(CoreProductStatus.TRASH)) + ) ).thenReturn(KotlinResult.failure(Exception(errorMessage))) } @@ -690,7 +744,7 @@ class WooPosSyncActionTest { ).thenReturn(KotlinResult.failure(Exception(errorMessage ?: "Generic error"))) } - private fun givenProductCatalogTooLarge(totalPages: Int, maxPages: Int) = runBlocking { + private fun givenProductCatalogTooLarge(totalPages: Int) = runBlocking { whenever( posLocalCatalogStore.fetchRecentlyModifiedProducts(eq(site), anyOrNull(), eq(1), any(), eq(null)) ).thenReturn( @@ -707,7 +761,7 @@ class WooPosSyncActionTest { ) } - private fun givenVariationCatalogTooLarge(totalPages: Int, maxPages: Int) = runBlocking { + private fun givenVariationCatalogTooLarge(totalPages: Int) = runBlocking { whenever( posLocalCatalogStore.fetchRecentlyModifiedVariations(eq(site), anyOrNull(), eq(1), any()) ).thenReturn( @@ -772,7 +826,7 @@ class WooPosSyncActionTest { whenever(posLocalCatalogStore.upsertVariations(any())).thenReturn(KotlinResult.success(Unit)) } - @Suppress("UNCHECKED_CAST") + @Suppress("UNCHECKED_CAST") private fun givenTransactionFailure(errorMessage: String) = runBlocking { whenever( posLocalCatalogStore.executeInTransaction(any KotlinResult>()) @@ -806,4 +860,4 @@ class WooPosSyncActionTest { ) } } -} \ No newline at end of file +} From 963598a8cbfa766600718cdbfc73277b1abf6af8 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Wed, 29 Oct 2025 16:56:01 +0100 Subject: [PATCH 5/7] Refactor WooPosSyncAction for better readability --- .../woopos/localcatalog/WooPosSyncAction.kt | 129 ++++++++++-------- 1 file changed, 70 insertions(+), 59 deletions(-) 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 ff8eb9c48f3e..ad957c090764 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 @@ -23,76 +23,87 @@ class WooPosSyncAction @Inject constructor( ): WooPosSyncResult { return runCatching { val isFullSync = modifiedAfterGmt == null + val fetchResults = fetchAllCatalogData(site, modifiedAfterGmt, pageSize, maxPages, isFullSync) - val 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) - } - - val (products, productsServerDate) = regularProductsDeferred.await() - val trashProducts = trashProductsDeferred.await() - val (variations, variationsServerDate) = variationsDeferred.await() + executeDatabaseTransaction(site, fetchResults, isFullSync) + }.fold( + onSuccess = { result -> result }, + onFailure = { error -> handleSyncError(error) } + ) + } - FetchResults(products, trashProducts, productsServerDate, variations, variationsServerDate) + private suspend fun fetchAllCatalogData( + site: SiteModel, + modifiedAfterGmt: String?, + pageSize: Int, + 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) + } - val allProducts = fetchResults.products + fetchResults.trashProducts + val (products, productsServerDate) = regularProductsDeferred.await() + val trashProducts = trashProductsDeferred.await() + val (variations, variationsServerDate) = variationsDeferred.await() - posLocalCatalogStore.executeInTransaction { - if (isFullSync) { - posLocalCatalogStore.deleteAllProducts( - siteId = site.localId() - ).getOrThrow() - posLocalCatalogStore.deleteAllVariations( - siteId = site.localId() - ).getOrThrow() - } + FetchResults(products, trashProducts, productsServerDate, variations, variationsServerDate) + } - 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) - } - ) - }.fold( - onSuccess = { result -> result }, - onFailure = { error -> - logger.e("Failed to sync catalog: ${error.message}") - when (error) { - is CatalogTooLargeException -> WooPosSyncResult.Failed.CatalogTooLarge( - error.totalPages, - error.maxPages - ) + private suspend fun executeDatabaseTransaction( + site: SiteModel, + fetchResults: FetchResults, + isFullSync: Boolean + ): WooPosSyncResult { + val allProducts = fetchResults.products + fetchResults.trashProducts - else -> WooPosSyncResult.Failed.UnexpectedError( - error.message ?: "Failed to sync catalog" - ) - } + return posLocalCatalogStore.executeInTransaction { + if (isFullSync) { + posLocalCatalogStore.deleteAllProducts(site.localId()).getOrThrow() + posLocalCatalogStore.deleteAllVariations(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) } ) } + 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 + ) + + else -> WooPosSyncResult.Failed.UnexpectedError( + error.message ?: "Failed to sync catalog" + ) + } + } + private data class FetchResults( val products: List, val trashProducts: List, From 5c95269e1f00d3251e7abe44ec49151c2ed53c32 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Thu, 30 Oct 2025 21:16:48 +0100 Subject: [PATCH 6/7] Use DateTimeProvider --- .../localcatalog/WooPosLocalCatalogSyncRepository.kt | 7 ++++--- .../localcatalog/WooPosLocalCatalogSyncRepositoryTest.kt | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) 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 2460c4cf55e9..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") @@ -113,7 +114,7 @@ class WooPosLocalCatalogSyncRepository @Inject constructor( } } - val syncDuration = System.currentTimeMillis() - startTime + val syncDuration = dateTimeProvider.now() - startTime return PosLocalCatalogSyncResult.Success( productsSynced = successResult.productsSynced, 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 4a275ad862ee..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 { From 71ea3aaa96eccdabe11f32a21cdf696f82731596 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Thu, 30 Oct 2025 21:17:36 +0100 Subject: [PATCH 7/7] Rename method for more clarity --- .../android/ui/woopos/localcatalog/WooPosSyncAction.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ad957c090764..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 @@ -25,7 +25,7 @@ class WooPosSyncAction @Inject constructor( val isFullSync = modifiedAfterGmt == null val fetchResults = fetchAllCatalogData(site, modifiedAfterGmt, pageSize, maxPages, isFullSync) - executeDatabaseTransaction(site, fetchResults, isFullSync) + updateDatabaseWithFetchedItems(site, fetchResults, isFullSync) }.fold( onSuccess = { result -> result }, onFailure = { error -> handleSyncError(error) } @@ -61,7 +61,7 @@ class WooPosSyncAction @Inject constructor( FetchResults(products, trashProducts, productsServerDate, variations, variationsServerDate) } - private suspend fun executeDatabaseTransaction( + private suspend fun updateDatabaseWithFetchedItems( site: SiteModel, fetchResults: FetchResults, isFullSync: Boolean