diff --git a/example/src/test/java/org/wordpress/android/fluxc/wc/order/WCOrderStoreTest.kt b/example/src/test/java/org/wordpress/android/fluxc/wc/order/WCOrderStoreTest.kt index 5669209883..20e70a02be 100644 --- a/example/src/test/java/org/wordpress/android/fluxc/wc/order/WCOrderStoreTest.kt +++ b/example/src/test/java/org/wordpress/android/fluxc/wc/order/WCOrderStoreTest.kt @@ -35,7 +35,10 @@ import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.WCOrderListDescriptor import org.wordpress.android.fluxc.model.WCOrderStatusModel import org.wordpress.android.fluxc.model.WCOrderSummaryModel +import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.BatchOrderApiResponse import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.CoreOrderStatus +import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.CoreOrderStatus.COMPLETED +import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderDto import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient import org.wordpress.android.fluxc.persistence.OrderSqlUtils import org.wordpress.android.fluxc.persistence.WCAndroidDatabase @@ -46,6 +49,7 @@ import org.wordpress.android.fluxc.persistence.dao.OrdersDaoDecorator import org.wordpress.android.fluxc.store.InsertOrder import org.wordpress.android.fluxc.store.WCOrderFetcher import org.wordpress.android.fluxc.store.WCOrderStore +import org.wordpress.android.fluxc.store.WCOrderStore.BulkUpdateOrderStatusResponsePayload import org.wordpress.android.fluxc.store.WCOrderStore.FetchHasOrdersResponsePayload import org.wordpress.android.fluxc.store.WCOrderStore.FetchOrderListResponsePayload import org.wordpress.android.fluxc.store.WCOrderStore.FetchOrderStatusOptionsResponsePayload @@ -605,6 +609,109 @@ class WCOrderStoreTest { } } + @Test + fun `given successful response for all orders when batch updating status then returns successful orders`() { + runBlocking { + // Given + val site = SiteModel().apply { id = 1 } + val orderIds = listOf(1L, 2L, 3L) + val newStatus = COMPLETED.value + + // Create mocked OrderDto objects for success responses + val order1 = mock().apply { + whenever(id).thenReturn(1L) + whenever(status).thenReturn(COMPLETED.value) + } + val order2 = mock().apply { + whenever(id).thenReturn(2L) + whenever(status).thenReturn(COMPLETED.value) + } + val order3 = mock().apply { + whenever(id).thenReturn(3L) + whenever(status).thenReturn(COMPLETED.value) + } + + val successResponses = listOf( + BatchOrderApiResponse.OrderResponse.Success(order1), + BatchOrderApiResponse.OrderResponse.Success(order2), + BatchOrderApiResponse.OrderResponse.Success(order3) + ) + + whenever(orderRestClient.batchUpdateOrdersStatus(site, orderIds, newStatus)) + .thenReturn(BulkUpdateOrderStatusResponsePayload(successResponses)) + + // When + val result = orderStore.batchUpdateOrdersStatus( + site, + orderIds, + WCOrderStatusModel(COMPLETED.value) + ) + + // Then + assertThat(result.isError).isFalse() + result.model?.let { updateResult -> + assertEquals(orderIds, updateResult.updatedOrders) + assertTrue(updateResult.failedOrders.isEmpty()) + } + } + } + + @Test + fun `given mixed response when batch updating status then returns successful and failed orders`() { + runBlocking { + // Given + val site = SiteModel().apply { id = 1 } + val orderIds = listOf(1L, 2L, 3L) + val newStatus = COMPLETED.value + + // Mock successful orders + val order1 = mock().apply { + whenever(id).thenReturn(1L) + whenever(status).thenReturn(COMPLETED.value) + } + val order3 = mock().apply { + whenever(id).thenReturn(3L) + whenever(status).thenReturn(COMPLETED.value) + } + + val mixedResponses = listOf( + BatchOrderApiResponse.OrderResponse.Success(order1), + BatchOrderApiResponse.OrderResponse.Error( + id = 2L, + error = BatchOrderApiResponse.ErrorResponse( + code = "woocommerce_rest_shop_order_invalid_id", + message = "Invalid ID.", + data = BatchOrderApiResponse.ErrorData(status = 400) + ) + ), + BatchOrderApiResponse.OrderResponse.Success(order3) + ) + + whenever(orderRestClient.batchUpdateOrdersStatus(site, orderIds, newStatus)) + .thenReturn(BulkUpdateOrderStatusResponsePayload(mixedResponses)) + + // When + val result = orderStore.batchUpdateOrdersStatus( + site, + orderIds, + WCOrderStatusModel(COMPLETED.value) + ) + + // Then + assertThat(result.isError).isFalse() + result.model?.let { updateResult -> + assertEquals(listOf(1L, 3L), updateResult.updatedOrders) + assertEquals(1, updateResult.failedOrders.size) + with(updateResult.failedOrders[0]) { + assertEquals(2L, id) + assertEquals("woocommerce_rest_shop_order_invalid_id", errorCode) + assertEquals("Invalid ID.", errorMessage) + assertEquals(400, errorStatus) + } + } + } + } + private fun setupMissingOrders(): MutableMap { return mutableMapOf().apply { (21L..30L).forEach { index -> diff --git a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/BatchOrderApiResponse.kt b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/BatchOrderApiResponse.kt index d2c942a799..346fd245ce 100644 --- a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/BatchOrderApiResponse.kt +++ b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/BatchOrderApiResponse.kt @@ -7,6 +7,43 @@ import com.google.gson.annotations.JsonAdapter import java.lang.reflect.Type import org.wordpress.android.fluxc.network.Response +/** + * Represents the response from WooCommerce's Batch Order Update API endpoint. + * https://woocommerce.github.io/woocommerce-rest-api-docs/?shell#batch-update-orders + * + * While the WooCommerce REST API orders batch endpoint supports three operations at once + * (create, update, delete), this class specifically handles only the "update" operation + * responses, because we don't yet support the other operations. + * + * The response contains a list of order updates, where each update can be + * either successful or failed. + * 1. Success: Contains the complete updated order data (OrderDto) + * 2. Error: Contains the failed order ID and error details + * + * Also refer to the orders-batch.json file in test resources. + * + * Example successful response: + * { + * "update": [{ + * "id": 1032, + * "status": "completed", + * // ... other order fields + * }] + * } + * + * Example error response: + * { + * "update": [{ + * "id": "525", + * "error": { + * "code": "woocommerce_rest_shop_order_invalid_id", + * "message": "Invalid ID.", + * "data": { "status": 400 } + * } + * }] + * } + * + */ data class BatchOrderApiResponse( val update: List ) : Response { diff --git a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/OrderRestClient.kt b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/OrderRestClient.kt index b0a239be55..a481ee20ef 100644 --- a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/OrderRestClient.kt +++ b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/order/OrderRestClient.kt @@ -30,6 +30,7 @@ import org.wordpress.android.fluxc.network.rest.wpcom.wc.toWooError import org.wordpress.android.fluxc.persistence.entity.OrderNoteEntity import org.wordpress.android.fluxc.store.WCOrderStore import org.wordpress.android.fluxc.store.WCOrderStore.AddOrderShipmentTrackingResponsePayload +import org.wordpress.android.fluxc.store.WCOrderStore.BulkUpdateOrderStatusResponsePayload import org.wordpress.android.fluxc.store.WCOrderStore.DeleteOrderShipmentTrackingResponsePayload import org.wordpress.android.fluxc.store.WCOrderStore.FetchHasOrdersResponsePayload import org.wordpress.android.fluxc.store.WCOrderStore.FetchOrderListResponsePayload @@ -1073,6 +1074,63 @@ class OrderRestClient @Inject constructor( } } + /** + * Performs a batch update of order statuses via the WooCommerce REST API. + * + * This endpoint enables updating multiple orders to the same status in a single network request. + * The WooCommerce API has a limit of 100 orders per batch update. + * + * @param site The site to perform the update on + * @param orderIds List of order IDs to update. Error if exceeds [BATCH_UPDATE_LIMIT] + * @param newStatus The new status to set for all specified orders + * @return [BulkUpdateOrderStatusResponsePayload] containing either the update results or an error + */ + suspend fun batchUpdateOrdersStatus( + site: SiteModel, + orderIds: List, + newStatus: String + ): BulkUpdateOrderStatusResponsePayload { + // Check batch update limit + if (orderIds.size > BATCH_UPDATE_LIMIT) { + return BulkUpdateOrderStatusResponsePayload( + error = OrderError( + type = OrderErrorType.BULK_UPDATE_LIMIT_EXCEEDED, + message = "Cannot update more than 100 orders at once" + ) + ) + } + + val url = WOOCOMMERCE.orders.batch.pathV3 + val updateRequests = orderIds.map { orderId -> + mapOf( + "id" to orderId, + "status" to newStatus + ) + } + + val response = wooNetwork.executePostGsonRequest( + site = site, + path = url, + clazz = BatchOrderApiResponse::class.java, + body = mapOf("update" to updateRequests) + ) + + return when (response) { + is WPAPIResponse.Success -> { + response.data?.let { + BulkUpdateOrderStatusResponsePayload(it.update) + } ?: BulkUpdateOrderStatusResponsePayload( + OrderError(GENERIC_ERROR, "Success response with empty data") + ) + } + + is WPAPIResponse.Error -> { + val orderError = wpAPINetworkErrorToOrderError(response.error) + BulkUpdateOrderStatusResponsePayload(orderError) + } + } + } + private fun UpdateOrderRequest.toNetworkRequest(): Map { return mutableMapOf().apply { customerId?.let { put("customer_id", it) } @@ -1202,6 +1260,8 @@ class OrderRestClient @Inject constructor( "tracking_number", "tracking_provider" ).joinToString(separator = ",") + + private const val BATCH_UPDATE_LIMIT = 100 } enum class SortOrder(val value: String) { diff --git a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/store/WCOrderStore.kt b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/store/WCOrderStore.kt index b9eee2b456..b79d42680a 100644 --- a/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/store/WCOrderStore.kt +++ b/plugins/woocommerce/src/main/kotlin/org/wordpress/android/fluxc/store/WCOrderStore.kt @@ -26,6 +26,7 @@ import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.SERVER_E 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.WooResult +import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.BatchOrderApiResponse import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient.OrderBy import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient.SortOrder @@ -43,6 +44,7 @@ import org.wordpress.android.fluxc.store.WCOrderStore.OrderErrorType.PARSE_ERROR import org.wordpress.android.fluxc.store.WCOrderStore.OrderErrorType.TIMEOUT_ERROR import org.wordpress.android.fluxc.store.WCOrderStore.UpdateOrderResult.OptimisticUpdateResult import org.wordpress.android.fluxc.store.WCOrderStore.UpdateOrderResult.RemoteUpdateResult +import org.wordpress.android.fluxc.store.WCOrderStore.UpdateOrdersStatusResult.FailedOrder import org.wordpress.android.fluxc.tools.CoroutineEngine import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T.API @@ -297,6 +299,26 @@ class WCOrderStore @Inject constructor( } } + class BulkUpdateOrderStatusResponsePayload( + val response: List + ) : Payload() { + constructor(error: OrderError) : this(emptyList()) { + this.error = error + } + } + + data class UpdateOrdersStatusResult( + val updatedOrders: List = emptyList(), + val failedOrders: List = emptyList() + ) { + data class FailedOrder( + val id: Long, + val errorCode: String, + val errorMessage: String, + val errorStatus: Int + ) + } + data class OrderError(val type: OrderErrorType = GENERIC_ERROR, val message: String = "") : OnChangedError enum class OrderErrorType { @@ -308,7 +330,8 @@ class WCOrderStore @Inject constructor( GENERIC_ERROR, PARSE_ERROR, TIMEOUT_ERROR, - EMPTY_BILLING_EMAIL; + EMPTY_BILLING_EMAIL, + BULK_UPDATE_LIMIT_EXCEEDED; companion object { private val reverseMap = values().associateBy(OrderErrorType::name) @@ -1146,4 +1169,42 @@ class WCOrderStore @Inject constructor( WooResult(orders) } } + + @Suppress("NestedBlockDepth") + suspend fun batchUpdateOrdersStatus( + site: SiteModel, + orderIds: List, + newStatus: WCOrderStatusModel + ): WooResult { + val result = wcOrderRestClient.batchUpdateOrdersStatus(site, orderIds, newStatus.statusKey) + + return if (!result.isError) { + val orders = result.response + val updatedOrders = mutableListOf() + val failedOrders = mutableListOf() + + orders.forEach { response -> + when (response) { + is BatchOrderApiResponse.OrderResponse.Success -> { + response.order.id?.let { updatedOrders.add(it) } + } + + is BatchOrderApiResponse.OrderResponse.Error -> { + failedOrders.add( + FailedOrder( + id = response.id, + errorCode = response.error.code, + errorMessage = response.error.message, + errorStatus = response.error.data.status + ) + ) + } + } + } + + WooResult(UpdateOrdersStatusResult(updatedOrders, failedOrders)) + } else { + WooResult(WooError(API_ERROR, SERVER_ERROR, result.error.message)) + } + } }