Add Validator (#500)
* Resolve conflicts Signed-off-by: mramotar <mramotar@dropbox.com> * Fix rebase issues Signed-off-by: mramotar <mramotar@dropbox.com> * Fix tests Signed-off-by: mramotar <mramotar@dropbox.com> Signed-off-by: mramotar <mramotar@dropbox.com>
This commit is contained in:
parent
ae07e0672c
commit
01cfe83ea6
17 changed files with 389 additions and 100 deletions
|
@ -2,29 +2,29 @@ package org.mobilenativefoundation.store.store5
|
|||
|
||||
interface Converter<Network : Any, Output : Any, Local : Any> {
|
||||
fun fromNetworkToOutput(network: Network): Output?
|
||||
fun fromOutputToLocal(common: Output): Local?
|
||||
fun fromLocalToOutput(sourceOfTruth: Local): Output?
|
||||
fun fromOutputToLocal(output: Output): Local?
|
||||
fun fromLocalToOutput(local: Local): Output?
|
||||
|
||||
class Builder<Network : Any, Output : Any, Local : Any> {
|
||||
|
||||
private var fromOutputToLocal: ((value: Output) -> Local)? = null
|
||||
private var fromNetworkToOutput: ((value: Network) -> Output)? = null
|
||||
private var fromLocalToOutput: ((value: Local) -> Output)? = null
|
||||
private var fromOutputToLocal: ((output: Output) -> Local)? = null
|
||||
private var fromNetworkToOutput: ((network: Network) -> Output)? = null
|
||||
private var fromLocalToOutput: ((local: Local) -> Output)? = null
|
||||
|
||||
fun build(): Converter<Network, Output, Local> =
|
||||
RealConverter(fromOutputToLocal, fromNetworkToOutput, fromLocalToOutput)
|
||||
|
||||
fun fromOutputToLocal(converter: (value: Output) -> Local): Builder<Network, Output, Local> {
|
||||
fun fromOutputToLocal(converter: (output: Output) -> Local): Builder<Network, Output, Local> {
|
||||
fromOutputToLocal = converter
|
||||
return this
|
||||
}
|
||||
|
||||
fun fromLocalToOutput(converter: (value: Local) -> Output): Builder<Network, Output, Local> {
|
||||
fun fromLocalToOutput(converter: (local: Local) -> Output): Builder<Network, Output, Local> {
|
||||
fromLocalToOutput = converter
|
||||
return this
|
||||
}
|
||||
|
||||
fun fromNetworkToOutput(converter: (value: Network) -> Output): Builder<Network, Output, Local> {
|
||||
fun fromNetworkToOutput(converter: (network: Network) -> Output): Builder<Network, Output, Local> {
|
||||
fromNetworkToOutput = converter
|
||||
return this
|
||||
}
|
||||
|
@ -32,16 +32,16 @@ interface Converter<Network : Any, Output : Any, Local : Any> {
|
|||
}
|
||||
|
||||
private class RealConverter<Network : Any, Output : Any, Local : Any>(
|
||||
private val fromOutputToLocal: ((value: Output) -> Local)?,
|
||||
private val fromNetworkToOutput: ((value: Network) -> Output)?,
|
||||
private val fromLocalToOutput: ((value: Local) -> Output)?,
|
||||
private val fromOutputToLocal: ((output: Output) -> Local)?,
|
||||
private val fromNetworkToOutput: ((network: Network) -> Output)?,
|
||||
private val fromLocalToOutput: ((local: Local) -> Output)?,
|
||||
) : Converter<Network, Output, Local> {
|
||||
override fun fromNetworkToOutput(network: Network): Output? =
|
||||
fromNetworkToOutput?.invoke(network)
|
||||
|
||||
override fun fromOutputToLocal(common: Output): Local? =
|
||||
fromOutputToLocal?.invoke(common)
|
||||
override fun fromOutputToLocal(output: Output): Local? =
|
||||
fromOutputToLocal?.invoke(output)
|
||||
|
||||
override fun fromLocalToOutput(sourceOfTruth: Local): Output? =
|
||||
fromLocalToOutput?.invoke(sourceOfTruth)
|
||||
override fun fromLocalToOutput(local: Local): Output? =
|
||||
fromLocalToOutput?.invoke(local)
|
||||
}
|
||||
|
|
|
@ -54,6 +54,8 @@ interface StoreBuilder<Key : Any, Network : Any, Output : Any, Local : Any> {
|
|||
fun converter(converter: Converter<Network, Output, Local>):
|
||||
StoreBuilder<Key, Network, Output, Local>
|
||||
|
||||
fun validator(validator: Validator<Output>): StoreBuilder<Key, Network, Output, Local>
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
|
|
|
@ -93,11 +93,9 @@ internal class FetcherController<Key : Any, Network : Any, Output : Any, Local :
|
|||
piggybackingDownstream = true,
|
||||
onEach = { response ->
|
||||
response.dataOrNull()?.let { network ->
|
||||
val input =
|
||||
network as? Output ?: converter?.fromNetworkToOutput(network)
|
||||
if (input != null) {
|
||||
sourceOfTruth?.write(key, input)
|
||||
}
|
||||
val output = converter?.fromNetworkToOutput(network)
|
||||
val input = output ?: network
|
||||
sourceOfTruth?.write(key, input as Output)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -203,10 +203,7 @@ internal class RealMutableStore<Key : Any, Network : Any, Output : Any, Local :
|
|||
return lastFailedSync != null || writeRequestsQueueIsEmpty(key).not()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private suspend fun writeRequestsQueueIsEmpty(key: Key): Boolean = withThreadSafety(key) {
|
||||
keyToWriteRequestQueue[key].isNullOrEmpty()
|
||||
}
|
||||
private fun writeRequestsQueueIsEmpty(key: Key): Boolean = keyToWriteRequestQueue[key].isNullOrEmpty()
|
||||
|
||||
private suspend fun <Response : Any> addWriteRequestToQueue(writeRequest: StoreWriteRequest<Key, Output, Response>) =
|
||||
withWriteRequestQueueLock(writeRequest.key) {
|
||||
|
|
|
@ -36,6 +36,7 @@ import org.mobilenativefoundation.store.store5.Store
|
|||
import org.mobilenativefoundation.store.store5.StoreReadRequest
|
||||
import org.mobilenativefoundation.store.store5.StoreReadResponse
|
||||
import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin
|
||||
import org.mobilenativefoundation.store.store5.Validator
|
||||
import org.mobilenativefoundation.store.store5.impl.operators.Either
|
||||
import org.mobilenativefoundation.store.store5.impl.operators.merge
|
||||
import org.mobilenativefoundation.store.store5.internal.result.StoreDelegateWriteResult
|
||||
|
@ -44,7 +45,8 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
|
|||
scope: CoroutineScope,
|
||||
fetcher: Fetcher<Key, Network>,
|
||||
sourceOfTruth: SourceOfTruth<Key, Local>? = null,
|
||||
converter: Converter<Network, Output, Local>? = null,
|
||||
private val converter: Converter<Network, Output, Local>? = null,
|
||||
private val validator: Validator<Output>?,
|
||||
private val memoryPolicy: MemoryPolicy<Key, Output>?
|
||||
) : Store<Key, Output> {
|
||||
/**
|
||||
|
@ -88,12 +90,18 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
|
|||
converter = converter
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun stream(request: StoreReadRequest<Key>): Flow<StoreReadResponse<Output>> =
|
||||
flow {
|
||||
val cachedToEmit = if (request.shouldSkipCache(CacheType.MEMORY)) {
|
||||
null
|
||||
} else {
|
||||
memCache?.getIfPresent(request.key)
|
||||
val output = memCache?.getIfPresent(request.key)
|
||||
when {
|
||||
output == null -> null
|
||||
validator?.isValid(output) == false -> null
|
||||
else -> output
|
||||
}
|
||||
}
|
||||
|
||||
cachedToEmit?.let {
|
||||
|
@ -114,27 +122,43 @@ internal class RealStore<Key : Any, Network : Any, Output : Any, Local : Any>(
|
|||
diskNetworkCombined(request, sourceOfTruth)
|
||||
}
|
||||
emitAll(
|
||||
stream.transform {
|
||||
emit(it)
|
||||
if (it is StoreReadResponse.NoNewData && cachedToEmit == null) {
|
||||
// In the special case where fetcher returned no new data we actually want to
|
||||
// serve cache data (even if the request specified skipping cache and/or SoT)
|
||||
//
|
||||
// For stream(Request.cached(key, refresh=true)) we will return:
|
||||
// Cache
|
||||
// Source of truth
|
||||
// Fetcher - > Loading
|
||||
// Fetcher - > NoNewData
|
||||
// (future Source of truth updates)
|
||||
//
|
||||
// For stream(Request.fresh(key)) we will return:
|
||||
// Fetcher - > Loading
|
||||
// Fetcher - > NoNewData
|
||||
// Cache
|
||||
// Source of truth
|
||||
// (future Source of truth updates)
|
||||
memCache?.getIfPresent(request.key)?.let {
|
||||
emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache))
|
||||
stream.transform { output ->
|
||||
val data = output.dataOrNull()
|
||||
val shouldSkipValidation = validator == null || data == null || output.origin == StoreReadResponseOrigin.Fetcher
|
||||
if (data != null && !shouldSkipValidation && validator?.isValid(data) == false) {
|
||||
fetcherController.getFetcher(request.key, false).collect { storeReadResponse ->
|
||||
val network = storeReadResponse.dataOrNull()
|
||||
if (network != null) {
|
||||
val newOutput = converter?.fromNetworkToOutput(network) ?: network as? Output
|
||||
if (newOutput != null) {
|
||||
emit(StoreReadResponse.Data(newOutput, origin = StoreReadResponseOrigin.Fetcher))
|
||||
} else {
|
||||
emit(StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Fetcher))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emit(output)
|
||||
if (output is StoreReadResponse.NoNewData && cachedToEmit == null) {
|
||||
// In the special case where fetcher returned no new data we actually want to
|
||||
// serve cache data (even if the request specified skipping cache and/or SoT)
|
||||
//
|
||||
// For stream(Request.cached(key, refresh=true)) we will return:
|
||||
// Cache
|
||||
// Source of truth
|
||||
// Fetcher - > Loading
|
||||
// Fetcher - > NoNewData
|
||||
// (future Source of truth updates)
|
||||
//
|
||||
// For stream(Request.fresh(key)) we will return:
|
||||
// Fetcher - > Loading
|
||||
// Fetcher - > NoNewData
|
||||
// Cache
|
||||
// Source of truth
|
||||
// (future Source of truth updates)
|
||||
memCache?.getIfPresent(request.key)?.let {
|
||||
emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import org.mobilenativefoundation.store.store5.Store
|
|||
import org.mobilenativefoundation.store.store5.StoreBuilder
|
||||
import org.mobilenativefoundation.store.store5.StoreDefaults
|
||||
import org.mobilenativefoundation.store.store5.Updater
|
||||
import org.mobilenativefoundation.store.store5.Validator
|
||||
import org.mobilenativefoundation.store.store5.impl.extensions.asMutableStore
|
||||
|
||||
fun <Key : Any, Network : Any, Output : Any> storeBuilderFromFetcher(
|
||||
|
@ -31,6 +32,7 @@ internal class RealStoreBuilder<Key : Any, Network : Any, Output : Any, Local :
|
|||
private var scope: CoroutineScope? = null
|
||||
private var cachePolicy: MemoryPolicy<Key, Output>? = StoreDefaults.memoryPolicy
|
||||
private var converter: Converter<Network, Output, Local>? = null
|
||||
private var validator: Validator<Output>? = null
|
||||
|
||||
override fun scope(scope: CoroutineScope): StoreBuilder<Key, Network, Output, Local> {
|
||||
this.scope = scope
|
||||
|
@ -47,6 +49,11 @@ internal class RealStoreBuilder<Key : Any, Network : Any, Output : Any, Local :
|
|||
return this
|
||||
}
|
||||
|
||||
override fun validator(validator: Validator<Output>): StoreBuilder<Key, Network, Output, Local> {
|
||||
this.validator = validator
|
||||
return this
|
||||
}
|
||||
|
||||
override fun converter(converter: Converter<Network, Output, Local>): StoreBuilder<Key, Network, Output, Local> {
|
||||
this.converter = converter
|
||||
return this
|
||||
|
@ -57,7 +64,8 @@ internal class RealStoreBuilder<Key : Any, Network : Any, Output : Any, Local :
|
|||
sourceOfTruth = sourceOfTruth,
|
||||
fetcher = fetcher,
|
||||
memoryPolicy = cachePolicy,
|
||||
converter = converter
|
||||
converter = converter,
|
||||
validator = validator
|
||||
)
|
||||
|
||||
override fun <UpdaterResult : Any> build(
|
||||
|
|
|
@ -76,7 +76,7 @@ internal class SourceOfTruthWithBarrier<Key : Any, Network : Any, Output : Any,
|
|||
}
|
||||
val readFlow: Flow<StoreReadResponse<Output?>> = when (barrierMessage) {
|
||||
is BarrierMsg.Open ->
|
||||
delegate.reader(key).mapIndexed { index, sourceOfTruth ->
|
||||
delegate.reader(key).mapIndexed { index, local ->
|
||||
if (index == 0 && messageArrivedAfterMe) {
|
||||
val firstMsgOrigin = if (writeError == null) {
|
||||
// restarted barrier without an error means write succeeded
|
||||
|
@ -89,22 +89,24 @@ internal class SourceOfTruthWithBarrier<Key : Any, Network : Any, Output : Any,
|
|||
StoreReadResponseOrigin.SourceOfTruth
|
||||
}
|
||||
|
||||
val value = sourceOfTruth as? Output ?: if (sourceOfTruth != null) {
|
||||
converter?.fromLocalToOutput(sourceOfTruth)
|
||||
} else {
|
||||
null
|
||||
val output = when {
|
||||
local != null -> converter?.fromLocalToOutput(local) ?: local as? Output
|
||||
else -> null
|
||||
}
|
||||
|
||||
StoreReadResponse.Data(
|
||||
origin = firstMsgOrigin,
|
||||
value = value
|
||||
value = output
|
||||
)
|
||||
} else {
|
||||
val output = when {
|
||||
local != null -> converter?.fromLocalToOutput(local) ?: local as? Output
|
||||
else -> null
|
||||
}
|
||||
|
||||
StoreReadResponse.Data(
|
||||
origin = StoreReadResponseOrigin.SourceOfTruth,
|
||||
value = sourceOfTruth as? Output
|
||||
?: if (sourceOfTruth != null) converter?.fromLocalToOutput(
|
||||
sourceOfTruth
|
||||
) else null
|
||||
value = output
|
||||
) as StoreReadResponse<Output?>
|
||||
}
|
||||
}.catch { throwable ->
|
||||
|
@ -151,10 +153,9 @@ internal class SourceOfTruthWithBarrier<Key : Any, Network : Any, Output : Any,
|
|||
try {
|
||||
barrier.emit(BarrierMsg.Blocked(versionCounter.incrementAndGet()))
|
||||
val writeError = try {
|
||||
val input = value as? Local ?: converter?.fromOutputToLocal(value)
|
||||
if (input != null) {
|
||||
delegate.write(key, input)
|
||||
}
|
||||
val local = converter?.fromOutputToLocal(value)
|
||||
val input = local ?: value
|
||||
delegate.write(key, input as Local)
|
||||
null
|
||||
} catch (throwable: Throwable) {
|
||||
if (throwable !is CancellationException) {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package org.mobilenativefoundation.store.store5.impl.extensions
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
internal fun now() = Clock.System.now().toEpochMilliseconds()
|
||||
internal fun inHours(n: Int) = Clock.System.now().plus(n.hours).toEpochMilliseconds()
|
||||
|
|
|
@ -1,46 +1,53 @@
|
|||
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.asMutableStore
|
||||
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.NotesUpdaterProvider
|
||||
import org.mobilenativefoundation.store.store5.util.fake.NotesValidator
|
||||
import org.mobilenativefoundation.store.store5.util.model.CommonNote
|
||||
import org.mobilenativefoundation.store.store5.util.model.NetworkNote
|
||||
import org.mobilenativefoundation.store.store5.util.model.Note
|
||||
import org.mobilenativefoundation.store.store5.util.model.NoteData
|
||||
import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse
|
||||
import org.mobilenativefoundation.store.store5.util.model.SOTNote
|
||||
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 givenEmptyMarketWhenWriteThenSuccessResponsesAndApiUpdated() = testScope.runTest {
|
||||
val updater = Updater.by<String, CommonNote, NotesWriteResponse>(
|
||||
post = { key, common ->
|
||||
val response = api.post(key, common)
|
||||
if (response.ok) {
|
||||
UpdaterResult.Success.Typed(response)
|
||||
} else {
|
||||
UpdaterResult.Error.Message("Failed to sync")
|
||||
}
|
||||
}
|
||||
)
|
||||
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,
|
||||
|
@ -48,30 +55,165 @@ class UpdaterTests {
|
|||
clearAll = bookkeeping::clear
|
||||
)
|
||||
|
||||
val store = StoreBuilder.from<String, NetworkNote, CommonNote>(
|
||||
fetcher = Fetcher.ofFlow { key ->
|
||||
val network = NetworkNote(NoteData.Single(Note("$key-id", "$key-title", "$key-content")))
|
||||
flow { emit(network) }
|
||||
}
|
||||
val store = StoreBuilder.from<String, NetworkNote, CommonNote, SOTNote>(
|
||||
fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) },
|
||||
sourceOfTruth = SourceOfTruth.of(
|
||||
nonFlowReader = { key -> notes.get(key) },
|
||||
writer = { key, sot -> notes.put(key, sot) },
|
||||
delete = { key -> notes.clear(key) },
|
||||
deleteAll = { notes.clear() }
|
||||
)
|
||||
)
|
||||
.build()
|
||||
.asMutableStore<String, NetworkNote, CommonNote, SOTNote, NotesWriteResponse>(
|
||||
.converter(converter)
|
||||
.validator(validator)
|
||||
.build(
|
||||
updater = updater,
|
||||
bookkeeper = bookkeeper
|
||||
)
|
||||
|
||||
val noteKey = "1-id"
|
||||
val noteTitle = "1-title"
|
||||
val noteContent = "1-content"
|
||||
val noteData = NoteData.Single(Note(noteKey, noteTitle, noteContent))
|
||||
val readRequest = StoreReadRequest.fresh(Notes.One.id)
|
||||
|
||||
val stream = store.stream<NotesWriteResponse>(readRequest)
|
||||
|
||||
// Read is success
|
||||
assertEmitsExactly(
|
||||
stream,
|
||||
listOf(
|
||||
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher),
|
||||
StoreReadResponse.Data(CommonNote(NoteData.Single(Notes.One), ttl = ttl), StoreReadResponseOrigin.Fetcher)
|
||||
)
|
||||
)
|
||||
|
||||
val newNote = Notes.One.copy(title = "New Title-1")
|
||||
val writeRequest = StoreWriteRequest.of<String, CommonNote, NotesWriteResponse>(
|
||||
key = noteKey,
|
||||
value = CommonNote(noteData)
|
||||
key = Notes.One.id,
|
||||
value = CommonNote(NoteData.Single(newNote))
|
||||
)
|
||||
|
||||
val storeWriteResponse = store.write(writeRequest)
|
||||
|
||||
assertEquals(StoreWriteResponse.Success.Typed(NotesWriteResponse(noteKey, true)), storeWriteResponse)
|
||||
assertEquals(NetworkNote(noteData), api.db[noteKey])
|
||||
// Write is success
|
||||
assertEquals(StoreWriteResponse.Success.Typed(NotesWriteResponse(Notes.One.id, true)), storeWriteResponse)
|
||||
|
||||
val cachedReadRequest = StoreReadRequest.cached(Notes.One.id, refresh = false)
|
||||
val cachedStream = store.stream<NotesWriteResponse>(cachedReadRequest)
|
||||
|
||||
// Cache + SOT are updated
|
||||
val firstResponse = cachedStream.first()
|
||||
assertEquals(StoreReadResponse.Data(CommonNote(NoteData.Single(newNote), ttl = null), StoreReadResponseOrigin.Cache), firstResponse)
|
||||
|
||||
val secondResponse = cachedStream.take(2).last()
|
||||
assertIs<StoreReadResponse.Data<CommonNote>>(secondResponse)
|
||||
val data = 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(Notes.One.id, true)), storeWriteResponse)
|
||||
assertEquals(NetworkNote(NoteData.Single(newNote), ttl = null), api.db[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 = StoreBuilder.from<String, NetworkNote, CommonNote, SOTNote>(
|
||||
fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) },
|
||||
sourceOfTruth = SourceOfTruth.of(
|
||||
nonFlowReader = { key -> notes.get(key) },
|
||||
writer = { key, sot -> 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(Notes.One.id)
|
||||
|
||||
val stream = store.stream<NotesWriteResponse>(readRequest)
|
||||
|
||||
// Fetch is success and validator is not used
|
||||
assertEmitsExactly(
|
||||
stream,
|
||||
listOf(
|
||||
StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher),
|
||||
StoreReadResponse.Data(CommonNote(NoteData.Single(Notes.One), ttl = ttl), StoreReadResponseOrigin.Fetcher)
|
||||
)
|
||||
)
|
||||
|
||||
val cachedReadRequest = StoreReadRequest.cached(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.Data(CommonNote(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 = StoreBuilder.from<String, NetworkNote, CommonNote, SOTNote>(
|
||||
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(converter)
|
||||
.validator(validator)
|
||||
.build(
|
||||
updater = updater,
|
||||
bookkeeper = bookkeeper
|
||||
)
|
||||
|
||||
val newNote = Notes.One.copy(title = "New Title-1")
|
||||
val writeRequest = StoreWriteRequest.of<String, CommonNote, NotesWriteResponse>(
|
||||
key = Notes.One.id,
|
||||
value = CommonNote(NoteData.Single(newNote))
|
||||
)
|
||||
val storeWriteResponse = store.write(writeRequest)
|
||||
|
||||
assertEquals(StoreWriteResponse.Success.Typed(NotesWriteResponse(Notes.One.id, true)), storeWriteResponse)
|
||||
assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[Notes.One.id])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package org.mobilenativefoundation.store.store5.util
|
||||
|
||||
internal interface TestApi<Key : Any, Network : Any, Output : Any, Response : Any> {
|
||||
fun get(key: Key, fail: Boolean = false): Network?
|
||||
fun get(key: Key, fail: Boolean = false, ttl: Long? = null): Network?
|
||||
fun post(key: Key, value: Output, fail: Boolean = false): Response
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package org.mobilenativefoundation.store.store5.util.fake
|
||||
|
||||
import org.mobilenativefoundation.store.store5.util.model.Note
|
||||
|
||||
internal object Notes {
|
||||
val One = Note("1", "Title-1", "Content-1")
|
||||
val Two = Note("2", "Title-2", "Content-2")
|
||||
val Three = Note("3", "Title-3", "Content-3")
|
||||
val Four = Note("4", "Title-4", "Content-4")
|
||||
val Five = Note("5", "Title-5", "Content-5")
|
||||
val Six = Note("6", "Title-6", "Content-6")
|
||||
val Seven = Note("7", "Title-7", "Content-7")
|
||||
val Eight = Note("8", "Title-8", "Content-8")
|
||||
val Nine = Note("9", "Title-9", "Content-9")
|
||||
val Ten = Note("10", "Title-10", "Content-10")
|
||||
}
|
|
@ -3,7 +3,6 @@ package org.mobilenativefoundation.store.store5.util.fake
|
|||
import org.mobilenativefoundation.store.store5.util.TestApi
|
||||
import org.mobilenativefoundation.store.store5.util.model.CommonNote
|
||||
import org.mobilenativefoundation.store.store5.util.model.NetworkNote
|
||||
import org.mobilenativefoundation.store.store5.util.model.Note
|
||||
import org.mobilenativefoundation.store.store5.util.model.NoteData
|
||||
import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse
|
||||
|
||||
|
@ -14,12 +13,17 @@ internal class NotesApi : TestApi<String, NetworkNote, CommonNote, NotesWriteRes
|
|||
seed()
|
||||
}
|
||||
|
||||
override fun get(key: String, fail: Boolean): NetworkNote? {
|
||||
override fun get(key: String, fail: Boolean, ttl: Long?): NetworkNote {
|
||||
if (fail) {
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
return db[key]
|
||||
val networkNote = db[key]!!
|
||||
return if (ttl != null) {
|
||||
networkNote.copy(ttl = ttl)
|
||||
} else {
|
||||
networkNote
|
||||
}
|
||||
}
|
||||
|
||||
override fun post(key: String, value: CommonNote, fail: Boolean): NotesWriteResponse {
|
||||
|
@ -33,8 +37,15 @@ internal class NotesApi : TestApi<String, NetworkNote, CommonNote, NotesWriteRes
|
|||
}
|
||||
|
||||
private fun seed() {
|
||||
db["1-id"] = NetworkNote(NoteData.Single(Note("1-id", "1-title", "1-content")))
|
||||
db["2-id"] = NetworkNote(NoteData.Single(Note("2-id", "2-title", "2-content")))
|
||||
db["3-id"] = NetworkNote(NoteData.Single(Note("3-id", "3-title", "3-content")))
|
||||
db[Notes.One.id] = NetworkNote(NoteData.Single(Notes.One))
|
||||
db[Notes.Two.id] = NetworkNote(NoteData.Single(Notes.Two))
|
||||
db[Notes.Three.id] = NetworkNote(NoteData.Single(Notes.Three))
|
||||
db[Notes.Four.id] = NetworkNote(NoteData.Single(Notes.Four))
|
||||
db[Notes.Five.id] = NetworkNote(NoteData.Single(Notes.Five))
|
||||
db[Notes.Six.id] = NetworkNote(NoteData.Single(Notes.Six))
|
||||
db[Notes.Seven.id] = NetworkNote(NoteData.Single(Notes.Seven))
|
||||
db[Notes.Eight.id] = NetworkNote(NoteData.Single(Notes.Eight))
|
||||
db[Notes.Nine.id] = NetworkNote(NoteData.Single(Notes.Nine))
|
||||
db[Notes.Ten.id] = NetworkNote(NoteData.Single(Notes.Ten))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package org.mobilenativefoundation.store.store5.util.fake
|
||||
|
||||
import org.mobilenativefoundation.store.store5.Converter
|
||||
import org.mobilenativefoundation.store.store5.impl.extensions.inHours
|
||||
import org.mobilenativefoundation.store.store5.util.model.CommonNote
|
||||
import org.mobilenativefoundation.store.store5.util.model.NetworkNote
|
||||
import org.mobilenativefoundation.store.store5.util.model.SOTNote
|
||||
|
||||
internal class NotesConverterProvider {
|
||||
fun provide(): Converter<NetworkNote, CommonNote, SOTNote> = Converter.Builder<NetworkNote, CommonNote, SOTNote>()
|
||||
.fromLocalToOutput { value -> CommonNote(data = value.data, ttl = value.ttl) }
|
||||
.fromOutputToLocal { value -> SOTNote(data = value.data, ttl = value.ttl ?: inHours(12)) }
|
||||
.fromNetworkToOutput { value -> CommonNote(data = value.data, ttl = value.ttl) }
|
||||
.build()
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package org.mobilenativefoundation.store.store5.util.fake
|
||||
|
||||
import org.mobilenativefoundation.store.store5.util.model.SOTNote
|
||||
|
||||
internal class NotesDatabase {
|
||||
private val db: MutableMap<String, SOTNote?> = mutableMapOf()
|
||||
fun put(key: String, input: SOTNote, fail: Boolean = false): Boolean {
|
||||
if (fail) {
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
db[key] = input
|
||||
return true
|
||||
}
|
||||
|
||||
fun get(key: String, fail: Boolean = false): SOTNote? {
|
||||
if (fail) {
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
return db[key]
|
||||
}
|
||||
|
||||
fun clear(key: String, fail: Boolean = false): Boolean {
|
||||
if (fail) {
|
||||
throw Exception()
|
||||
}
|
||||
db.remove(key)
|
||||
return true
|
||||
}
|
||||
|
||||
fun clear(fail: Boolean = false): Boolean {
|
||||
if (fail) {
|
||||
throw Exception()
|
||||
}
|
||||
db.clear()
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package org.mobilenativefoundation.store.store5.util.fake
|
||||
|
||||
import org.mobilenativefoundation.store.store5.Updater
|
||||
import org.mobilenativefoundation.store.store5.UpdaterResult
|
||||
import org.mobilenativefoundation.store.store5.util.model.CommonNote
|
||||
import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse
|
||||
|
||||
internal class NotesUpdaterProvider(private val api: NotesApi) {
|
||||
fun provide(): Updater<String, CommonNote, NotesWriteResponse> = Updater.by(
|
||||
post = { key, input ->
|
||||
val response = api.post(key, input)
|
||||
if (response.ok) {
|
||||
UpdaterResult.Success.Typed(response)
|
||||
} else {
|
||||
UpdaterResult.Error.Message("Failed to sync")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package org.mobilenativefoundation.store.store5.util.fake
|
||||
|
||||
import org.mobilenativefoundation.store.store5.Validator
|
||||
import org.mobilenativefoundation.store.store5.impl.extensions.now
|
||||
import org.mobilenativefoundation.store.store5.util.model.CommonNote
|
||||
|
||||
internal class NotesValidator(private val expiration: Long = now()) : Validator<CommonNote> {
|
||||
override suspend fun isValid(item: CommonNote): Boolean = when {
|
||||
item.ttl == null -> true
|
||||
else -> item.ttl > expiration
|
||||
}
|
||||
}
|
|
@ -11,15 +11,18 @@ internal data class NotesWriteResponse(
|
|||
)
|
||||
|
||||
internal data class NetworkNote(
|
||||
val data: NoteData? = null
|
||||
val data: NoteData? = null,
|
||||
val ttl: Long? = null,
|
||||
)
|
||||
|
||||
internal data class CommonNote(
|
||||
val data: NoteData? = null
|
||||
val data: NoteData? = null,
|
||||
val ttl: Long? = null,
|
||||
)
|
||||
|
||||
internal data class SOTNote(
|
||||
val data: NoteData? = null
|
||||
val data: NoteData? = null,
|
||||
val ttl: Long
|
||||
)
|
||||
|
||||
internal data class Note(
|
||||
|
|
Loading…
Reference in a new issue