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 d9017310ccef..6bbe44f985f0 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 @@ -3,6 +3,8 @@ 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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow 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.persistence.entity.OrderEntity @@ -17,11 +19,18 @@ class WooPosOrdersDataSource @Inject constructor( private val restClient: OrderRestClient, private val selectedSite: SelectedSite, private val orderMapper: OrderMapper, + private val ordersCache: WooPosOrdersInMemoryCache ) { - suspend fun loadOrders(): LoadOrdersResult { + companion object { + const val POS_ORDERS_PAGE_SIZE = 25 + } + fun loadOrders(): Flow = flow { + val cached = ordersCache.getAll() + emit(LoadOrdersResult.Success(cached)) + val result = restClient.fetchOrders( site = selectedSite.get(), - count = 25, + count = POS_ORDERS_PAGE_SIZE, page = 1, orderBy = OrderBy.DATE, sortOrder = OrderRestClient.SortOrder.DESCENDING, @@ -29,14 +38,16 @@ class WooPosOrdersDataSource @Inject constructor( createdVia = "pos-rest-api" ) - return if (result.isError) { - LoadOrdersResult.Error(result.error.message) + if (result.isError) { + emit(LoadOrdersResult.Error(result.error.message)) } else { - LoadOrdersResult.Success(result.orders.toAppModels()) + val mapped = result.orders.toAppModels() + ordersCache.setAll(mapped) + emit(LoadOrdersResult.Success(result.orders.toAppModels())) } } private suspend fun List.toAppModels(): List = map { orderMapper.toAppModel(it) - } + } ?: emptyList() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersInMemoryCache.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersInMemoryCache.kt new file mode 100644 index 000000000000..cfda1cb0b832 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersInMemoryCache.kt @@ -0,0 +1,21 @@ +package com.woocommerce.android.ui.woopos.orders + +import com.woocommerce.android.model.Order +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WooPosOrdersInMemoryCache @Inject constructor() { + private val ordersCache = AtomicReference>(emptyList()) + + fun setAll(orders: List) { + ordersCache.set(orders.toList()) + } + + fun getAll(): List = ordersCache.get() + + fun clear() { + ordersCache.set(emptyList()) + } +} 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 310b1b02a42f..5a8d12c7bf3f 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 @@ -30,25 +30,28 @@ class WooPosOrdersViewModel @Inject constructor( viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } - when (val result = ordersDataSource.loadOrders()) { - is LoadOrdersResult.Error -> { - _state.update { - it.copy( - isLoading = false, - error = result.message ?: "Unknown error" - ) + ordersDataSource.loadOrders().collect { result -> + when (result) { + is LoadOrdersResult.Error -> { + _state.update { + it.copy( + isLoading = false, + error = result.message + ) + } } - } - is LoadOrdersResult.Success -> { - val list = result.orders - _state.update { prev -> - prev.copy( - isLoading = false, - orders = list, - selectedOrderId = prev.selectedOrderId?.takeIf { id -> - list.any { o -> o.id == id } - } ?: list.firstOrNull()?.id - ) + + is LoadOrdersResult.Success -> { + val list = result.orders + _state.update { prev -> + prev.copy( + isLoading = false, + orders = list, + selectedOrderId = prev.selectedOrderId?.takeIf { id -> + list.any { o -> o.id == id } + } ?: list.firstOrNull()?.id + ) + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt index cc5ab787c2fc..a23c099e7e8f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.woocommerce.android.ui.woopos.common.data.WooPosPopularProductsProvider import com.woocommerce.android.ui.woopos.home.items.products.WooPosProductsDataSource +import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.Loaded @@ -22,6 +23,7 @@ class WooPosSplashViewModel @Inject constructor( private val popularProductsProvider: WooPosPopularProductsProvider, private val analyticsTracker: WooPosAnalyticsTracker, private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab, + private val ordersCache: WooPosOrdersInMemoryCache ) : ViewModel() { private val _state = MutableStateFlow(WooPosSplashState.Loading) val state: StateFlow = _state @@ -38,7 +40,8 @@ class WooPosSplashViewModel @Inject constructor( joinAll( launch { productsDataSource.prepopulateProductsCache() }, - launch { popularProductsProvider.fetchAndCachePopularProducts() } + launch { popularProductsProvider.fetchAndCachePopularProducts() }, + launch { ordersCache.clear() } ) _state.value = WooPosSplashState.Loaded trackPosLoaded(splashScreenStartTime) diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosOrdersInMemoryCacheTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosOrdersInMemoryCacheTest.kt new file mode 100644 index 000000000000..c7956970f92d --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/common/data/WooPosOrdersInMemoryCacheTest.kt @@ -0,0 +1,83 @@ +package com.woocommerce.android.ui.woopos.common.data + +import com.woocommerce.android.ui.orders.OrderTestUtils +import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class WooPosOrdersInMemoryCacheTest { + + private lateinit var cache: WooPosOrdersInMemoryCache + + @Before + fun setup() { + cache = WooPosOrdersInMemoryCache() + } + + @Test + fun `when cache is empty, then getAll returns empty list`() { + // WHEN + val result = cache.getAll() + + // THEN + assertThat(result).isEmpty() + } + + @Test + fun `when setAll is called, then getAll returns the same elements`() { + // GIVEN + val orders = listOf( + OrderTestUtils.generateTestOrder(1), + OrderTestUtils.generateTestOrder(2) + ) + + // WHEN + cache.setAll(orders) + val result = cache.getAll() + + // THEN + assertThat(result).containsExactlyElementsOf(orders) + } + + @Test + fun `when setAll is called twice, then last write wins`() { + // GIVEN + val first = listOf( + OrderTestUtils.generateTestOrder(1), + OrderTestUtils.generateTestOrder(2) + ) + val second = listOf( + OrderTestUtils.generateTestOrder(3), + OrderTestUtils.generateTestOrder(4), + OrderTestUtils.generateTestOrder(5) + ) + + // WHEN + cache.setAll(first) + cache.setAll(second) + val result = cache.getAll() + + // THEN + assertThat(result).containsExactlyElementsOf(second) + } + + @Test + fun `when cache is cleared, then getAll returns empty list`() { + // GIVEN + val orders = listOf( + OrderTestUtils.generateTestOrder(1), + OrderTestUtils.generateTestOrder(2) + ) + cache.setAll(orders) + + // WHEN + cache.clear() + val result = cache.getAll() + + // THEN + assertThat(result).isEmpty() + } +} 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 42d466f48440..8e30dbe90fa9 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 @@ -5,6 +5,7 @@ import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.orders.OrderTestUtils import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Rule @@ -12,6 +13,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.fluxc.model.LocalOrRemoteId @@ -33,25 +35,35 @@ class WooPosOrdersDataSourceTest { private val siteModel: SiteModel = mock() private val selectedSite: SelectedSite = mock { on { get() }.thenReturn(siteModel) } private val orderMapper: OrderMapper = mock() + private val ordersCache: WooPosOrdersInMemoryCache = mock() private val sut = WooPosOrdersDataSource( restClient = orderRestClient, selectedSite = selectedSite, - orderMapper = orderMapper + orderMapper = orderMapper, + ordersCache = ordersCache ) @Test - fun `given rest client returns entities, when loadOrders called, then should map them to app models`() = runTest { + fun `given cache and successful fetch, when loadOrders collected, then emits cache first then mapped network and stores in cache`() = runTest { // GIVEN + val cachedOrder = OrderTestUtils.generateTestOrder() + whenever(ordersCache.getAll()).thenReturn(listOf(cachedOrder)) + + // Network returns two entities val e1 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 1) val e2 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 2) val entities = listOf( e1 to emptyList(), e2 to emptyList() ) + val firstOrder = OrderTestUtils.generateTestOrder() val secondOrder = OrderTestUtils.generateTestOrder() + whenever(orderMapper.toAppModel(e1)).thenReturn(firstOrder) + whenever(orderMapper.toAppModel(e2)).thenReturn(secondOrder) + val payload = WCOrderStore.FetchOrdersResponsePayload( site = siteModel, ordersWithMeta = entities @@ -69,18 +81,24 @@ class WooPosOrdersDataSourceTest { ) ).thenReturn(payload) - whenever(orderMapper.toAppModel(e1)).thenReturn(firstOrder) - whenever(orderMapper.toAppModel(e2)).thenReturn(secondOrder) - // WHEN - val result = sut.loadOrders() + val emissions = sut.loadOrders().toList(mutableListOf()) // THEN - assertThat(result).isInstanceOf(LoadOrdersResult.Success::class.java) - val success = result as LoadOrdersResult.Success - assertThat(success.orders).containsExactly(firstOrder, secondOrder) + assertThat(emissions).hasSize(2) + // First emission = cache + val first = emissions[0] + assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java) + assertThat((first as LoadOrdersResult.Success).orders).containsExactly(cachedOrder) + + // Second emission = network mapped + val second = emissions[1] + assertThat(second).isInstanceOf(LoadOrdersResult.Success::class.java) + assertThat((second as LoadOrdersResult.Success).orders).containsExactly(firstOrder, secondOrder) verify(selectedSite).get() + verify(ordersCache).getAll() + verify(ordersCache).setAll(listOf(firstOrder, secondOrder)) verify(orderRestClient).fetchOrders( site = eq(siteModel), count = eq(25), @@ -93,8 +111,11 @@ class WooPosOrdersDataSourceTest { } @Test - fun `given store returns error, when loadOrders called, then should return error result`() = runTest { + fun `given cache and fetch error, when loadOrders collected, then emits cache then error without caching`() = runTest { // GIVEN + val cachedOrder = OrderTestUtils.generateTestOrder() + whenever(ordersCache.getAll()).thenReturn(listOf(cachedOrder)) + val orderError = WCOrderStore.OrderError( type = WCOrderStore.OrderErrorType.GENERIC_ERROR, message = "generic error" @@ -118,13 +139,21 @@ class WooPosOrdersDataSourceTest { ).thenReturn(payload) // WHEN - val result = sut.loadOrders() + val emissions = sut.loadOrders().toList(mutableListOf()) // THEN - assertThat(result).isInstanceOf(LoadOrdersResult.Error::class.java) - val error = result as LoadOrdersResult.Error - assertThat(error.message).isEqualTo(orderError.message) + assertThat(emissions).hasSize(2) + + val first = emissions[0] + assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java) + assertThat((first as LoadOrdersResult.Success).orders).containsExactly(cachedOrder) + + val second = emissions[1] + assertThat(second).isInstanceOf(LoadOrdersResult.Error::class.java) + assertThat((second as LoadOrdersResult.Error).message).isEqualTo("generic error") + verify(ordersCache).getAll() + verify(ordersCache, never()).setAll(any()) verify(orderRestClient).fetchOrders( site = eq(siteModel), count = eq(25), @@ -137,13 +166,14 @@ class WooPosOrdersDataSourceTest { } @Test - fun `given default site and pagination, when loadOrders called, then should forward params including createdVia`() = runTest { + fun `given empty cache, when loadOrders collected, then forwards params including createdVia and emits empty then empty`() = runTest { // GIVEN + whenever(ordersCache.getAll()).thenReturn(emptyList()) + val payload = WCOrderStore.FetchOrdersResponsePayload( site = siteModel, ordersWithMeta = emptyList() ) - whenever( orderRestClient.fetchOrders( site = eq(siteModel), @@ -157,14 +187,22 @@ class WooPosOrdersDataSourceTest { ).thenReturn(payload) // WHEN - val result = sut.loadOrders() + val emissions = sut.loadOrders().toList(mutableListOf()) // THEN - assertThat(result).isInstanceOf(LoadOrdersResult.Success::class.java) - val success = result as LoadOrdersResult.Success - assertThat(success.orders).isEmpty() + assertThat(emissions).hasSize(2) + + val first = emissions[0] + assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java) + assertThat((first as LoadOrdersResult.Success).orders).isEmpty() + + val second = emissions[1] + assertThat(second).isInstanceOf(LoadOrdersResult.Success::class.java) + assertThat((second as LoadOrdersResult.Success).orders).isEmpty() verify(selectedSite).get() + verify(ordersCache).getAll() + verify(ordersCache).setAll(emptyList()) verify(orderRestClient).fetchOrders( site = eq(siteModel), count = eq(25), diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt index a214fd91ab3a..b148a3772830 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt @@ -2,6 +2,7 @@ package com.woocommerce.android.ui.woopos.splash import com.woocommerce.android.ui.woopos.common.data.WooPosPopularProductsProvider import com.woocommerce.android.ui.woopos.home.items.products.WooPosProductsDataSource +import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule @@ -23,6 +24,7 @@ import kotlin.test.Test @ExperimentalCoroutinesApi class WooPosSplashViewModelTest { private val productsDataSource: WooPosProductsDataSource = mock() + private val ordersCache: WooPosOrdersInMemoryCache = mock() private val analyticsTracker: WooPosAnalyticsTracker = mock() private val popularProductsProvider: WooPosPopularProductsProvider = mock() private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab = mock() @@ -109,6 +111,15 @@ class WooPosSplashViewModelTest { verify(popularProductsProvider).fetchAndCachePopularProducts() } + @Test + fun `when sut is created, then should clear orders cache`() = runTest { + // WHEN + createSut() + + // THEN + verify(ordersCache).clear() + } + @Test fun `given product population fails, should still update state to Loaded`() = runTest { // GIVEN @@ -145,6 +156,7 @@ class WooPosSplashViewModelTest { productsDataSource, popularProductsProvider, analyticsTracker, - posCanBeLaunchedInTab + posCanBeLaunchedInTab, + ordersCache ) }