From 10da116ea6de117980a1279e368b35ed45d25cd9 Mon Sep 17 00:00:00 2001 From: Mez Pahlan Date: Sun, 31 May 2020 00:47:14 +0100 Subject: [PATCH] Add Store RxJava3 module (#171) --- buildsystem/dependencies.gradle | 5 +- settings.gradle | 2 +- store-rx2/build.gradle | 2 +- store-rx3/.gitignore | 1 + store-rx3/api/store-rx3.api | 26 ++++ store-rx3/build.gradle | 39 ++++++ store-rx3/gradle.properties | 18 +++ .../kotlin/com/dropbox/store/rx3/RxFetcher.kt | 71 ++++++++++ .../com/dropbox/store/rx3/RxSourceOfTruth.kt | 62 +++++++++ .../kotlin/com/dropbox/store/rx3/RxStore.kt | 52 +++++++ .../com/dropbox/store/rx3/RxStoreBuilder.kt | 21 +++ .../com/dropbox/store/rx3/test/FlowTestExt.kt | 86 ++++++++++++ .../store/rx3/test/HotRxSingleStoreTest.kt | 83 ++++++++++++ .../store/rx3/test/RxFlowableStoreTest.kt | 109 +++++++++++++++ .../rx3/test/RxSingleStoreExtensionsTest.kt | 77 +++++++++++ .../store/rx3/test/RxSingleStoreTest.kt | 128 ++++++++++++++++++ 16 files changed, 779 insertions(+), 3 deletions(-) create mode 100644 store-rx3/.gitignore create mode 100644 store-rx3/api/store-rx3.api create mode 100644 store-rx3/build.gradle create mode 100644 store-rx3/gradle.properties create mode 100644 store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxFetcher.kt create mode 100644 store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxSourceOfTruth.kt create mode 100644 store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStore.kt create mode 100644 store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStoreBuilder.kt create mode 100644 store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/FlowTestExt.kt create mode 100644 store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/HotRxSingleStoreTest.kt create mode 100644 store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxFlowableStoreTest.kt create mode 100644 store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreExtensionsTest.kt create mode 100644 store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreTest.kt diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle index e712447..5e6504b 100644 --- a/buildsystem/dependencies.gradle +++ b/buildsystem/dependencies.gradle @@ -35,6 +35,7 @@ ext.versions = [ navigation : '2.2.1', constraintLayout : '1.1.3', rx2 : '2.2.19', + rx3 : '3.0.3', // Testing. junit : '4.13', @@ -69,6 +70,7 @@ ext.libraries = [ roomRuntime : "androidx.room:room-ktx:$versions.room", coreKtx : "androidx.core:core-ktx:$versions.coreKtx", rx2 : "io.reactivex.rxjava2:rxjava:$versions.rx2", + rx3 : "io.reactivex.rxjava3:rxjava:$versions.rx3", // Testing. junit : "junit:junit:$versions.junit", @@ -76,7 +78,8 @@ ext.libraries = [ mockito : "org.mockito:mockito-core:$versions.mockito", mockitoKotlin : "com.nhaarman.mockitokotlin2:mockito-kotlin:$versions.mockitoKotlin", coroutinesCore : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines", - coroutinesRx : "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$versions.coroutines", + coroutinesRx2 : "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$versions.coroutines", + coroutinesRx3 : "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$versions.coroutines", coroutinesReactive : "org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$versions.coroutines", coroutinesAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines", coroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.coroutines", diff --git a/settings.gradle b/settings.gradle index aa065e4..8d36a6b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':app', ':store', ':cache', ':filesystem', ':multicast',':store-rx2' +include ':app', ':store', ':cache', ':filesystem', ':multicast',':store-rx2', ':store-rx3' diff --git a/store-rx2/build.gradle b/store-rx2/build.gradle index 5c6cb8d..a3b501b 100644 --- a/store-rx2/build.gradle +++ b/store-rx2/build.gradle @@ -6,7 +6,7 @@ plugins { dependencies { implementation libraries.kotlinStdLib implementation libraries.coroutinesCore - implementation libraries.coroutinesRx + implementation libraries.coroutinesRx2 implementation libraries.coroutinesReactive implementation project(path: ':store') implementation libraries.rx2 diff --git a/store-rx3/.gitignore b/store-rx3/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/store-rx3/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/store-rx3/api/store-rx3.api b/store-rx3/api/store-rx3.api new file mode 100644 index 0000000..a6e6a2c --- /dev/null +++ b/store-rx3/api/store-rx3.api @@ -0,0 +1,26 @@ +public final class com/dropbox/store/rx3/RxFetcherKt { + public static final fun flowableFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; + public static final fun flowableValueFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; + public static final fun singleFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; + public static final fun singleValueFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; +} + +public final class com/dropbox/store/rx3/RxSourceOfTruthKt { + public static final fun fromFlowable (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static synthetic fun fromFlowable$default (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static final fun fromMaybe (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static synthetic fun fromMaybe$default (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/SourceOfTruth; +} + +public final class com/dropbox/store/rx3/RxStoreBuilderKt { + public static final fun withScheduler (Lcom/dropbox/android/external/store4/StoreBuilder;Lio/reactivex/rxjava3/core/Scheduler;)Lcom/dropbox/android/external/store4/StoreBuilder; +} + +public final class com/dropbox/store/rx3/RxStoreKt { + public static final fun freshSingle (Lcom/dropbox/android/external/store4/Store;Ljava/lang/Object;)Lio/reactivex/rxjava3/core/Single; + public static final fun getSingle (Lcom/dropbox/android/external/store4/Store;Ljava/lang/Object;)Lio/reactivex/rxjava3/core/Single; + public static final fun observe (Lcom/dropbox/android/external/store4/Store;Lcom/dropbox/android/external/store4/StoreRequest;)Lio/reactivex/rxjava3/core/Flowable; + public static final fun observeClear (Lcom/dropbox/android/external/store4/Store;Ljava/lang/Object;)Lio/reactivex/rxjava3/core/Completable; + public static final fun observeClearAll (Lcom/dropbox/android/external/store4/Store;)Lio/reactivex/rxjava3/core/Completable; +} + diff --git a/store-rx3/build.gradle b/store-rx3/build.gradle new file mode 100644 index 0000000..1ba1a5e --- /dev/null +++ b/store-rx3/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.dokka' +} + +dependencies { + implementation libraries.kotlinStdLib + implementation libraries.coroutinesCore + implementation libraries.coroutinesRx3 + implementation libraries.coroutinesReactive + implementation project(path: ':store') + implementation libraries.rx3 + + testImplementation libraries.junit + testImplementation libraries.truth + testImplementation libraries.mockito + testImplementation libraries.coroutinesTest + +} +group = GROUP +version = VERSION_NAME +apply from: rootProject.file("gradle/maven-push.gradle") +apply from: rootProject.file("gradle/jacoco.gradle") +targetCompatibility = 1.8 +sourceCompatibility = 1.8 + +compileKotlin { + kotlinOptions { + jvmTarget = "1.8" + } +} +compileTestKotlin { + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs += [ + '-Xopt-in=kotlin.RequiresOptIn', + ] + } +} \ No newline at end of file diff --git a/store-rx3/gradle.properties b/store-rx3/gradle.properties new file mode 100644 index 0000000..a403613 --- /dev/null +++ b/store-rx3/gradle.properties @@ -0,0 +1,18 @@ +# +# Copyright 2020 Dropbox LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +POM_NAME=com.dropbox.mobile.store +POM_ARTIFACT_ID=store-rx3 +POM_PACKAGING=jar diff --git a/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxFetcher.kt b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxFetcher.kt new file mode 100644 index 0000000..a7e7540 --- /dev/null +++ b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxFetcher.kt @@ -0,0 +1,71 @@ +package com.dropbox.store.rx3 + +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.Store +import com.dropbox.android.external.store4.valueFetcher +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.reactive.asFlow + +/** + * Creates a new [Fetcher] from a [flowableFactory]. + * + * [Store] does not catch exception thrown in [flowableFactory] or in the returned [Flowable]. These + * exception will be propagated to the caller. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request + * network protocol (e.g Web Sockets). + * + * @param flowableFactory a factory for a [Flowable] source of network records. + */ +fun flowableFetcher( + flowableFactory: (key: Key) -> Flowable> +): Fetcher = { key: Key -> flowableFactory(key).asFlow() } + +/** + * "Creates" a [Fetcher] from a [singleFactory]. + * + * [Store] does not catch exception thrown in [singleFactory] or in the returned [Single]. These + * exception will be propagated to the caller. + * + * Use when creating a [Store] that fetches objects in a single response per request network + * protocol (e.g Http). + * + * @param singleFactory a factory for a [Single] source of network records. + */ +fun singleFetcher( + singleFactory: (key: Key) -> Single> +): Fetcher = { key: Key -> singleFactory(key).toFlowable().asFlow() } + +/** + * "Creates" a [Fetcher] from a [flowableFactory] and translate the results to a [FetcherResult]. + * + * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the stream then + * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowableFactory] itself are + * not caught and will be returned to the caller. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request + * network protocol (e.g Web Sockets). + * + * @param flowFactory a factory for a [Flowable] source of network records. + */ +fun flowableValueFetcher( + flowableFactory: (key: Key) -> Flowable +): Fetcher = valueFetcher { key: Key -> flowableFactory(key).asFlow() } + +/** + * Creates a new [Fetcher] from a [singleFactory] and translate the results to a [FetcherResult]. + * + * The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then + * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are + * not caught and will be returned to the caller. + * + * Use when creating a [Store] that fetches objects in a single response per request network + * protocol (e.g Http). + * + * @param singleFactory a factory for a [Single] source of network records. + */ +fun singleValueFetcher( + singleFactory: (key: Key) -> Single +): Fetcher = flowableValueFetcher { key: Key -> singleFactory(key).toFlowable() } diff --git a/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxSourceOfTruth.kt b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxSourceOfTruth.kt new file mode 100644 index 0000000..262076a --- /dev/null +++ b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxSourceOfTruth.kt @@ -0,0 +1,62 @@ +package com.dropbox.store.rx3 + +import com.dropbox.android.external.store4.SourceOfTruth +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.rx3.await + +/** + * Creates a [Maybe] source of truth that is accessible via [reader], [writer], [delete] and + * [deleteAll]. + * + * @param reader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth + * + */ +fun SourceOfTruth.Companion.fromMaybe( + reader: (Key) -> Maybe, + writer: (Key, Input) -> Completable, + delete: ((Key) -> Completable)? = null, + deleteAll: (() -> Completable)? = null +): SourceOfTruth { + val deleteFun: (suspend (Key) -> Unit)? = + if (delete != null) { key -> delete(key).await() } else null + val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } + return fromNonFlow( + reader = { key -> reader.invoke(key).await() }, + writer = { key, output -> writer.invoke(key, output).await() }, + delete = deleteFun, + deleteAll = deleteAllFun + ) +} + +/** + * Creates a ([Flowable]) source of truth that is accessed via [reader], [writer], [delete] and + * [deleteAll]. + * + * @param reader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth + * + */ +fun SourceOfTruth.Companion.fromFlowable( + reader: (Key) -> Flowable, + writer: (Key, Input) -> Completable, + delete: ((Key) -> Completable)? = null, + deleteAll: (() -> Completable)? = null +): SourceOfTruth { + val deleteFun: (suspend (Key) -> Unit)? = + if (delete != null) { key -> delete(key).await() } else null + val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } + return from( + reader = { key -> reader.invoke(key).asFlow() }, + writer = { key, output -> writer.invoke(key, output).await() }, + delete = deleteFun, + deleteAll = deleteAllFun + ) +} diff --git a/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStore.kt b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStore.kt new file mode 100644 index 0000000..8006473 --- /dev/null +++ b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStore.kt @@ -0,0 +1,52 @@ +package com.dropbox.store.rx3 + +import com.dropbox.android.external.store4.ExperimentalStoreApi +import com.dropbox.android.external.store4.Store +import com.dropbox.android.external.store4.StoreBuilder +import com.dropbox.android.external.store4.StoreRequest +import com.dropbox.android.external.store4.StoreResponse +import com.dropbox.android.external.store4.fresh +import com.dropbox.android.external.store4.get +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.rx3.asFlowable +import kotlinx.coroutines.rx3.rxCompletable +import kotlinx.coroutines.rx3.rxSingle + +/** + * Return a [Flowable] for the given key + * @param request - see [StoreRequest] for configurations + */ +@ExperimentalCoroutinesApi +fun Store.observe(request: StoreRequest): Flowable> = + stream(request).asFlowable() + +/** + * Purge a particular entry from memory and disk cache. + * Persistent storage will only be cleared if a delete function was passed to + * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. + */ +fun Store.observeClear(key: Key): Completable = + rxCompletable { clear(key) } + +/** + * Purge all entries from memory and disk cache. + * Persistent storage will only be cleared if a deleteAll function was passed to + * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. + */ +@ExperimentalStoreApi +fun Store.observeClearAll(): Completable = + rxCompletable { clearAll() } + +/** + * Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches) + */ +fun Store.getSingle(key: Key) = + rxSingle { this@getSingle.get(key) } + +/** + * Helper factory that will return fresh data as a [Single] for [key] while updating your caches + */ +fun Store.freshSingle(key: Key) = + rxSingle { this@freshSingle.fresh(key) } diff --git a/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStoreBuilder.kt b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStoreBuilder.kt new file mode 100644 index 0000000..dc79c70 --- /dev/null +++ b/store-rx3/src/main/kotlin/com/dropbox/store/rx3/RxStoreBuilder.kt @@ -0,0 +1,21 @@ +package com.dropbox.store.rx3 + +import com.dropbox.android.external.store4.StoreBuilder +import io.reactivex.rxjava3.core.Scheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.rx3.asCoroutineDispatcher + +/** + * Define what scheduler fetcher requests will be called on, + * if a scheduler is not set Store will use [GlobalScope] + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun StoreBuilder.withScheduler( + scheduler: Scheduler +): StoreBuilder { + return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) +} diff --git a/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/FlowTestExt.kt b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/FlowTestExt.kt new file mode 100644 index 0000000..86dd0a5 --- /dev/null +++ b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/FlowTestExt.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dropbox.store.rx3.test + +import com.google.common.truth.FailureMetadata +import com.google.common.truth.Subject +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestCoroutineScope + +@OptIn(ExperimentalCoroutinesApi::class) +internal fun TestCoroutineScope.assertThat(flow: Flow): FlowSubject { + return Truth.assertAbout( + FlowSubject.Factory( + this + ) + ).that(flow) +} + +@OptIn(ExperimentalCoroutinesApi::class) +internal class FlowSubject constructor( + failureMetadata: FailureMetadata, + private val testCoroutineScope: TestCoroutineScope, + private val actual: Flow +) : Subject(failureMetadata, actual) { + /** + * Takes all items in the flow that are available by collecting on it as long as there are + * active jobs in the given [TestCoroutineScope]. + * + * It ensures all expected items are dispatched as well as no additional unexpected items are + * dispatched. + */ + suspend fun emitsExactly(vararg expected: T) { + val collectedSoFar = mutableListOf() + val collectionCoroutine = testCoroutineScope.async { + actual.collect { + collectedSoFar.add(it) + if (collectedSoFar.size > expected.size) { + assertWithMessage("Too many emissions in the flow (only first additional item is shown)") + .that(collectedSoFar) + .isEqualTo(expected) + } + } + } + testCoroutineScope.advanceUntilIdle() + if (!collectionCoroutine.isActive) { + collectionCoroutine.getCompletionExceptionOrNull()?.let { + throw it + } + } + collectionCoroutine.cancelAndJoin() + assertWithMessage("Flow didn't exactly emit expected items") + .that(collectedSoFar) + .isEqualTo(expected.toList()) + } + + class Factory( + private val testCoroutineScope: TestCoroutineScope + ) : Subject.Factory, Flow> { + override fun createSubject(metadata: FailureMetadata, actual: Flow): FlowSubject { + return FlowSubject( + failureMetadata = metadata, + actual = actual, + testCoroutineScope = testCoroutineScope + ) + } + } +} diff --git a/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/HotRxSingleStoreTest.kt b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/HotRxSingleStoreTest.kt new file mode 100644 index 0000000..9bbbc64 --- /dev/null +++ b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/HotRxSingleStoreTest.kt @@ -0,0 +1,83 @@ +package com.dropbox.store.rx3.test + +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.StoreBuilder +import com.dropbox.android.external.store4.StoreRequest +import com.dropbox.android.external.store4.StoreResponse +import com.dropbox.store.rx3.singleFetcher +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +@FlowPreview +@ExperimentalCoroutinesApi +class HotRxSingleStoreTest { + private val testScope = TestCoroutineScope() + + @Test + fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = + testScope.runBlockingTest { + val fetcher: FakeRxFetcher> = + FakeRxFetcher( + 3 to FetcherResult.Data("three-1"), + 3 to FetcherResult.Data("three-2") + ) + val pipeline = StoreBuilder.from(singleFetcher { fetcher.fetch(it) }) + .scope(testScope) + .build() + + assertThat(pipeline.stream(StoreRequest.cached(3, refresh = false))) + .emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Data( + value = "three-1", + origin = ResponseOrigin.Fetcher + ) + ) + assertThat( + pipeline.stream(StoreRequest.cached(3, refresh = false)) + ).emitsExactly( + StoreResponse.Data( + value = "three-1", + origin = ResponseOrigin.Cache + ) + ) + + assertThat(pipeline.stream(StoreRequest.fresh(3))) + .emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), + StoreResponse.Data( + value = "three-2", + origin = ResponseOrigin.Fetcher + ) + ) + } +} + +class FakeRxFetcher( + vararg val responses: Pair +) { + private var index = 0 + + @Suppress("RedundantSuspendModifier") // needed for function reference + fun fetch(key: Key): Single { + // will throw if fetcher called more than twice + if (index >= responses.size) { + throw AssertionError("unexpected fetch request") + } + val pair = responses[index++] + assertThat(pair.first).isEqualTo(key) + return Single.just(pair.second) + } +} diff --git a/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxFlowableStoreTest.kt b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxFlowableStoreTest.kt new file mode 100644 index 0000000..e901ec0 --- /dev/null +++ b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxFlowableStoreTest.kt @@ -0,0 +1,109 @@ +package com.dropbox.store.rx3.test + +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.SourceOfTruth +import com.dropbox.android.external.store4.StoreBuilder +import com.dropbox.android.external.store4.StoreRequest +import com.dropbox.android.external.store4.StoreResponse +import com.dropbox.store.rx3.flowableFetcher +import com.dropbox.store.rx3.fromFlowable +import com.dropbox.store.rx3.observe +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.schedulers.TestScheduler +import io.reactivex.rxjava3.subscribers.TestSubscriber +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(JUnit4::class) +@FlowPreview +@ExperimentalCoroutinesApi +class RxFlowableStoreTest { + private val testScheduler = TestScheduler() + private val atomicInteger = AtomicInteger(0) + private val fakeDisk = mutableMapOf() + private val store = + StoreBuilder.from( + flowableFetcher { + Flowable.create({ emitter -> + emitter.onNext( + FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence") + ) + emitter.onNext( + FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence") + ) + emitter.onComplete() + }, BackpressureStrategy.LATEST) + }, + sourceOfTruth = SourceOfTruth.fromFlowable( + reader = { + if (fakeDisk[it] != null) + Flowable.fromCallable { fakeDisk[it]!! } + else + Flowable.empty() + }, + writer = { key, value -> + Completable.fromAction { fakeDisk[key] = value } + } + )) + .build() + + @Test + fun simpleTest() { + var testSubscriber = TestSubscriber>() + store.observe(StoreRequest.fresh(3)) + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber + .awaitCount(3) + .assertValues( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Data("3 1 occurrence", ResponseOrigin.Fetcher), + StoreResponse.Data("3 2 occurrence", ResponseOrigin.Fetcher) + ) + + testSubscriber = TestSubscriber>() + store.observe(StoreRequest.cached(3, false)) + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber + .awaitCount(2) + .assertValues( + StoreResponse.Data("3 2 occurrence", ResponseOrigin.Cache), + StoreResponse.Data("3 2 occurrence", ResponseOrigin.SourceOfTruth) + ) + + testSubscriber = TestSubscriber>() + store.observe(StoreRequest.fresh(3)) + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber + .awaitCount(3) + .assertValues( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Data("3 3 occurrence", ResponseOrigin.Fetcher), + StoreResponse.Data("3 4 occurrence", ResponseOrigin.Fetcher) + ) + + testSubscriber = TestSubscriber>() + store.observe(StoreRequest.cached(3, false)) + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber + .awaitCount(2) + .assertValues( + StoreResponse.Data("3 4 occurrence", ResponseOrigin.Cache), + StoreResponse.Data("3 4 occurrence", ResponseOrigin.SourceOfTruth) + ) + } +} diff --git a/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreExtensionsTest.kt b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreExtensionsTest.kt new file mode 100644 index 0000000..798ea7d --- /dev/null +++ b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreExtensionsTest.kt @@ -0,0 +1,77 @@ +package com.dropbox.store.rx3.test + +import com.dropbox.android.external.store4.ExperimentalStoreApi +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.SourceOfTruth +import com.dropbox.android.external.store4.StoreBuilder +import com.dropbox.store.rx3.freshSingle +import com.dropbox.store.rx3.fromMaybe +import com.dropbox.store.rx3.getSingle +import com.dropbox.store.rx3.singleFetcher +import com.dropbox.store.rx3.withScheduler +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.util.concurrent.atomic.AtomicInteger + +@ExperimentalStoreApi +@RunWith(JUnit4::class) +@FlowPreview +@ExperimentalCoroutinesApi +class RxSingleStoreExtensionsTest { + private val atomicInteger = AtomicInteger(0) + private var fakeDisk = mutableMapOf() + private val store = + StoreBuilder.from( + fetcher = singleFetcher { + Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } + }, + sourceOfTruth = SourceOfTruth.fromMaybe( + reader = { Maybe.fromCallable { fakeDisk[it] } }, + writer = { key, value -> + Completable.fromAction { fakeDisk[key] = value } + }, + delete = { key -> + Completable.fromAction { fakeDisk.remove(key) } + }, + deleteAll = { + Completable.fromAction { fakeDisk.clear() } + } + ) + ) + .withScheduler(Schedulers.trampoline()) + .build() + + @Test + fun `store rx extension tests`() { + // Return from cache - after initial fetch + store.getSingle(3) + .test() + .await() + .assertValue("3 1") + + // Return from cache + store.getSingle(3) + .test() + .await() + .assertValue("3 1") + + // Return from fresh - forcing a new fetch + store.freshSingle(3) + .test() + .await() + .assertValue("3 2") + + // Return from cache - different to initial + store.getSingle(3) + .test() + .await() + .assertValue("3 2") + } +} diff --git a/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreTest.kt b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreTest.kt new file mode 100644 index 0000000..85a4e04 --- /dev/null +++ b/store-rx3/src/test/kotlin/com/dropbox/store/rx3/test/RxSingleStoreTest.kt @@ -0,0 +1,128 @@ +package com.dropbox.store.rx3.test + +import com.dropbox.android.external.store4.ExperimentalStoreApi +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.SourceOfTruth +import com.dropbox.android.external.store4.StoreBuilder +import com.dropbox.android.external.store4.StoreRequest +import com.dropbox.android.external.store4.StoreResponse +import com.dropbox.store.rx3.fromMaybe +import com.dropbox.store.rx3.observe +import com.dropbox.store.rx3.observeClear +import com.dropbox.store.rx3.observeClearAll +import com.dropbox.store.rx3.singleFetcher +import com.dropbox.store.rx3.withScheduler +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.util.concurrent.atomic.AtomicInteger + +@ExperimentalStoreApi +@RunWith(JUnit4::class) +@FlowPreview +@ExperimentalCoroutinesApi +class RxSingleStoreTest { + private val atomicInteger = AtomicInteger(0) + private var fakeDisk = mutableMapOf() + private val store = + StoreBuilder.from( + fetcher = singleFetcher { + Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } + }, + sourceOfTruth = SourceOfTruth.fromMaybe( + reader = { Maybe.fromCallable { fakeDisk[it] } }, + writer = { key, value -> + Completable.fromAction { fakeDisk[key] = value } + }, + delete = { key -> + Completable.fromAction { fakeDisk.remove(key) } + }, + deleteAll = { + Completable.fromAction { fakeDisk.clear() } + } + ) + ) + .withScheduler(Schedulers.trampoline()) + .build() + + @Test + fun simpleTest() { + store.observe(StoreRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Data("3 1", ResponseOrigin.Fetcher) + ) + + store.observe(StoreRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreResponse.Data("3 1", ResponseOrigin.Cache), + StoreResponse.Data("3 1", ResponseOrigin.SourceOfTruth) + ) + + store.observe(StoreRequest.fresh(3)) + .test() + .awaitCount(2) + .assertValues( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Data("3 2", ResponseOrigin.Fetcher) + ) + + store.observe(StoreRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreResponse.Data("3 2", ResponseOrigin.Cache), + StoreResponse.Data("3 2", ResponseOrigin.SourceOfTruth) + ) + } + + @Test + fun `GIVEN a store with persister values WHEN observeClear is Called THEN next Store get hits network`() { + fakeDisk[3] = "seeded occurrence" + + store.observeClear(3).blockingAwait() + + store.observe(StoreRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Data("3 1", ResponseOrigin.Fetcher) + ) + } + + @Test + fun `GIVEN a store with persister values WHEN observeClearAll is called THEN next Store get calls both hit network`() { + fakeDisk[3] = "seeded occurrence" + fakeDisk[4] = "another seeded occurrence" + + store.observeClearAll().blockingAwait() + + store.observe(StoreRequest.cached(3, false)) + .test() + .awaitCount(2) + .assertValues( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Data("3 1", ResponseOrigin.Fetcher) + ) + + store.observe(StoreRequest.cached(4, false)) + .test() + .awaitCount(2) + .assertValues( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Data("4 2", ResponseOrigin.Fetcher) + ) + } +}