diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt index 1099ef2d7..2a7c30140 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt @@ -77,9 +77,10 @@ internal class RealStore( 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 } } @@ -122,7 +123,12 @@ internal class RealStore( // 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 + ) + ) } } } @@ -201,7 +207,8 @@ internal class RealStore( 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 @@ -230,8 +237,11 @@ internal class RealStore( } 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 @@ -241,7 +251,7 @@ internal class RealStore( // 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) } } @@ -295,7 +305,9 @@ internal class RealStore( 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() diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt new file mode 100644 index 000000000..a02df3caa --- /dev/null +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt @@ -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( + 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(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( + 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(cachedReadRequest) + + // Cache + SOT are updated + val firstResponse: StoreReadResponse = cachedStream.first() +// assertEquals( +// StoreReadResponse.Data( +// OutputNote(NoteData.Single(newNote), ttl = 0), +// StoreReadResponseOrigin.Cache +// ), + firstResponse +// ) + + val secondResponse = cachedStream.take(2).last() + assertIs>(secondResponse) + val data: NoteData? = secondResponse.value.data + assertIs(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( + 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(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(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( + 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( + 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)]) + } +} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt index 1b19a82c3..94afac4ad 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt @@ -6,7 +6,7 @@ import org.mobilenativefoundation.store.store5.util.model.OutputNote internal class NotesValidator(private val expiration: Long = now()) : Validator { override suspend fun isValid(item: OutputNote): Boolean = when { - item.ttl == null -> true + item.ttl == 0L -> true else -> item.ttl > expiration } }