Skip to content

Commit 08931bc

Browse files
authored
Merge pull request #14569 from woocommerce/feat/WOOMOB-1145-pos-historical-orders-in-memory-cache
[POS - Historical Orders] In-memory Cache
2 parents 78acbef + 9720e3c commit 08931bc

File tree

7 files changed

+217
-46
lines changed

7 files changed

+217
-46
lines changed

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.woocommerce.android.ui.woopos.orders
33
import com.woocommerce.android.model.Order
44
import com.woocommerce.android.model.OrderMapper
55
import com.woocommerce.android.tools.SelectedSite
6+
import kotlinx.coroutines.flow.Flow
7+
import kotlinx.coroutines.flow.flow
68
import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient
79
import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient.OrderBy
810
import org.wordpress.android.fluxc.persistence.entity.OrderEntity
@@ -17,26 +19,35 @@ class WooPosOrdersDataSource @Inject constructor(
1719
private val restClient: OrderRestClient,
1820
private val selectedSite: SelectedSite,
1921
private val orderMapper: OrderMapper,
22+
private val ordersCache: WooPosOrdersInMemoryCache
2023
) {
21-
suspend fun loadOrders(): LoadOrdersResult {
24+
companion object {
25+
const val POS_ORDERS_PAGE_SIZE = 25
26+
}
27+
fun loadOrders(): Flow<LoadOrdersResult> = flow {
28+
val cached = ordersCache.getAll()
29+
emit(LoadOrdersResult.Success(cached))
30+
2231
val result = restClient.fetchOrders(
2332
site = selectedSite.get(),
24-
count = 25,
33+
count = POS_ORDERS_PAGE_SIZE,
2534
page = 1,
2635
orderBy = OrderBy.DATE,
2736
sortOrder = OrderRestClient.SortOrder.DESCENDING,
2837
statusFilter = null,
2938
createdVia = "pos-rest-api"
3039
)
3140

32-
return if (result.isError) {
33-
LoadOrdersResult.Error(result.error.message)
41+
if (result.isError) {
42+
emit(LoadOrdersResult.Error(result.error.message))
3443
} else {
35-
LoadOrdersResult.Success(result.orders.toAppModels())
44+
val mapped = result.orders.toAppModels()
45+
ordersCache.setAll(mapped)
46+
emit(LoadOrdersResult.Success(result.orders.toAppModels()))
3647
}
3748
}
3849

3950
private suspend fun List<OrderEntity>.toAppModels(): List<Order> = map {
4051
orderMapper.toAppModel(it)
41-
}
52+
} ?: emptyList()
4253
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.woocommerce.android.ui.woopos.orders
2+
3+
import com.woocommerce.android.model.Order
4+
import java.util.concurrent.atomic.AtomicReference
5+
import javax.inject.Inject
6+
import javax.inject.Singleton
7+
8+
@Singleton
9+
class WooPosOrdersInMemoryCache @Inject constructor() {
10+
private val ordersCache = AtomicReference<List<Order>>(emptyList())
11+
12+
fun setAll(orders: List<Order>) {
13+
ordersCache.set(orders.toList())
14+
}
15+
16+
fun getAll(): List<Order> = ordersCache.get()
17+
18+
fun clear() {
19+
ordersCache.set(emptyList())
20+
}
21+
}

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,28 @@ class WooPosOrdersViewModel @Inject constructor(
3030
viewModelScope.launch {
3131
_state.update { it.copy(isLoading = true, error = null) }
3232

33-
when (val result = ordersDataSource.loadOrders()) {
34-
is LoadOrdersResult.Error -> {
35-
_state.update {
36-
it.copy(
37-
isLoading = false,
38-
error = result.message ?: "Unknown error"
39-
)
33+
ordersDataSource.loadOrders().collect { result ->
34+
when (result) {
35+
is LoadOrdersResult.Error -> {
36+
_state.update {
37+
it.copy(
38+
isLoading = false,
39+
error = result.message
40+
)
41+
}
4042
}
41-
}
42-
is LoadOrdersResult.Success -> {
43-
val list = result.orders
44-
_state.update { prev ->
45-
prev.copy(
46-
isLoading = false,
47-
orders = list,
48-
selectedOrderId = prev.selectedOrderId?.takeIf { id ->
49-
list.any { o -> o.id == id }
50-
} ?: list.firstOrNull()?.id
51-
)
43+
44+
is LoadOrdersResult.Success -> {
45+
val list = result.orders
46+
_state.update { prev ->
47+
prev.copy(
48+
isLoading = false,
49+
orders = list,
50+
selectedOrderId = prev.selectedOrderId?.takeIf { id ->
51+
list.any { o -> o.id == id }
52+
} ?: list.firstOrNull()?.id
53+
)
54+
}
5255
}
5356
}
5457
}

WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
55
import com.woocommerce.android.ui.woopos.common.data.WooPosPopularProductsProvider
66
import com.woocommerce.android.ui.woopos.home.items.products.WooPosProductsDataSource
7+
import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache
78
import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab
89
import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability
910
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.Loaded
@@ -22,6 +23,7 @@ class WooPosSplashViewModel @Inject constructor(
2223
private val popularProductsProvider: WooPosPopularProductsProvider,
2324
private val analyticsTracker: WooPosAnalyticsTracker,
2425
private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab,
26+
private val ordersCache: WooPosOrdersInMemoryCache
2527
) : ViewModel() {
2628
private val _state = MutableStateFlow<WooPosSplashState>(WooPosSplashState.Loading)
2729
val state: StateFlow<WooPosSplashState> = _state
@@ -38,7 +40,8 @@ class WooPosSplashViewModel @Inject constructor(
3840

3941
joinAll(
4042
launch { productsDataSource.prepopulateProductsCache() },
41-
launch { popularProductsProvider.fetchAndCachePopularProducts() }
43+
launch { popularProductsProvider.fetchAndCachePopularProducts() },
44+
launch { ordersCache.clear() }
4245
)
4346
_state.value = WooPosSplashState.Loaded
4447
trackPosLoaded(splashScreenStartTime)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.woocommerce.android.ui.woopos.common.data
2+
3+
import com.woocommerce.android.ui.orders.OrderTestUtils
4+
import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache
5+
import kotlinx.coroutines.ExperimentalCoroutinesApi
6+
import org.assertj.core.api.Assertions.assertThat
7+
import org.junit.Before
8+
import org.junit.Test
9+
10+
@ExperimentalCoroutinesApi
11+
class WooPosOrdersInMemoryCacheTest {
12+
13+
private lateinit var cache: WooPosOrdersInMemoryCache
14+
15+
@Before
16+
fun setup() {
17+
cache = WooPosOrdersInMemoryCache()
18+
}
19+
20+
@Test
21+
fun `when cache is empty, then getAll returns empty list`() {
22+
// WHEN
23+
val result = cache.getAll()
24+
25+
// THEN
26+
assertThat(result).isEmpty()
27+
}
28+
29+
@Test
30+
fun `when setAll is called, then getAll returns the same elements`() {
31+
// GIVEN
32+
val orders = listOf(
33+
OrderTestUtils.generateTestOrder(1),
34+
OrderTestUtils.generateTestOrder(2)
35+
)
36+
37+
// WHEN
38+
cache.setAll(orders)
39+
val result = cache.getAll()
40+
41+
// THEN
42+
assertThat(result).containsExactlyElementsOf(orders)
43+
}
44+
45+
@Test
46+
fun `when setAll is called twice, then last write wins`() {
47+
// GIVEN
48+
val first = listOf(
49+
OrderTestUtils.generateTestOrder(1),
50+
OrderTestUtils.generateTestOrder(2)
51+
)
52+
val second = listOf(
53+
OrderTestUtils.generateTestOrder(3),
54+
OrderTestUtils.generateTestOrder(4),
55+
OrderTestUtils.generateTestOrder(5)
56+
)
57+
58+
// WHEN
59+
cache.setAll(first)
60+
cache.setAll(second)
61+
val result = cache.getAll()
62+
63+
// THEN
64+
assertThat(result).containsExactlyElementsOf(second)
65+
}
66+
67+
@Test
68+
fun `when cache is cleared, then getAll returns empty list`() {
69+
// GIVEN
70+
val orders = listOf(
71+
OrderTestUtils.generateTestOrder(1),
72+
OrderTestUtils.generateTestOrder(2)
73+
)
74+
cache.setAll(orders)
75+
76+
// WHEN
77+
cache.clear()
78+
val result = cache.getAll()
79+
80+
// THEN
81+
assertThat(result).isEmpty()
82+
}
83+
}

WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSourceTest.kt

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import com.woocommerce.android.tools.SelectedSite
55
import com.woocommerce.android.ui.orders.OrderTestUtils
66
import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule
77
import kotlinx.coroutines.ExperimentalCoroutinesApi
8+
import kotlinx.coroutines.flow.toList
89
import kotlinx.coroutines.test.runTest
910
import org.assertj.core.api.Assertions.assertThat
1011
import org.junit.Rule
1112
import org.mockito.kotlin.any
1213
import org.mockito.kotlin.anyOrNull
1314
import org.mockito.kotlin.eq
1415
import org.mockito.kotlin.mock
16+
import org.mockito.kotlin.never
1517
import org.mockito.kotlin.verify
1618
import org.mockito.kotlin.whenever
1719
import org.wordpress.android.fluxc.model.LocalOrRemoteId
@@ -33,25 +35,35 @@ class WooPosOrdersDataSourceTest {
3335
private val siteModel: SiteModel = mock()
3436
private val selectedSite: SelectedSite = mock { on { get() }.thenReturn(siteModel) }
3537
private val orderMapper: OrderMapper = mock()
38+
private val ordersCache: WooPosOrdersInMemoryCache = mock()
3639

3740
private val sut = WooPosOrdersDataSource(
3841
restClient = orderRestClient,
3942
selectedSite = selectedSite,
40-
orderMapper = orderMapper
43+
orderMapper = orderMapper,
44+
ordersCache = ordersCache
4145
)
4246

4347
@Test
44-
fun `given rest client returns entities, when loadOrders called, then should map them to app models`() = runTest {
48+
fun `given cache and successful fetch, when loadOrders collected, then emits cache first then mapped network and stores in cache`() = runTest {
4549
// GIVEN
50+
val cachedOrder = OrderTestUtils.generateTestOrder()
51+
whenever(ordersCache.getAll()).thenReturn(listOf(cachedOrder))
52+
53+
// Network returns two entities
4654
val e1 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 1)
4755
val e2 = OrderEntity(localSiteId = LocalOrRemoteId.LocalId(1), 2)
4856
val entities = listOf(
4957
e1 to emptyList<WCMetaData>(),
5058
e2 to emptyList<WCMetaData>()
5159
)
60+
5261
val firstOrder = OrderTestUtils.generateTestOrder()
5362
val secondOrder = OrderTestUtils.generateTestOrder()
5463

64+
whenever(orderMapper.toAppModel(e1)).thenReturn(firstOrder)
65+
whenever(orderMapper.toAppModel(e2)).thenReturn(secondOrder)
66+
5567
val payload = WCOrderStore.FetchOrdersResponsePayload(
5668
site = siteModel,
5769
ordersWithMeta = entities
@@ -69,18 +81,24 @@ class WooPosOrdersDataSourceTest {
6981
)
7082
).thenReturn(payload)
7183

72-
whenever(orderMapper.toAppModel(e1)).thenReturn(firstOrder)
73-
whenever(orderMapper.toAppModel(e2)).thenReturn(secondOrder)
74-
7584
// WHEN
76-
val result = sut.loadOrders()
85+
val emissions = sut.loadOrders().toList(mutableListOf())
7786

7887
// THEN
79-
assertThat(result).isInstanceOf(LoadOrdersResult.Success::class.java)
80-
val success = result as LoadOrdersResult.Success
81-
assertThat(success.orders).containsExactly(firstOrder, secondOrder)
88+
assertThat(emissions).hasSize(2)
89+
// First emission = cache
90+
val first = emissions[0]
91+
assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java)
92+
assertThat((first as LoadOrdersResult.Success).orders).containsExactly(cachedOrder)
93+
94+
// Second emission = network mapped
95+
val second = emissions[1]
96+
assertThat(second).isInstanceOf(LoadOrdersResult.Success::class.java)
97+
assertThat((second as LoadOrdersResult.Success).orders).containsExactly(firstOrder, secondOrder)
8298

8399
verify(selectedSite).get()
100+
verify(ordersCache).getAll()
101+
verify(ordersCache).setAll(listOf(firstOrder, secondOrder))
84102
verify(orderRestClient).fetchOrders(
85103
site = eq(siteModel),
86104
count = eq(25),
@@ -93,8 +111,11 @@ class WooPosOrdersDataSourceTest {
93111
}
94112

95113
@Test
96-
fun `given store returns error, when loadOrders called, then should return error result`() = runTest {
114+
fun `given cache and fetch error, when loadOrders collected, then emits cache then error without caching`() = runTest {
97115
// GIVEN
116+
val cachedOrder = OrderTestUtils.generateTestOrder()
117+
whenever(ordersCache.getAll()).thenReturn(listOf(cachedOrder))
118+
98119
val orderError = WCOrderStore.OrderError(
99120
type = WCOrderStore.OrderErrorType.GENERIC_ERROR,
100121
message = "generic error"
@@ -118,13 +139,21 @@ class WooPosOrdersDataSourceTest {
118139
).thenReturn(payload)
119140

120141
// WHEN
121-
val result = sut.loadOrders()
142+
val emissions = sut.loadOrders().toList(mutableListOf())
122143

123144
// THEN
124-
assertThat(result).isInstanceOf(LoadOrdersResult.Error::class.java)
125-
val error = result as LoadOrdersResult.Error
126-
assertThat(error.message).isEqualTo(orderError.message)
145+
assertThat(emissions).hasSize(2)
146+
147+
val first = emissions[0]
148+
assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java)
149+
assertThat((first as LoadOrdersResult.Success).orders).containsExactly(cachedOrder)
150+
151+
val second = emissions[1]
152+
assertThat(second).isInstanceOf(LoadOrdersResult.Error::class.java)
153+
assertThat((second as LoadOrdersResult.Error).message).isEqualTo("generic error")
127154

155+
verify(ordersCache).getAll()
156+
verify(ordersCache, never()).setAll(any())
128157
verify(orderRestClient).fetchOrders(
129158
site = eq(siteModel),
130159
count = eq(25),
@@ -137,13 +166,14 @@ class WooPosOrdersDataSourceTest {
137166
}
138167

139168
@Test
140-
fun `given default site and pagination, when loadOrders called, then should forward params including createdVia`() = runTest {
169+
fun `given empty cache, when loadOrders collected, then forwards params including createdVia and emits empty then empty`() = runTest {
141170
// GIVEN
171+
whenever(ordersCache.getAll()).thenReturn(emptyList())
172+
142173
val payload = WCOrderStore.FetchOrdersResponsePayload(
143174
site = siteModel,
144175
ordersWithMeta = emptyList()
145176
)
146-
147177
whenever(
148178
orderRestClient.fetchOrders(
149179
site = eq(siteModel),
@@ -157,14 +187,22 @@ class WooPosOrdersDataSourceTest {
157187
).thenReturn(payload)
158188

159189
// WHEN
160-
val result = sut.loadOrders()
190+
val emissions = sut.loadOrders().toList(mutableListOf())
161191

162192
// THEN
163-
assertThat(result).isInstanceOf(LoadOrdersResult.Success::class.java)
164-
val success = result as LoadOrdersResult.Success
165-
assertThat(success.orders).isEmpty()
193+
assertThat(emissions).hasSize(2)
194+
195+
val first = emissions[0]
196+
assertThat(first).isInstanceOf(LoadOrdersResult.Success::class.java)
197+
assertThat((first as LoadOrdersResult.Success).orders).isEmpty()
198+
199+
val second = emissions[1]
200+
assertThat(second).isInstanceOf(LoadOrdersResult.Success::class.java)
201+
assertThat((second as LoadOrdersResult.Success).orders).isEmpty()
166202

167203
verify(selectedSite).get()
204+
verify(ordersCache).getAll()
205+
verify(ordersCache).setAll(emptyList())
168206
verify(orderRestClient).fetchOrders(
169207
site = eq(siteModel),
170208
count = eq(25),

0 commit comments

Comments
 (0)