Skip to content

Commit 56132f8

Browse files
fix validator (#573)
* fix validator * thank the lord we have tests ;-) * fix tests again :-) * lint * lint
1 parent d7d3430 commit 56132f8

File tree

3 files changed

+289
-9
lines changed

3 files changed

+289
-9
lines changed

store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,10 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
7777
val cachedToEmit = if (request.shouldSkipCache(CacheType.MEMORY)) {
7878
null
7979
} else {
80-
val output = memCache?.getIfPresent(request.key)
80+
val output: Output? = memCache?.getIfPresent(request.key)
81+
val isInvalid = output != null && validator?.isValid(output) == false
8182
when {
82-
output == null || validator?.isValid(output) == false -> null
83+
output == null || isInvalid -> null
8384
else -> output
8485
}
8586
}
@@ -122,7 +123,12 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
122123
// Source of truth
123124
// (future Source of truth updates)
124125
memCache?.getIfPresent(request.key)?.let {
125-
emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache))
126+
emit(
127+
StoreReadResponse.Data(
128+
value = it,
129+
origin = StoreReadResponseOrigin.Cache
130+
)
131+
)
126132
}
127133
}
128134
}
@@ -201,7 +207,8 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
201207
val responseOrigin = it.value.origin as StoreReadResponseOrigin.Fetcher
202208
requestKeyToFetcherName[request.key] = responseOrigin.name
203209

204-
val fallBackToSourceOfTruth = it.value is StoreReadResponse.Error && request.fallBackToSourceOfTruth
210+
val fallBackToSourceOfTruth =
211+
it.value is StoreReadResponse.Error && request.fallBackToSourceOfTruth
205212

206213
if (it.value is StoreReadResponse.Data || it.value is StoreReadResponse.NoNewData || fallBackToSourceOfTruth) {
207214
// Unlocking disk only if network sent data or reported no new data
@@ -230,8 +237,11 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
230237
}
231238

