diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 9f770f19..e2d7732b 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -9,6 +9,7 @@
+
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index 6e6eec11..79ee123c 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -1,6 +1,5 @@
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 67937129..cc957f54 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@
## Coroutine + Flow = MVI :heart:
* Play MVI with Kotlin Coroutines Flow.
* Multiple modules, Clean Architecture.
+* Unit tests for MVI ViewModel, domain and data layer.
* Master branch using Koin for DI.
* **Checkout [dagger_hilt branch](https://github.com/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/tree/dagger_hilt), using Dagger Hilt for DI** (_obsolete_).
* **[Download latest debug APK here](https://nightly.link/Kotlin-Android-Open-Source/MVI-Coroutines-Flow/workflows/build/master/app-debug.zip)**.
diff --git a/app/src/main/java/com/hoc/flowmvi/App.kt b/app/src/main/java/com/hoc/flowmvi/App.kt
index 6c95e0b2..0c26257d 100644
--- a/app/src/main/java/com/hoc/flowmvi/App.kt
+++ b/app/src/main/java/com/hoc/flowmvi/App.kt
@@ -16,6 +16,7 @@ import org.koin.core.logger.Level
import kotlin.time.ExperimentalTime
@Suppress("unused")
+@ExperimentalStdlibApi
@FlowPreview
@ExperimentalCoroutinesApi
@ExperimentalTime
diff --git a/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt b/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
index 0bd9c84d..088c9ac8 100644
--- a/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
+++ b/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
@@ -5,7 +5,7 @@ import com.hoc.flowmvi.core.navigator.Navigator
import org.koin.dsl.module
val coreModule = module {
- single { CoroutineDispatchersImpl() }
+ single { DefaultCoroutineDispatchers() }
single { NavigatorImpl(add = get(), search = get()) }
}
diff --git a/app/src/main/java/com/hoc/flowmvi/core/CoroutineDispatchersImpl.kt b/app/src/main/java/com/hoc/flowmvi/core/DefaultCoroutineDispatchers.kt
similarity index 64%
rename from app/src/main/java/com/hoc/flowmvi/core/CoroutineDispatchersImpl.kt
rename to app/src/main/java/com/hoc/flowmvi/core/DefaultCoroutineDispatchers.kt
index f9c86268..7e157ddb 100644
--- a/app/src/main/java/com/hoc/flowmvi/core/CoroutineDispatchersImpl.kt
+++ b/app/src/main/java/com/hoc/flowmvi/core/DefaultCoroutineDispatchers.kt
@@ -4,7 +4,7 @@ import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
-internal class CoroutineDispatchersImpl(
- override val main: CoroutineDispatcher = Dispatchers.Main,
+internal class DefaultCoroutineDispatchers : CoroutineDispatchers {
+ override val main: CoroutineDispatcher = Dispatchers.Main
override val io: CoroutineDispatcher = Dispatchers.IO
-) : CoroutineDispatchers
+}
diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt
index bb3b224f..3a9b9be1 100644
--- a/buildSrc/src/main/kotlin/deps.kt
+++ b/buildSrc/src/main/kotlin/deps.kt
@@ -46,7 +46,7 @@ object deps {
const val retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
const val converterMoshi = "com.squareup.retrofit2:converter-moshi:2.9.0"
const val loggingInterceptor = "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2"
- const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.11.0"
+ const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.12.0"
const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.7"
}
@@ -69,6 +69,11 @@ object deps {
const val viewBindingDelegate = "com.github.hoc081098:ViewBindingDelegate:1.2.0"
const val flowExt = "io.github.hoc081098:FlowExt:0.0.7-SNAPSHOT"
+ object arrow {
+ private const val version = "1.0.0"
+ const val core = "io.arrow-kt:arrow-core:$version"
+ }
+
object test {
const val junit = "junit:junit:4.13.2"
const val androidxJunit = "androidx.test.ext:junit:1.1.2"
@@ -86,6 +91,7 @@ inline val PDsS.androidApplication: PDS get() = id("com.android.application")
inline val PDsS.androidLib: PDS get() = id("com.android.library")
inline val PDsS.kotlinAndroid: PDS get() = id("kotlin-android")
inline val PDsS.kotlin: PDS get() = id("kotlin")
+inline val PDsS.kotlinKapt: PDS get() = id("kotlin-kapt")
inline val DependencyHandler.domain get() = project(":domain")
inline val DependencyHandler.core get() = project(":core")
diff --git a/core/src/main/java/com/hoc/flowmvi/core/Either.kt b/core/src/main/java/com/hoc/flowmvi/core/Either.kt
deleted file mode 100644
index 1b4affa7..00000000
--- a/core/src/main/java/com/hoc/flowmvi/core/Either.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.hoc.flowmvi.core
-
-sealed class Either {
- data class Left(val l: L) : Either()
- data class Right(val r: R) : Either()
-
- fun rightOrNull(): R? = when (this) {
- is Left -> null
- is Right -> r
- }
-
- fun leftOrNull(): L? = when (this) {
- is Left -> l
- is Right -> null
- }
-}
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 06ab8ed1..728d958d 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
androidLib
kotlinAndroid
+ kotlinKapt
}
android {
@@ -49,6 +50,7 @@ dependencies {
implementation(deps.squareup.loggingInterceptor)
implementation(deps.koin.core)
+ implementation(deps.arrow.core)
addUnitTest()
}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
index aef3591d..710fac77 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
@@ -1,11 +1,13 @@
package com.hoc.flowmvi.data
import com.hoc.flowmvi.data.mapper.UserDomainToUserBodyMapper
-import com.hoc.flowmvi.data.mapper.UserDomainToUserResponseMapper
+import com.hoc.flowmvi.data.mapper.UserErrorMapper
import com.hoc.flowmvi.data.mapper.UserResponseToUserDomainMapper
+import com.hoc.flowmvi.data.remote.ErrorResponse
import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.domain.repository.UserRepository
import com.squareup.moshi.Moshi
+import com.squareup.moshi.adapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import okhttp3.OkHttpClient
@@ -18,8 +20,9 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import kotlin.time.ExperimentalTime
-private const val BASE_URL = "BASE_URL"
+val BASE_URL_QUALIFIER = named("BASE_URL")
+@ExperimentalStdlibApi
@ExperimentalTime
@ExperimentalCoroutinesApi
val dataModule = module {
@@ -27,7 +30,7 @@ val dataModule = module {
single {
provideRetrofit(
- baseUrl = get(named(BASE_URL)),
+ baseUrl = get(BASE_URL_QUALIFIER),
moshi = get(),
client = get()
)
@@ -37,21 +40,23 @@ val dataModule = module {
single { provideOkHttpClient() }
- factory(named(BASE_URL)) { "https://mvi-coroutines-flow-server.herokuapp.com/" }
+ factory(BASE_URL_QUALIFIER) { "https://mvi-coroutines-flow-server.herokuapp.com/" }
factory { UserResponseToUserDomainMapper() }
- factory { UserDomainToUserResponseMapper() }
-
factory { UserDomainToUserBodyMapper() }
+ factory { get().adapter() }
+
+ factory { UserErrorMapper(errorResponseJsonAdapter = get()) }
+
single {
UserRepositoryImpl(
userApiService = get(),
dispatchers = get(),
responseToDomain = get(),
- domainToResponse = get(),
- domainToBody = get()
+ domainToBody = get(),
+ errorMapper = get(),
)
}
}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
index e86547aa..d86a82c8 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
@@ -1,6 +1,10 @@
package com.hoc.flowmvi.data
import android.util.Log
+import arrow.core.Either
+import arrow.core.left
+import arrow.core.leftWiden
+import arrow.core.right
import com.hoc.flowmvi.core.Mapper
import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import com.hoc.flowmvi.core.retrySuspend
@@ -8,32 +12,35 @@ import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.data.remote.UserBody
import com.hoc.flowmvi.data.remote.UserResponse
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.withContext
+import java.io.IOException
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
@ExperimentalTime
@ExperimentalCoroutinesApi
-internal class UserRepositoryImpl constructor(
+internal class UserRepositoryImpl(
private val userApiService: UserApiService,
private val dispatchers: CoroutineDispatchers,
private val responseToDomain: Mapper,
- private val domainToResponse: Mapper,
- private val domainToBody: Mapper
+ private val domainToBody: Mapper,
+ private val errorMapper: Mapper,
) : UserRepository {
private sealed class Change {
- data class Removed(val removed: User) : Change()
- data class Refreshed(val user: List) : Change()
+ class Removed(val removed: User) : Change()
+ class Refreshed(val user: List) : Change()
class Added(val user: User) : Change()
}
@@ -45,6 +52,7 @@ internal class UserRepositoryImpl constructor(
times = 3,
initialDelay = Duration.milliseconds(500),
factor = 2.0,
+ shouldRetry = { it is IOException }
) {
Log.d("###", "[USER_REPO] Retry times=$it")
userApiService.getUsers().map(responseToDomain)
@@ -52,52 +60,50 @@ internal class UserRepositoryImpl constructor(
}
}
- override fun getUsers(): Flow> {
- return flow {
- val initial = getUsersFromRemote()
+ override fun getUsers() = flow {
+ val initial = getUsersFromRemote()
- changesFlow
- .onEach { Log.d("###", "[USER_REPO] Change=$it") }
- .scan(initial) { acc, change ->
- when (change) {
- is Change.Removed -> acc.filter { it.id != change.removed.id }
- is Change.Refreshed -> change.user
- is Change.Added -> acc + change.user
- }
+ changesFlow
+ .onEach { Log.d("###", "[USER_REPO] Change=$it") }
+ .scan(initial) { acc, change ->
+ when (change) {
+ is Change.Removed -> acc.filter { it.id != change.removed.id }
+ is Change.Refreshed -> change.user
+ is Change.Added -> acc + change.user
}
- .onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
- .let { emitAll(it) }
- }
+ }
+ .onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
+ .let { emitAll(it) }
}
+ .map { it.right().leftWiden>() }
+ .catch { emit(errorMapper(it).left()) }
- override suspend fun refresh() =
+ override suspend fun refresh() = Either.catch(errorMapper) {
getUsersFromRemote().let { changesFlow.emit(Change.Refreshed(it)) }
+ }
- override suspend fun remove(user: User) {
+ override suspend fun remove(user: User) = Either.catch(errorMapper) {
withContext(dispatchers.io) {
- val response = userApiService.remove(domainToResponse(user).id)
+ val response = userApiService.remove(user.id)
changesFlow.emit(Change.Removed(responseToDomain(response)))
}
}
- override suspend fun add(user: User) {
+ override suspend fun add(user: User) = Either.catch(errorMapper) {
withContext(dispatchers.io) {
- val body = domainToBody(user).copy(avatar = avatarUrls.random())
+ val body = domainToBody(user)
val response = userApiService.add(body)
changesFlow.emit(Change.Added(responseToDomain(response)))
- delay(400)
+ extraDelay()
}
}
- override suspend fun search(query: String) = withContext(dispatchers.io) {
- delay(400)
- userApiService.search(query).map(responseToDomain)
+ override suspend fun search(query: String) = Either.catch(errorMapper) {
+ withContext(dispatchers.io) {
+ extraDelay()
+ userApiService.search(query).map(responseToDomain)
+ }
}
- companion object {
- private val avatarUrls =
- (0 until 100).map { "https://randomuser.me/api/portraits/men/$it.jpg" } +
- (0 until 100).map { "https://randomuser.me/api/portraits/women/$it.jpg" } +
- (0 until 10).map { "https://randomuser.me/api/portraits/lego/$it.jpg" }
- }
+ private suspend inline fun extraDelay() = delay(400)
}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt
index 86227ca7..85265caf 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt
@@ -8,7 +8,6 @@ internal class UserDomainToUserBodyMapper : Mapper {
override fun invoke(domain: User): UserBody {
return UserBody(
email = domain.email,
- avatar = domain.avatar,
firstName = domain.firstName,
lastName = domain.lastName
)
diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt
deleted file mode 100644
index b25e8039..00000000
--- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.hoc.flowmvi.data.mapper
-
-import com.hoc.flowmvi.core.Mapper
-import com.hoc.flowmvi.data.remote.UserResponse
-import com.hoc.flowmvi.domain.entity.User
-
-internal class UserDomainToUserResponseMapper : Mapper {
- override fun invoke(domain: User): UserResponse {
- return UserResponse(
- id = domain.id,
- avatar = domain.avatar,
- email = domain.email,
- firstName = domain.firstName,
- lastName = domain.lastName
- )
- }
-}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt
new file mode 100644
index 00000000..033ed08b
--- /dev/null
+++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt
@@ -0,0 +1,54 @@
+package com.hoc.flowmvi.data.mapper
+
+import arrow.core.nonFatalOrThrow
+import com.hoc.flowmvi.core.Mapper
+import com.hoc.flowmvi.data.remote.ErrorResponse
+import com.hoc.flowmvi.domain.repository.UserError
+import com.squareup.moshi.JsonAdapter
+import okhttp3.ResponseBody
+import retrofit2.HttpException
+import java.io.IOException
+import java.net.SocketException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+
+internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter) :
+ Mapper {
+ override fun invoke(throwable: Throwable): UserError {
+ throwable.nonFatalOrThrow()
+
+ return runCatching {
+ when (throwable) {
+ is IOException -> when (throwable) {
+ is UnknownHostException -> UserError.NetworkError
+ is SocketTimeoutException -> UserError.NetworkError
+ is SocketException -> UserError.NetworkError
+ else -> UserError.NetworkError
+ }
+ is HttpException ->
+ throwable.response()!!
+ .takeUnless { it.isSuccessful }!!
+ .errorBody()!!
+ .use(ResponseBody::string)
+ .let { mapResponseError(it) }
+ else -> UserError.Unexpected
+ }
+ }.getOrElse {
+ it.nonFatalOrThrow()
+ UserError.Unexpected
+ }
+ }
+
+ @Throws(Throwable::class)
+ private fun mapResponseError(json: String): UserError {
+ val errorResponse = errorResponseJsonAdapter.fromJson(json)!!
+
+ return when (errorResponse.error) {
+ "internal-error" -> UserError.ServerError
+ "invalid-id" -> UserError.InvalidId(id = errorResponse.data as String)
+ "user-not-found" -> UserError.UserNotFound(id = errorResponse.data as String)
+ "validation-failed" -> UserError.ValidationFailed
+ else -> UserError.Unexpected
+ }
+ }
+}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/remote/ErrorResponse.kt b/data/src/main/java/com/hoc/flowmvi/data/remote/ErrorResponse.kt
new file mode 100644
index 00000000..88c6bd4e
--- /dev/null
+++ b/data/src/main/java/com/hoc/flowmvi/data/remote/ErrorResponse.kt
@@ -0,0 +1,14 @@
+package com.hoc.flowmvi.data.remote
+
+import com.squareup.moshi.Json
+
+data class ErrorResponse(
+ @Json(name = "statusCode")
+ val statusCode: Int, // 404
+ @Json(name = "error")
+ val error: String? = null, // Not Found
+ @Json(name = "message")
+ val message: String, // Cannot GET /23
+ @Json(name = "data")
+ val data: Any? = null,
+)
diff --git a/data/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt b/data/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt
index 1cc94e54..c0c4f042 100644
--- a/data/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt
@@ -9,6 +9,4 @@ internal data class UserBody(
val firstName: String,
@Json(name = "last_name")
val lastName: String,
- @Json(name = "avatar")
- val avatar: String
)
diff --git a/data/src/test/java/com/hoc/flowmvi/data/ExampleUnitTest.kt b/data/src/test/java/com/hoc/flowmvi/data/ExampleUnitTest.kt
deleted file mode 100644
index 85c2e904..00000000
--- a/data/src/test/java/com/hoc/flowmvi/data/ExampleUnitTest.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.hoc.flowmvi.data
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt
new file mode 100644
index 00000000..eb0d8e05
--- /dev/null
+++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt
@@ -0,0 +1,376 @@
+package com.hoc.flowmvi.data
+
+import arrow.core.Either
+import arrow.core.getOrHandle
+import arrow.core.identity
+import com.hoc.flowmvi.core.Mapper
+import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
+import com.hoc.flowmvi.data.remote.UserApiService
+import com.hoc.flowmvi.data.remote.UserBody
+import com.hoc.flowmvi.data.remote.UserResponse
+import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifySequence
+import io.mockk.confirmVerified
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifySequence
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestCoroutineDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.setMain
+import java.io.IOException
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.ExperimentalTime
+
+private val USER_BODY = UserBody(
+ email = "email1@gmail.com",
+ firstName = "first",
+ lastName = "last",
+)
+
+private val USER_RESPONSES = listOf(
+ UserResponse(
+ id = "1",
+ email = "email1@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar1",
+ ),
+ UserResponse(
+ id = "2",
+ email = "email2@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar2",
+ ),
+ UserResponse(
+ id = "3",
+ email = "email3@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar3",
+ ),
+)
+
+private val USERS = listOf(
+ User(
+ id = "1",
+ email = "email1@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar1",
+ ),
+ User(
+ id = "2",
+ email = "email2@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar2",
+ ),
+ User(
+ id = "3",
+ email = "email3@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar3",
+ ),
+)
+
+@ExperimentalCoroutinesApi
+class TestDispatchersImpl(testDispatcher: TestCoroutineDispatcher) : CoroutineDispatchers {
+ override val main: CoroutineDispatcher = testDispatcher
+ override val io: CoroutineDispatcher = testDispatcher
+}
+
+@ExperimentalCoroutinesApi
+@ExperimentalTime
+class UserRepositoryImplTest {
+ private val testDispatcher = TestCoroutineDispatcher()
+
+ private lateinit var repo: UserRepositoryImpl
+ private lateinit var userApiService: UserApiService
+ private lateinit var responseToDomain: Mapper
+ private lateinit var domainToBody: Mapper
+ private lateinit var errorMapper: Mapper
+
+ @BeforeTest
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+
+ userApiService = mockk()
+ responseToDomain = mockk()
+ domainToBody = mockk()
+ errorMapper = mockk()
+
+ repo = UserRepositoryImpl(
+ userApiService = userApiService,
+ dispatchers = TestDispatchersImpl(testDispatcher),
+ responseToDomain = responseToDomain,
+ domainToBody = domainToBody,
+ errorMapper = errorMapper
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ testDispatcher.cleanupTestCoroutines()
+ Dispatchers.resetMain()
+
+ confirmVerified(
+ userApiService,
+ responseToDomain,
+ domainToBody,
+ errorMapper,
+ )
+ clearAllMocks()
+ }
+
+ @Test
+ fun test_refresh_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ coEvery { userApiService.getUsers() } returns USER_RESPONSES
+ every { responseToDomain(any()) } returnsMany USERS
+
+ val result = repo.refresh()
+
+ assertTrue(result.isRight())
+ assertNotNull(result.orNull())
+
+ coVerify { userApiService.getUsers() }
+ verifySequence {
+ USER_RESPONSES.forEach {
+ responseToDomain(it)
+ }
+ }
+ }
+
+ @Test
+ fun test_refresh_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ val ioException = IOException()
+ coEvery { userApiService.getUsers() } throws ioException
+ every { errorMapper(ofType()) } returns UserError.NetworkError
+
+ val result = repo.refresh()
+
+ assertTrue(result.isLeft())
+ assertEquals(UserError.NetworkError, result.leftOrThrow)
+ coVerify(exactly = 3) { userApiService.getUsers() } // retry 3 times
+ verify(exactly = 1) { errorMapper(ofType()) }
+ }
+
+ @Test
+ fun test_remove_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ val user = USERS[0]
+ val userResponse = USER_RESPONSES[0]
+
+ coEvery { userApiService.remove(user.id) } returns userResponse
+ every { responseToDomain(userResponse) } returns user
+
+ val result = repo.remove(user)
+
+ assertTrue(result.isRight())
+ assertNotNull(result.orNull())
+
+ coVerify { userApiService.remove(user.id) }
+ coVerify { responseToDomain(userResponse) }
+ }
+
+ @Test
+ fun test_remove_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ val user = USERS[0]
+ coEvery { userApiService.remove(user.id) } throws IOException()
+ every { errorMapper(ofType()) } returns UserError.NetworkError
+
+ val result = repo.remove(user)
+
+ assertTrue(result.isLeft())
+ assertEquals(UserError.NetworkError, result.leftOrThrow)
+ coVerify(exactly = 1) { userApiService.remove(user.id) }
+ verify(exactly = 1) { errorMapper(ofType()) }
+ }
+
+ @Test
+ fun test_add_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ val user = USERS[0]
+ val userResponse = USER_RESPONSES[0]
+
+ coEvery { userApiService.add(USER_BODY) } returns userResponse
+ every { domainToBody(user) } returns USER_BODY
+ every { responseToDomain(userResponse) } returns user
+
+ val result = repo.add(user)
+
+ assertTrue(result.isRight())
+ assertNotNull(result.orNull())
+
+ coVerify { userApiService.add(USER_BODY) }
+ verify { domainToBody(user) }
+ coVerify { responseToDomain(userResponse) }
+ }
+
+ @Test
+ fun test_add_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ val user = USERS[0]
+ coEvery { userApiService.add(USER_BODY) } throws IOException()
+ every { domainToBody(user) } returns USER_BODY
+ every { errorMapper(ofType()) } returns UserError.NetworkError
+
+ val result = repo.add(user)
+
+ assertTrue(result.isLeft())
+ assertEquals(UserError.NetworkError, result.leftOrThrow)
+
+ coVerify(exactly = 1) { userApiService.add(USER_BODY) }
+ verify(exactly = 1) { domainToBody(user) }
+ verify(exactly = 1) { errorMapper(ofType()) }
+ }
+
+ @Test
+ fun test_search_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest {
+ val q = "hoc081098"
+ coEvery { userApiService.search(q) } returns USER_RESPONSES
+ every { responseToDomain(any()) } returnsMany USERS
+
+ val result = repo.search(q)
+
+ assertTrue(result.isRight())
+ assertNotNull(result.orNull())
+ assertContentEquals(USERS, result.getOrThrow)
+
+ coVerify { userApiService.search(q) }
+ coVerifySequence {
+ USER_RESPONSES.forEach {
+ responseToDomain(it)
+ }
+ }
+ }
+
+ @Test
+ fun test_search_withApiCallError_returnsLeft() = testDispatcher.runBlockingTest {
+ val q = "hoc081098"
+ coEvery { userApiService.search(q) } throws IOException()
+ every { errorMapper(ofType()) } returns UserError.NetworkError
+
+ val result = repo.search(q)
+
+ assertTrue(result.isLeft())
+ assertEquals(UserError.NetworkError, result.leftOrThrow)
+
+ coVerify(exactly = 1) { userApiService.search(q) }
+ verify(exactly = 1) { errorMapper(ofType()) }
+ }
+
+ @Test
+ fun test_getUsers_withApiCallSuccess_emitsInitial() = testDispatcher.runBlockingTest {
+ coEvery { userApiService.getUsers() } returns USER_RESPONSES
+ every { responseToDomain(any()) } returnsMany USERS
+
+ val events = mutableListOf>>()
+ val job = launch(start = CoroutineStart.UNDISPATCHED) {
+ repo.getUsers().toList(events)
+ }
+ delay(5_000)
+ job.cancel()
+
+ assertEquals(1, events.size)
+ val result = events.single()
+ assertTrue(result.isRight())
+ assertNotNull(result.orNull())
+ assertEquals(USERS, result.getOrThrow)
+
+ coVerify { userApiService.getUsers() }
+ verifySequence {
+ USER_RESPONSES.forEach {
+ responseToDomain(it)
+ }
+ }
+ }
+
+ @Test
+ fun test_getUsers_withApiCallError_rethrows() = testDispatcher.runBlockingTest {
+ coEvery { userApiService.getUsers() } throws IOException()
+ every { errorMapper(ofType()) } returns UserError.NetworkError
+
+ val events = mutableListOf>>()
+ val job = launch(start = CoroutineStart.UNDISPATCHED) {
+ repo.getUsers().toList(events)
+ }
+ delay(20_000)
+ job.cancel()
+
+ assertEquals(1, events.size)
+ val result = events.single()
+ assertTrue(result.isLeft())
+ assertNull(result.orNull())
+ assertEquals(UserError.NetworkError, result.leftOrThrow)
+
+ coVerify(exactly = 3) { userApiService.getUsers() } // retry 3 times.
+ verify(exactly = 1) { errorMapper(ofType()) }
+ }
+
+ @Test
+ fun test_getUsers_withApiCallSuccess_emitsInitialAndUpdatedUsers() =
+ testDispatcher.runBlockingTest {
+ val user = USERS.last()
+ val userResponse = USER_RESPONSES.last()
+ coEvery { userApiService.getUsers() } returns USER_RESPONSES.dropLast(1)
+ coEvery { userApiService.add(USER_BODY) } returns userResponse
+ coEvery { userApiService.remove(user.id) } returns userResponse
+ every { domainToBody(user) } returns USER_BODY
+ USER_RESPONSES.zip(USERS).forEach { (r, u) -> every { responseToDomain(r) } returns u }
+
+ val events = mutableListOf>>()
+ val job = launch(start = CoroutineStart.UNDISPATCHED) {
+ repo.getUsers().toList(events)
+ }
+ repo.add(user)
+ repo.remove(user)
+ delay(120_000)
+ job.cancel()
+
+ assertContentEquals(
+ events.map { it.getOrThrow },
+ listOf(
+ USERS.dropLast(1),
+ USERS,
+ USERS.dropLast(1),
+ )
+ )
+
+ coVerify { userApiService.getUsers() }
+ coVerify { userApiService.add(USER_BODY) }
+ coVerify { userApiService.remove(user.id) }
+ verify { domainToBody(user) }
+ verifySequence {
+ USER_RESPONSES.forEach { responseToDomain(it) }
+ responseToDomain(USER_RESPONSES.last())
+ }
+ }
+}
+
+private inline val Either.leftOrThrow: L
+ get() = fold(::identity) {
+ if (it is Throwable) throw it
+ else error("$this - $it - Should not reach here!")
+ }
+
+private inline val Either.getOrThrow: R
+ get() = getOrHandle { error("$this - $it - Should not reach here!") }
diff --git a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapperTest.kt b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapperTest.kt
new file mode 100644
index 00000000..3ecea14a
--- /dev/null
+++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapperTest.kt
@@ -0,0 +1,32 @@
+package com.hoc.flowmvi.data.mapper
+
+import com.hoc.flowmvi.data.remote.UserBody
+import com.hoc.flowmvi.domain.entity.User
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class UserDomainToUserBodyMapperTest {
+ private val mapper = UserDomainToUserBodyMapper()
+
+ @Test
+ fun test_UserDomainToUserBodyMapper() {
+ val body = mapper(
+ User(
+ id = "id",
+ email = "email@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar",
+ )
+ )
+
+ assertEquals(
+ UserBody(
+ email = "email@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ ),
+ body
+ )
+ }
+}
diff --git a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt
new file mode 100644
index 00000000..e2ac0c1e
--- /dev/null
+++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt
@@ -0,0 +1,164 @@
+package com.hoc.flowmvi.data.mapper
+
+import com.hoc.flowmvi.data.remote.ErrorResponse
+import com.hoc.flowmvi.domain.repository.UserError
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.adapter
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.ResponseBody.Companion.toResponseBody
+import retrofit2.HttpException
+import retrofit2.Response
+import java.io.IOException
+import java.net.SocketException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.coroutines.cancellation.CancellationException as KotlinCancellationException
+import kotlinx.coroutines.CancellationException as KotlinXCancellationException
+
+@ExperimentalStdlibApi
+class UserErrorMapperTest {
+ private val moshi = Moshi
+ .Builder()
+ .add(KotlinJsonAdapterFactory())
+ .build()
+ private val errorResponseJsonAdapter = moshi.adapter()
+ private val errorMapper = UserErrorMapper(errorResponseJsonAdapter)
+
+ private fun buildHttpException(error: String, data: Any?) =
+ HttpException(
+ Response.error(
+ 400,
+ errorResponseJsonAdapter.toJson(
+ ErrorResponse(
+ statusCode = 400,
+ error = error,
+ message = "error=$error",
+ data = data,
+ )
+ ).toResponseBody("application/json".toMediaType())
+ )
+ )
+
+ @Test
+ fun test_withFatalError_rethrows() {
+ assertFailsWith { errorMapper(KotlinCancellationException()) }
+ assertFailsWith { errorMapper(KotlinXCancellationException()) }
+ }
+
+ @Test
+ fun test_withIOException_returnsNetworkError() {
+ assertEquals(
+ UserError.NetworkError,
+ errorMapper(IOException()),
+ )
+ assertEquals(
+ UserError.NetworkError,
+ errorMapper(UnknownHostException()),
+ )
+ assertEquals(
+ UserError.NetworkError,
+ errorMapper(SocketTimeoutException()),
+ )
+ assertEquals(
+ UserError.NetworkError,
+ errorMapper(SocketException()),
+ )
+ assertEquals(
+ UserError.NetworkError,
+ errorMapper(object : IOException() {}),
+ )
+ }
+
+ @Test
+ fun test_withHttpException_returnsUnexpectedError() {
+ assertEquals(
+ UserError.Unexpected,
+ errorMapper(
+ HttpException(
+ Response.success(null)
+ )
+ ),
+ )
+
+ assertEquals(
+ UserError.Unexpected,
+ errorMapper(
+ HttpException(
+ Response.error(
+ 400,
+ "{}".toResponseBody("application/json".toMediaType())
+ )
+ )
+ ),
+ )
+
+ assertEquals(
+ UserError.Unexpected,
+ errorMapper(
+ buildHttpException(
+ "hello",
+ mapOf(
+ "1" to mapOf(
+ "2" to 3,
+ "3" to listOf("4", "5"),
+ "6" to "7"
+ ),
+ "2" to null,
+ "3" to listOf(
+ hashMapOf("1" to "2"),
+ hashMapOf("2" to "3"),
+ )
+ ),
+ )
+ ),
+ )
+
+ val id = mapOf("1" to "2")
+ assertEquals(
+ UserError.Unexpected,
+ errorMapper(buildHttpException("invalid-id", id)),
+ )
+ assertEquals(
+ UserError.Unexpected,
+ errorMapper(buildHttpException("user-not-found", id)),
+ )
+ }
+
+ @Test
+ fun test_withHttpException_returnsCorrespondingUserError() {
+ assertEquals(
+ UserError.ServerError,
+ errorMapper(buildHttpException("internal-error", null)),
+ )
+
+ val id = "id"
+ assertEquals(
+ UserError.InvalidId(id),
+ errorMapper(buildHttpException("invalid-id", id)),
+ )
+ assertEquals(
+ UserError.UserNotFound(id),
+ errorMapper(buildHttpException("user-not-found", id)),
+ )
+ assertEquals(
+ UserError.ValidationFailed,
+ errorMapper(buildHttpException("validation-failed", null)),
+ )
+ }
+
+ @Test
+ fun test_withOtherwiseExceptions_returnsUnexpectedError() {
+ assertEquals(
+ UserError.Unexpected,
+ errorMapper(RuntimeException("Test 1")),
+ )
+ assertEquals(
+ UserError.Unexpected,
+ errorMapper(IndexOutOfBoundsException("Test 2")),
+ )
+ }
+}
diff --git a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt
new file mode 100644
index 00000000..718bb8c9
--- /dev/null
+++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt
@@ -0,0 +1,34 @@
+package com.hoc.flowmvi.data.mapper
+
+import com.hoc.flowmvi.data.remote.UserResponse
+import com.hoc.flowmvi.domain.entity.User
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class UserResponseToUserDomainMapperTest {
+ private val mapper = UserResponseToUserDomainMapper()
+
+ @Test
+ fun test_UserDomainToUserResponseMapper() {
+ val domain = mapper(
+ UserResponse(
+ id = "id",
+ email = "email@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar",
+ )
+ )
+
+ assertEquals(
+ User(
+ id = "id",
+ email = "email@gmail.com",
+ firstName = "first",
+ lastName = "last",
+ avatar = "avatar",
+ ),
+ domain
+ )
+ }
+}
diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts
index 70551289..60b79fc4 100644
--- a/domain/build.gradle.kts
+++ b/domain/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
dependencies {
implementation(deps.coroutines.core)
implementation(deps.koin.core)
+ implementation(deps.arrow.core)
addUnitTest()
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt b/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
index e3efec74..9117af40 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
@@ -1,16 +1,26 @@
package com.hoc.flowmvi.domain.repository
+import arrow.core.Either
import com.hoc.flowmvi.domain.entity.User
import kotlinx.coroutines.flow.Flow
+sealed interface UserError {
+ object NetworkError : UserError
+ data class UserNotFound(val id: String) : UserError
+ data class InvalidId(val id: String) : UserError
+ object ValidationFailed : UserError
+ object ServerError : UserError
+ object Unexpected : UserError
+}
+
interface UserRepository {
- fun getUsers(): Flow>
+ fun getUsers(): Flow>>
- suspend fun refresh()
+ suspend fun refresh(): Either
- suspend fun remove(user: User)
+ suspend fun remove(user: User): Either
- suspend fun add(user: User)
+ suspend fun add(user: User): Either
- suspend fun search(query: String): List
+ suspend fun search(query: String): Either>
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
index d92959e4..d04acce1 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
@@ -1,8 +1,10 @@
package com.hoc.flowmvi.domain.usecase
+import arrow.core.Either
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
class AddUserUseCase(private val userRepository: UserRepository) {
- suspend operator fun invoke(user: User) = userRepository.add(user)
+ suspend operator fun invoke(user: User): Either = userRepository.add(user)
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
index 553f7a30..53b59862 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
@@ -1,7 +1,11 @@
package com.hoc.flowmvi.domain.usecase
+import arrow.core.Either
+import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
+import kotlinx.coroutines.flow.Flow
class GetUsersUseCase(private val userRepository: UserRepository) {
- operator fun invoke() = userRepository.getUsers()
+ operator fun invoke(): Flow>> = userRepository.getUsers()
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
index ba6de220..f67cee4c 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
@@ -1,7 +1,9 @@
package com.hoc.flowmvi.domain.usecase
+import arrow.core.Either
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
class RefreshGetUsersUseCase(private val userRepository: UserRepository) {
- suspend operator fun invoke() = userRepository.refresh()
+ suspend operator fun invoke(): Either = userRepository.refresh()
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
index 2ffa6c7a..72aae8c1 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
@@ -1,8 +1,10 @@
package com.hoc.flowmvi.domain.usecase
+import arrow.core.Either
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
class RemoveUserUseCase(private val userRepository: UserRepository) {
- suspend operator fun invoke(user: User) = userRepository.remove(user)
+ suspend operator fun invoke(user: User): Either = userRepository.remove(user)
}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/SearchUsersUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/SearchUsersUseCase.kt
index bc7c4db5..60858a7c 100644
--- a/domain/src/main/java/com/hoc/flowmvi/domain/usecase/SearchUsersUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/SearchUsersUseCase.kt
@@ -1,8 +1,11 @@
package com.hoc.flowmvi.domain.usecase
+import arrow.core.Either
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
class SearchUsersUseCase(private val userRepository: UserRepository) {
- suspend operator fun invoke(query: String): List = userRepository.search(query)
+ suspend operator fun invoke(query: String): Either> =
+ userRepository.search(query)
}
diff --git a/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt b/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt
index 73e55090..f396b5b4 100644
--- a/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt
+++ b/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt
@@ -1,6 +1,9 @@
package com.hoc.flowmvi.domain
+import arrow.core.left
+import arrow.core.right
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.repository.UserRepository
import com.hoc.flowmvi.domain.usecase.AddUserUseCase
import com.hoc.flowmvi.domain.usecase.GetUsersUseCase
@@ -10,21 +13,19 @@ import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase
import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.coVerify
+import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
-import java.io.IOException
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
private val USERS = listOf(
User(
@@ -54,117 +55,132 @@ private val USERS = listOf(
class UseCaseTest {
private val testDispatcher = TestCoroutineDispatcher()
- private val userRepository: UserRepository = mockk(relaxed = true)
- private val getUsersUseCase: GetUsersUseCase = GetUsersUseCase(userRepository)
- private val refreshUseCase: RefreshGetUsersUseCase = RefreshGetUsersUseCase(userRepository)
- private val removeUserUseCase: RemoveUserUseCase = RemoveUserUseCase(userRepository)
- private val addUserUseCase: AddUserUseCase = AddUserUseCase(userRepository)
- private val searchUsersUseCase: SearchUsersUseCase = SearchUsersUseCase(userRepository)
+ private lateinit var userRepository: UserRepository
+ private lateinit var getUsersUseCase: GetUsersUseCase
+ private lateinit var refreshUseCase: RefreshGetUsersUseCase
+ private lateinit var removeUserUseCase: RemoveUserUseCase
+ private lateinit var addUserUseCase: AddUserUseCase
+ private lateinit var searchUsersUseCase: SearchUsersUseCase
+
+ private val errorLeft = UserError.NetworkError.left()
@BeforeTest
fun setup() {
+ userRepository = mockk()
+
+ getUsersUseCase = GetUsersUseCase(userRepository)
+ refreshUseCase = RefreshGetUsersUseCase(userRepository)
+ removeUserUseCase = RemoveUserUseCase(userRepository)
+ addUserUseCase = AddUserUseCase(userRepository)
+ searchUsersUseCase = SearchUsersUseCase(userRepository)
}
@AfterTest
fun tearDown() {
+ confirmVerified(userRepository)
clearAllMocks()
}
@Test
fun test_getUsersUseCase_whenSuccess_emitsUsers() = testDispatcher.runBlockingTest {
- every { userRepository.getUsers() } returns flowOf(USERS)
+ val usersRight = USERS.right()
+ every { userRepository.getUsers() } returns flowOf(usersRight)
val result = getUsersUseCase()
verify { userRepository.getUsers() }
- assertEquals(USERS, result.first())
+ assertEquals(usersRight, result.first())
}
@Test
fun test_getUsersUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
- every { userRepository.getUsers() } returns flow { throw Exception() }
+ every { userRepository.getUsers() } returns flowOf(errorLeft)
val result = getUsersUseCase()
verify { userRepository.getUsers() }
- assertFailsWith { result.first() }
+ assertEquals(errorLeft, result.first())
}
@Test
fun test_refreshUseCase_whenSuccess_returnsUnit() = testDispatcher.runBlockingTest {
- coEvery { userRepository.refresh() } returns Unit
+ coEvery { userRepository.refresh() } returns Unit.right()
val result = refreshUseCase()
coVerify { userRepository.refresh() }
- assertEquals(Unit, result)
+ assertEquals(Unit.right(), result)
}
@Test
fun test_refreshUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
- coEvery { userRepository.refresh() } throws IOException()
+ coEvery { userRepository.refresh() } returns errorLeft
- assertFailsWith { refreshUseCase() }
+ val result = refreshUseCase()
coVerify { userRepository.refresh() }
+ assertEquals(errorLeft, result)
}
@Test
fun test_removeUserUseCase_whenSuccess_returnsUnit() = testDispatcher.runBlockingTest {
- coEvery { userRepository.remove(any()) } returns Unit
+ coEvery { userRepository.remove(any()) } returns Unit.right()
val result = removeUserUseCase(USERS[0])
coVerify { userRepository.remove(USERS[0]) }
- assertEquals(Unit, result)
+ assertEquals(Unit.right(), result)
}
@Test
fun test_removeUserUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
- coEvery { userRepository.remove(any()) } throws IOException()
+ coEvery { userRepository.remove(any()) } returns errorLeft
- assertFailsWith { removeUserUseCase(USERS[0]) }
+ val result = removeUserUseCase(USERS[0])
coVerify { userRepository.remove(USERS[0]) }
+ assertEquals(errorLeft, result)
}
@Test
fun test_addUserUseCase_whenSuccess_returnsUnit() = testDispatcher.runBlockingTest {
- coEvery { userRepository.add(any()) } returns Unit
+ coEvery { userRepository.add(any()) } returns Unit.right()
val result = addUserUseCase(USERS[0])
coVerify { userRepository.add(USERS[0]) }
- assertEquals(Unit, result)
+ assertEquals(Unit.right(), result)
}
@Test
fun test_addUserUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
- coEvery { userRepository.add(any()) } throws IOException()
+ coEvery { userRepository.add(any()) } returns errorLeft
- assertFailsWith { addUserUseCase(USERS[0]) }
+ val result = addUserUseCase(USERS[0])
coVerify { userRepository.add(USERS[0]) }
+ assertEquals(errorLeft, result)
}
@Test
fun test_searchUsersUseCase_whenSuccess_returnsUsers() = testDispatcher.runBlockingTest {
- coEvery { userRepository.search(any()) } returns USERS
+ coEvery { userRepository.search(any()) } returns USERS.right()
val query = "hoc081098"
val result = searchUsersUseCase(query)
coVerify { userRepository.search(query) }
- assertEquals(USERS, result)
+ assertEquals(USERS.right(), result)
}
@Test
fun test_searchUsersUseCase_whenError_throwsError() = testDispatcher.runBlockingTest {
- coEvery { userRepository.search(any()) } throws IOException()
+ coEvery { userRepository.search(any()) } returns errorLeft
val query = "hoc081098"
- assertFailsWith { searchUsersUseCase(query) }
+ val result = searchUsersUseCase(query)
coVerify { userRepository.search(query) }
+ assertEquals(errorLeft, result)
}
}
diff --git a/feature-add/build.gradle.kts b/feature-add/build.gradle.kts
index 9869db2c..6a194817 100644
--- a/feature-add/build.gradle.kts
+++ b/feature-add/build.gradle.kts
@@ -54,6 +54,7 @@ dependencies {
implementation(deps.coroutines.core)
implementation(deps.koin.android)
+ implementation(deps.arrow.core)
implementation(deps.viewBindingDelegate)
implementation(deps.flowExt)
diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
index 0bc8d7df..7448f25c 100644
--- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
@@ -1,6 +1,7 @@
package com.hoc.flowmvi.ui.add
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
internal enum class ValidationError {
INVALID_EMAIL_ADDRESS,
@@ -61,7 +62,7 @@ internal sealed interface PartialStateChange {
sealed class AddUser : PartialStateChange {
object Loading : AddUser()
data class AddUserSuccess(val user: User) : AddUser()
- data class AddUserFailure(val user: User, val throwable: Throwable) : AddUser()
+ data class AddUserFailure(val user: User, val error: UserError) : AddUser()
override fun reduce(viewState: ViewState): ViewState {
return when (this) {
@@ -103,5 +104,5 @@ internal sealed interface PartialStateChange {
internal sealed interface SingleEvent {
data class AddUserSuccess(val user: User) : SingleEvent
- data class AddUserFailure(val user: User, val throwable: Throwable) : SingleEvent
+ data class AddUserFailure(val user: User, val error: UserError) : SingleEvent
}
diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
index f2ebc6b5..764ccbf6 100644
--- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
@@ -5,7 +5,8 @@ import androidx.core.util.PatternsCompat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.hoc.flowmvi.core.Either
+import arrow.core.Either
+import arrow.core.identity
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.usecase.AddUserUseCase
import com.hoc081098.flowext.flatMapFirst
@@ -68,7 +69,7 @@ internal class AddVM(
is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(change.user)
is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure(
change.user,
- change.throwable
+ change.error
)
PartialStateChange.FirstChange.EmailChangedFirstTime -> return@onEach
PartialStateChange.FirstChange.FirstNameChangedFirstTime -> return@onEach
@@ -130,15 +131,16 @@ internal class AddVM(
val addUserChanges = filterIsInstance()
.withLatestFrom(userFormFlow) { _, userForm -> userForm }
- .mapNotNull { it.rightOrNull() }
+ .mapNotNull { it.orNull() }
.flatMapFirst { user ->
flow { emit(addUser(user)) }
- .map {
- @Suppress("USELESS_CAST")
- PartialStateChange.AddUser.AddUserSuccess(user) as PartialStateChange.AddUser
+ .map { result ->
+ result.fold(
+ ifLeft = { PartialStateChange.AddUser.AddUserFailure(user, it) },
+ ifRight = { PartialStateChange.AddUser.AddUserSuccess(user) }
+ )
}
.onStart { emit(PartialStateChange.AddUser.Loading) }
- .catch { emit(PartialStateChange.AddUser.AddUserFailure(user, it)) }
}
val firstChanges = merge(
@@ -169,8 +171,7 @@ internal class AddVM(
userFormFlow
.map {
PartialStateChange.ErrorsChanged(
- it.leftOrNull()
- ?: emptySet()
+ it.fold(::identity) { emptySet() }
)
},
addUserChanges,
diff --git a/feature-main/build.gradle.kts b/feature-main/build.gradle.kts
index 3b4e355f..3bce8ff3 100644
--- a/feature-main/build.gradle.kts
+++ b/feature-main/build.gradle.kts
@@ -66,6 +66,7 @@ dependencies {
implementation(deps.coil)
implementation(deps.viewBindingDelegate)
implementation(deps.flowExt)
+ implementation(deps.arrow.core)
addUnitTest()
testImplementation(mviTesting)
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
index 27615a50..e1a790fa 100644
--- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
@@ -17,6 +17,7 @@ import com.hoc.flowmvi.core.collectIn
import com.hoc.flowmvi.core.navigator.Navigator
import com.hoc.flowmvi.core.refreshes
import com.hoc.flowmvi.core.toast
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.mvi_base.MviView
import com.hoc.flowmvi.ui.main.databinding.ActivityMainBinding
import com.hoc081098.viewbindingdelegate.viewBinding
@@ -126,7 +127,16 @@ class MainActivity :
mainBinding.run {
errorGroup.isVisible = viewState.error !== null
- errorMessageTextView.text = viewState.error?.message
+ errorMessageTextView.text = viewState.error?.let {
+ when (it) {
+ is UserError.InvalidId -> "Invalid id"
+ UserError.NetworkError -> "Network error"
+ UserError.ServerError -> "Server error"
+ UserError.Unexpected -> "Unexpected error"
+ is UserError.UserNotFound -> "User not found"
+ UserError.ValidationFailed -> "Validation failed"
+ }
+ }
progressBar.isVisible = viewState.isLoading
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
index dccd67d7..cb159cc1 100644
--- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
@@ -1,6 +1,7 @@
package com.hoc.flowmvi.ui.main
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.mvi_base.MviIntent
import com.hoc.flowmvi.mvi_base.MviSingleEvent
import com.hoc.flowmvi.mvi_base.MviViewState
@@ -41,7 +42,7 @@ sealed interface ViewIntent : MviIntent {
data class ViewState(
val userItems: List,
val isLoading: Boolean,
- val error: Throwable?,
+ val error: UserError?,
val isRefreshing: Boolean
) : MviViewState {
companion object {
@@ -78,7 +79,7 @@ internal sealed interface PartialChange {
object Loading : GetUser()
data class Data(val users: List) : GetUser()
- data class Error(val error: Throwable) : GetUser()
+ data class Error(val error: UserError) : GetUser()
}
sealed class Refresh : PartialChange {
@@ -92,12 +93,12 @@ internal sealed interface PartialChange {
object Loading : Refresh()
object Success : Refresh()
- data class Failure(val error: Throwable) : Refresh()
+ data class Failure(val error: UserError) : Refresh()
}
sealed class RemoveUser : PartialChange {
data class Success(val user: UserItem) : RemoveUser()
- data class Failure(val user: UserItem, val error: Throwable) : RemoveUser()
+ data class Failure(val user: UserItem, val error: UserError) : RemoveUser()
override fun reduce(vs: ViewState) = vs
}
@@ -106,13 +107,13 @@ internal sealed interface PartialChange {
sealed interface SingleEvent : MviSingleEvent {
sealed interface Refresh : SingleEvent {
object Success : Refresh
- data class Failure(val error: Throwable) : Refresh
+ data class Failure(val error: UserError) : Refresh
}
- data class GetUsersError(val error: Throwable) : SingleEvent
+ data class GetUsersError(val error: UserError) : SingleEvent
sealed interface RemoveUser : SingleEvent {
data class Success(val user: UserItem) : RemoveUser
- data class Failure(val user: UserItem, val error: Throwable) : RemoveUser
+ data class Failure(val user: UserItem, val error: UserError) : RemoveUser
}
}
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
index fcf11a1a..5018d79c 100644
--- a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
@@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
-@Suppress("USELESS_CAST")
@FlowPreview
@ExperimentalCoroutinesApi
class MainVM(
@@ -90,19 +89,24 @@ class MainVM(
private fun Flow.toPartialChangeFlow(): Flow =
shareIn(viewModelScope, SharingStarted.WhileSubscribed()).run {
val getUserChanges = defer(getUsersUseCase::invoke)
- .onEach { Log.d("###", "[MAIN_VM] Emit users.size=${it.size}") }
- .map {
- val items = it.map(::UserItem)
- PartialChange.GetUser.Data(items) as PartialChange.GetUser
+ .onEach { either -> Log.d("###", "[MAIN_VM] Emit users.size=${either.map { it.size }}") }
+ .map { result ->
+ result.fold(
+ ifLeft = { PartialChange.GetUser.Error(it) },
+ ifRight = { PartialChange.GetUser.Data(it.map(::UserItem)) }
+ )
}
.onStart { emit(PartialChange.GetUser.Loading) }
- .catch { emit(PartialChange.GetUser.Error(it)) }
val refreshChanges = refreshGetUsers::invoke
.asFlow()
- .map { PartialChange.Refresh.Success as PartialChange.Refresh }
+ .map { result ->
+ result.fold(
+ ifLeft = { PartialChange.Refresh.Failure(it) },
+ ifRight = { PartialChange.Refresh.Success }
+ )
+ }
.onStart { emit(PartialChange.Refresh.Loading) }
- .catch { emit(PartialChange.Refresh.Failure(it)) }
return merge(
filterIsInstance()
@@ -126,8 +130,12 @@ class MainVM(
.let { removeUser(it) }
.let { emit(it) }
}
- .map { PartialChange.RemoveUser.Success(userItem) as PartialChange.RemoveUser }
- .catch { emit(PartialChange.RemoveUser.Failure(userItem, it)) }
+ .map { result ->
+ result.fold(
+ ifLeft = { PartialChange.RemoveUser.Failure(userItem, it) },
+ ifRight = { PartialChange.RemoveUser.Success(userItem) },
+ )
+ }
}
)
}
diff --git a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt
index 6275ddd5..d8871384 100644
--- a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt
+++ b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt
@@ -1,13 +1,17 @@
package com.hoc.flowmvi.ui.main
+import arrow.core.left
+import arrow.core.right
import com.flowmvi.mvi_testing.BaseMviViewModelTest
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.domain.usecase.GetUsersUseCase
import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase
import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifySequence
+import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@@ -15,10 +19,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
-import java.io.IOException
import kotlin.test.Test
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
@@ -33,30 +35,38 @@ class MainVMTest : BaseMviViewModelTest<
MainVM
>() {
private lateinit var vm: MainVM
- private val getUserUseCase: GetUsersUseCase = mockk(relaxed = true)
- private val refreshGetUsersUseCase: RefreshGetUsersUseCase = mockk(relaxed = true)
- private val removeUser: RemoveUserUseCase = mockk(relaxed = true)
+ private lateinit var getUserUseCase: GetUsersUseCase
+ private lateinit var refreshGetUsersUseCase: RefreshGetUsersUseCase
+ private lateinit var removeUser: RemoveUserUseCase
override fun setup() {
super.setup()
+ getUserUseCase = mockk()
+ refreshGetUsersUseCase = mockk()
+ removeUser = mockk()
+
vm = MainVM(
getUsersUseCase = getUserUseCase,
refreshGetUsers = refreshGetUsersUseCase,
removeUser = removeUser,
)
- println("DONE setup $vm")
}
override fun tearDown() {
+ confirmVerified(
+ getUserUseCase,
+ refreshGetUsersUseCase,
+ removeUser,
+ )
+
super.tearDown()
- println("DONE tearDown")
}
@Test
fun test_withInitialIntentWhenSuccess_returnsUserItems() = test(
vmProducer = {
- every { getUserUseCase() } returns flowOf(USERS)
+ every { getUserUseCase() } returns flowOf(USERS.right())
vm
},
intents = flowOf(ViewIntent.Initial),
@@ -74,11 +84,11 @@ class MainVMTest : BaseMviViewModelTest<
@Test
fun test_withInitialIntentWhenError_returnsErrorState() {
- val ioException = IOException()
+ val userError = UserError.NetworkError
test(
vmProducer = {
- every { getUserUseCase() } returns flow { throw ioException }
+ every { getUserUseCase() } returns flowOf(userError.left())
vm
},
intents = flowOf(ViewIntent.Initial),
@@ -87,13 +97,13 @@ class MainVMTest : BaseMviViewModelTest<
ViewState(
userItems = emptyList(),
isLoading = false,
- error = ioException,
+ error = userError,
isRefreshing = false
)
),
expectedEvents = listOf(
SingleEvent.GetUsersError(
- error = ioException,
+ error = userError,
),
),
) { verify(exactly = 1) { getUserUseCase() } }
@@ -102,8 +112,8 @@ class MainVMTest : BaseMviViewModelTest<
@Test
fun test_withRefreshIntentWhenSuccess_isNotRefreshing() = test(
vmProducer = {
- every { getUserUseCase() } returns flowOf(USERS)
- coEvery { refreshGetUsersUseCase() } returns Unit
+ every { getUserUseCase() } returns flowOf(USERS.right())
+ coEvery { refreshGetUsersUseCase() } returns Unit.right()
vm
},
intentsBeforeCollecting = flowOf(ViewIntent.Initial),
@@ -131,16 +141,19 @@ class MainVMTest : BaseMviViewModelTest<
expectedEvents = listOf(
SingleEvent.Refresh.Success
),
- ) { coVerify(exactly = 1) { refreshGetUsersUseCase() } }
+ ) {
+ coVerify(exactly = 1) { getUserUseCase() }
+ coVerify(exactly = 1) { refreshGetUsersUseCase() }
+ }
@Test
fun test_withRefreshIntentWhenFailure_isNotRefreshing() {
- val ioException = IOException()
+ val userError = UserError.NetworkError
test(
vmProducer = {
- coEvery { getUserUseCase() } returns flowOf(USERS)
- coEvery { refreshGetUsersUseCase() } throws ioException
+ coEvery { getUserUseCase() } returns flowOf(USERS.right())
+ coEvery { refreshGetUsersUseCase() } returns userError.left()
vm
},
intentsBeforeCollecting = flowOf(ViewIntent.Initial),
@@ -166,16 +179,19 @@ class MainVMTest : BaseMviViewModelTest<
),
),
expectedEvents = listOf(
- SingleEvent.Refresh.Failure(ioException)
+ SingleEvent.Refresh.Failure(userError)
),
- ) { coVerify(exactly = 1) { refreshGetUsersUseCase() } }
+ ) {
+ coVerify(exactly = 1) { getUserUseCase() }
+ coVerify(exactly = 1) { refreshGetUsersUseCase() }
+ }
}
@Test
fun test_withRefreshIntent_ignoredWhenIsLoading() {
test(
vmProducer = {
- coEvery { refreshGetUsersUseCase() } returns Unit
+ coEvery { refreshGetUsersUseCase() } returns Unit.right()
vm
},
intents = flowOf(ViewIntent.Refresh),
@@ -187,11 +203,12 @@ class MainVMTest : BaseMviViewModelTest<
@Test
fun test_withRefreshIntent_ignoredWhenHavingError() {
- val ioException = IOException()
+ val userError = UserError.NetworkError
+
test(
vmProducer = {
- every { getUserUseCase() } returns flow { throw ioException }
- coEvery { refreshGetUsersUseCase() } returns Unit
+ every { getUserUseCase() } returns flowOf(userError.left())
+ coEvery { refreshGetUsersUseCase() } returns Unit.right()
vm
},
intentsBeforeCollecting = flowOf(ViewIntent.Initial),
@@ -200,15 +217,18 @@ class MainVMTest : BaseMviViewModelTest<
ViewState(
userItems = emptyList(),
isLoading = false,
- error = ioException,
+ error = userError,
isRefreshing = false,
)
),
expectedEvents = listOf(
- SingleEvent.GetUsersError(ioException),
+ SingleEvent.GetUsersError(userError),
),
delayAfterDispatchingIntents = Duration.milliseconds(100),
- ) { coVerify(exactly = 0) { refreshGetUsersUseCase() } }
+ ) {
+ coVerify(exactly = 1) { getUserUseCase() }
+ coVerify(exactly = 0) { refreshGetUsersUseCase() }
+ }
}
@Test
@@ -227,12 +247,13 @@ class MainVMTest : BaseMviViewModelTest<
@Test
fun test_withRetryIntentWhenSuccess_returnsUserItems() {
- val ioException = IOException()
+ val userError = UserError.NetworkError
+
test(
vmProducer = {
every { getUserUseCase() } returnsMany listOf(
- flow { throw ioException },
- flowOf(USERS),
+ flowOf(userError.left()),
+ flowOf(USERS.right()),
)
vm
},
@@ -242,7 +263,7 @@ class MainVMTest : BaseMviViewModelTest<
ViewState(
userItems = emptyList(),
isLoading = false,
- error = ioException,
+ error = userError,
isRefreshing = false,
),
ViewState(
@@ -259,20 +280,21 @@ class MainVMTest : BaseMviViewModelTest<
)
),
expectedEvents = listOf(
- SingleEvent.GetUsersError(ioException),
+ SingleEvent.GetUsersError(userError),
),
) { verify(exactly = 2) { getUserUseCase() } }
}
@Test
fun test_withRetryIntentWhenSuccess_returnsErrorState() {
- val ioException1 = IOException()
- val ioException2 = IOException()
+ val userError1 = UserError.NetworkError
+ val userError2 = UserError.Unexpected
+
test(
vmProducer = {
every { getUserUseCase() } returnsMany listOf(
- flow { throw ioException1 },
- flow { throw ioException2 },
+ flowOf(userError1.left()),
+ flowOf(userError2.left()),
)
vm
},
@@ -282,7 +304,7 @@ class MainVMTest : BaseMviViewModelTest<
ViewState(
userItems = emptyList(),
isLoading = false,
- error = ioException1,
+ error = userError1,
isRefreshing = false,
),
ViewState(
@@ -294,13 +316,13 @@ class MainVMTest : BaseMviViewModelTest<
ViewState(
userItems = emptyList(),
isLoading = false,
- error = ioException2,
+ error = userError2,
isRefreshing = false,
)
),
expectedEvents = listOf(
- SingleEvent.GetUsersError(ioException1),
- SingleEvent.GetUsersError(ioException2),
+ SingleEvent.GetUsersError(userError1),
+ SingleEvent.GetUsersError(userError2),
),
) { verify(exactly = 2) { getUserUseCase() } }
}
@@ -311,15 +333,18 @@ class MainVMTest : BaseMviViewModelTest<
val user2 = USERS[1]
val item1 = USER_ITEMS[0]
val item2 = USER_ITEMS[1]
- val usersFlow = MutableStateFlow(USERS)
+ val usersFlow = MutableStateFlow(USERS.right())
test(
vmProducer = {
every { getUserUseCase() } returns usersFlow
coEvery { removeUser(any()) } coAnswers {
- usersFlow.update { users ->
- users.filter { it.id != firstArg().id }
+ usersFlow.update { either ->
+ either.map { users ->
+ users.filter { it.id != firstArg().id }
+ }
}
+ Unit.right()
}
vm
},
@@ -356,6 +381,7 @@ class MainVMTest : BaseMviViewModelTest<
SingleEvent.RemoveUser.Success(item2),
)
) {
+ coVerify(exactly = 1) { getUserUseCase() }
coVerifySequence {
removeUser(user1)
removeUser(user2)
@@ -367,12 +393,12 @@ class MainVMTest : BaseMviViewModelTest<
fun test_withRemoveUserIntentWhenError_stateDoNotChange() {
val user = USERS[0]
val item = USER_ITEMS[0]
- val ioException = IOException()
+ val userError = UserError.NetworkError
test(
vmProducer = {
- every { getUserUseCase() } returns flowOf(USERS)
- coEvery { removeUser(any()) } throws ioException
+ every { getUserUseCase() } returns flowOf(USERS.right())
+ coEvery { removeUser(any()) } returns userError.left()
vm
},
intentsBeforeCollecting = flowOf(ViewIntent.Initial),
@@ -386,9 +412,10 @@ class MainVMTest : BaseMviViewModelTest<
),
),
expectedEvents = listOf(
- SingleEvent.RemoveUser.Failure(item, ioException),
+ SingleEvent.RemoveUser.Failure(item, userError),
)
) {
+ coVerify(exactly = 1) { getUserUseCase() }
coVerify(exactly = 1) { removeUser(user) }
}
}
diff --git a/feature-search/build.gradle.kts b/feature-search/build.gradle.kts
index ca469d7e..ddcc0ea9 100644
--- a/feature-search/build.gradle.kts
+++ b/feature-search/build.gradle.kts
@@ -60,6 +60,7 @@ dependencies {
implementation(deps.coil)
implementation(deps.viewBindingDelegate)
implementation(deps.flowExt)
+ implementation(deps.arrow.core)
addUnitTest()
}
diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt
index 0794df6d..e66ee252 100644
--- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchActivity.kt
@@ -19,6 +19,7 @@ import com.hoc.flowmvi.core.collectIn
import com.hoc.flowmvi.core.navigator.IntentProviders
import com.hoc.flowmvi.core.queryTextEvents
import com.hoc.flowmvi.core.toast
+import com.hoc.flowmvi.domain.repository.UserError
import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding
import com.hoc081098.viewbindingdelegate.viewBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -60,7 +61,16 @@ class SearchActivity : AppCompatActivity(R.layout.activity_search) {
textQuery.text = "Search results for '${viewState.query}'"
errorGroup.isVisible = viewState.error !== null
- errorMessageTextView.text = viewState.error?.message
+ errorMessageTextView.text = viewState.error?.let {
+ when (it) {
+ is UserError.InvalidId -> "Invalid id"
+ UserError.NetworkError -> "Network error"
+ UserError.ServerError -> "Server error"
+ UserError.Unexpected -> "Unexpected error"
+ is UserError.UserNotFound -> "User not found"
+ UserError.ValidationFailed -> "Validation failed"
+ }
+ }
progressBar.isVisible = viewState.isLoading
}
diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt
index 235fd122..bcd4f5cb 100644
--- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt
@@ -1,6 +1,7 @@
package com.hoc.flowmvi.ui.search
import com.hoc.flowmvi.domain.entity.User
+import com.hoc.flowmvi.domain.repository.UserError
import dev.ahmedmourad.nocopy.annotations.NoCopy
@Suppress("DataClassPrivateConstructor")
@@ -31,7 +32,7 @@ internal sealed interface ViewIntent {
internal data class ViewState(
val users: List,
val isLoading: Boolean,
- val error: Throwable?,
+ val error: UserError?,
val query: String,
) {
companion object Factory {
@@ -49,7 +50,7 @@ internal data class ViewState(
internal sealed interface PartialStateChange {
object Loading : PartialStateChange
data class Success(val users: List, val query: String) : PartialStateChange
- data class Failure(val error: Throwable, val query: String) : PartialStateChange
+ data class Failure(val error: UserError, val query: String) : PartialStateChange
fun reduce(state: ViewState) = when (this) {
is Failure -> state.copy(
@@ -69,5 +70,5 @@ internal sealed interface PartialStateChange {
}
internal sealed interface SingleEvent {
- data class SearchFailure(val error: Throwable) : SingleEvent
+ data class SearchFailure(val error: UserError) : SingleEvent
}
diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt
index c887301f..88958e70 100644
--- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt
+++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt
@@ -59,15 +59,18 @@ internal class SearchVM(
private fun Flow.toPartialStateChangesFlow(): Flow {
val executeSearch: suspend (String) -> Flow = { query: String ->
flow { emit(searchUsersUseCase(query)) }
- .map {
- @Suppress("USELESS_CAST")
- PartialStateChange.Success(
- it.map(UserItem::from),
- query,
- ) as PartialStateChange
+ .map { result ->
+ result.fold(
+ ifLeft = { PartialStateChange.Failure(it, query) },
+ ifRight = {
+ PartialStateChange.Success(
+ it.map(UserItem::from),
+ query
+ )
+ }
+ )
}
.onStart { emit(PartialStateChange.Loading) }
- .catch { emit(PartialStateChange.Failure(it, query)) }
}
val queryFlow = filterIsInstance()