Skip to content

Commit

Permalink
fix validator (#573)
Browse files Browse the repository at this point in the history
* fix validator

* thank the lord we have tests ;-)

* fix tests again :-)

* lint

* lint
  • Loading branch information
digitalbuddha authored Aug 11, 2023
1 parent d7d3430 commit 56132f8
Show file tree
Hide file tree
Showing 3 changed files with 289 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
val cachedToEmit = if (request.shouldSkipCache(CacheType.MEMORY)) {
null
} else {
val output = memCache?.getIfPresent(request.key)
val output: Output? = memCache?.getIfPresent(request.key)
val isInvalid = output != null && validator?.isValid(output) == false
when {
output == null || validator?.isValid(output) == false -> null
output == null || isInvalid -> null
else -> output
}
}
Expand Down Expand Up @@ -122,7 +123,12 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
// Source of truth
// (future Source of truth updates)
memCache?.getIfPresent(request.key)?.let {
emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache))
emit(
StoreReadResponse.Data(
value = it,
origin = StoreReadResponseOrigin.Cache
)
)
}
}
}
Expand Down Expand Up @@ -201,7 +207,8 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
val responseOrigin = it.value.origin as StoreReadResponseOrigin.Fetcher
requestKeyToFetcherName[request.key] = responseOrigin.name

val fallBackToSourceOfTruth = it.value is StoreReadResponse.Error && request.fallBackToSourceOfTruth
val fallBackToSourceOfTruth =
it.value is StoreReadResponse.Error && request.fallBackToSourceOfTruth

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

val diskValue = diskData.value
val isValid = diskValue?.let { it1 -> validator?.isValid(it1) } == true
if (diskValue != null) {
val isValid = (validator == null && diskValue != null) ||
diskData.origin is StoreReadResponseOrigin.Fetcher ||
(diskValue != null && validator?.isValid(diskValue) ?: true)

if (isValid) {
@Suppress("UNCHECKED_CAST")
val output =
diskData.copy(origin = responseOriginWithFetcherName) as StoreReadResponse<Output>
Expand All @@ -241,7 +251,7 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
// or refresh was requested
// or the disk value is not valid
// then allow fetcher to start emitting values.
if (request.refresh || diskData.value == null) {
if (request.refresh || diskData.value == null || !isValid) {
networkLock.complete(Unit)
}
}
Expand Down Expand Up @@ -295,7 +305,9 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
StoreDelegateWriteResult.Error.Exception(error)
}

internal suspend fun latestOrNull(key: Key): Output? = fromMemCache(key) ?: fromSourceOfTruth(key)
internal suspend fun latestOrNull(key: Key): Output? =
fromMemCache(key) ?: fromSourceOfTruth(key)

private suspend fun fromSourceOfTruth(key: Key) =
sourceOfTruth?.reader(key, CompletableDeferred(Unit))?.map { it.dataOrNull() }?.first()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package org.mobilenativefoundation.store.store5

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.mobilenativefoundation.store.store5.impl.extensions.inHours
import org.mobilenativefoundation.store.store5.util.assertEmitsExactly
import org.mobilenativefoundation.store.store5.util.fake.Notes
import org.mobilenativefoundation.store.store5.util.fake.NotesApi
import org.mobilenativefoundation.store.store5.util.fake.NotesBookkeeping
import org.mobilenativefoundation.store.store5.util.fake.NotesConverterProvider
import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase
import org.mobilenativefoundation.store.store5.util.fake.NotesKey
import org.mobilenativefoundation.store.store5.util.fake.NotesUpdaterProvider
import org.mobilenativefoundation.store.store5.util.fake.NotesValidator
import org.mobilenativefoundation.store.store5.util.model.InputNote
import org.mobilenativefoundation.store.store5.util.model.NetworkNote
import org.mobilenativefoundation.store.store5.util.model.NoteData
import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse
import org.mobilenativefoundation.store.store5.util.model.OutputNote
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull

@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class)
class UpdaterTests {
private val testScope = TestScope()
private lateinit var api: NotesApi
private lateinit var bookkeeping: NotesBookkeeping
private lateinit var notes: NotesDatabase

@BeforeTest
fun before() {
api = NotesApi()
bookkeeping = NotesBookkeeping()
notes = NotesDatabase()
}

@Test
fun givenNonEmptyMarketWhenWriteThenStoredAndAPIUpdated() = testScope.runTest {
val ttl = inHours(1)

val converter = NotesConverterProvider().provide()
val validator = NotesValidator()
val updater = NotesUpdaterProvider(api).provide()
val bookkeeper = Bookkeeper.by(
getLastFailedSync = bookkeeping::getLastFailedSync,
setLastFailedSync = bookkeeping::setLastFailedSync,
clear = bookkeeping::clear,
clearAll = bookkeeping::clear
)

val store = MutableStoreBuilder.from<NotesKey, NetworkNote, InputNote, OutputNote>(
fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) },
sourceOfTruth = SourceOfTruth.of(
nonFlowReader = { key -> notes.get(key) },
writer = { key, sot: InputNote -> notes.put(key, sot) },
delete = { key -> notes.clear(key) },
deleteAll = { notes.clear() }
),
converter = converter
)
.validator(validator)
.build(
updater = updater,
bookkeeper = bookkeeper
)

val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id))

val stream = store.stream<NotesWriteResponse>(readRequest)

// Read is success
val expected = listOf(
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()),
StoreReadResponse.Data(
OutputNote(NoteData.Single(Notes.One), ttl = ttl),
StoreReadResponseOrigin.Fetcher()
)
)
assertEmitsExactly(
stream,
expected
)

