From 600f49df843e62ca56d900d68f498c947b9e91ab Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Mon, 11 Oct 2021 20:02:47 +0700 Subject: [PATCH 01/24] implements --- buildSrc/src/main/kotlin/deps.kt | 9 +- .../main/java/com/hoc/flowmvi/core/Either.kt | 16 ---- data/build.gradle.kts | 3 + .../java/com/hoc/flowmvi/data/DataModule.kt | 9 +- .../hoc/flowmvi/data/UserRepositoryImpl.kt | 56 ++++++++----- .../flowmvi/data/mapper/UserErrorMapper.kt | 53 ++++++++++++ .../hoc/flowmvi/data/remote/ErrorResponse.kt | 14 ++++ domain/build.gradle.kts | 1 + .../domain/repository/UserRepository.kt | 23 +++-- .../flowmvi/domain/usecase/AddUserUseCase.kt | 4 +- .../flowmvi/domain/usecase/GetUsersUseCase.kt | 6 +- .../domain/usecase/RefreshGetUsersUseCase.kt | 4 +- .../domain/usecase/RemoveUserUseCase.kt | 4 +- .../domain/usecase/SearchUsersUseCase.kt | 5 +- .../com/hoc/flowmvi/domain/UseCaseTest.kt | 52 +++++++----- feature-add/build.gradle.kts | 1 + .../com/hoc/flowmvi/ui/add/AddContract.kt | 5 +- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 18 ++-- feature-main/build.gradle.kts | 1 + .../com/hoc/flowmvi/ui/main/MainContract.kt | 15 ++-- .../java/com/hoc/flowmvi/ui/main/MainVM.kt | 28 ++++--- .../com/hoc/flowmvi/ui/main/MainVMTest.kt | 84 ++++++++++--------- .../hoc/flowmvi/ui/search/SearchContract.kt | 7 +- .../com/hoc/flowmvi/ui/search/SearchVM.kt | 17 ++-- 24 files changed, 288 insertions(+), 147 deletions(-) delete mode 100644 core/src/main/java/com/hoc/flowmvi/core/Either.kt create mode 100644 data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt create mode 100644 data/src/main/java/com/hoc/flowmvi/data/remote/ErrorResponse.kt diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index bb3b224f..ee8db309 100644 --- a/buildSrc/src/main/kotlin/deps.kt +++ b/buildSrc/src/main/kotlin/deps.kt @@ -46,7 +46,8 @@ 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 moshiCodegen = "com.squareup.moshi:moshi-kotlin-codegen:1.12.0" const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.7" } @@ -69,6 +70,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 +92,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..7fe8363e 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,6 +1,7 @@ plugins { androidLib kotlinAndroid + kotlinKapt } android { @@ -47,8 +48,10 @@ dependencies { implementation(deps.squareup.moshiKotlin) implementation(deps.squareup.converterMoshi) implementation(deps.squareup.loggingInterceptor) + kapt(deps.squareup.moshiCodegen) 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..e27fc538 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt @@ -2,7 +2,9 @@ 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.ErrorResponseJsonAdapter import com.hoc.flowmvi.data.remote.UserApiService import com.hoc.flowmvi.domain.repository.UserRepository import com.squareup.moshi.Moshi @@ -45,13 +47,18 @@ val dataModule = module { factory { UserDomainToUserBodyMapper() } + factory { UserErrorMapper(errorResponseJsonAdapter = get()) } + + factory { ErrorResponseJsonAdapter(moshi = 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..d1da18c9 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,9 @@ package com.hoc.flowmvi.data import android.util.Log +import arrow.core.Either +import arrow.core.left +import arrow.core.right import com.hoc.flowmvi.core.Mapper import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers import com.hoc.flowmvi.core.retrySuspend @@ -8,13 +11,15 @@ 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 @@ -28,7 +33,8 @@ internal class UserRepositoryImpl constructor( 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 { @@ -52,35 +58,39 @@ 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 { + @Suppress("USELESS_CAST") + it.right() as Either> + } + .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) 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 response = userApiService.add(body) @@ -89,9 +99,11 @@ internal class UserRepositoryImpl constructor( } } - 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) { + delay(400) + userApiService.search(query).map(responseToDomain) + } } companion object { 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..1a5265a5 --- /dev/null +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt @@ -0,0 +1,53 @@ +package com.hoc.flowmvi.data.mapper + +import com.hoc.flowmvi.core.Mapper +import com.hoc.flowmvi.data.remote.ErrorResponse +import com.hoc.flowmvi.data.remote.ErrorResponseJsonAdapter +import com.hoc.flowmvi.domain.repository.UserError +import retrofit2.HttpException +import java.io.IOException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +class UserErrorMapper(private val errorResponseJsonAdapter: ErrorResponseJsonAdapter) : + Mapper { + override fun invoke(throwable: Throwable): UserError { + throwable is UserError && return throwable + + return when (throwable) { + is IOException -> { + when (throwable) { + is UnknownHostException -> UserError.NetworkError( + throwable, + "UnknownHostException: ${throwable.message}" + ) + is SocketTimeoutException -> UserError.NetworkError( + throwable, + "SocketTimeoutException: ${throwable.message}" + ) + is SocketException -> UserError.NetworkError( + throwable, + "SocketException: ${throwable.message}" + ) + else -> UserError.NetworkError( + throwable, + "Unknown IOException: ${throwable.message}" + ) + } + } + is HttpException -> { + throwable.response()!! + .takeUnless { it.isSuccessful }!! + .errorBody()!! + .use { body -> errorResponseJsonAdapter.fromJson(body.string())!! } + .let(::mapResponseError) + } + else -> UserError.Unexpected(throwable) + } + } + + private fun mapResponseError(response: ErrorResponse): UserError { + TODO() + } +} 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..3f4783f7 --- /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 +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +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 +) 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..470eb095 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,29 @@ package com.hoc.flowmvi.domain.repository +import arrow.core.Either import com.hoc.flowmvi.domain.entity.User import kotlinx.coroutines.flow.Flow +sealed class UserError(message: String?, cause: Throwable?) : Exception(message, cause) { + data class NetworkError( + override val cause: Throwable?, + override val message: String? = cause?.message, + ) : UserError(message, cause) + + data class Unexpected( + override val cause: Throwable?, + override val message: String? = cause?.message, + ) : UserError(message, cause) +} + 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..780df752 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 @@ -15,7 +18,6 @@ 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 @@ -24,7 +26,6 @@ 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( @@ -61,6 +62,8 @@ class UseCaseTest { private val addUserUseCase: AddUserUseCase = AddUserUseCase(userRepository) private val searchUsersUseCase: SearchUsersUseCase = SearchUsersUseCase(userRepository) + private val errorLeft = UserError.NetworkError(IOException()).left() + @BeforeTest fun setup() { } @@ -72,99 +75,104 @@ class UseCaseTest { @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..9f46ccd0 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,7 +171,7 @@ internal class AddVM( userFormFlow .map { PartialStateChange.ErrorsChanged( - it.leftOrNull() + it.fold(::identity) { null } ?: emptySet() ) }, 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/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..4ea974a4 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,7 +1,10 @@ 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 @@ -15,7 +18,6 @@ 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 @@ -56,7 +58,7 @@ class MainVMTest : BaseMviViewModelTest< @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 +76,11 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withInitialIntentWhenError_returnsErrorState() { - val ioException = IOException() + val userError = UserError.NetworkError(IOException()) test( vmProducer = { - every { getUserUseCase() } returns flow { throw ioException } + every { getUserUseCase() } returns flowOf(userError.left()) vm }, intents = flowOf(ViewIntent.Initial), @@ -87,13 +89,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 +104,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), @@ -135,12 +137,12 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withRefreshIntentWhenFailure_isNotRefreshing() { - val ioException = IOException() + val userError = UserError.NetworkError(IOException()) 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,7 +168,7 @@ class MainVMTest : BaseMviViewModelTest< ), ), expectedEvents = listOf( - SingleEvent.Refresh.Failure(ioException) + SingleEvent.Refresh.Failure(userError) ), ) { coVerify(exactly = 1) { refreshGetUsersUseCase() } } } @@ -175,7 +177,7 @@ class MainVMTest : BaseMviViewModelTest< fun test_withRefreshIntent_ignoredWhenIsLoading() { test( vmProducer = { - coEvery { refreshGetUsersUseCase() } returns Unit + coEvery { refreshGetUsersUseCase() } returns Unit.right() vm }, intents = flowOf(ViewIntent.Refresh), @@ -187,11 +189,12 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withRefreshIntent_ignoredWhenHavingError() { - val ioException = IOException() + val userError = UserError.NetworkError(IOException()) + 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,12 +203,12 @@ 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() } } @@ -227,12 +230,13 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withRetryIntentWhenSuccess_returnsUserItems() { - val ioException = IOException() + val userError = UserError.NetworkError(IOException()) + test( vmProducer = { every { getUserUseCase() } returnsMany listOf( - flow { throw ioException }, - flowOf(USERS), + flowOf(userError.left()), + flowOf(USERS.right()), ) vm }, @@ -242,7 +246,7 @@ class MainVMTest : BaseMviViewModelTest< ViewState( userItems = emptyList(), isLoading = false, - error = ioException, + error = userError, isRefreshing = false, ), ViewState( @@ -259,20 +263,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(IOException()) + val userError2 = UserError.NetworkError(IOException()) + test( vmProducer = { every { getUserUseCase() } returnsMany listOf( - flow { throw ioException1 }, - flow { throw ioException2 }, + flowOf(userError1.left()), + flowOf(userError2.left()), ) vm }, @@ -282,7 +287,7 @@ class MainVMTest : BaseMviViewModelTest< ViewState( userItems = emptyList(), isLoading = false, - error = ioException1, + error = userError1, isRefreshing = false, ), ViewState( @@ -294,13 +299,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 +316,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 }, @@ -367,12 +375,12 @@ class MainVMTest : BaseMviViewModelTest< fun test_withRemoveUserIntentWhenError_stateDoNotChange() { val user = USERS[0] val item = USER_ITEMS[0] - val ioException = IOException() + val userError = UserError.NetworkError(IOException()) 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,7 +394,7 @@ class MainVMTest : BaseMviViewModelTest< ), ), expectedEvents = listOf( - SingleEvent.RemoveUser.Failure(item, ioException), + SingleEvent.RemoveUser.Failure(item, userError), ) ) { coVerify(exactly = 1) { removeUser(user) } 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() From c5219749477e57232d5e84fbde30aa47502b4ea2 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Mon, 11 Oct 2021 20:13:58 +0700 Subject: [PATCH 02/24] fix deps --- feature-search/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) 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() } From dce0c4fc9f4aee77159da412fba19fd4b3a72d31 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Tue, 12 Oct 2021 15:50:24 +0700 Subject: [PATCH 03/24] wip --- app/src/main/java/com/hoc/flowmvi/App.kt | 1 + buildSrc/src/main/kotlin/deps.kt | 1 - data/build.gradle.kts | 1 - .../java/com/hoc/flowmvi/data/DataModule.kt | 8 ++- .../hoc/flowmvi/data/UserRepositoryImpl.kt | 2 +- .../flowmvi/data/mapper/UserErrorMapper.kt | 65 +++++++++---------- .../hoc/flowmvi/data/remote/ErrorResponse.kt | 6 +- .../domain/repository/UserRepository.kt | 17 ++--- .../main/java/com/hoc/flowmvi/ui/add/AddVM.kt | 3 +- .../com/hoc/flowmvi/ui/main/MainActivity.kt | 12 +++- .../hoc/flowmvi/ui/search/SearchActivity.kt | 12 +++- 11 files changed, 70 insertions(+), 58 deletions(-) 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/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index ee8db309..3a9b9be1 100644 --- a/buildSrc/src/main/kotlin/deps.kt +++ b/buildSrc/src/main/kotlin/deps.kt @@ -47,7 +47,6 @@ object deps { 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.12.0" - const val moshiCodegen = "com.squareup.moshi:moshi-kotlin-codegen:1.12.0" const val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.7" } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 7fe8363e..728d958d 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -48,7 +48,6 @@ dependencies { implementation(deps.squareup.moshiKotlin) implementation(deps.squareup.converterMoshi) implementation(deps.squareup.loggingInterceptor) - kapt(deps.squareup.moshiCodegen) implementation(deps.koin.core) implementation(deps.arrow.core) 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 e27fc538..f5118752 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt @@ -4,10 +4,11 @@ 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.ErrorResponseJsonAdapter +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 @@ -22,6 +23,7 @@ import kotlin.time.ExperimentalTime private const val BASE_URL = "BASE_URL" +@ExperimentalStdlibApi @ExperimentalTime @ExperimentalCoroutinesApi val dataModule = module { @@ -47,9 +49,9 @@ val dataModule = module { factory { UserDomainToUserBodyMapper() } - factory { UserErrorMapper(errorResponseJsonAdapter = get()) } + factory { get().adapter() } - factory { ErrorResponseJsonAdapter(moshi = get()) } + factory { UserErrorMapper(errorResponseJsonAdapter = get()) } single { UserRepositoryImpl( 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 d1da18c9..693060dd 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt @@ -28,7 +28,7 @@ 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, 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 index 1a5265a5..4a6efc79 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt @@ -2,52 +2,47 @@ package com.hoc.flowmvi.data.mapper import com.hoc.flowmvi.core.Mapper import com.hoc.flowmvi.data.remote.ErrorResponse -import com.hoc.flowmvi.data.remote.ErrorResponseJsonAdapter 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 -class UserErrorMapper(private val errorResponseJsonAdapter: ErrorResponseJsonAdapter) : +class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter) : Mapper { - override fun invoke(throwable: Throwable): UserError { - throwable is UserError && return throwable - - return when (throwable) { - is IOException -> { - when (throwable) { - is UnknownHostException -> UserError.NetworkError( - throwable, - "UnknownHostException: ${throwable.message}" - ) - is SocketTimeoutException -> UserError.NetworkError( - throwable, - "SocketTimeoutException: ${throwable.message}" - ) - is SocketException -> UserError.NetworkError( - throwable, - "SocketException: ${throwable.message}" - ) - else -> UserError.NetworkError( - throwable, - "Unknown IOException: ${throwable.message}" - ) + override fun invoke(t: Throwable): UserError { + return runCatching { + when (t) { + is IOException -> when (t) { + is UnknownHostException -> UserError.NetworkError + is SocketTimeoutException -> UserError.NetworkError + is SocketException -> UserError.NetworkError + else -> UserError.NetworkError } + is HttpException -> + t.response()!! + .takeUnless { it.isSuccessful }!! + .errorBody()!! + .use(ResponseBody::string) + .let { mapResponseError(it) } + else -> UserError.Unexpected } - is HttpException -> { - throwable.response()!! - .takeUnless { it.isSuccessful }!! - .errorBody()!! - .use { body -> errorResponseJsonAdapter.fromJson(body.string())!! } - .let(::mapResponseError) - } - else -> UserError.Unexpected(throwable) - } + }.getOrElse { UserError.Unexpected } } - private fun mapResponseError(response: ErrorResponse): UserError { - TODO() + @Throws + 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 index 3f4783f7..88c6bd4e 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/remote/ErrorResponse.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/remote/ErrorResponse.kt @@ -1,14 +1,14 @@ package com.hoc.flowmvi.data.remote import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -@JsonClass(generateAdapter = true) 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 + val message: String, // Cannot GET /23 + @Json(name = "data") + val data: Any? = null, ) 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 470eb095..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 @@ -4,16 +4,13 @@ import arrow.core.Either import com.hoc.flowmvi.domain.entity.User import kotlinx.coroutines.flow.Flow -sealed class UserError(message: String?, cause: Throwable?) : Exception(message, cause) { - data class NetworkError( - override val cause: Throwable?, - override val message: String? = cause?.message, - ) : UserError(message, cause) - - data class Unexpected( - override val cause: Throwable?, - override val message: String? = cause?.message, - ) : UserError(message, cause) +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 { 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 9f46ccd0..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 @@ -171,8 +171,7 @@ internal class AddVM( userFormFlow .map { PartialStateChange.ErrorsChanged( - it.fold(::identity) { null } - ?: emptySet() + it.fold(::identity) { emptySet() } ) }, addUserChanges, 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-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 } From dd5c44d30b01629b35b54285ef3f2aaac8ff901e Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Tue, 12 Oct 2021 16:25:21 +0700 Subject: [PATCH 04/24] wip --- .../java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 4a6efc79..8f609a12 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt @@ -1,5 +1,6 @@ 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 @@ -13,9 +14,9 @@ import java.net.UnknownHostException class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter) : Mapper { - override fun invoke(t: Throwable): UserError { + override fun invoke(throwable: Throwable): UserError { return runCatching { - when (t) { + when (val t = throwable.nonFatalOrThrow()) { is IOException -> when (t) { is UnknownHostException -> UserError.NetworkError is SocketTimeoutException -> UserError.NetworkError @@ -33,7 +34,7 @@ class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter Date: Tue, 12 Oct 2021 17:00:38 +0700 Subject: [PATCH 05/24] mapper tests --- .../java/com/hoc/flowmvi/data/DataModule.kt | 4 -- .../hoc/flowmvi/data/UserRepositoryImpl.kt | 3 +- .../mapper/UserDomainToUserResponseMapper.kt | 17 ------- .../flowmvi/data/mapper/UserErrorMapper.kt | 13 +++-- .../mapper/UserDomainToUserBodyMapperTest.kt | 33 ++++++++++++ .../data/mapper/UserErrorMapperTest.kt | 50 +++++++++++++++++++ .../UserResponseToUserDomainMapperTest.kt | 34 +++++++++++++ 7 files changed, 127 insertions(+), 27 deletions(-) delete mode 100644 data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt create mode 100644 data/src/test/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapperTest.kt create mode 100644 data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt create mode 100644 data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt 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 f5118752..90f5c322 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt @@ -1,7 +1,6 @@ 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 @@ -45,8 +44,6 @@ val dataModule = module { factory { UserResponseToUserDomainMapper() } - factory { UserDomainToUserResponseMapper() } - factory { UserDomainToUserBodyMapper() } factory { get().adapter() } @@ -58,7 +55,6 @@ val dataModule = module { userApiService = get(), dispatchers = get(), responseToDomain = get(), - domainToResponse = 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 693060dd..2a348d43 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt @@ -32,7 +32,6 @@ 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 errorMapper: Mapper, ) : UserRepository { @@ -85,7 +84,7 @@ internal class UserRepositoryImpl( 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))) } } 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 index 8f609a12..81b8399c 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt @@ -12,11 +12,13 @@ import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException -class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter) : +internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter) : Mapper { - override fun invoke(throwable: Throwable): UserError { + override fun invoke(t: Throwable): UserError { + t.nonFatalOrThrow() + return runCatching { - when (val t = throwable.nonFatalOrThrow()) { + when (t) { is IOException -> when (t) { is UnknownHostException -> UserError.NetworkError is SocketTimeoutException -> UserError.NetworkError @@ -31,7 +33,10 @@ class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter UserError.Unexpected } - }.getOrElse { UserError.Unexpected } + }.getOrElse { + t.nonFatalOrThrow() + UserError.Unexpected + } } @Throws(Throwable::class) 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..ef6d438e --- /dev/null +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapperTest.kt @@ -0,0 +1,33 @@ +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", + avatar = "avatar", + ), + 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..48f0e045 --- /dev/null +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt @@ -0,0 +1,50 @@ +package com.hoc.flowmvi.data.mapper + +import com.hoc.flowmvi.data.remote.ErrorResponse +import com.hoc.flowmvi.domain.repository.UserError +import com.squareup.moshi.JsonAdapter +import io.mockk.mockk +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 + +class UserErrorMapperTest { + private val errorResponseJsonAdapter: JsonAdapter = mockk() + private val errorMapper = UserErrorMapper(errorResponseJsonAdapter) + + @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() {}), + ) + } +} 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 + ) + } +} From 83506b672f852216f74516869aec24879ec71a45 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Tue, 12 Oct 2021 17:28:19 +0700 Subject: [PATCH 06/24] mapper tests --- .../flowmvi/data/mapper/UserErrorMapper.kt | 12 ++-- .../data/mapper/UserErrorMapperTest.kt | 70 ++++++++++++++++++- 2 files changed, 73 insertions(+), 9 deletions(-) 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 index 81b8399c..033ed08b 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserErrorMapper.kt @@ -14,19 +14,19 @@ import java.net.UnknownHostException internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter) : Mapper { - override fun invoke(t: Throwable): UserError { - t.nonFatalOrThrow() + override fun invoke(throwable: Throwable): UserError { + throwable.nonFatalOrThrow() return runCatching { - when (t) { - is IOException -> when (t) { + when (throwable) { + is IOException -> when (throwable) { is UnknownHostException -> UserError.NetworkError is SocketTimeoutException -> UserError.NetworkError is SocketException -> UserError.NetworkError else -> UserError.NetworkError } is HttpException -> - t.response()!! + throwable.response()!! .takeUnless { it.isSuccessful }!! .errorBody()!! .use(ResponseBody::string) @@ -34,7 +34,7 @@ internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter else -> UserError.Unexpected } }.getOrElse { - t.nonFatalOrThrow() + it.nonFatalOrThrow() UserError.Unexpected } } 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 index 48f0e045..6c4db4e5 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt @@ -2,8 +2,13 @@ package com.hoc.flowmvi.data.mapper import com.hoc.flowmvi.data.remote.ErrorResponse import com.hoc.flowmvi.domain.repository.UserError -import com.squareup.moshi.JsonAdapter -import io.mockk.mockk +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 @@ -14,8 +19,13 @@ import kotlin.test.assertFailsWith import kotlin.coroutines.cancellation.CancellationException as KotlinCancellationException import kotlinx.coroutines.CancellationException as KotlinXCancellationException +@ExperimentalStdlibApi class UserErrorMapperTest { - private val errorResponseJsonAdapter: JsonAdapter = mockk() + private val moshi = Moshi + .Builder() + .add(KotlinJsonAdapterFactory()) + .build() + private val errorResponseJsonAdapter = moshi.adapter() private val errorMapper = UserErrorMapper(errorResponseJsonAdapter) @Test @@ -47,4 +57,58 @@ class UserErrorMapperTest { 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( + HttpException( + Response.error( + 400, + errorResponseJsonAdapter.toJson( + ErrorResponse( + statusCode = 400, + error = "hello", + message = "hello", + data = 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"), + ) + ), + ) + ).toResponseBody("application/json".toMediaType()) + ) + ) + ), + ) + } } From 24b5249961e4ec74384088e74df8b2a00f1ea3c8 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Tue, 12 Oct 2021 17:31:13 +0700 Subject: [PATCH 07/24] fix unit tests --- .../java/com/hoc/flowmvi/domain/UseCaseTest.kt | 3 +-- .../java/com/hoc/flowmvi/ui/main/MainVMTest.kt | 15 +++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) 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 780df752..c5f0d578 100644 --- a/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt +++ b/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.first 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 @@ -62,7 +61,7 @@ class UseCaseTest { private val addUserUseCase: AddUserUseCase = AddUserUseCase(userRepository) private val searchUsersUseCase: SearchUsersUseCase = SearchUsersUseCase(userRepository) - private val errorLeft = UserError.NetworkError(IOException()).left() + private val errorLeft = UserError.NetworkError.left() @BeforeTest fun setup() { 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 4ea974a4..88410714 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 @@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow 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 @@ -76,7 +75,7 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withInitialIntentWhenError_returnsErrorState() { - val userError = UserError.NetworkError(IOException()) + val userError = UserError.NetworkError test( vmProducer = { @@ -137,7 +136,7 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withRefreshIntentWhenFailure_isNotRefreshing() { - val userError = UserError.NetworkError(IOException()) + val userError = UserError.NetworkError test( vmProducer = { @@ -189,7 +188,7 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withRefreshIntent_ignoredWhenHavingError() { - val userError = UserError.NetworkError(IOException()) + val userError = UserError.NetworkError test( vmProducer = { @@ -230,7 +229,7 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withRetryIntentWhenSuccess_returnsUserItems() { - val userError = UserError.NetworkError(IOException()) + val userError = UserError.NetworkError test( vmProducer = { @@ -270,8 +269,8 @@ class MainVMTest : BaseMviViewModelTest< @Test fun test_withRetryIntentWhenSuccess_returnsErrorState() { - val userError1 = UserError.NetworkError(IOException()) - val userError2 = UserError.NetworkError(IOException()) + val userError1 = UserError.NetworkError + val userError2 = UserError.Unexpected test( vmProducer = { @@ -375,7 +374,7 @@ class MainVMTest : BaseMviViewModelTest< fun test_withRemoveUserIntentWhenError_stateDoNotChange() { val user = USERS[0] val item = USER_ITEMS[0] - val userError = UserError.NetworkError(IOException()) + val userError = UserError.NetworkError test( vmProducer = { From 29f31ca408f718f8dc85f46e9a7db272ca164c1e Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Tue, 12 Oct 2021 18:01:14 +0700 Subject: [PATCH 08/24] fix unit tests --- .../data/mapper/UserErrorMapperTest.kt | 84 ++++++++++++++----- 1 file changed, 61 insertions(+), 23 deletions(-) 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 index 6c4db4e5..7ecdff77 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserErrorMapperTest.kt @@ -28,6 +28,21 @@ class UserErrorMapperTest { private val errorResponseJsonAdapter = moshi.adapter() private val errorMapper = UserErrorMapper(errorResponseJsonAdapter) + private fun getBuildError(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()) } @@ -84,31 +99,54 @@ class UserErrorMapperTest { assertEquals( UserError.Unexpected, errorMapper( - HttpException( - Response.error( - 400, - errorResponseJsonAdapter.toJson( - ErrorResponse( - statusCode = 400, - error = "hello", - message = "hello", - data = 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"), - ) - ), - ) - ).toResponseBody("application/json".toMediaType()) - ) + getBuildError( + "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(getBuildError("invalid-id", id)), + ) + assertEquals( + UserError.Unexpected, + errorMapper(getBuildError("user-not-found", id)), + ) + } + + @Test + fun test_withHttpException_returnsCorrespondingUserError() { + assertEquals( + UserError.ServerError, + errorMapper(getBuildError("internal-error", null)), + ) + + val id = "id" + assertEquals( + UserError.InvalidId(id), + errorMapper(getBuildError("invalid-id", id)), + ) + assertEquals( + UserError.UserNotFound(id), + errorMapper(getBuildError("user-not-found", id)), + ) + assertEquals( + UserError.ValidationFailed, + errorMapper(getBuildError("validation-failed", null)), + ) } } From f6fba52eda60669286fe765820b237a85b79cd88 Mon Sep 17 00:00:00 2001 From: Petrus Nguyen Thai Hoc Date: Tue, 12 Oct 2021 18:03:05 +0700 Subject: [PATCH 09/24] fix unit tests --- .idea/codeStyles/Project.xml | 1 + .idea/codeStyles/codeStyleConfig.xml | 1 - .../flowmvi/data/mapper/UserErrorMapperTest.kt | 16 ++++++++-------- 3 files changed, 9 insertions(+), 9 deletions(-) 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 @@