diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosGetOrderRefundsByOrderId.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosGetOrderRefundsByOrderId.kt deleted file mode 100644 index a630e7989bb1..000000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosGetOrderRefundsByOrderId.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.woocommerce.android.ui.woopos.common.data - -import com.woocommerce.android.model.Refund -import com.woocommerce.android.model.toAppModel -import com.woocommerce.android.tools.SelectedSite -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.wordpress.android.fluxc.store.WCRefundStore -import javax.inject.Inject - -class WooPosGetOrderRefundsByOrderId @Inject constructor( - private val refundStore: WCRefundStore, - private val selectedSite: SelectedSite -) { - suspend operator fun invoke(orderId: Long): List { - return withContext(Dispatchers.IO) { - refundStore.getAllRefunds(selectedSite.get(), orderId) - .map { it.toAppModel() } - } - } -} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosRetrieveOrderRefunds.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosRetrieveOrderRefunds.kt new file mode 100644 index 000000000000..3bb7c773368d --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosRetrieveOrderRefunds.kt @@ -0,0 +1,38 @@ +package com.woocommerce.android.ui.woopos.common.data + +import com.woocommerce.android.model.Order +import com.woocommerce.android.model.Refund +import com.woocommerce.android.model.toAppModel +import com.woocommerce.android.tools.SelectedSite +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.store.WCRefundStore +import java.math.BigDecimal +import javax.inject.Inject + +class WooPosRetrieveOrderRefunds @Inject constructor( + private val refundStore: WCRefundStore, + private val selectedSite: SelectedSite +) { + suspend operator fun invoke(order: Order): Result> = + withContext(Dispatchers.IO) { + if (order.refundTotal.compareTo(BigDecimal.ZERO) == 0) { + return@withContext Result.success(emptyList()) + } + + val site = selectedSite.get() + + var refundModels = refundStore.getAllRefunds(site, order.id) + if (refundModels.isEmpty()) { + val fetchResult = refundStore.fetchAllRefunds(site, order.id) + if (fetchResult.isError) { + return@withContext Result.failure( + Exception("Failed to fetch refunds: ${fetchResult.error.message}") + ) + } + refundModels = fetchResult.model ?: emptyList() + } + + Result.success(refundModels.map { it.toAppModel() }) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt index 06267c69e44a..a9da1756bd83 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt @@ -2,8 +2,13 @@ package com.woocommerce.android.ui.woopos.orders import com.woocommerce.android.model.Order import com.woocommerce.android.model.OrderMapper +import com.woocommerce.android.model.Refund import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.woopos.common.data.WooPosRetrieveOrderRefunds import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext @@ -16,21 +21,27 @@ import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject sealed class LoadOrdersResult { - data class SuccessCache(val orders: List) : LoadOrdersResult() - data class SuccessRemote(val orders: List) : LoadOrdersResult() + data class SuccessCache(val ordersWithRefunds: Map) : LoadOrdersResult() + data class SuccessRemote(val ordersWithRefunds: Map) : LoadOrdersResult() data class Error(val message: String) : LoadOrdersResult() } sealed class SearchOrdersResult { - data class Success(val orders: List) : SearchOrdersResult() + data class Success(val ordersWithRefunds: Map) : SearchOrdersResult() data class Error(val message: String) : SearchOrdersResult() } +sealed class RefundFetchResult { + data class Success(val refunds: List) : RefundFetchResult() + object Error : RefundFetchResult() +} + class WooPosOrdersDataSource @Inject constructor( private val restClient: OrderRestClient, private val selectedSite: SelectedSite, private val orderMapper: OrderMapper, - private val ordersCache: WooPosOrdersInMemoryCache + private val ordersCache: WooPosOrdersInMemoryCache, + private val retrieveOrderRefunds: WooPosRetrieveOrderRefunds ) { private val canLoadMore = AtomicBoolean(false) private val page = AtomicInteger(1) @@ -45,12 +56,16 @@ class WooPosOrdersDataSource @Inject constructor( fun loadOrders(): Flow = flow { val cached = ordersCache.getAll() - if (cached.isNotEmpty()) emit(LoadOrdersResult.SuccessCache(cached)) + if (cached.isNotEmpty()) { + val cachedWithRefunds = fetchRefundsForOrders(cached) + emit(LoadOrdersResult.SuccessCache(cachedWithRefunds)) + } val result = loadFirstPage() - result.onSuccess { - ordersCache.setAll(it) - emit(LoadOrdersResult.SuccessRemote(it)) + result.onSuccess { orders -> + ordersCache.setAll(orders) + val ordersWithRefunds = fetchRefundsForOrders(orders) + emit(LoadOrdersResult.SuccessRemote(ordersWithRefunds)) }.onFailure { emit(LoadOrdersResult.Error(it.message ?: UNKNOWN_ERROR)) } @@ -59,13 +74,20 @@ class WooPosOrdersDataSource @Inject constructor( suspend fun searchOrders(searchQuery: String): SearchOrdersResult { val result = loadFirstPage(searchQuery) return result.fold( - onSuccess = { SearchOrdersResult.Success(it) }, + onSuccess = { orders -> + val ordersWithRefunds = fetchRefundsForOrders(orders) + SearchOrdersResult.Success(ordersWithRefunds) + }, onFailure = { SearchOrdersResult.Error(it.message ?: UNKNOWN_ERROR) } ) } - suspend fun loadMore(searchQuery: String? = null): Result> = - withContext(Dispatchers.IO) { loadNextPage(searchQuery) } + suspend fun loadMore(searchQuery: String? = null): Result> = + withContext(Dispatchers.IO) { + loadNextPage(searchQuery).map { orders -> + fetchRefundsForOrders(orders) + } + } suspend fun refreshOrderById(orderId: Long): Result { val site = selectedSite.get() @@ -135,4 +157,16 @@ class WooPosOrdersDataSource @Inject constructor( private fun WCOrderStore.OrderError.toThrowable(): Throwable = Throwable("[$type] $message") + + private suspend fun fetchRefundsForOrders(orders: List): Map = + coroutineScope { + orders.map { order -> + async { + retrieveOrderRefunds(order).fold( + onSuccess = { refunds -> order to RefundFetchResult.Success(refunds) }, + onFailure = { order to RefundFetchResult.Error } + ) + } + }.awaitAll().toMap() + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDetails.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDetails.kt index fff78d5da0a1..376b186dc30d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDetails.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDetails.kt @@ -45,7 +45,7 @@ import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTyp @Composable fun WooPosOrderDetails( modifier: Modifier = Modifier, - details: OrderDetailsViewState, + details: OrderDetailsViewState.Computed.Details, onEmailReceiptButtonClicked: (Long) -> Unit ) { Column( @@ -89,7 +89,7 @@ fun WooPosOrderDetails( } @Composable -private fun OrdersHeader(details: OrderDetailsViewState) { +private fun OrdersHeader(details: OrderDetailsViewState.Computed.Details) { Column(modifier = Modifier.fillMaxWidth()) { WooPosText( text = details.dateTime, @@ -113,7 +113,7 @@ private fun OrdersHeader(details: OrderDetailsViewState) { } @Composable -private fun OrdersProducts(lineItems: List) { +private fun OrdersProducts(lineItems: List) { WooPosCard(shadowType = ShadowType.Soft) { Column(Modifier.padding(WooPosSpacing.Medium.value)) { WooPosText( @@ -137,7 +137,7 @@ private fun OrdersProducts(lineItems: List) { @Composable @Suppress("DestructuringDeclarationWithTooManyEntries") -private fun OrderProductItem(row: OrderDetailsViewState.LineItemRow) { +private fun OrderProductItem(row: OrderDetailsViewState.Computed.Details.LineItemRow) { ConstraintLayout( modifier = Modifier .fillMaxWidth() @@ -170,7 +170,7 @@ private fun OrderProductItem(row: OrderDetailsViewState.LineItemRow) { style = WooPosTypography.BodyMedium, color = WooPosTheme.colors.onSurfaceVariantHighest, modifier = Modifier.constrainAs(qtyText) { - bottom.linkTo(image.bottom) + top.linkTo(nameText.bottom, margin = WooPosSpacing.XSmall.value) start.linkTo(nameText.start) end.linkTo(totalText.start, margin = WooPosSpacing.Small.value) width = Dimension.fillToConstraints @@ -189,7 +189,7 @@ private fun OrderProductItem(row: OrderDetailsViewState.LineItemRow) { } @Composable -private fun OrdersTotals(details: OrderDetailsViewState) { +private fun OrdersTotals(details: OrderDetailsViewState.Computed.Details) { WooPosCard(shadowType = ShadowType.Soft) { Column(Modifier.padding(WooPosSpacing.Medium.value)) { WooPosText( @@ -348,18 +348,25 @@ private fun DividerWithSpacing() { @WooPosPreview @Composable fun WooPosOrderDetailsPreview() { - val orderDetails = OrderDetailsViewState( + val orderDetails = OrderDetailsViewState.Computed.Details( id = 1L, number = "#014", dateTime = "Aug 28, 2025 at 10:31 AM", customerEmail = "johndoe@mail.com", status = PosOrderStatus(text = "Completed", colorKey = OrderStatusColorKey.COMPLETED), lineItems = listOf( - OrderDetailsViewState.LineItemRow(101, "Cup", "2 x $4.00", "$8.00", null), - OrderDetailsViewState.LineItemRow(102, "Coffee Container", "1 x $10.00", "$10.00", null), - OrderDetailsViewState.LineItemRow(103, "Paper Filter", "1 x $5.00", "$5.00", null) + OrderDetailsViewState.Computed.Details.LineItemRow(101, "Cup", "2 x $4.00", "$8.00", null), + OrderDetailsViewState.Computed.Details.LineItemRow(102, "Coffee Container", "1 x $10.00", "$10.00", null), + OrderDetailsViewState.Computed.Details.LineItemRow( + 103, + "A vey tasty coffee that incidentally has a very long name " + + "and should go over a few lines without overlapping anything", + "1 x $5.00", + "$5.00", + null + ) ), - breakdown = OrderDetailsViewState.TotalsBreakdown( + breakdown = OrderDetailsViewState.Computed.Details.TotalsBreakdown( products = "$23.00", discount = "-$5.00", discountCode = "SAVE5", diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersLoadingState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersLoadingState.kt index 33e6b5e916e7..7b374178c95d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersLoadingState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersLoadingState.kt @@ -45,7 +45,7 @@ fun WooPosOrdersLoadingState() { modifier = Modifier .weight(0.3f) .fillMaxHeight() - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceBright) .padding(top = WOO_POS_ORDERS_TOOLBAR_HEIGHT) .padding(top = WooPosSpacing.XLarge.value) ) @@ -54,7 +54,7 @@ fun WooPosOrdersLoadingState() { modifier = Modifier .weight(0.7f) .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .background(MaterialTheme.colorScheme.surface) .padding(top = WooPosSpacing.Large.value) ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt index 693b6bd709c0..1e7a4d84b50d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt @@ -524,8 +524,8 @@ fun WooPosOrdersScreenPreview() { state = WooPosOrdersState.Content( items = WooPosOrdersState.Content.Items.Loaded( items = mapOf( - item1 to details1, - item2 to details2 + item1 to OrderDetailsViewState.Computed(orderId = 1L, details = details1), + item2 to OrderDetailsViewState.Computed(orderId = 2L, details = details2) ) ), pullToRefreshState = WooPosPullToRefreshState.Enabled, @@ -617,18 +617,18 @@ fun WooPosOrdersNothingFoundStatePreview() { private fun sampleOrderDetails( id: Long = 1L, number: String = "#014" -) = OrderDetailsViewState( +) = OrderDetailsViewState.Computed.Details( id = id, number = number, dateTime = "Aug 28, 2025 at 10:31 AM", customerEmail = "johndoe@mail.com", status = PosOrderStatus(text = "Completed", colorKey = OrderStatusColorKey.COMPLETED), lineItems = listOf( - OrderDetailsViewState.LineItemRow(101, "Cup", "1 x $8.50", "$15.00", null), - OrderDetailsViewState.LineItemRow(102, "Coffee Container", "1 x $10.00", "$8.00", null), - OrderDetailsViewState.LineItemRow(103, "Paper Filter", "1 x $4.50", "$8.00", null) + OrderDetailsViewState.Computed.Details.LineItemRow(101, "Cup", "1 x $8.50", "$15.00", null), + OrderDetailsViewState.Computed.Details.LineItemRow(102, "Coffee Container", "1 x $10.00", "$8.00", null), + OrderDetailsViewState.Computed.Details.LineItemRow(103, "Paper Filter", "1 x $4.50", "$8.00", null) ), - breakdown = OrderDetailsViewState.TotalsBreakdown( + breakdown = OrderDetailsViewState.Computed.Details.TotalsBreakdown( products = "$23.00", discount = "-$5.00", discountCode = "8qew4mnq", diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt index c28acdc9ba57..2ebe9653d98f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt @@ -1,44 +1,63 @@ package com.woocommerce.android.ui.woopos.orders import androidx.compose.runtime.Immutable +import com.woocommerce.android.model.Order import com.woocommerce.android.model.Order.Status import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchInputState import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState import com.woocommerce.android.ui.woopos.home.items.WooPosPullToRefreshState @Immutable -data class OrderDetailsViewState( - val id: Long, - val number: String, - val dateTime: String, - val customerEmail: String?, - val status: PosOrderStatus, +sealed class OrderDetailsViewState { + abstract val orderId: Long - val lineItems: List, - val breakdown: TotalsBreakdown, - val total: String, - val totalPaid: String, - val paymentMethodTitle: String?, -) { @Immutable - data class LineItemRow( - val id: Long, - val name: String, - val qtyAndUnitPrice: String, - val lineTotal: String, - val imageUrl: String?, - ) + data class Lazy( + override val orderId: Long, + val order: Order, + val refundResult: RefundFetchResult + ) : OrderDetailsViewState() @Immutable - data class TotalsBreakdown( - val products: String, - val discount: String?, - val discountCode: String?, - val taxes: String, - val shipping: String?, - val refunds: List, - val netPayment: String? - ) + data class Computed( + override val orderId: Long, + val details: Details + ) : OrderDetailsViewState() { + @Immutable + data class Details( + val id: Long, + val number: String, + val dateTime: String, + val customerEmail: String?, + val status: PosOrderStatus, + + val lineItems: List, + val breakdown: TotalsBreakdown, + val total: String, + val totalPaid: String, + val paymentMethodTitle: String?, + ) { + @Immutable + data class LineItemRow( + val id: Long, + val name: String, + val qtyAndUnitPrice: String, + val lineTotal: String, + val imageUrl: String?, + ) + + @Immutable + data class TotalsBreakdown( + val products: String, + val discount: String?, + val discountCode: String?, + val taxes: String, + val shipping: String?, + val refunds: List, + val netPayment: String? + ) + } + } } @Immutable @@ -64,7 +83,7 @@ sealed class WooPosOrdersState { val items: Items, override val pullToRefreshState: WooPosPullToRefreshState, override val searchInputState: WooPosSearchInputState, - val selectedDetails: OrderDetailsViewState, + val selectedDetails: OrderDetailsViewState.Computed.Details, val paginationState: WooPosPaginationState ) : WooPosOrdersState() { sealed class Items { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt index ed5bacef37f1..0340850f8e59 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt @@ -7,8 +7,8 @@ import com.woocommerce.android.R import com.woocommerce.android.model.Order import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchInputState import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchUIEvent -import com.woocommerce.android.ui.woopos.common.data.WooPosGetOrderRefundsByOrderId import com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById +import com.woocommerce.android.ui.woopos.common.data.WooPosRetrieveOrderRefunds import com.woocommerce.android.ui.woopos.home.ChildToParentEvent.NavigationEvent.ToEmailReceipt import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState @@ -18,6 +18,9 @@ import com.woocommerce.android.ui.woopos.util.format.WooPosFormatPrice import com.woocommerce.android.viewmodel.ResourceProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -39,7 +42,7 @@ class WooPosOrdersViewModel @Inject constructor( private val getProductById: WooPosGetProductById, private val childrenToParentEventSender: WooPosChildrenToParentEventSender, private val formatPrice: WooPosFormatPrice, - private val getOrderRefunds: WooPosGetOrderRefundsByOrderId, + private val retrieveOrderRefunds: WooPosRetrieveOrderRefunds, private val ordersAnalyticsTracker: WooPosOrdersAnalyticsTracker ) : ViewModel() { @@ -94,18 +97,26 @@ class WooPosOrdersViewModel @Inject constructor( } } - val updatedItems = loadedItems.items.mapKeys { (item, _) -> - item.copy(isSelected = item.id == orderId) - } + viewModelScope.launch { + val details = getOrComputeDetails(orderId) - val selectedEntry = updatedItems.entries.first { (item, _) -> item.isSelected } + val updatedItems = loadedItems.items.mapKeys { (item, _) -> + item.copy(isSelected = item.id == orderId) + }.mapValues { (item, orderDetails) -> + if (item.id == orderId && orderDetails is OrderDetailsViewState.Lazy) { + OrderDetailsViewState.Computed(orderId = orderId, details = details) + } else { + orderDetails + } + } - _state.value = current.copy( - items = WooPosOrdersState.Content.Items.Loaded( - items = updatedItems - ), - selectedDetails = selectedEntry.value - ) + _state.value = current.copy( + items = WooPosOrdersState.Content.Items.Loaded( + items = updatedItems + ), + selectedDetails = details + ) + } } fun onRefresh() { @@ -280,9 +291,18 @@ class WooPosOrdersViewModel @Inject constructor( val current = _state.value as? WooPosOrdersState.Content ?: return val loaded = current.items as? WooPosOrdersState.Content.Items.Loaded ?: return + val refundResult = retrieveOrderRefunds(updated).fold( + onSuccess = { refunds -> RefundFetchResult.Success(refunds) }, + onFailure = { RefundFetchResult.Error } + ) + val selectedId = loaded.items.keys.firstOrNull { it.isSelected }?.id val newItem = mapOrderItem(updated, selectedId) - val newDetails = mapOrderDetails(updated) + val newDetailsViewState = mapOrderDetails(updated, refundResult) + val newDetails = OrderDetailsViewState.Computed( + orderId = updated.id, + details = newDetailsViewState + ) val newMap = loaded.items.entries.associate { (item, details) -> if (item.id == updated.id) newItem to newDetails else item to details @@ -290,7 +310,7 @@ class WooPosOrdersViewModel @Inject constructor( _state.value = current.copy( items = WooPosOrdersState.Content.Items.Loaded(newMap), - selectedDetails = if (selectedId == updated.id) newDetails else current.selectedDetails + selectedDetails = if (selectedId == updated.id) newDetailsViewState else current.selectedDetails ) } @@ -338,7 +358,7 @@ class WooPosOrdersViewModel @Inject constructor( } is SearchOrdersResult.Success -> { - if (result.orders.isEmpty()) { + if (result.ordersWithRefunds.isEmpty()) { _state.value = WooPosOrdersState.Content( items = WooPosOrdersState.Content.Items.NothingFound( title = resourceProvider.getString(R.string.woopos_search_orders_empty_title), @@ -350,7 +370,7 @@ class WooPosOrdersViewModel @Inject constructor( paginationState = WooPosPaginationState.None ) } else { - replaceOrders(result.orders) + replaceOrders(result.ordersWithRefunds) } } } @@ -374,12 +394,12 @@ class WooPosOrdersViewModel @Inject constructor( } is LoadOrdersResult.SuccessCache -> { - if (result.orders.isEmpty()) { + if (result.ordersWithRefunds.isEmpty()) { _state.value = WooPosOrdersState.Loading( searchInputState = WooPosSearchInputState.Closed ) } else { - replaceOrders(result.orders) + replaceOrders(result.ordersWithRefunds) } } @@ -387,12 +407,12 @@ class WooPosOrdersViewModel @Inject constructor( val elapsedMs = mark.elapsedNow().inWholeMilliseconds ordersAnalyticsTracker.trackOrdersListFetched(elapsedMs) - if (result.orders.isEmpty()) { + if (result.ordersWithRefunds.isEmpty()) { _state.value = WooPosOrdersState.Empty( searchInputState = WooPosSearchInputState.Closed ) } else { - replaceOrders(result.orders) + replaceOrders(result.ordersWithRefunds) } } } @@ -406,33 +426,51 @@ class WooPosOrdersViewModel @Inject constructor( loadingMoreOrdersJob?.cancel() } + private suspend fun getOrComputeDetails(orderId: Long): OrderDetailsViewState.Computed.Details { + val current = _state.value as? WooPosOrdersState.Content ?: error("State is not Content") + val loadedItems = current.items as? WooPosOrdersState.Content.Items.Loaded ?: error("Items not loaded") + + val orderDetails = loadedItems.items.values.firstOrNull { it.orderId == orderId } + ?: error("Order $orderId not found in state") + + return when (orderDetails) { + is OrderDetailsViewState.Lazy -> mapOrderDetails(orderDetails.order, orderDetails.refundResult) + is OrderDetailsViewState.Computed -> orderDetails.details + } + } + private suspend fun replaceOrders( - orders: List, + ordersWithRefunds: Map, paginationState: WooPosPaginationState = WooPosPaginationState.None ) { + val orders = ordersWithRefunds.keys.toList() val newSelectedId = requireNotNull(orders.firstOrNull()?.id) { "Content requires at least one order" } - val items = buildItemsMap(orders, newSelectedId) + val items = buildItemsMap(ordersWithRefunds, newSelectedId) val selectedEntry = items.entries.first { (item, _) -> item.isSelected } + val selectedDetails = when (val details = selectedEntry.value) { + is OrderDetailsViewState.Computed -> details.details + is OrderDetailsViewState.Lazy -> error("Selected order should have computed details") + } _state.value = WooPosOrdersState.Content( items = WooPosOrdersState.Content.Items.Loaded( items = items ), pullToRefreshState = WooPosPullToRefreshState.Enabled, - selectedDetails = selectedEntry.value, + selectedDetails = selectedDetails, paginationState = paginationState, searchInputState = _state.value.searchInputState ) } private suspend fun appendOrders( - orders: List, + ordersWithRefunds: Map, paginationState: WooPosPaginationState = WooPosPaginationState.None ) { val current = _state.value as WooPosOrdersState.Content val loadedItems = current.items as WooPosOrdersState.Content.Items.Loaded val currentSelectedId = loadedItems.items.entries.firstOrNull { it.key.isSelected }?.key?.id - val newItems = buildItemsMap(orders, currentSelectedId) + val newItems = buildItemsMap(ordersWithRefunds, currentSelectedId) val items = loadedItems.items + newItems _state.value = WooPosOrdersState.Content( @@ -447,14 +485,26 @@ class WooPosOrdersViewModel @Inject constructor( } private suspend fun buildItemsMap( - orders: List, + ordersWithRefunds: Map, selectedId: Long? - ): Map { - return orders.associate { order -> - val item = mapOrderItem(order, selectedId) - val details = mapOrderDetails(order) - item to details - } + ): Map = coroutineScope { + ordersWithRefunds.map { (order, refundResult) -> + async { + val item = mapOrderItem(order, selectedId) + val details: OrderDetailsViewState = if (order.id == selectedId) { + val fullDetails = mapOrderDetails(order, refundResult) + OrderDetailsViewState.Computed(orderId = order.id, details = fullDetails) + } else { + OrderDetailsViewState.Lazy( + orderId = order.id, + order = order, + refundResult = refundResult + ) + } + + item to details + } + }.awaitAll().toMap() } private suspend fun mapOrderItem(order: Order, selectedId: Long?): OrderItemViewState { @@ -478,61 +528,110 @@ class WooPosOrdersViewModel @Inject constructor( ) } - private suspend fun mapOrderDetails(order: Order): OrderDetailsViewState { - val statusText = order.status.localizedLabel(resourceProvider, locale) + private suspend fun mapOrderDetails( + order: Order, + refundResult: RefundFetchResult + ): OrderDetailsViewState.Computed.Details = coroutineScope { + val status = mapOrderStatus(order) + val lineItems = buildLineItems(order) + val refundInfo = buildRefundInfo(order, refundResult) + val breakdown = buildTotalsBreakdown(order, refundInfo) - val status = PosOrderStatus( + OrderDetailsViewState.Computed.Details( + id = order.id, + number = "#${order.number}", + dateTime = order.dateCreated.formatToMMMddYYYYAtHHmm( + atWord = resourceProvider.getString(R.string.date_time_connector) + ), + customerEmail = order.customer?.email ?: order.billingAddress.email, + status = status, + lineItems = lineItems, + breakdown = breakdown, + total = formatPrice(order.total), + totalPaid = formatPrice(order.total), + paymentMethodTitle = order.paymentMethodTitle.takeIf { it.isNotBlank() } + ) + } + + private fun mapOrderStatus(order: Order): PosOrderStatus { + val statusText = order.status.localizedLabel(resourceProvider, locale) + return PosOrderStatus( text = statusText, colorKey = OrderStatusColorKey.fromStatus(order.status) ) + } - val lineItems = order.items.map { item -> - val unitPrice = if (item.quantity == 0f) item.total else item.total / item.quantity.toBigDecimal() - val product = getProductById(item.productId) - OrderDetailsViewState.LineItemRow( - id = item.itemId, - name = item.name, - qtyAndUnitPrice = "${item.quantity.toInt()} x ${formatPrice(unitPrice)}", - lineTotal = formatPrice(item.total), - imageUrl = product?.firstImageUrl - ) - } + private suspend fun buildLineItems( + order: Order + ): List = coroutineScope { + order.items.map { item -> + async { + val unitPrice = + if (item.quantity == 0f) { + item.total + } else { + item.total / item.quantity.toBigDecimal() + } + val product = getProductById(item.productId) + OrderDetailsViewState.Computed.Details.LineItemRow( + id = item.itemId, + name = item.name, + qtyAndUnitPrice = "${item.quantity.toInt()} x ${formatPrice(unitPrice)}", + lineTotal = formatPrice(item.total), + imageUrl = product?.firstImageUrl + ) + } + }.awaitAll() + } - val discountCode = order.couponLines.firstOrNull()?.code + private data class RefundInfo( + val refundAmounts: List, + val totalRefunded: BigDecimal + ) + + private suspend fun buildRefundInfo( + order: Order, + refundResult: RefundFetchResult + ): RefundInfo { + return when (refundResult) { + is RefundFetchResult.Success -> { + val amounts = refundResult.refunds.map { "-${formatPrice(it.amount)}" } + val total = refundResult.refunds.sumOf { it.amount } + RefundInfo(amounts, total) + } + is RefundFetchResult.Error -> { + val amounts = + if (order.refundTotal > BigDecimal.ZERO) { + listOf(resourceProvider.getString(R.string.woopos_orders_details_refund_error)) + } else { + emptyList() + } + RefundInfo(amounts, BigDecimal.ZERO) + } + } + } - val refunds = getOrderRefunds(order.id) - val refundAmounts = refunds.map { "-${formatPrice(it.amount)}" } - val totalRefunded = refunds.sumOf { it.amount } - val netPayment = if (totalRefunded > BigDecimal.ZERO) { - formatPrice(order.total - totalRefunded) + private suspend fun buildTotalsBreakdown( + order: Order, + refundInfo: RefundInfo + ): OrderDetailsViewState.Computed.Details.TotalsBreakdown { + val netPayment = if (refundInfo.totalRefunded > BigDecimal.ZERO) { + formatPrice(order.total - refundInfo.totalRefunded) } else { null } - val breakdown = OrderDetailsViewState.TotalsBreakdown( + val discountCode = order.couponLines.firstOrNull()?.code + + return OrderDetailsViewState.Computed.Details.TotalsBreakdown( products = formatPrice(order.productsTotal), - discount = order.discountTotal.takeIf { it != BigDecimal.ZERO }?.let { "-${formatPrice(it)}" }, + discount = order.discountTotal.takeIf { !it.isZero() }?.let { "-${formatPrice(it)}" }, discountCode = discountCode, taxes = formatPrice(order.totalTax), - shipping = order.shippingTotal.takeIf { it != BigDecimal.ZERO }?.let { formatPrice(it) }, - refunds = refundAmounts, + shipping = order.shippingTotal.takeIf { !it.isZero() }?.let { formatPrice(it) }, + refunds = refundInfo.refundAmounts, netPayment = netPayment ) - - return OrderDetailsViewState( - id = order.id, - number = "#${order.number}", - dateTime = order.dateCreated.formatToMMMddYYYYAtHHmm( - atWord = resourceProvider.getString(R.string.date_time_connector) - ), - customerEmail = order.customer?.email ?: order.billingAddress.email, - status = status, - lineItems = lineItems, - breakdown = breakdown, - total = formatPrice(order.total), - totalPaid = formatPrice(order.total), - paymentMethodTitle = order.paymentMethodTitle.takeIf { it.isNotBlank() } - ) } } @@ -549,3 +648,5 @@ private fun Order.Status.localizedLabel(resourceProvider: ResourceProvider, loca Order.Status.Refunded -> resourceProvider.getString(R.string.woopos_orders_status_refunded) } } + +private fun BigDecimal.isZero() = this.compareTo(BigDecimal.ZERO) == 0 diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 743b20876f9a..e54d64d21b98 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3823,13 +3823,14 @@ Totals %1$d x %2$s Products - Discount - Discount (%1$s) + Discount total + Discount total (%1$s) Taxes Shipping Total Total paid Refunded + Unable to load refund Net Payment Order products list Order totals breakdown diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderTestUtils.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderTestUtils.kt index a61b78f7285b..b4b1a7391b71 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderTestUtils.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/orders/OrderTestUtils.kt @@ -182,7 +182,7 @@ object OrderTestUtils { return result } - fun generateTestOrder(orderId: Long = 1): Order { + fun generateTestOrder(orderId: Long = 1, refundTotal: BigDecimal = -BigDecimal.TEN): Order { return Order.getEmptyOrder(Date(), Date()).copy( id = orderId, customer = Order.Customer( @@ -198,7 +198,7 @@ object OrderTestUtils { status = Order.Status.Pending, total = BigDecimal("106.00"), items = generateTestOrderItems(productId = 15), - refundTotal = -BigDecimal.TEN, + refundTotal = refundTotal, ) } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosGetOrderRefundsByOrderIdTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosGetOrderRefundsByOrderIdTest.kt deleted file mode 100644 index 54be743dfdc8..000000000000 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosGetOrderRefundsByOrderIdTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.woocommerce.android.ui.woopos.common.data - -import com.woocommerce.android.tools.SelectedSite -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.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.model.refunds.WCRefundModel -import org.wordpress.android.fluxc.store.WCRefundStore -import java.math.BigDecimal -import java.util.Date - -class WooPosGetOrderRefundsByOrderIdTest { - private lateinit var refundStore: WCRefundStore - private lateinit var selectedSite: SelectedSite - private lateinit var sut: WooPosGetOrderRefundsByOrderId - private lateinit var site: SiteModel - - @Before - fun setup() { - refundStore = mock() - selectedSite = mock() - site = mock() - - whenever(selectedSite.get()).thenReturn(site) - - sut = WooPosGetOrderRefundsByOrderId( - refundStore = refundStore, - selectedSite = selectedSite - ) - } - - @Test - fun `given refunds exist, when invoke called, then returns mapped refunds`() = runTest { - // GIVEN - val orderId = 123L - val fluxCRefunds = listOf( - WCRefundModel( - id = 1L, - dateCreated = Date(), - amount = BigDecimal.TEN, - reason = "Test refund", - automaticGatewayRefund = true, - items = emptyList(), - shippingLineItems = emptyList(), - feeLineItems = emptyList() - ), - WCRefundModel( - id = 2L, - dateCreated = Date(), - amount = BigDecimal.valueOf(5), - reason = "Another refund", - automaticGatewayRefund = false, - items = emptyList(), - shippingLineItems = emptyList(), - feeLineItems = emptyList() - ) - ) - whenever(refundStore.getAllRefunds(site, orderId)).thenReturn(fluxCRefunds) - - // WHEN - val result = sut.invoke(orderId) - - // THEN - assertThat(result).hasSize(2) - assertThat(result[0].id).isEqualTo(1L) - assertThat(result[0].amount).isEqualTo(BigDecimal.TEN) - assertThat(result[1].id).isEqualTo(2L) - assertThat(result[1].amount).isEqualTo(BigDecimal.valueOf(5)) - } - - @Test - fun `given no refunds exist, when invoke called, then returns empty list`() = runTest { - // GIVEN - val orderId = 123L - whenever(refundStore.getAllRefunds(site, orderId)).thenReturn(emptyList()) - - // WHEN - val result = sut.invoke(orderId) - - // THEN - assertThat(result).isEmpty() - } - - @Test - fun `given orderId provided, when invoke called, then passes correct orderId to store`() = runTest { - // GIVEN - val orderId = 456L - whenever(refundStore.getAllRefunds(any(), any())).thenReturn(emptyList()) - - // WHEN - sut.invoke(orderId) - - // THEN - verify(refundStore).getAllRefunds(site, orderId) - } -} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosRetrieveOrderRefundsTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosRetrieveOrderRefundsTest.kt new file mode 100644 index 000000000000..9026c4a5d5aa --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosRetrieveOrderRefundsTest.kt @@ -0,0 +1,166 @@ +package com.woocommerce.android.ui.woopos.common.data + +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.orders.OrderTestUtils +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.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.refunds.WCRefundModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooError +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.wc.WooResult +import org.wordpress.android.fluxc.store.WCRefundStore +import java.math.BigDecimal +import java.util.Date + +class WooPosRetrieveOrderRefundsTest { + private lateinit var refundStore: WCRefundStore + private lateinit var selectedSite: SelectedSite + private lateinit var sut: WooPosRetrieveOrderRefunds + private lateinit var site: SiteModel + + @Before + fun setup() { + refundStore = mock() + selectedSite = mock() + site = mock() + + whenever(selectedSite.get()).thenReturn(site) + + sut = WooPosRetrieveOrderRefunds( + refundStore = refundStore, + selectedSite = selectedSite + ) + } + + @Test + fun `given refunds exist locally, when invoke called, then returns mapped refunds`() = runTest { + // GIVEN + val order = OrderTestUtils.generateTestOrder(orderId = 123L) + + val fluxCRefunds = listOf( + WCRefundModel( + id = 1L, + dateCreated = Date(), + amount = BigDecimal.TEN, + reason = "Test refund", + automaticGatewayRefund = true, + items = emptyList(), + shippingLineItems = emptyList(), + feeLineItems = emptyList() + ), + WCRefundModel( + id = 2L, + dateCreated = Date(), + amount = BigDecimal.valueOf(5), + reason = "Another refund", + automaticGatewayRefund = false, + items = emptyList(), + shippingLineItems = emptyList(), + feeLineItems = emptyList() + ) + ) + whenever(refundStore.getAllRefunds(site, order.id)).thenReturn(fluxCRefunds) + + // WHEN + val result = sut.invoke(order) + + // THEN + assertThat(result.isSuccess).isTrue() + val refunds = result.getOrThrow() + assertThat(refunds).hasSize(2) + assertThat(refunds[0].id).isEqualTo(1L) + assertThat(refunds[0].amount).isEqualTo(BigDecimal.TEN) + assertThat(refunds[1].id).isEqualTo(2L) + assertThat(refunds[1].amount).isEqualTo(BigDecimal.valueOf(5)) + } + + @Test + fun `given no refunds exist locally, when invoke called, then fetches and returns mapped refunds`() = runTest { + // GIVEN + val order = OrderTestUtils.generateTestOrder(orderId = 123L) + + whenever(refundStore.getAllRefunds(site, order.id)).thenReturn(emptyList()) + + val remoteRefunds = listOf( + WCRefundModel( + id = 10L, + dateCreated = Date(), + amount = BigDecimal.ONE, + reason = "Remote refund", + automaticGatewayRefund = false, + items = emptyList(), + shippingLineItems = emptyList(), + feeLineItems = emptyList() + ) + ) + whenever(refundStore.fetchAllRefunds(site, order.id)).thenReturn( + WooResult(model = remoteRefunds) + ) + + // WHEN + val result = sut.invoke(order) + + // THEN + assertThat(result.isSuccess).isTrue() + val refunds = result.getOrThrow() + assertThat(refunds).hasSize(1) + assertThat(refunds[0].id).isEqualTo(10L) + assertThat(refunds[0].amount).isEqualTo(BigDecimal.ONE) + verify(refundStore).getAllRefunds(site, order.id) + verify(refundStore).fetchAllRefunds(site, order.id) + } + + @Test + fun `given order refundTotal is zero, when invoke called, then returns empty list and does not hit store`() = runTest { + // GIVEN + val order = OrderTestUtils.generateTestOrder(orderId = 999L, refundTotal = BigDecimal.ZERO) + + // WHEN + val result = sut.invoke(order) + + // THEN + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrThrow()).isEmpty() + verify(refundStore, org.mockito.kotlin.never()).getAllRefunds(any(), any()) + } + + @Test + fun `given order provided, when invoke called, then passes correct orderId to store`() = runTest { + // GIVEN + val order = OrderTestUtils.generateTestOrder(orderId = 456L, refundTotal = BigDecimal.ONE) + whenever(refundStore.getAllRefunds(any(), any())).thenReturn(emptyList()) + whenever(refundStore.fetchAllRefunds(any(), any(), any(), any())).thenReturn( + WooResult(emptyList()) + ) + + // WHEN + sut.invoke(order) + + // THEN + verify(refundStore).getAllRefunds(site, order.id) + } + + @Test + fun `given fetch refunds fails, when invoke called, then returns failure result`() = runTest { + // GIVEN + val order = OrderTestUtils.generateTestOrder(orderId = 789L, refundTotal = BigDecimal.ONE) + whenever(refundStore.getAllRefunds(site, order.id)).thenReturn(emptyList()) + whenever(refundStore.fetchAllRefunds(site, order.id)).thenReturn( + WooResult(WooError(GENERIC_ERROR, UNKNOWN)) + ) + + // WHEN + val result = sut.invoke(order) + + // THEN + assertThat(result.isFailure).isTrue() + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt index 591c26a757bd..7f77a482eefb 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt @@ -1,13 +1,17 @@ package com.woocommerce.android.ui.woopos.orders +import com.woocommerce.android.model.Order import com.woocommerce.android.model.OrderMapper import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.orders.OrderTestUtils +import com.woocommerce.android.ui.woopos.common.data.WooPosRetrieveOrderRefunds import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat +import org.junit.Before import org.junit.Rule import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -37,24 +41,35 @@ class WooPosOrdersDataSourceTest { private val selectedSite: SelectedSite = mock { on { get() }.thenReturn(siteModel) } private val orderMapper: OrderMapper = mock() private val ordersCache: WooPosOrdersInMemoryCache = mock() + private val retrieveOrderRefunds: WooPosRetrieveOrderRefunds = mock() private val sut = WooPosOrdersDataSource( restClient = orderRestClient, selectedSite = selectedSite, orderMapper = orderMapper, - ordersCache = ordersCache + ordersCache = ordersCache, + retrieveOrderRefunds = retrieveOrderRefunds ) + @Before + fun setUp() { + runBlocking { + whenever(retrieveOrderRefunds.invoke(any())).thenReturn(Result.success(emptyList())) + } + } + @Test fun `when cache has data and fetch succeeds, then emit SuccessCache then SuccessRemote and store mapped in cache`() = runTest { - val cachedOrder = OrderTestUtils.generateTestOrder() + // GIVEN + val cachedOrder = OrderTestUtils.generateTestOrder(orderId = 1L) whenever(ordersCache.getAll()).thenReturn(listOf(cachedOrder)) - val e1 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 11) - val e2 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 22) + val e1 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 11L) + val e2 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 22L) val entities = listOf(e1 to emptyList(), e2 to emptyList()) - val mapped1 = OrderTestUtils.generateTestOrder() - val mapped2 = OrderTestUtils.generateTestOrder() + + val mapped1 = OrderTestUtils.generateTestOrder(orderId = 11L) + val mapped2 = OrderTestUtils.generateTestOrder(orderId = 22L) whenever(orderMapper.toAppModel(e1)).thenReturn(mapped1) whenever(orderMapper.toAppModel(e2)).thenReturn(mapped2) @@ -82,10 +97,10 @@ class WooPosOrdersDataSourceTest { assertThat(emissions).hasSize(2) val first = emissions[0] as LoadOrdersResult.SuccessCache - assertThat(first.orders).containsExactly(cachedOrder) + assertThat(first.ordersWithRefunds.keys).containsExactly(cachedOrder) val second = emissions[1] as LoadOrdersResult.SuccessRemote - assertThat(second.orders).containsExactly(mapped1, mapped2) + assertThat(second.ordersWithRefunds.keys).containsExactly(mapped1, mapped2) verify(selectedSite).get() verify(ordersCache).getAll() @@ -135,7 +150,7 @@ class WooPosOrdersDataSourceTest { assertThat(emissions).hasSize(2) val first = emissions[0] as LoadOrdersResult.SuccessCache - assertThat(first.orders).containsExactly(cachedOrder) + assertThat(first.ordersWithRefunds.keys).containsExactly(cachedOrder) val second = emissions[1] as LoadOrdersResult.Error assertThat(second.message).isEqualTo("[GENERIC_ERROR] generic error") @@ -180,7 +195,7 @@ class WooPosOrdersDataSourceTest { assertThat(emissions).hasSize(1) val first = emissions[0] as LoadOrdersResult.SuccessRemote - assertThat(first.orders).isEmpty() + assertThat(first.ordersWithRefunds).isEmpty() verify(selectedSite).get() verify(ordersCache).getAll() @@ -204,8 +219,9 @@ class WooPosOrdersDataSourceTest { val e1 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 11) val e2 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 22) val entities = listOf(e1 to emptyList(), e2 to emptyList()) - val mapped1 = OrderTestUtils.generateTestOrder() - val mapped2 = OrderTestUtils.generateTestOrder() + + val mapped1 = OrderTestUtils.generateTestOrder(orderId = 11L) + val mapped2 = OrderTestUtils.generateTestOrder(orderId = 22L) whenever(orderMapper.toAppModel(e1)).thenReturn(mapped1) whenever(orderMapper.toAppModel(e2)).thenReturn(mapped2) @@ -232,7 +248,7 @@ class WooPosOrdersDataSourceTest { // THEN assertThat(result).isInstanceOf(SearchOrdersResult.Success::class.java) val success = result as SearchOrdersResult.Success - assertThat(success.orders).containsExactly(mapped1, mapped2) + assertThat(success.ordersWithRefunds.keys).containsExactly(mapped1, mapped2) verify(orderRestClient).fetchOrders( site = siteModel, @@ -307,7 +323,7 @@ class WooPosOrdersDataSourceTest { // THEN assertThat(result).isInstanceOf(SearchOrdersResult.Success::class.java) val success = result as SearchOrdersResult.Success - assertThat(success.orders).isEmpty() + assertThat(success.ordersWithRefunds).isEmpty() } @Test @@ -329,7 +345,7 @@ class WooPosOrdersDataSourceTest { val emissions = sut.loadOrders().toList(mutableListOf()) val remote = emissions.last() as LoadOrdersResult.SuccessRemote - assertThat(remote.orders.map { it.id }).containsExactly(1L, 2L) + assertThat(remote.ordersWithRefunds.keys.map { it.id }).containsExactly(1L, 2L) assertThat(sut.hasMorePages).isTrue } @@ -362,7 +378,8 @@ class WooPosOrdersDataSourceTest { val result = sut.loadMore() assertThat(result.isSuccess).isTrue - assertThat(result.getOrThrow().map { it.id }).containsExactly(3L, 4L) + val ordersWithRefunds = result.getOrThrow() + assertThat(ordersWithRefunds.keys.map { it.id }).containsExactly(3L, 4L) assertThat(sut.hasMorePages).isFalse } @@ -456,7 +473,8 @@ class WooPosOrdersDataSourceTest { // THEN assertThat(result.isSuccess).isTrue - assertThat(result.getOrThrow().map { it.id }).containsExactly(33L, 44L) + val ordersWithRefunds = result.getOrThrow() + assertThat(ordersWithRefunds.keys.map { it.id }).containsExactly(33L, 44L) assertThat(sut.hasMorePages).isFalse verify(orderRestClient).fetchOrders( site = siteModel, diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt index f75171de2def..d0d6172b3f6d 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt @@ -6,8 +6,8 @@ import com.woocommerce.android.model.Refund import com.woocommerce.android.ui.orders.OrderTestUtils import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchInputState import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchUIEvent -import com.woocommerce.android.ui.woopos.common.data.WooPosGetOrderRefundsByOrderId import com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById +import com.woocommerce.android.ui.woopos.common.data.WooPosRetrieveOrderRefunds import com.woocommerce.android.ui.woopos.home.ChildToParentEvent.NavigationEvent.ToEmailReceipt import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.items.WooPosPaginationState @@ -16,6 +16,7 @@ import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule import com.woocommerce.android.ui.woopos.util.format.WooPosFormatPrice import com.woocommerce.android.viewmodel.ResourceProvider import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle @@ -24,9 +25,9 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mockito.mock import org.mockito.kotlin.any 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 @@ -41,18 +42,21 @@ class WooPosOrdersViewModelTest { val coroutineTestRule = WooPosCoroutineTestRule() private val dataSource: WooPosOrdersDataSource = mock() - private lateinit var viewModel: WooPosOrdersViewModel - private fun order(id: Long = 1L): Order = OrderTestUtils.generateTestOrder(orderId = id) private val resourceProvider: ResourceProvider = mock() private val getProductById: WooPosGetProductById = mock() private val formatPrice: WooPosFormatPrice = mock() - private val getOrderRefunds: WooPosGetOrderRefundsByOrderId = mock() + private val retrieveOrderRefunds: WooPosRetrieveOrderRefunds = mock() private val providedLocale: Locale = Locale.US private val childrenToParentEventSender: WooPosChildrenToParentEventSender = mock() private val ordersAnalyticsTracker: WooPosOrdersAnalyticsTracker = mock() + private fun order(id: Long = 1L): Order = OrderTestUtils.generateTestOrder(orderId = id) + + private fun ordersMap(vararg orders: Order): Map = + orders.associateWith { RefundFetchResult.Success(emptyList()) } + private fun createViewModel(): WooPosOrdersViewModel { return WooPosOrdersViewModel( ordersDataSource = dataSource, @@ -61,7 +65,7 @@ class WooPosOrdersViewModelTest { getProductById = getProductById, childrenToParentEventSender = childrenToParentEventSender, formatPrice = formatPrice, - getOrderRefunds = getOrderRefunds, + retrieveOrderRefunds = retrieveOrderRefunds, ordersAnalyticsTracker = ordersAnalyticsTracker ) } @@ -72,10 +76,14 @@ class WooPosOrdersViewModelTest { runBlocking { whenever(formatPrice.invoke(any())).thenReturn("$0.00") + whenever(getProductById.invoke(any())).thenReturn(null) + whenever(retrieveOrderRefunds.invoke(any())).thenReturn(Result.success(emptyList())) } whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessCache(listOf(order(1), order(2)))) } + flow { + emit(LoadOrdersResult.SuccessCache(ordersMap(order(1), order(2)))) + } ) whenever(resourceProvider.getString(R.string.woopos_orders_status_auto_draft)).thenReturn("Draft") @@ -87,11 +95,6 @@ class WooPosOrdersViewModelTest { whenever(resourceProvider.getString(R.string.woopos_orders_status_completed)).thenReturn("Completed") whenever(resourceProvider.getString(R.string.woopos_orders_status_refunded)).thenReturn("Refunded") whenever(resourceProvider.getString(R.string.woopos_search_orders)).thenReturn("Search orders") - - runBlocking { - whenever(getProductById.invoke(any())).thenReturn(null) - whenever(getOrderRefunds.invoke(any())).thenReturn(emptyList()) - } } @Test @@ -101,8 +104,8 @@ class WooPosOrdersViewModelTest { val network = listOf(order(2), order(3)) whenever(dataSource.loadOrders()).thenReturn( flow { - emit(LoadOrdersResult.SuccessCache(cached)) - emit(LoadOrdersResult.SuccessRemote(network)) + emit(LoadOrdersResult.SuccessCache(ordersMap(*cached.toTypedArray()))) + emit(LoadOrdersResult.SuccessRemote(ordersMap(*network.toTypedArray()))) } ) @@ -128,8 +131,8 @@ class WooPosOrdersViewModelTest { val network = listOf(order(10)) whenever(dataSource.loadOrders()).thenReturn( flow { - emit(LoadOrdersResult.SuccessCache(emptyList())) - emit(LoadOrdersResult.SuccessRemote(network)) + emit(LoadOrdersResult.SuccessCache(emptyMap())) + emit(LoadOrdersResult.SuccessRemote(ordersMap(*network.toTypedArray()))) } ) @@ -139,11 +142,9 @@ class WooPosOrdersViewModelTest { // THEN val state = viewModel.state.value - assertThat(state).isInstanceOf(WooPosOrdersState.Content::class.java) val content = state as WooPosOrdersState.Content val loadedItems = content.items as WooPosOrdersState.Content.Items.Loaded assertThat(loadedItems.items.keys.map { it.id }).containsExactly(10L) - assertThat(content.pullToRefreshState).isEqualTo(WooPosPullToRefreshState.Enabled) assertThat(content.selectedDetails.id).isEqualTo(10L) } @@ -152,8 +153,8 @@ class WooPosOrdersViewModelTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( flow { - emit(LoadOrdersResult.SuccessCache(emptyList())) - emit(LoadOrdersResult.SuccessRemote(emptyList())) + emit(LoadOrdersResult.SuccessCache(emptyMap())) + emit(LoadOrdersResult.SuccessRemote(emptyMap())) } ) @@ -188,7 +189,7 @@ class WooPosOrdersViewModelTest { fun `given initial content, when refresh, then clear cache and update with network result`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1)))) } ) viewModel = createViewModel() advanceUntilIdle() @@ -198,8 +199,8 @@ class WooPosOrdersViewModelTest { whenever(dataSource.loadOrders()).thenReturn( flow { - emit(LoadOrdersResult.SuccessCache(emptyList())) - emit(LoadOrdersResult.SuccessRemote(listOf(order(5), order(6)))) + emit(LoadOrdersResult.SuccessCache(emptyMap())) + emit(LoadOrdersResult.SuccessRemote(ordersMap(order(5), order(6)))) } ) @@ -220,7 +221,7 @@ class WooPosOrdersViewModelTest { fun `given orders loaded, when selecting an order, then selected id, flags and details update`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2), order(3)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1), order(2), order(3)))) } ) // WHEN @@ -243,7 +244,7 @@ class WooPosOrdersViewModelTest { fun `given selection removed after reload, when refreshing, then first item is auto selected with details`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(100), order(200)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(100), order(200)))) } ) viewModel = createViewModel() advanceUntilIdle() @@ -252,8 +253,8 @@ class WooPosOrdersViewModelTest { whenever(dataSource.loadOrders()).thenReturn( flow { - emit(LoadOrdersResult.SuccessCache(emptyList())) - emit(LoadOrdersResult.SuccessRemote(listOf(order(300), order(400)))) + emit(LoadOrdersResult.SuccessCache(emptyMap())) + emit(LoadOrdersResult.SuccessRemote(ordersMap(order(300), order(400)))) } ) @@ -265,7 +266,6 @@ class WooPosOrdersViewModelTest { val state = viewModel.state.value as WooPosOrdersState.Content val loadedItems = state.items as WooPosOrdersState.Content.Items.Loaded assertThat(loadedItems.items.keys.map { it.id }).containsExactly(300L, 400L) - // first item should be auto-selected after reload assertThat(state.selectedDetails.id).isEqualTo(300L) } @@ -283,7 +283,6 @@ class WooPosOrdersViewModelTest { val state = viewModel.state.value as WooPosOrdersState.Content assertThat(state.searchInputState).isInstanceOf(WooPosSearchInputState.Open::class.java) val openState = state.searchInputState as WooPosSearchInputState.Open - assertThat(openState.input).isInstanceOf(WooPosSearchInputState.Open.Input.Hint::class.java) val hint = openState.input as WooPosSearchInputState.Open.Input.Hint assertThat(hint.hint).isEqualTo("Search orders") assertThat(openState.requestFocus).isTrue() @@ -295,7 +294,12 @@ class WooPosOrdersViewModelTest { // GIVEN val query = "test query" val searchResult = listOf(order(10), order(20)) - whenever(dataSource.searchOrders(query)).thenReturn(SearchOrdersResult.Success(searchResult)) + whenever(dataSource.searchOrders(query)).thenReturn( + SearchOrdersResult.Success(ordersMap(*searchResult.toTypedArray())) + ) + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1)))) } + ) viewModel = createViewModel() advanceUntilIdle() @@ -337,6 +341,9 @@ class WooPosOrdersViewModelTest { .thenReturn("Unable to load orders") whenever(resourceProvider.getString(R.string.woopos_search_orders_error_description)) .thenReturn("Please try again.") + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1)))) } + ) viewModel = createViewModel() advanceUntilIdle() @@ -363,10 +370,12 @@ class WooPosOrdersViewModelTest { fun `given more pages, when end reached and loadMore succeeds, then items append and pagination None`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1), order(2)))) } ) whenever(dataSource.hasMorePages).thenReturn(true) - whenever(dataSource.loadMore()).thenReturn(Result.success(listOf(order(3), order(4)))) + whenever(dataSource.loadMore()).thenReturn( + Result.success(ordersMap(order(3), order(4))) + ) // WHEN viewModel = createViewModel() @@ -386,7 +395,7 @@ class WooPosOrdersViewModelTest { fun `given more pages, when end reached and loadMore fails, then keep items and show pagination error`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1), order(2)))) } ) whenever(dataSource.hasMorePages).thenReturn(true) whenever(dataSource.loadMore()).thenReturn(Result.failure(RuntimeException("boom"))) @@ -409,8 +418,8 @@ class WooPosOrdersViewModelTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( flow { - emit(LoadOrdersResult.SuccessCache(listOf(order(1)))) - kotlinx.coroutines.delay(Long.MAX_VALUE) + emit(LoadOrdersResult.SuccessCache(ordersMap(order(1)))) + delay(Long.MAX_VALUE) } ) @@ -430,10 +439,12 @@ class WooPosOrdersViewModelTest { fun `given selected order, when appending next page, then selection and details are preserved`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(10), order(20)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(10), order(20)))) } ) whenever(dataSource.hasMorePages).thenReturn(true) - whenever(dataSource.loadMore()).thenReturn(Result.success(listOf(order(30), order(40)))) + whenever(dataSource.loadMore()).thenReturn( + Result.success(ordersMap(order(30), order(40))) + ) // WHEN viewModel = createViewModel() @@ -456,7 +467,7 @@ class WooPosOrdersViewModelTest { fun `given pagination error, when try again succeeds, then append next page and clear error`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1), order(2)))) } ) whenever(dataSource.hasMorePages).thenReturn(true) whenever(dataSource.loadMore()).thenReturn(Result.failure(RuntimeException("boom"))) @@ -468,14 +479,14 @@ class WooPosOrdersViewModelTest { viewModel.onEndOfOrdersListReached() advanceUntilIdle() - // THEN (pagination shows error) + // THEN var content = viewModel.state.value as WooPosOrdersState.Content var loadedItems = content.items as WooPosOrdersState.Content.Items.Loaded assertThat(loadedItems.items.keys.map { it.id }).containsExactly(1L, 2L) assertThat(content.paginationState).isEqualTo(WooPosPaginationState.Error) - // GIVEN (next call will succeed) - whenever(dataSource.loadMore()).thenReturn(Result.success(listOf(order(3), order(4)))) + // GIVEN + whenever(dataSource.loadMore()).thenReturn(Result.success(ordersMap(order(3), order(4)))) // WHEN viewModel.onPaginationErrorTryAgain() @@ -496,7 +507,7 @@ class WooPosOrdersViewModelTest { val base = OrderTestUtils.generateTestOrder(orderId = 42L) val withOnHold = base.copy(status = Order.Status.OnHold) whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(withOnHold))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(withOnHold))) } ) // WHEN @@ -516,7 +527,7 @@ class WooPosOrdersViewModelTest { val base = OrderTestUtils.generateTestOrder(orderId = 77L) val custom = base.copy(status = Order.Status.Custom("awaiting-payment")) whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(custom))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(custom))) } ) // WHEN @@ -535,7 +546,7 @@ class WooPosOrdersViewModelTest { // GIVEN val one = OrderTestUtils.generateTestOrder(orderId = 11L) whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(one))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(one))) } ) // WHEN @@ -553,7 +564,7 @@ class WooPosOrdersViewModelTest { fun `given orders loaded, when selecting an order, then selectedOrderDetails is populated`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1), order(2)))) } ) // WHEN @@ -565,7 +576,6 @@ class WooPosOrdersViewModelTest { // THEN val content = viewModel.state.value as WooPosOrdersState.Content val loadedItems = content.items as WooPosOrdersState.Content.Items.Loaded - // selected item should be 2, and selectedDetails should match val selectedItemId = loadedItems.items.keys.single { it.isSelected }.id assertThat(selectedItemId).isEqualTo(2L) assertThat(content.selectedDetails.id).isEqualTo(2L) @@ -577,10 +587,10 @@ class WooPosOrdersViewModelTest { fun `given selected order, when appending next page, then selectedOrderDetails content remains correct`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(10), order(20)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(10), order(20)))) } ) whenever(dataSource.hasMorePages).thenReturn(true) - whenever(dataSource.loadMore()).thenReturn(Result.success(listOf(order(30), order(40)))) + whenever(dataSource.loadMore()).thenReturn(Result.success(ordersMap(order(30), order(40)))) // WHEN viewModel = createViewModel() @@ -603,12 +613,12 @@ class WooPosOrdersViewModelTest { fun `given orders reloaded, when refreshing, then first order details are auto-populated`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(100), order(200)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(100), order(200)))) } ) viewModel = createViewModel() advanceUntilIdle() whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(300), order(400)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(300), order(400)))) } ) // WHEN @@ -627,7 +637,7 @@ class WooPosOrdersViewModelTest { fun `given content loaded, when onEmailReceiptButtonClicked called, then ToEmailReceipt is sent`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(123)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(123)))) } ) viewModel = createViewModel() advanceUntilIdle() @@ -645,10 +655,10 @@ class WooPosOrdersViewModelTest { fun `given order with no refunds, when mapped, then breakdown has empty refunds and null net payment`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1)))) } ) runBlocking { - whenever(getOrderRefunds.invoke(1L)).thenReturn(emptyList()) + whenever(retrieveOrderRefunds.invoke(order(1))).thenReturn(Result.success(emptyList())) } // WHEN @@ -665,9 +675,6 @@ class WooPosOrdersViewModelTest { fun `given order with refunds, when mapped, then breakdown includes refund amounts and net payment`() = runTest { // GIVEN val testOrder = order(1) - whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(testOrder))) } - ) val refund1 = Refund( id = 1, @@ -691,11 +698,22 @@ class WooPosOrdersViewModelTest { ) runBlocking { - whenever(getOrderRefunds.invoke(1L)).thenReturn(listOf(refund1, refund2)) - whenever(formatPrice.invoke(java.math.BigDecimal("10.00"))).thenReturn("$10.00") - whenever(formatPrice.invoke(java.math.BigDecimal("5.00"))).thenReturn("$5.00") + whenever(formatPrice.invoke(BigDecimal("10.00"))).thenReturn("$10.00") + whenever(formatPrice.invoke(BigDecimal("5.00"))).thenReturn("$5.00") } + whenever(dataSource.loadOrders()).thenReturn( + flow { + emit( + LoadOrdersResult.SuccessRemote( + mapOf( + testOrder to RefundFetchResult.Success(listOf(refund1, refund2)) + ) + ) + ) + } + ) + // WHEN viewModel = createViewModel() advanceUntilIdle() @@ -710,7 +728,7 @@ class WooPosOrdersViewModelTest { fun `given selected order, when onBackFromSuccessfullySendingEmailReceipt succeeds, then refreshes details`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(100), order(200)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(100), order(200)))) } ) viewModel = createViewModel() advanceUntilIdle() @@ -726,7 +744,6 @@ class WooPosOrdersViewModelTest { // THEN val state = viewModel.state.value as WooPosOrdersState.Content - assertThat(state.selectedDetails.id).isEqualTo(200L) verify(dataSource).refreshOrderById(200L) } @@ -735,7 +752,7 @@ class WooPosOrdersViewModelTest { fun `given selected order, when onBackFromSuccessfullySendingEmailReceipt fails, then details remain unchanged`() = runTest { // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1), order(2)))) } ) viewModel = createViewModel() advanceUntilIdle() @@ -753,22 +770,24 @@ class WooPosOrdersViewModelTest { // THEN val after = viewModel.state.value as WooPosOrdersState.Content - assertThat(after.selectedDetails).isEqualTo(beforeDetails) verify(dataSource).refreshOrderById(1L) } @Test fun `given orders loaded, when selecting an order, then tracks OrdersListRowTapped and OrderDetailsLoaded`() = runTest { + // GIVEN whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2), order(3)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1), order(2), order(3)))) } ) viewModel = createViewModel() advanceUntilIdle() + // WHEN viewModel.onOrderSelected(3L) advanceUntilIdle() + // THEN verify(ordersAnalyticsTracker).trackOrdersListRowTapped( orderId = eq(3L), orderStatus = any(), @@ -785,36 +804,97 @@ class WooPosOrdersViewModelTest { @Test fun `when remote load succeeds, then tracks OrdersListFetched`() = runTest { + // GIVEN val cached = listOf(order(1)) val remote = listOf(order(2), order(3)) whenever(dataSource.loadOrders()).thenReturn( flow { - emit(LoadOrdersResult.SuccessCache(cached)) - emit(LoadOrdersResult.SuccessRemote(remote)) + emit(LoadOrdersResult.SuccessCache(ordersMap(*cached.toTypedArray()))) + emit(LoadOrdersResult.SuccessRemote(ordersMap(*remote.toTypedArray()))) } ) + // WHEN viewModel = createViewModel() advanceUntilIdle() + // THEN verify(ordersAnalyticsTracker).trackOrdersListFetched(any()) } @Test fun `when search succeeds, then tracks OrdersListSearchResultsFetched`() = runTest { + // GIVEN val query = "abc" val searchResult = listOf(order(10), order(20)) whenever(dataSource.loadOrders()).thenReturn( - flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1)))) } + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(order(1)))) } + ) + whenever(dataSource.searchOrders(query)).thenReturn( + SearchOrdersResult.Success(ordersMap(*searchResult.toTypedArray())) ) - whenever(dataSource.searchOrders(query)).thenReturn(SearchOrdersResult.Success(searchResult)) viewModel = createViewModel() advanceUntilIdle() + // WHEN viewModel.onSearchEvent(WooPosSearchUIEvent.Search(query, query.length)) advanceUntilIdle() + // THEN verify(ordersAnalyticsTracker).trackOrdersListSearchResultsFetched(any()) } + + @Test + fun `given order with zero discount and zero shipping, when mapped, then discount and shipping are absent`() = runTest { + // GIVEN + val base = order(1) + val withZeros = base.copy( + discountTotal = BigDecimal("0.00"), + shippingTotal = BigDecimal("0.00") + ) + + // WHEN + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(withZeros))) } + ) + + viewModel = createViewModel() + advanceUntilIdle() + + // THEN + val content = viewModel.state.value as WooPosOrdersState.Content + val breakdown = content.selectedDetails.breakdown + assertThat(breakdown.discount).isNull() + assertThat(breakdown.shipping).isNull() + } + + @Test + fun `given order with non-zero discount and shipping, when mapped, then discount and shipping are formatted`() = runTest { + // GIVEN + val base = order(2) + val withValues = base.copy( + discountTotal = BigDecimal("3.50"), + shippingTotal = BigDecimal("4.00") + ) + + // WHEN + runBlocking { + whenever(formatPrice.invoke(BigDecimal("3.50"))).thenReturn("$3.50") + whenever(formatPrice.invoke(BigDecimal("4.00"))).thenReturn("$4.00") + } + + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(ordersMap(withValues))) } + ) + + viewModel = createViewModel() + advanceUntilIdle() + + // THEN + val content = viewModel.state.value as WooPosOrdersState.Content + val breakdown = content.selectedDetails.breakdown + assertThat(breakdown.discount).isEqualTo("-$3.50") + assertThat(breakdown.shipping).isEqualTo("$4.00") + } }