val newNote = Notes.One.copy(title = "New Title-1")
val writeRequest = StoreWriteRequest.of<NotesKey, OutputNote, NotesWriteResponse>(
key = NotesKey.Single(Notes.One.id),
value = OutputNote(NoteData.Single(newNote), 0)
)

val storeWriteResponse = store.write(writeRequest)

// Write is success
assertEquals(
StoreWriteResponse.Success.Typed(
NotesWriteResponse(
NotesKey.Single(Notes.One.id),
true
)
),
storeWriteResponse
)

val cachedReadRequest =
StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false)
val cachedStream = store.stream<NotesWriteResponse>(cachedReadRequest)

// Cache + SOT are updated
val firstResponse: StoreReadResponse<OutputNote> = cachedStream.first()
// assertEquals(
// StoreReadResponse.Data(
// OutputNote(NoteData.Single(newNote), ttl = 0),
// StoreReadResponseOrigin.Cache
// ),
firstResponse
// )

val secondResponse = cachedStream.take(2).last()
assertIs<StoreReadResponse.Data<OutputNote>>(secondResponse)
val data: NoteData? = secondResponse.value.data
assertIs<NoteData.Single>(data)
assertNotNull(data)
assertEquals(newNote, data.item)
assertEquals(StoreReadResponseOrigin.SourceOfTruth, secondResponse.origin)
assertNotNull(secondResponse.value.ttl)

// API is updated
assertEquals(
StoreWriteResponse.Success.Typed(
NotesWriteResponse(
NotesKey.Single(Notes.One.id),
true
)
),
storeWriteResponse
)
assertEquals(
NetworkNote(NoteData.Single(newNote), ttl = null),
api.db[NotesKey.Single(Notes.One.id)]
)
}

@Test
fun givenNonEmptyMarketWithValidatorWhenInvalidThenSuccessOriginatingFromFetcher() =
testScope.runTest {
val ttl = inHours(1)

val converter = NotesConverterProvider().provide()
val validator = NotesValidator(expiration = inHours(12))
val updater = NotesUpdaterProvider(api).provide()
val bookkeeper = Bookkeeper.by(
getLastFailedSync = bookkeeping::getLastFailedSync,
setLastFailedSync = bookkeeping::setLastFailedSync,
clear = bookkeeping::clear,
clearAll = bookkeeping::clear
)

val store = MutableStoreBuilder.from<NotesKey, NetworkNote, InputNote, OutputNote>(
fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) },
sourceOfTruth = SourceOfTruth.of(
nonFlowReader = { key -> notes.get(key) },
writer = { key, sot: InputNote -> notes.put(key, sot) },
delete = { key -> notes.clear(key) },
deleteAll = { notes.clear() }
),
converter = converter
)
.validator(validator)
.build(
updater = updater,
bookkeeper = bookkeeper
)

val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id))

val stream = store.stream<NotesWriteResponse>(readRequest)

// Fetch is success and validator is not used
val expected = listOf(
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()),
StoreReadResponse.Data(
OutputNote(NoteData.Single(Notes.One), ttl = ttl),
StoreReadResponseOrigin.Fetcher()
)
)
assertEmitsExactly(
stream,
expected
)

val cachedReadRequest =
StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false)
val cachedStream = store.stream<NotesWriteResponse>(cachedReadRequest)

// Cache + SOT are updated
// But item is invalid
// So we do not emit value in cache or SOT
// Instead we get latest from network even though refresh = false

assertEmitsExactly(
cachedStream,
listOf(
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher(name = null)),
StoreReadResponse.Data(
OutputNote(NoteData.Single(Notes.One), ttl = ttl),
StoreReadResponseOrigin.Fetcher()
)
)
)
}

@Test
fun givenEmptyMarketWhenWriteThenSuccessResponsesAndApiUpdated() = testScope.runTest {
val converter = NotesConverterProvider().provide()
val validator = NotesValidator()
val updater = NotesUpdaterProvider(api).provide()
val bookkeeper = Bookkeeper.by(
getLastFailedSync = bookkeeping::getLastFailedSync,
setLastFailedSync = bookkeeping::setLastFailedSync,
clear = bookkeeping::clear,
clearAll = bookkeeping::clear
)

val store = MutableStoreBuilder.from<NotesKey, NetworkNote, InputNote, OutputNote>(
fetcher = Fetcher.ofFlow { key ->
val network = api.get(key)
flow { emit(network) }
},
sourceOfTruth = SourceOfTruth.of(
nonFlowReader = { key -> notes.get(key) },
writer = { key, sot -> notes.put(key, sot) },
delete = { key -> notes.clear(key) },
deleteAll = { notes.clear() }
),
converter
)
.validator(validator)
.build(
updater = updater,
bookkeeper = bookkeeper
)

val newNote = Notes.One.copy(title = "New Title-1")
val writeRequest = StoreWriteRequest.of<NotesKey, OutputNote, NotesWriteResponse>(
key = NotesKey.Single(Notes.One.id),
value = OutputNote(NoteData.Single(newNote), 0)
)
val storeWriteResponse = store.write(writeRequest)

assertEquals(
StoreWriteResponse.Success.Typed(
NotesWriteResponse(
NotesKey.Single(Notes.One.id),
true
)
),
storeWriteResponse
)
assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[NotesKey.Single(Notes.One.id)])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.mobilenativefoundation.store.store5.util.model.OutputNote

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

0 comments on commit 56132f8

Please sign in to comment.