diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 9b5fb161baa7..39a34a68dff4 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -4,6 +4,7 @@ 25.3 ----- +- [*] Improved product duplication to preserve product details managed by WooCommerce [https://github.com/woocommerce/woocommerce-android/pull/16262] - [*] Fixed the store login error message showing a placeholder instead of the status code [https://github.com/woocommerce/woocommerce-android/pull/16246] - [*] Fixed the order list not scrolling to the top after creating a new order [https://github.com/woocommerce/woocommerce-android/pull/16240] diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt index fe9b7be99610..504e20a5805a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/DuplicateProduct.kt @@ -10,6 +10,7 @@ import com.woocommerce.android.util.WooLog import com.woocommerce.android.viewmodel.ResourceProvider import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooError +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.API_NOT_FOUND import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.GENERIC_ERROR import javax.inject.Inject @@ -20,6 +21,21 @@ class DuplicateProduct @Inject constructor( ) { suspend operator fun invoke(productAggregate: ProductAggregate): Result { + val coreResult = productDetailRepository.duplicateProduct(productAggregate.remoteId) + if (coreResult.isSuccess) return coreResult + + val error = (coreResult.exceptionOrNull() as? WooException)?.error + val shouldUseLegacyFlow = error?.run { + type == API_NOT_FOUND && apiErrorCode == REST_NO_ROUTE_ERROR_CODE + } == true + return if (shouldUseLegacyFlow) { + duplicateProductWithLegacyFlow(productAggregate) + } else { + coreResult + } + } + + private suspend fun duplicateProductWithLegacyFlow(productAggregate: ProductAggregate): Result { val newProduct = productAggregate.copy( product = productAggregate.product.copy( remoteId = 0, @@ -92,4 +108,8 @@ class DuplicateProduct @Inject constructor( } ) } + + private companion object { + const val REST_NO_ROUTE_ERROR_CODE = "rest_no_route" + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt index 507eada5f879..4d83eae59a80 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepository.kt @@ -1,6 +1,7 @@ package com.woocommerce.android.ui.products.details import com.woocommerce.android.AppConstants +import com.woocommerce.android.WooException import com.woocommerce.android.analytics.AnalyticsEvent.PRODUCT_DETAIL_UPDATE_ERROR import com.woocommerce.android.analytics.AnalyticsEvent.PRODUCT_DETAIL_UPDATE_SUCCESS import com.woocommerce.android.analytics.AnalyticsTracker @@ -41,6 +42,8 @@ import org.wordpress.android.fluxc.action.WCProductAction.UPDATE_PRODUCT_PASSWOR import org.wordpress.android.fluxc.generated.WCProductActionBuilder import org.wordpress.android.fluxc.model.metadata.MetadataChanges import org.wordpress.android.fluxc.model.metadata.WCMetaData +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooError +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.INVALID_RESPONSE import org.wordpress.android.fluxc.store.WCGlobalAttributeStore import org.wordpress.android.fluxc.store.WCProductStore import org.wordpress.android.fluxc.store.WCProductStore.FetchProductSkuAvailabilityPayload @@ -54,6 +57,7 @@ import org.wordpress.android.fluxc.store.WCTaxStore import javax.inject.Inject import kotlin.coroutines.Continuation import kotlin.coroutines.resume +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE as INVALID_RESPONSE_ORIGINAL class ProductDetailRepository @Inject constructor( private val dispatcher: Dispatcher, @@ -189,6 +193,24 @@ class ProductDetailRepository @Inject constructor( */ suspend fun addProduct(product: Product): Pair = addProduct(ProductAggregate(product, null)) + suspend fun duplicateProduct(remoteProductId: Long): Result { + val result = productStore.duplicateProduct(selectedSite.get(), remoteProductId) + val duplicatedProductId = result.model + return when { + result.isError -> Result.failure(WooException(result.error)) + duplicatedProductId != null -> Result.success(duplicatedProductId) + else -> Result.failure( + WooException( + WooError( + type = INVALID_RESPONSE, + original = INVALID_RESPONSE_ORIGINAL, + message = "Product duplication returned no product ID" + ) + ) + ) + } + } + /** * Fires the request to update the product password * diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt index e35815c74807..287b81773ab7 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/DuplicateProductTest.kt @@ -1,5 +1,6 @@ package com.woocommerce.android.ui.products +import com.woocommerce.android.WooException import com.woocommerce.android.model.ProductAggregate import com.woocommerce.android.model.ProductVariation import com.woocommerce.android.ui.products.details.ProductDetailRepository @@ -15,8 +16,14 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.stub import org.mockito.kotlin.verify +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_FOUND +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooError +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.API_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.API_NOT_FOUND @OptIn(ExperimentalCoroutinesApi::class) class DuplicateProductTest : BaseUnitTest() { @@ -31,6 +38,9 @@ class DuplicateProductTest : BaseUnitTest() { @Before fun setUp() { + productDetailRepository.stub { + on { duplicateProduct(any()) } doReturn Result.failure(WooException(ROUTE_MISSING_ERROR)) + } sut = DuplicateProduct( productDetailRepository, variationRepository, @@ -39,43 +49,137 @@ class DuplicateProductTest : BaseUnitTest() { } @Test - fun `should duplicate a product and set expected properties`() = testBlocking { - // GIVEN - val productToDuplicate = ProductAggregate( - ProductTestUtils.generateProduct().copy( - sku = SOURCE_SKU, - slug = SOURCE_SLUG, - permalink = SOURCE_PERMALINK, + fun `given core route is missing, when duplicating a product, then legacy product is created with expected properties`() = + testBlocking { + // GIVEN + val productToDuplicate = ProductAggregate( + ProductTestUtils.generateProduct().copy( + sku = SOURCE_SKU, + slug = SOURCE_SLUG, + permalink = SOURCE_PERMALINK, + ) ) - ) - productDetailRepository.stub { - on { addProduct(any()) } doReturn Pair(true, 123) + productDetailRepository.stub { + on { addProduct(any()) } doReturn Pair(true, 123) + } + + // WHEN + val result = sut.invoke(productToDuplicate) + + // THEN + val duplicationRequestCapture = argumentCaptor() + verify(productDetailRepository).duplicateProduct(productToDuplicate.remoteId) + verify(productDetailRepository).addProduct(duplicationRequestCapture.capture()) + assertThat(result).isEqualTo(Result.success(123L)) + + assertThat(duplicationRequestCapture.firstValue) + .matches { + it.remoteId == 0L && it.product.name == "copied name" && + it.product.sku == "" && it.product.status == ProductStatus.DRAFT && + it.product.slug == "" && it.product.permalink == "" + } + .usingRecursiveComparison() + .ignoringFields( + "product.remoteId", + "product.name", + "product.sku", + "product.status", + "product.slug", + "product.permalink" + ) + .isEqualTo(productToDuplicate) } - // WHEN - sut.invoke(productToDuplicate) + @Test + fun `given core duplication succeeds, when duplicating a product, then ID is returned without legacy calls`() = + testBlocking { + // GIVEN + val productToDuplicate = ProductAggregate(ProductTestUtils.generateProduct().copy(numVariations = 10)) + productDetailRepository.stub { + on { duplicateProduct(productToDuplicate.remoteId) } doReturn Result.success(DUPLICATED_PRODUCT_ID) + } + + // WHEN + val result = sut(productToDuplicate) - // THEN - val duplicationRequestCapture = argumentCaptor() - verify(productDetailRepository).addProduct(duplicationRequestCapture.capture()) + // THEN + assertThat(result).isEqualTo(Result.success(DUPLICATED_PRODUCT_ID)) + verify(productDetailRepository, never()).addProduct(any()) + verify(variationRepository, never()).fetchProductVariations(any(), any()) + verify(variationRepository, never()).createVariations(any(), any()) + } - assertThat(duplicationRequestCapture.firstValue) - .matches { - it.remoteId == 0L && it.product.name == "copied name" && - it.product.sku == "" && it.product.status == ProductStatus.DRAFT && - it.product.slug == "" && it.product.permalink == "" + @Test + fun `given core route is missing and legacy creation fails, when duplicating a product, then failure is returned once`() = + testBlocking { + // GIVEN + val productToDuplicate = ProductAggregate(ProductTestUtils.generateProduct()) + productDetailRepository.stub { + on { addProduct(any()) } doReturn Pair(false, 0L) } - .usingRecursiveComparison() - .ignoringFields( - "product.remoteId", - "product.name", - "product.sku", - "product.status", - "product.slug", - "product.permalink" + + // WHEN + val result = sut(productToDuplicate) + + // THEN + assertThat(result.isFailure).isTrue() + verify(productDetailRepository).duplicateProduct(productToDuplicate.remoteId) + verify(productDetailRepository).addProduct(any()) + } + + @Test + fun `given core duplication fails for a non-route error, when duplicating a product, then legacy flow is not called`() = + testBlocking { + // GIVEN + val productToDuplicate = ProductAggregate(ProductTestUtils.generateProduct()) + val endpointError = WooError( + type = API_NOT_FOUND, + original = NOT_FOUND, + apiErrorCode = "woocommerce_rest_product_invalid_id" ) - .isEqualTo(productToDuplicate) - } + productDetailRepository.stub { + on { duplicateProduct(productToDuplicate.remoteId) } doReturn + Result.failure(WooException(endpointError)) + } + + // WHEN + val result = sut(productToDuplicate) + + // THEN + assertThat(result.exceptionOrNull()).isInstanceOf(WooException::class.java) + assertThat((result.exceptionOrNull() as WooException).error).isEqualTo(endpointError) + verify(productDetailRepository, never()).addProduct(any()) + verify(variationRepository, never()).fetchProductVariations(any(), any()) + verify(variationRepository, never()).createVariations(any(), any()) + } + + @Test + fun `given rest no route code has non-not-found type, when duplicating a product, then legacy flow is not called`() = + testBlocking { + // GIVEN + val productToDuplicate = ProductAggregate( + ProductTestUtils.generateProduct().copy(numVariations = 10) + ) + val endpointError = WooError( + type = API_ERROR, + original = NETWORK_ERROR, + apiErrorCode = "rest_no_route" + ) + productDetailRepository.stub { + on { duplicateProduct(productToDuplicate.remoteId) } doReturn + Result.failure(WooException(endpointError)) + } + + // WHEN + val result = sut(productToDuplicate) + + // THEN + assertThat(result.exceptionOrNull()).isInstanceOf(WooException::class.java) + assertThat((result.exceptionOrNull() as WooException).error).isEqualTo(endpointError) + verify(productDetailRepository, never()).addProduct(any()) + verify(variationRepository, never()).fetchProductVariations(any(), any()) + verify(variationRepository, never()).createVariations(any(), any()) + } @Test fun `given product has slug and permalink, when duplicated, then source product slug and permalink are unchanged`() = @@ -141,8 +245,14 @@ class DuplicateProductTest : BaseUnitTest() { } private companion object { + const val DUPLICATED_PRODUCT_ID = 123L const val SOURCE_SKU = "not an empty value" const val SOURCE_SLUG = "acme-water" const val SOURCE_PERMALINK = "https://example.com/product/acme-water" + val ROUTE_MISSING_ERROR = WooError( + type = API_NOT_FOUND, + original = NOT_FOUND, + apiErrorCode = "rest_no_route" + ) } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepositoryTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepositoryTest.kt new file mode 100644 index 000000000000..f0fb9b24c8ea --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRepositoryTest.kt @@ -0,0 +1,99 @@ +package com.woocommerce.android.ui.products.details + +import com.woocommerce.android.WooException +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.util.CoroutineDispatchers +import com.woocommerce.android.viewmodel.BaseUnitTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooError +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.API_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooResult +import org.wordpress.android.fluxc.store.WCGlobalAttributeStore +import org.wordpress.android.fluxc.store.WCProductStore +import org.wordpress.android.fluxc.store.WCTaxStore +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE as INVALID_RESPONSE_ORIGINAL + +@OptIn(ExperimentalCoroutinesApi::class) +class ProductDetailRepositoryTest : BaseUnitTest() { + private val site = SiteModel() + private val productStore: WCProductStore = mock() + private val selectedSite: SelectedSite = mock { + on { get() } doReturn site + } + private val sut = ProductDetailRepository( + dispatcher = mock(), + productStore = productStore, + globalAttributeStore = mock(), + selectedSite = selectedSite, + taxStore = mock(), + coroutineDispatchers = mock() + ) + + @Test + fun `given product store duplication succeeds, when duplicating product, then return successful ID`() = testBlocking { + // GIVEN + givenProductStoreReturns(WooResult(DUPLICATED_PRODUCT_ID)) + + // WHEN + val result = sut.duplicateProduct(SOURCE_PRODUCT_ID) + + // THEN + assertThat(result).isEqualTo(Result.success(DUPLICATED_PRODUCT_ID)) + } + + @Test + fun `given product store duplication fails, when duplicating product, then preserve exact error in failure`() = + testBlocking { + // GIVEN + val expectedError = WooError( + type = API_ERROR, + original = NETWORK_ERROR, + message = "Unable to duplicate product", + apiErrorCode = "woocommerce_rest_product_invalid_id", + errorData = mock() + ) + givenProductStoreReturns(WooResult(expectedError)) + + // WHEN + val result = sut.duplicateProduct(SOURCE_PRODUCT_ID) + + // THEN + assertThat(result.exceptionOrNull()).isInstanceOf(WooException::class.java) + assertThat((result.exceptionOrNull() as WooException).error).isEqualTo(expectedError) + } + + @Test + fun `given product store duplication returns no model or error, when duplicating product, then return invalid response failure`() = + testBlocking { + // GIVEN + givenProductStoreReturns(WooResult()) + + // WHEN + val result = sut.duplicateProduct(SOURCE_PRODUCT_ID) + + // THEN + assertThat(result.exceptionOrNull()).isInstanceOf(WooException::class.java) + val error = (result.exceptionOrNull() as WooException).error + assertThat(error.type).isEqualTo(INVALID_RESPONSE) + assertThat(error.original).isEqualTo(INVALID_RESPONSE_ORIGINAL) + } + + private suspend fun givenProductStoreReturns(result: WooResult) { + whenever(productStore.duplicateProduct(site, SOURCE_PRODUCT_ID)).thenReturn(result) + } + + private companion object { + const val SOURCE_PRODUCT_ID = 42L + const val DUPLICATED_PRODUCT_ID = 84L + } +} diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/DuplicateProductApiResponse.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/DuplicateProductApiResponse.kt new file mode 100644 index 000000000000..8dc79dc3b594 --- /dev/null +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/DuplicateProductApiResponse.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.wc.product + +import com.google.gson.annotations.SerializedName + +data class DuplicateProductApiResponse( + @SerializedName("id") val id: Long? +) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClient.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClient.kt index 083a7dddec5b..34686b951951 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClient.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClient.kt @@ -1835,6 +1835,19 @@ class ProductRestClient @Inject constructor( } } + suspend fun duplicateProduct( + site: SiteModel, + productId: Long + ): WooPayload { + val response = wooNetwork.executePostGsonRequest( + site = site, + path = WOOCOMMERCE.products.id(productId).duplicate.pathV3, + body = emptyMap(), + clazz = DuplicateProductApiResponse::class.java + ) + return response.toWooPayload() + } + /** * Makes a POST request to `/wp-json/wc/v3/products` to add a product * diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/WCProductStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/WCProductStore.kt index 80986d80d84f..7cc3f724c52b 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/WCProductStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/WCProductStore.kt @@ -1139,6 +1139,25 @@ class WCProductStore @Inject internal constructor( return productsDao.getCompositeProducts(site, remoteProductId) } + suspend fun duplicateProduct(site: SiteModel, productId: Long): WooResult = + coroutineEngine.withDefaultContext(API, this, "duplicateProduct") { + val result = wcProductRestClient.duplicateProduct(site, productId) + if (result.isError) { + return@withDefaultContext WooResult(result.error) + } + + val duplicatedProductId = result.result?.id?.takeIf { it > 0 } + ?: return@withDefaultContext WooResult( + WooError( + type = INVALID_RESPONSE, + original = GenericErrorType.INVALID_RESPONSE, + message = "Success response with missing or invalid product ID" + ) + ) + + WooResult(duplicatedProductId) + } + suspend fun submitProductAttributeChanges( site: SiteModel, productId: Long, diff --git a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClientTest.kt b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClientTest.kt index a9588e0a51f7..c010cfa8bbc5 100644 --- a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClientTest.kt +++ b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/wc/product/ProductRestClientTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.fluxc.network.rest.wpcom.wc.product +import com.google.gson.Gson import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat @@ -17,8 +18,13 @@ import org.wordpress.android.fluxc.generated.endpoint.WOOCOMMERCE import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.WCProductModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_FOUND +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse import org.wordpress.android.fluxc.network.rest.wpcom.WPComNetwork +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.API_NOT_FOUND +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.INVALID_ID import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooNetwork import org.wordpress.android.fluxc.store.WCProductStore import org.wordpress.android.fluxc.utils.initCoroutineEngine @@ -42,6 +48,98 @@ class ProductRestClientTest { sut = ProductRestClient(mock(), wooNetwork, wpComNetwork, initCoroutineEngine(), mock(), mock()) } + @Test + fun `when duplicating a product, then exact endpoint and empty body are used and duplicated ID is parsed`() = + runTest { + // GIVEN + val response = Gson().fromJson( + """ + { + "id": 123, + "name": "Coffee (Copy)", + "slug": "coffee-copy", + "date_created": {"date": "2026-07-14 12:00:00", "timezone_type": 3, "timezone": "UTC"}, + "category_ids": [12], + "image_id": 8, + "gallery_image_ids": [9], + "meta_data": [{"id": 9, "key": "_subscription_price", "value": "10"}], + "downloads": [] + } + """.trimIndent(), + DuplicateProductApiResponse::class.java + ) + whenever( + wooNetwork.executePostGsonRequest( + site = any(), + path = any(), + clazz = eq(DuplicateProductApiResponse::class.java), + body = any() + ) + ).thenReturn(WPAPIResponse.Success(response, emptyList())) + + // WHEN + val result = sut.duplicateProduct(site, productId) + + // THEN + verify(wooNetwork).executePostGsonRequest( + site = eq(site), + path = eq(WOOCOMMERCE.products.id(productId).duplicate.pathV3), + clazz = eq(DuplicateProductApiResponse::class.java), + body = eq(emptyMap()) + ) + assertThat(result.result?.id).isEqualTo(123L) + } + + @Test + fun `given duplicate route is missing, when duplicating a product, then rest no route error is preserved`() = + runTest { + // GIVEN + val networkError = WPAPINetworkError( + BaseNetworkError(NOT_FOUND, "No route"), + errorCode = "rest_no_route" + ) + whenever( + wooNetwork.executePostGsonRequest( + site = any(), + path = any(), + clazz = eq(DuplicateProductApiResponse::class.java), + body = any() + ) + ).thenReturn(WPAPIResponse.Error(networkError)) + + // WHEN + val result = sut.duplicateProduct(site, productId) + + // THEN + assertThat(result.error?.type).isEqualTo(API_NOT_FOUND) + assertThat(result.error?.apiErrorCode).isEqualTo("rest_no_route") + } + + @Test + fun `given source product is invalid, when duplicating a product, then invalid ID error is not treated as missing route`() = + runTest { + // GIVEN + val networkError = WPAPINetworkError( + BaseNetworkError(NOT_FOUND, "Invalid product ID"), + errorCode = "woocommerce_rest_product_invalid_id" + ) + whenever( + wooNetwork.executePostGsonRequest( + site = any(), + path = any(), + clazz = eq(DuplicateProductApiResponse::class.java), + body = any() + ) + ).thenReturn(WPAPIResponse.Error(networkError)) + + // WHEN + val result = sut.duplicateProduct(site, productId) + + // THEN + assertThat(result.error?.type).isEqualTo(INVALID_ID) + assertThat(result.error?.apiErrorCode).isEqualTo("woocommerce_rest_product_invalid_id") + } + @Test fun `send only updated parameters with id if products differ`() = runTest { // given diff --git a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/WCProductStoreTest.kt b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/WCProductStoreTest.kt index a5e75099ec63..3a67f26ece77 100644 --- a/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/WCProductStoreTest.kt +++ b/libs/fluxc-plugin/src/test/java/org/wordpress/android/fluxc/store/WCProductStoreTest.kt @@ -9,6 +9,7 @@ import junit.framework.TestCase.assertTrue import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject import org.junit.Before import org.junit.Rule import org.junit.Test @@ -35,13 +36,17 @@ import org.wordpress.android.fluxc.model.WCProductVariationModel import org.wordpress.android.fluxc.model.WCProductVariationModel.ProductVariantOption import org.wordpress.android.fluxc.model.metadata.WCMetaData import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_FOUND import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooError +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.API_NOT_FOUND import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.INVALID_RESPONSE import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooPayload import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.BatchProductUpdateApiResponse import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.BatchProductUpdateResult import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.BatchProductVariationsApiResponse import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.CoreProductStockStatus +import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.DuplicateProductApiResponse import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.ProductRestClient import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.ProductVariationApiResponse import org.wordpress.android.fluxc.persistence.DatabaseTestRule @@ -118,6 +123,59 @@ class WCProductStoreTest { ) } + @Test + fun `given core duplication succeeds, when duplicating product, then duplicated product ID is returned`() = runTest { + // GIVEN + val site = SiteModel() + whenever(productRestClient.duplicateProduct(site, 42L)) + .thenReturn(WooPayload(DuplicateProductApiResponse(84L))) + + // WHEN + val result = productStore.duplicateProduct(site, 42L) + + // THEN + assertThat(result.model).isEqualTo(84L) + } + + @Test + fun `given core duplication response has missing or invalid ID, when duplicating product, then invalid response is returned`() = + runTest { + // GIVEN + val site = SiteModel() + listOf(null, 0L, -1L).forEach { responseId -> + whenever(productRestClient.duplicateProduct(site, 42L)) + .thenReturn(WooPayload(DuplicateProductApiResponse(responseId))) + + // WHEN + val result = productStore.duplicateProduct(site, 42L) + + // THEN + assertThat(result.error.type).isEqualTo(INVALID_RESPONSE) + } + } + + @Test + fun `given core duplication returns an error, when duplicating product, then full error is forwarded unchanged`() = + runTest { + // GIVEN + val site = SiteModel() + val expectedError = WooError( + type = API_NOT_FOUND, + original = NOT_FOUND, + message = "No route was found matching the URL and request method", + apiErrorCode = "rest_no_route", + errorData = JSONObject().put("status", 404) + ) + whenever(productRestClient.duplicateProduct(site, 42L)) + .thenReturn(WooPayload(expectedError)) + + // WHEN + val result = productStore.duplicateProduct(site, 42L) + + // THEN + assertThat(result.error).isEqualTo(expectedError) + } + @Test fun testSimpleInsertionAndRetrieval() = runTest { val productModel = ProductTestUtils.generateSampleProduct(42) diff --git a/libs/fluxc-processor/src/main/resources/wc-wp-api-endpoints.txt b/libs/fluxc-processor/src/main/resources/wc-wp-api-endpoints.txt index 9ef3eff244e7..7a9b8890bd6b 100644 --- a/libs/fluxc-processor/src/main/resources/wc-wp-api-endpoints.txt +++ b/libs/fluxc-processor/src/main/resources/wc-wp-api-endpoints.txt @@ -11,6 +11,7 @@ /products// /products//availability/ +/products//duplicate /products//variations/ /products//variations/ /products//variations/batch diff --git a/libs/fluxc-tests/src/test/java/org/wordpress/android/fluxc/endpoints/WCWPAPIEndpointTest.kt b/libs/fluxc-tests/src/test/java/org/wordpress/android/fluxc/endpoints/WCWPAPIEndpointTest.kt index 8ab6d5953dff..6d75f34870d0 100644 --- a/libs/fluxc-tests/src/test/java/org/wordpress/android/fluxc/endpoints/WCWPAPIEndpointTest.kt +++ b/libs/fluxc-tests/src/test/java/org/wordpress/android/fluxc/endpoints/WCWPAPIEndpointTest.kt @@ -30,4 +30,9 @@ class WCWPAPIEndpointTest { assertEquals("/wc/v4/refunds/", WOOCOMMERCE.refunds.pathV4) assertEquals("/wc/v4/refunds/preview/", WOOCOMMERCE.refunds.preview.pathV4) } + + @Test + fun `when building product duplicate path, then v3 route is correct`() { + assertEquals("/wc/v3/products/123/duplicate/", WOOCOMMERCE.products.id(123).duplicate.pathV3) + } }