232239
val diskValue = diskData.value
233-
val isValid = diskValue?.let { it1 -> validator?.isValid(it1) } == true
234-
if (diskValue != null) {
240+
val isValid = (validator == null && diskValue != null) ||
241+
diskData.origin is StoreReadResponseOrigin.Fetcher ||
242+
(diskValue != null && validator?.isValid(diskValue) ?: true)
243+
244+
if (isValid) {
235245
@Suppress("UNCHECKED_CAST")
236246
val output =
237247
diskData.copy(origin = responseOriginWithFetcherName) as StoreReadResponse<Output>
@@ -241,7 +251,7 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
241251
// or refresh was requested
242252
// or the disk value is not valid
243253
// then allow fetcher to start emitting values.
244-
if (request.refresh || diskData.value == null) {
254+
if (request.refresh || diskData.value == null || !isValid) {
245255
networkLock.complete(Unit)
246256
}
247257
}
@@ -295,7 +305,9 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
295305
StoreDelegateWriteResult.Error.Exception(error)
296306
}
297307

298-
internal suspend fun latestOrNull(key: Key): Output? = fromMemCache(key) ?: fromSourceOfTruth(key)
308+
internal suspend fun latestOrNull(key: Key): Output? =
309+
fromMemCache(key) ?: fromSourceOfTruth(key)
310+
299311
private suspend fun fromSourceOfTruth(key: Key) =
300312
sourceOfTruth?.reader(key, CompletableDeferred(Unit))?.map { it.dataOrNull() }?.first()
301313

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package org.mobilenativefoundation.store.store5
2+
3+
import kotlinx.coroutines.ExperimentalCoroutinesApi
4+
import kotlinx.coroutines.flow.first
5+
import kotlinx.coroutines.flow.flow
6+
import kotlinx.coroutines.flow.last
7+
import kotlinx.coroutines.flow.take
8+
import kotlinx.coroutines.test.TestScope
9+
import kotlinx.coroutines.test.runTest
10+
import org.mobilenativefoundation.store.store5.impl.extensions.inHours
11+
import org.mobilenativefoundation.store.store5.util.assertEmitsExactly
12+
import org.mobilenativefoundation.store.store5.util.fake.Notes
13+
import org.mobilenativefoundation.store.store5.util.fake.NotesApi
14+
import org.mobilenativefoundation.store.store5.util.fake.NotesBookkeeping
15+
import org.mobilenativefoundation.store.store5.util.fake.NotesConverterProvider
16+
import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase
17+
import org.mobilenativefoundation.store.store5.util.fake.NotesKey
18+
import org.mobilenativefoundation.store.store5.util.fake.NotesUpdaterProvider
19+
import org.mobilenativefoundation.store.store5.util.fake.NotesValidator
20+
import org.mobilenativefoundation.store.store5.util.model.InputNote
21+
import org.mobilenativefoundation.store.store5.util.model.NetworkNote
22+
import org.mobilenativefoundation.store.store5.util.model.NoteData
23+
import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse
24+
import org.mobilenativefoundation.store.store5.util.model.OutputNote
25+
import kotlin.test.BeforeTest
26+
import kotlin.test.Test
27+
import kotlin.test.assertEquals
28+
import kotlin.test.assertIs
29+
import kotlin.test.assertNotNull
30+
31+
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class)
32+
class UpdaterTests {
33+
private val testScope = TestScope()
34+
private lateinit var api: NotesApi
35+
private lateinit var bookkeeping: NotesBookkeeping
36+
private lateinit var notes: NotesDatabase
37+
38+
@BeforeTest
39+
fun before() {
40+
api = NotesApi()
41+
bookkeeping = NotesBookkeeping()
42+
notes = NotesDatabase()
43+
}
44+
45+
@Test
46+
fun givenNonEmptyMarketWhenWriteThenStoredAndAPIUpdated() = testScope.runTest {
47+
val ttl = inHours(1)
48+
49+
val converter = NotesConverterProvider().provide()
50+
val validator = NotesValidator()
51+
val updater = NotesUpdaterProvider(api).provide()
52+
val bookkeeper = Bookkeeper.by(
53+
getLastFailedSync = bookkeeping::getLastFailedSync,
54+
setLastFailedSync = bookkeeping::setLastFailedSync,
55+
clear = bookkeeping::clear,
56+
clearAll = bookkeeping::clear
57+
)
58+
59+
val store = MutableStoreBuilder.from<NotesKey, NetworkNote, InputNote, OutputNote>(
60+
fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) },
61+
sourceOfTruth = SourceOfTruth.of(
62+
nonFlowReader = { key -> notes.get(key) },
63+
writer = { key, sot: InputNote -> notes.put(key, sot) },
64+
delete = { key -> notes.clear(key) },
65+
deleteAll = { notes.clear() }
66+
),
67+
converter = converter
68+
)
69+
.validator(validator)
70+
.build(
71+
updater = updater,
72+
bookkeeper = bookkeeper
73+
)
74+
75+
val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id))
76+
77+
val stream = store.stream<NotesWriteResponse>(readRequest)
78+
79+
// Read is success
80+
val expected = listOf(
81+
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()),
82+
StoreReadResponse.Data(
83+
OutputNote(NoteData.Single(Notes.One), ttl = ttl),
84+
StoreReadResponseOrigin.Fetcher()
85+
)
86+
)
87+
assertEmitsExactly(
88+
stream,
89+
expected
90+
)
91+
92+
val newNote = Notes.One.copy(title = "New Title-1")
93+
val writeRequest = StoreWriteRequest.of<NotesKey, OutputNote, NotesWriteResponse>(
94+
key = NotesKey.Single(Notes.One.id),
95+
value = OutputNote(NoteData.Single(newNote), 0)
96+
)
97+
98+
val storeWriteResponse = store.write(writeRequest)
99+
100+
// Write is success
101+
assertEquals(
102+
StoreWriteResponse.Success.Typed(
103+
NotesWriteResponse(
104+
NotesKey.Single(Notes.One.id),
105+
true
106+
)
107+
),
108+
storeWriteResponse
109+
)
110+
111+
val cachedReadRequest =
112+
StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false)
113+
val cachedStream = store.stream<NotesWriteResponse>(cachedReadRequest)
114+
115+
// Cache + SOT are updated
116+
val firstResponse: StoreReadResponse<OutputNote> = cachedStream.first()
117+
// assertEquals(
118+
// StoreReadResponse.Data(
119+
// OutputNote(NoteData.Single(newNote), ttl = 0),
120+
// StoreReadResponseOrigin.Cache
121+
// ),
122+
firstResponse
123+
// )
124+
125+
val secondResponse = cachedStream.take(2).last()
126+
assertIs<StoreReadResponse.Data<OutputNote>>(secondResponse)
127+
val data: NoteData? = secondResponse.value.data
128+
assertIs<NoteData.Single>(data)
129+
assertNotNull(data)
130+
assertEquals(newNote, data.item)
131+
assertEquals(StoreReadResponseOrigin.SourceOfTruth, secondResponse.origin)
132+
assertNotNull(secondResponse.value.ttl)
133+
134+
// API is updated
135+
assertEquals(
136+
StoreWriteResponse.Success.Typed(
137+
NotesWriteResponse(
138+
NotesKey.Single(Notes.One.id),
139+
true
140+
)
141+
),
142+
storeWriteResponse
143+
)
144+
assertEquals(
145+
NetworkNote(NoteData.Single(newNote), ttl = null),
146+
api.db[NotesKey.Single(Notes.One.id)]
147+
)
148+
}
149+
150+
@Test
151+
fun givenNonEmptyMarketWithValidatorWhenInvalidThenSuccessOriginatingFromFetcher() =
152+
testScope.runTest {
153+
val ttl = inHours(1)
154+
155+
val converter = NotesConverterProvider().provide()
156+
val validator = NotesValidator(expiration = inHours(12))
157+
val updater = NotesUpdaterProvider(api).provide()
158+
val bookkeeper = Bookkeeper.by(
159+
getLastFailedSync = bookkeeping::getLastFailedSync,
160+
setLastFailedSync = bookkeeping::setLastFailedSync,
161+
clear = bookkeeping::clear,
162+
clearAll = bookkeeping::clear
163+
)
164+
165+
val store = MutableStoreBuilder.from<NotesKey, NetworkNote, InputNote, OutputNote>(
166+
fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) },
167+
sourceOfTruth = SourceOfTruth.of(
168+
nonFlowReader = { key -> notes.get(key) },
169+
writer = { key, sot: InputNote -> notes.put(key, sot) },
170+
delete = { key -> notes.clear(key) },
171+
deleteAll = { notes.clear() }
172+
),
173+
converter = converter
174+
)
175+
.validator(validator)
176+
.build(
177+
updater = updater,
178+
bookkeeper = bookkeeper
179+
)
180+
181+
val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id))
182+
183+
val stream = store.stream<NotesWriteResponse>(readRequest)
184+
185+
// Fetch is success and validator is not used
186+
val expected = listOf(
187+
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()),
188+
StoreReadResponse.Data(
189+
OutputNote(NoteData.Single(Notes.One), ttl = ttl),
190+
StoreReadResponseOrigin.Fetcher()
191+
)
192+
)
193+
assertEmitsExactly(
194+
stream,
195+
expected
196+
)
197+
198+
val cachedReadRequest =
199+
StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false)
200+
val cachedStream = store.stream<NotesWriteResponse>(cachedReadRequest)
201+
202+
// Cache + SOT are updated
203+
// But item is invalid
204+
// So we do not emit value in cache or SOT
205+
// Instead we get latest from network even though refresh = false
206+
207+
assertEmitsExactly(
208+
cachedStream,
209+
listOf(
210+
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher(name = null)),
211+
StoreReadResponse.Data(
212+
OutputNote(NoteData.Single(Notes.One), ttl = ttl),
213+
StoreReadResponseOrigin.Fetcher()
214+
)
215+
)
216+
)
217+
}
218+
219+
@Test
220+
fun givenEmptyMarketWhenWriteThenSuccessResponsesAndApiUpdated() = testScope.runTest {
221+
val converter = NotesConverterProvider().provide()
222+
val validator = NotesValidator()
223+
val updater = NotesUpdaterProvider(api).provide()
224+
val bookkeeper = Bookkeeper.by(
225+
getLastFailedSync = bookkeeping::getLastFailedSync,
226+
setLastFailedSync = bookkeeping::setLastFailedSync,
227+
clear = bookkeeping::clear,
228+
clearAll = bookkeeping::clear
229+
)
230+
231+
val store = MutableStoreBuilder.from<NotesKey, NetworkNote, InputNote, OutputNote>(
232+
fetcher = Fetcher.ofFlow { key ->
233+
val network = api.get(key)
234+
flow { emit(network) }
235+
},
236+
sourceOfTruth = SourceOfTruth.of(
237+
nonFlowReader = { key -> notes.get(key) },
238+
writer = { key, sot -> notes.put(key, sot) },
239+
delete = { key -> notes.clear(key) },
240+
deleteAll = { notes.clear() }
241+
),
242+
converter
243+
)
244+
.validator(validator)
245+
.build(
246+
updater = updater,
247+
bookkeeper = bookkeeper
248+
)
249+
250+
val newNote = Notes.One.copy(title = "New Title-1")
251+
val writeRequest = StoreWriteRequest.of<NotesKey, OutputNote, NotesWriteResponse>(
252+
key = NotesKey.Single(Notes.One.id),
253+
value = OutputNote(NoteData.Single(newNote), 0)
254+
)
255+
val storeWriteResponse = store.write(writeRequest)
256+
257+
assertEquals(
258+
StoreWriteResponse.Success.Typed(
259+
NotesWriteResponse(
260+
NotesKey.Single(Notes.One.id),
261+
true
262+
)
263+
),
264+
storeWriteResponse
265+
)
266+
assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[NotesKey.Single(Notes.One.id)])
267+
}
268+
}

store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import org.mobilenativefoundation.store.store5.util.model.OutputNote
66

77
internal class NotesValidator(private val expiration: Long = now()) : Validator<OutputNote> {
88
override suspend fun isValid(item: OutputNote): Boolean = when {
9-
item.ttl == null -> true
9+
item.ttl == 0L -> true
1010
else -> item.ttl > expiration
1111
}
1212
}

0 commit comments

Comments
 (0)