diff --git a/.editorconfig b/.editorconfig index da044eaa..129c9dcd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,4 +8,4 @@ trim_trailing_whitespace=true insert_final_newline=true [*.{kt,kts}] -kotlin_imports_layout=ascii +ij_kotlin_imports_layout=* diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 15649223..a460842d 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -7,6 +7,7 @@ on: pull_request: branches: [ master ] paths-ignore: [ '**.md', '**.MD' ] + workflow_dispatch: env: CI: true diff --git a/app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt similarity index 90% rename from app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt rename to app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt index 50a9efaf..fae6bb64 100644 --- a/app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt +++ b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt @@ -6,6 +6,7 @@ import io.mockk.mockkClass import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.junit.Rule +import org.koin.test.AutoCloseKoinTest import org.koin.test.check.checkKoinModules import org.koin.test.mock.MockProviderRule import kotlin.test.Test @@ -15,7 +16,7 @@ import kotlin.time.ExperimentalTime @FlowPreview @ExperimentalCoroutinesApi @ExperimentalTime -class ExampleUnitTest { +class CheckModulesTest : AutoCloseKoinTest() { @get:Rule val mockProvider = MockProviderRule.create { clazz -> mockkClass(clazz).also { o -> diff --git a/build.gradle.kts b/build.gradle.kts index 8dbd5d00..2fd3cbf7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.util.EnumSet +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent import org.jetbrains.kotlin.gradle.tasks.KotlinCompile // Top-level build file where you can add configuration options common to all sub-projects/modules. @@ -33,7 +36,9 @@ subprojects { // TODO this should all come from editorconfig https://github.com/diffplug/spotless/issues/142 mapOf( "indent_size" to "2", - "kotlin_imports_layout" to "ascii" + "ij_kotlin_imports_layout" to "*", + "end_of_line" to "lf", + "charset" to "utf-8" ) ) @@ -56,7 +61,9 @@ subprojects { ktlint(ktlintVersion).userData( mapOf( "indent_size" to "2", - "kotlin_imports_layout" to "ascii" + "ij_kotlin_imports_layout" to "*", + "end_of_line" to "lf", + "charset" to "utf-8" ) ) @@ -83,6 +90,21 @@ subprojects { isIncludeNoLocationClasses = true excludes = listOf("jdk.internal.*") } + + testLogging { + showExceptions = true + showCauses = true + showStackTraces = true + showStandardStreams = true + events = EnumSet.of( + TestLogEvent.PASSED, + TestLogEvent.FAILED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT, + TestLogEvent.STANDARD_ERROR + ) + exceptionFormat = TestExceptionFormat.FULL + } } } } diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index 690412f8..6d3a5879 100644 --- a/buildSrc/src/main/kotlin/deps.kt +++ b/buildSrc/src/main/kotlin/deps.kt @@ -6,7 +6,7 @@ import org.gradle.kotlin.dsl.project import org.gradle.plugin.use.PluginDependenciesSpec import org.gradle.plugin.use.PluginDependencySpec -const val ktlintVersion = "0.41.0" +const val ktlintVersion = "0.43.0" const val kotlinVersion = "1.5.31" object appConfig { diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 7bcd2199..6aa10c16 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -56,4 +56,5 @@ dependencies { addUnitTest() testImplementation(testUtils) + testImplementation(deps.koin.testJunit4) } 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 f2da9786..f38aad37 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt @@ -1,17 +1,20 @@ package com.hoc.flowmvi.data -import arrow.core.Either +import arrow.core.ValidatedNel +import arrow.core.computations.either import arrow.core.left import arrow.core.leftWiden import arrow.core.right +import arrow.core.valueOr import com.hoc.flowmvi.core.Mapper import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers import com.hoc.flowmvi.core.retrySuspend 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.model.User +import com.hoc.flowmvi.domain.model.UserError +import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.domain.repository.UserRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -27,13 +30,14 @@ import timber.log.Timber import java.io.IOException import kotlin.time.Duration import kotlin.time.ExperimentalTime +import arrow.core.Either.Companion.catch as catchEither @ExperimentalTime @ExperimentalCoroutinesApi internal class UserRepositoryImpl( private val userApiService: UserApiService, private val dispatchers: CoroutineDispatchers, - private val responseToDomain: Mapper, + private val responseToDomain: Mapper>, private val domainToBody: Mapper, private val errorMapper: Mapper, ) : UserRepository { @@ -44,8 +48,23 @@ internal class UserRepositoryImpl( class Added(val user: User) : Change() } + private val responseToDomainThrows: (UserResponse) -> User = { response -> + responseToDomain(response).let { validated -> + validated.valueOr { + val t = UserError.ValidationFailed(it.toSet()) + logError(t, "Map $response to user") + throw t + } + } + } + private val changesFlow = MutableSharedFlow(extraBufferCapacity = 64) + private suspend inline fun sendChange(change: Change) = changesFlow.emit(change) + + @Suppress("NOTHING_TO_INLINE") + private inline fun logError(t: Throwable, message: String) = Timber.tag(TAG).e(t, message) + private suspend fun getUsersFromRemote(): List { return withContext(dispatchers.io) { retrySuspend( @@ -53,9 +72,11 @@ internal class UserRepositoryImpl( initialDelay = Duration.milliseconds(500), factor = 2.0, shouldRetry = { it is IOException } - ) { - Timber.d("[USER_REPO] Retry times=$it") - userApiService.getUsers().map(responseToDomain) + ) { times -> + Timber.d("[USER_REPO] Retry times=$times") + userApiService + .getUsers() + .map(responseToDomainThrows) } } } @@ -77,40 +98,57 @@ internal class UserRepositoryImpl( } .map { it.right().leftWiden>() } .catch { - Timber.tag("UserRepositoryImpl").e(it, "getUsers") + logError(it, "getUsers") emit(errorMapper(it).left()) } - override suspend fun refresh() = Either.catch { - getUsersFromRemote().let { changesFlow.emit(Change.Refreshed(it)) } - }.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "refresh") } + override suspend fun refresh() = catchEither { getUsersFromRemote() } + .tap { sendChange(Change.Refreshed(it)) } + .map { } + .tapLeft { logError(it, "refresh") } .mapLeft(errorMapper) - override suspend fun remove(user: User) = Either.catch { + override suspend fun remove(user: User) = either { withContext(dispatchers.io) { - val response = userApiService.remove(user.id) - changesFlow.emit(Change.Removed(responseToDomain(response))) - } - }.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "remove user=$user") } - .mapLeft(errorMapper) + val response = catchEither { userApiService.remove(user.id) } + .tapLeft { logError(it, "remove user=$user") } + .mapLeft(errorMapper) + .bind() - override suspend fun add(user: User) = Either.catch { - withContext(dispatchers.io) { - val body = domainToBody(user) - val response = userApiService.add(body) - changesFlow.emit(Change.Added(responseToDomain(response))) - extraDelay() + val deleted = responseToDomain(response) + .mapLeft { UserError.ValidationFailed(it.toSet()) } + .tapInvalid { logError(it, "remove user=$user") } + .bind() + + sendChange(Change.Removed(deleted)) } - }.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "add user=$user") } - .mapLeft(errorMapper) + } - override suspend fun search(query: String) = Either.catch { + override suspend fun add(user: User) = either { withContext(dispatchers.io) { - extraDelay() - userApiService.search(query).map(responseToDomain) + val response = catchEither { userApiService.add(domainToBody(user)) } + .tapLeft { logError(it, "add user=$user") } + .mapLeft(errorMapper) + .bind() + + delay(400) // TODO + + val added = responseToDomain(response) + .mapLeft { UserError.ValidationFailed(it.toSet()) } + .tapInvalid { logError(it, "add user=$user") } + .bind() + + sendChange(Change.Added(added)) } - }.tapLeft { Timber.tag("UserRepositoryImpl").e(it, "search query=$query") } - .mapLeft(errorMapper) + } - private suspend inline fun extraDelay() = delay(400) + override suspend fun search(query: String) = withContext(dispatchers.io) { + catchEither { userApiService.search(query).map(responseToDomainThrows) } + .tapLeft { logError(it, "search query=$query") } + .mapLeft(errorMapper) + } + + private companion object { + private val TAG = UserRepositoryImpl::class.java.simpleName + } } 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 85265caf..088b3a50 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 @@ -2,14 +2,14 @@ package com.hoc.flowmvi.data.mapper import com.hoc.flowmvi.core.Mapper import com.hoc.flowmvi.data.remote.UserBody -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User internal class UserDomainToUserBodyMapper : Mapper { override fun invoke(domain: User): UserBody { return UserBody( - email = domain.email, - firstName = domain.firstName, - lastName = domain.lastName + email = domain.email.value, + firstName = domain.firstName.value, + lastName = domain.lastName.value ) } } 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 033ed08b..9125621a 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 @@ -3,7 +3,7 @@ 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.hoc.flowmvi.domain.model.UserError import com.squareup.moshi.JsonAdapter import okhttp3.ResponseBody import retrofit2.HttpException @@ -19,6 +19,7 @@ internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter return runCatching { when (throwable) { + is UserError -> throwable is IOException -> when (throwable) { is UnknownHostException -> UserError.NetworkError is SocketTimeoutException -> UserError.NetworkError @@ -47,7 +48,7 @@ internal class UserErrorMapper(private val errorResponseJsonAdapter: JsonAdapter "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 + "validation-failed" -> UserError.ValidationFailed(errors = emptySet()) else -> UserError.Unexpected } } diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt index f8827cbf..29ab3857 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt @@ -1,12 +1,14 @@ package com.hoc.flowmvi.data.mapper +import arrow.core.ValidatedNel import com.hoc.flowmvi.core.Mapper import com.hoc.flowmvi.data.remote.UserResponse -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserValidationError -internal class UserResponseToUserDomainMapper : Mapper { - override fun invoke(response: UserResponse): User { - return User( +internal class UserResponseToUserDomainMapper : Mapper> { + override fun invoke(response: UserResponse): ValidatedNel { + return User.create( id = response.id, avatar = response.avatar, email = response.email, diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt new file mode 100644 index 00000000..cd2a2b68 --- /dev/null +++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt @@ -0,0 +1,103 @@ +package com.hoc.flowmvi.data + +import android.util.Log +import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers +import com.hoc.flowmvi.domain.repository.UserRepository +import com.hoc.flowmvi.test_utils.getOrThrow +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.KoinTestRule +import org.koin.test.inject +import timber.log.Timber +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime + +@ExperimentalCoroutinesApi +@ExperimentalTime +@ExperimentalStdlibApi +class UserRepositoryImplRealAPITest : KoinTest { + @get:Rule + val koinRuleTest = KoinTestRule.create { + printLogger() + modules( + dataModule, + module { + factory { + object : CoroutineDispatchers { + override val main: CoroutineDispatcher get() = Main + override val io: CoroutineDispatcher get() = IO + } + } + } + ) + } + + @get:Rule + val timberRule = TimberRule() + + private val userRepo by inject() + + @Test + fun getUsers() = runBlocking { + val result = userRepo + .getUsers() + .first() + assertTrue(result.isRight()) + assertTrue(result.getOrThrow.isNotEmpty()) + } +} + +class TimberRule : TestWatcher() { + private val tree = ConsoleTree() + + override fun starting(description: Description) { + Timber.plant(tree) + } + + override fun finished(description: Description) { + Timber.uproot(tree) + } +} + +class ConsoleTree : Timber.DebugTree() { + private val anonymousClassPattern = """(\$\d+)+$""".toRegex() + + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val dateTime = LocalDateTime.now().format(dateTimeFormatter) + val priorityChar = when (priority) { + Log.VERBOSE -> 'V' + Log.DEBUG -> 'D' + Log.INFO -> 'I' + Log.WARN -> 'W' + Log.ERROR -> 'E' + Log.ASSERT -> 'A' + else -> '?' + } + + println("$dateTime $priorityChar/$tag: $message") + } + + override fun createStackElementTag(element: StackTraceElement): String { + val className = element.className + val tag = if (anonymousClassPattern.containsMatchIn(className)) { + anonymousClassPattern.replace(className, "") + } else { + className + } + return tag.substringAfterLast('.') + } +} diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt index 18541457..83376dfd 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt @@ -1,16 +1,20 @@ package com.hoc.flowmvi.data import arrow.core.Either -import arrow.core.getOrHandle -import arrow.core.identity +import arrow.core.ValidatedNel +import arrow.core.validNel import com.hoc.flowmvi.core.Mapper 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.model.User +import com.hoc.flowmvi.domain.model.UserError +import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.test_utils.TestCoroutineDispatcherRule import com.hoc.flowmvi.test_utils.TestDispatchers +import com.hoc.flowmvi.test_utils.getOrThrow +import com.hoc.flowmvi.test_utils.leftOrThrow +import com.hoc.flowmvi.test_utils.valueOrThrow import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify @@ -69,28 +73,30 @@ private val USER_RESPONSES = listOf( ) private val USERS = listOf( - User( + User.create( id = "1", email = "email1@gmail.com", firstName = "first", lastName = "last", avatar = "avatar1", ), - User( + User.create( id = "2", email = "email2@gmail.com", firstName = "first", lastName = "last", avatar = "avatar2", ), - User( + User.create( id = "3", email = "email3@gmail.com", firstName = "first", lastName = "last", avatar = "avatar3", ), -) +).map { it.valueOrThrow } + +private val VALID_NEL_USERS = USERS.map(User::validNel) @ExperimentalCoroutinesApi @ExperimentalTime @@ -101,7 +107,7 @@ class UserRepositoryImplTest { private lateinit var repo: UserRepositoryImpl private lateinit var userApiService: UserApiService - private lateinit var responseToDomain: Mapper + private lateinit var responseToDomain: Mapper> private lateinit var domainToBody: Mapper private lateinit var errorMapper: Mapper @@ -135,7 +141,7 @@ class UserRepositoryImplTest { @Test fun test_refresh_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest { coEvery { userApiService.getUsers() } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany USERS + every { responseToDomain(any()) } returnsMany VALID_NEL_USERS val result = repo.refresh() @@ -170,7 +176,7 @@ class UserRepositoryImplTest { val userResponse = USER_RESPONSES[0] coEvery { userApiService.remove(user.id) } returns userResponse - every { responseToDomain(userResponse) } returns user + every { responseToDomain(userResponse) } returns user.validNel() val result = repo.remove(user) @@ -202,7 +208,7 @@ class UserRepositoryImplTest { coEvery { userApiService.add(USER_BODY) } returns userResponse every { domainToBody(user) } returns USER_BODY - every { responseToDomain(userResponse) } returns user + every { responseToDomain(userResponse) } returns user.validNel() val result = repo.add(user) @@ -235,7 +241,7 @@ class UserRepositoryImplTest { fun test_search_withApiCallSuccess_returnsRight() = testDispatcher.runBlockingTest { val q = "hoc081098" coEvery { userApiService.search(q) } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany USERS + every { responseToDomain(any()) } returnsMany VALID_NEL_USERS val result = repo.search(q) @@ -269,7 +275,7 @@ class UserRepositoryImplTest { @Test fun test_getUsers_withApiCallSuccess_emitsInitial() = testDispatcher.runBlockingTest { coEvery { userApiService.getUsers() } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany USERS + every { responseToDomain(any()) } returnsMany VALID_NEL_USERS val events = mutableListOf>>() val job = launch(start = CoroutineStart.UNDISPATCHED) { @@ -323,7 +329,8 @@ class UserRepositoryImplTest { 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 } + USER_RESPONSES.zip(USERS) + .forEach { (r, u) -> every { responseToDomain(r) } returns u.validNel() } val events = mutableListOf>>() val job = launch(start = CoroutineStart.UNDISPATCHED) { @@ -353,12 +360,3 @@ class UserRepositoryImplTest { } } } - -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 index 3ecea14a..c0c9a20c 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapperTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapperTest.kt @@ -1,7 +1,8 @@ package com.hoc.flowmvi.data.mapper import com.hoc.flowmvi.data.remote.UserBody -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.test_utils.valueOrThrow import kotlin.test.Test import kotlin.test.assertEquals @@ -11,13 +12,13 @@ class UserDomainToUserBodyMapperTest { @Test fun test_UserDomainToUserBodyMapper() { val body = mapper( - User( + User.create( id = "id", email = "email@gmail.com", firstName = "first", lastName = "last", avatar = "avatar", - ) + ).valueOrThrow ) assertEquals( 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 e2ac0c1e..68d79432 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 @@ -1,7 +1,7 @@ package com.hoc.flowmvi.data.mapper import com.hoc.flowmvi.data.remote.ErrorResponse -import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.domain.model.UserError import com.squareup.moshi.Moshi import com.squareup.moshi.adapter import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory @@ -43,6 +43,19 @@ class UserErrorMapperTest { ) ) + @Test + fun test_withUserError_returnsItself() { + assertEquals(UserError.NetworkError, errorMapper(UserError.NetworkError)) + assertEquals(UserError.UserNotFound("1"), errorMapper(UserError.UserNotFound("1"))) + assertEquals(UserError.InvalidId("1"), errorMapper(UserError.InvalidId("1"))) + assertEquals( + UserError.ValidationFailed(emptySet()), + errorMapper(UserError.ValidationFailed(emptySet())), + ) + assertEquals(UserError.ServerError, errorMapper(UserError.ServerError)) + assertEquals(UserError.Unexpected, errorMapper(UserError.Unexpected)) + } + @Test fun test_withFatalError_rethrows() { assertFailsWith { errorMapper(KotlinCancellationException()) } @@ -145,7 +158,7 @@ class UserErrorMapperTest { errorMapper(buildHttpException("user-not-found", id)), ) assertEquals( - UserError.ValidationFailed, + UserError.ValidationFailed(emptySet()), errorMapper(buildHttpException("validation-failed", null)), ) } 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 index 718bb8c9..acdcae2f 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt @@ -1,16 +1,20 @@ package com.hoc.flowmvi.data.mapper import com.hoc.flowmvi.data.remote.UserResponse -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserValidationError +import com.hoc.flowmvi.test_utils.invalidValueOrThrow +import com.hoc.flowmvi.test_utils.valueOrThrow import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class UserResponseToUserDomainMapperTest { private val mapper = UserResponseToUserDomainMapper() @Test - fun test_UserDomainToUserResponseMapper() { - val domain = mapper( + fun testUserDomainToUserResponseMapper_withValidResponse_returnsValid() { + val validated = mapper( UserResponse( id = "id", email = "email@gmail.com", @@ -19,16 +23,34 @@ class UserResponseToUserDomainMapperTest { avatar = "avatar", ) ) - + assertTrue(validated.isValid) assertEquals( - User( + User.create( id = "id", email = "email@gmail.com", firstName = "first", lastName = "last", avatar = "avatar", - ), - domain + ).valueOrThrow, + validated.valueOrThrow, + ) + } + + @Test + fun testUserDomainToUserResponseMapper_withInvalidResponse_returnsInvalid() { + val validated = mapper( + UserResponse( + id = "id", + email = "email@", + firstName = "first", + lastName = "last", + avatar = "avatar", + ) + ) + assertTrue(validated.isInvalid) + assertEquals( + UserValidationError.INVALID_EMAIL_ADDRESS, + validated.invalidValueOrThrow.head, ) } } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/entity/User.kt b/domain/src/main/java/com/hoc/flowmvi/domain/entity/User.kt deleted file mode 100644 index 63d0b6be..00000000 --- a/domain/src/main/java/com/hoc/flowmvi/domain/entity/User.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.hoc.flowmvi.domain.entity - -data class User( - val id: String, - val email: String, - val firstName: String, - val lastName: String, - val avatar: String -) diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt new file mode 100644 index 00000000..10733e02 --- /dev/null +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt @@ -0,0 +1,11 @@ +package com.hoc.flowmvi.domain.model + +import arrow.core.ValidatedNel + +@JvmInline +value class Email private constructor(val value: String) { + companion object { + fun create(value: String?): ValidatedNel = + validateEmail(value).map(::Email) + } +} diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt new file mode 100644 index 00000000..ef8a3bfd --- /dev/null +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt @@ -0,0 +1,11 @@ +package com.hoc.flowmvi.domain.model + +import arrow.core.ValidatedNel + +@JvmInline +value class FirstName private constructor(val value: String) { + companion object { + fun create(value: String?): ValidatedNel = + validateFirstName(value).map(::FirstName) + } +} diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt new file mode 100644 index 00000000..51316b90 --- /dev/null +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt @@ -0,0 +1,11 @@ +package com.hoc.flowmvi.domain.model + +import arrow.core.ValidatedNel + +@JvmInline +value class LastName private constructor(val value: String) { + companion object { + fun create(value: String?): ValidatedNel = + validateLastName(value).map(::LastName) + } +} diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt new file mode 100644 index 00000000..f71a6d03 --- /dev/null +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt @@ -0,0 +1,64 @@ +package com.hoc.flowmvi.domain.model + +import arrow.core.ValidatedNel +import arrow.core.validNel +import arrow.core.zip + +data class User( + val id: String, + val email: Email, + val firstName: FirstName, + val lastName: LastName, + val avatar: String, +) { + companion object { + fun create( + id: String, + email: String?, + firstName: String?, + lastName: String?, + avatar: String, + ): ValidatedNel = Email.create(email) + .zip( + FirstName.create(firstName), + LastName.create(lastName), + ) { e, f, l -> + User( + firstName = f, + email = e, + lastName = l, + id = id, + avatar = avatar + ) + } + } +} + +internal fun validateFirstName(firstName: String?): ValidatedNel { + if (firstName == null || firstName.length < MIN_LENGTH_FIRST_NAME) { + return UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel + } + // more validations here + return firstName.validNel() +} + +internal fun validateLastName(lastName: String?): ValidatedNel { + if (lastName == null || lastName.length < MIN_LENGTH_LAST_NAME) { + return UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel + } + // more validations here + return lastName.validNel() +} + +internal fun validateEmail(email: String?): ValidatedNel { + if (email == null || !EMAIL_ADDRESS_REGEX.matches(email)) { + return UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel + } + // more validations here + return email.validNel() +} + +private const val MIN_LENGTH_FIRST_NAME = 3 +private const val MIN_LENGTH_LAST_NAME = 3 +private val EMAIL_ADDRESS_REGEX = + Regex("""[a-zA-Z0-9+._%\-']{1,256}@[a-zA-Z0-9][a-zA-Z0-9\-]{0,64}(\.[a-zA-Z0-9][a-zA-Z0-9\-]{0,25})+""") diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserError.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserError.kt new file mode 100644 index 00000000..33a723ec --- /dev/null +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserError.kt @@ -0,0 +1,10 @@ +package com.hoc.flowmvi.domain.model + +sealed class UserError : Throwable() { + object NetworkError : UserError() + data class UserNotFound(val id: String) : UserError() + data class InvalidId(val id: String) : UserError() + data class ValidationFailed(val errors: Set) : UserError() + object ServerError : UserError() + object Unexpected : UserError() +} diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt new file mode 100644 index 00000000..b7daa835 --- /dev/null +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt @@ -0,0 +1,12 @@ +package com.hoc.flowmvi.domain.model + +import arrow.core.ValidatedNel +import arrow.core.invalidNel + +enum class UserValidationError { + INVALID_EMAIL_ADDRESS, + TOO_SHORT_FIRST_NAME, + TOO_SHORT_LAST_NAME; + + val asInvalidNel: ValidatedNel = invalidNel() +} 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 9117af40..acb9843e 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,18 +1,10 @@ package com.hoc.flowmvi.domain.repository import arrow.core.Either -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserError 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>> 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 d04acce1..59eeb0e6 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,8 @@ 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.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.repository.UserRepository class AddUserUseCase(private val userRepository: UserRepository) { 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 53b59862..8fdb9de9 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,8 +1,8 @@ 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.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.repository.UserRepository import kotlinx.coroutines.flow.Flow 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 f67cee4c..ae6e17a5 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,7 @@ package com.hoc.flowmvi.domain.usecase import arrow.core.Either -import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.repository.UserRepository class RefreshGetUsersUseCase(private val userRepository: UserRepository) { 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 72aae8c1..fce13af0 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,8 @@ 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.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.repository.UserRepository class RemoveUserUseCase(private val userRepository: UserRepository) { 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 60858a7c..fd6713c0 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,8 @@ 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.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.repository.UserRepository class SearchUsersUseCase(private val userRepository: UserRepository) { diff --git a/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt b/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt new file mode 100644 index 00000000..3f1e8219 --- /dev/null +++ b/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt @@ -0,0 +1,105 @@ +package com.hoc.flowmvi.ui.add + +import arrow.core.orNull +import com.hoc.flowmvi.domain.model.Email +import com.hoc.flowmvi.domain.model.FirstName +import com.hoc.flowmvi.domain.model.LastName +import com.hoc.flowmvi.domain.model.UserValidationError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +@Suppress("ClassName") +class Email_FirstName_LastName_Test { + @Test + fun testCreateEmail_withValidEmail_returnsValid() { + val validated = Email.create("hoc081098@gmail.com") + assertTrue(validated.isValid) + assertEquals( + "hoc081098@gmail.com", + validated.orNull()?.value, + ) + } + + @Test + fun testCreateEmail_withInvalidEmail_returnsInvalid() { + assertEquals( + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + Email.create(null), + ) + assertEquals( + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + Email.create(""), + ) + assertEquals( + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + Email.create("a"), + ) + assertEquals( + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + Email.create("a@"), + ) + } + + @Test + fun testCreateFirstName_withValidFirstName_returnsValid() { + val validated = FirstName.create("hoc081098") + assertTrue(validated.isValid) + assertEquals( + "hoc081098", + validated.orNull()?.value, + ) + } + + @Test + fun testCreateFirstName_withInvalidFirstName_returnsInvalid() { + assertEquals( + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + FirstName.create(null), + ) + assertEquals( + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + FirstName.create(""), + ) + assertEquals( + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + FirstName.create("a"), + ) + assertEquals( + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + FirstName.create("ab"), + ) + } + + @Test + fun testCreateLastName_withValidLastName_returnsValid() { + val validated = LastName.create("hoc081098") + assertTrue(validated.isValid) + assertEquals( + "hoc081098", + validated.orNull()?.value, + ) + } + + @Test + fun testCreateLastName_withInvalidLastName_returnsInvalid() { + assertEquals( + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + LastName.create(null), + ) + assertEquals( + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + LastName.create(""), + ) + assertEquals( + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + LastName.create("a"), + ) + assertEquals( + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + LastName.create("ab"), + ) + } +} 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 d35c840e..9082db08 100644 --- a/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt +++ b/domain/src/test/java/com/hoc/flowmvi/domain/UseCaseTest.kt @@ -2,8 +2,8 @@ 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.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.repository.UserRepository import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.domain.usecase.GetUsersUseCase @@ -11,6 +11,7 @@ import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase import com.hoc.flowmvi.test_utils.TestCoroutineDispatcherRule +import com.hoc.flowmvi.test_utils.valueOrThrow import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify @@ -29,28 +30,28 @@ import kotlin.test.Test import kotlin.test.assertEquals private val USERS = listOf( - User( + User.create( id = "1", email = "email1@gmail.com", firstName = "first1", lastName = "last1", avatar = "1.png" ), - User( + User.create( id = "2", email = "email1@gmail.com", firstName = "first2", lastName = "last2", avatar = "2.png" ), - User( + User.create( id = "3", email = "email1@gmail.com", firstName = "first3", lastName = "last3", avatar = "3.png" ), -) +).map { it.valueOrThrow } @ExperimentalCoroutinesApi class UseCaseTest { diff --git a/domain/src/test/java/com/hoc/flowmvi/domain/UserTest.kt b/domain/src/test/java/com/hoc/flowmvi/domain/UserTest.kt new file mode 100644 index 00000000..79d952e9 --- /dev/null +++ b/domain/src/test/java/com/hoc/flowmvi/domain/UserTest.kt @@ -0,0 +1,147 @@ +package com.hoc.flowmvi.domain + +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserValidationError +import com.hoc.flowmvi.test_utils.invalidValueOrThrow +import com.hoc.flowmvi.test_utils.valueOrThrow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val ID = "id" +private const val VALID_EMAIL = "hoc081098@gmail.com" +private const val VALID_NAME = "hoc081098" +private const val AVATAR = "avatar" + +class UserTest { + @Test + fun testCreateUser_withValidValues_returnsValid() { + val validated = User.create( + id = ID, + email = VALID_EMAIL, + firstName = VALID_NAME, + lastName = VALID_NAME, + avatar = AVATAR, + ) + assertTrue(validated.isValid) + validated.valueOrThrow.let { user -> + assertEquals(ID, user.id) + assertEquals(VALID_EMAIL, user.email.value) + assertEquals(VALID_NAME, user.firstName.value) + assertEquals(VALID_NAME, user.lastName.value) + assertEquals(AVATAR, user.avatar) + } + } + + @Test + fun testCreateUser_withInvalidEmail_returnsInvalid() { + val validated = User.create( + id = ID, + email = "invalid email", + firstName = VALID_NAME, + lastName = VALID_NAME, + avatar = AVATAR, + ) + assertTrue(validated.isInvalid) + assertEquals(UserValidationError.INVALID_EMAIL_ADDRESS, validated.invalidValueOrThrow.single()) + } + + @Test + fun testCreateUser_withInvalidFirstName_returnsInvalid() { + val validated = User.create( + id = ID, + email = VALID_EMAIL, + firstName = "h", + lastName = VALID_NAME, + avatar = AVATAR, + ) + assertTrue(validated.isInvalid) + assertEquals(UserValidationError.TOO_SHORT_FIRST_NAME, validated.invalidValueOrThrow.single()) + } + + @Test + fun testCreateUser_withInvalidLastName_returnsInvalid() { + val validated = User.create( + id = ID, + email = VALID_EMAIL, + firstName = VALID_NAME, + lastName = "h", + avatar = AVATAR, + ) + assertTrue(validated.isInvalid) + assertEquals(UserValidationError.TOO_SHORT_LAST_NAME, validated.invalidValueOrThrow.single()) + } + + @Test + fun testCreateUser_withInvalidEmailAndFirstName_returnsInvalid() { + val validated = User.create( + id = ID, + email = "h", + firstName = "h", + lastName = VALID_NAME, + avatar = AVATAR, + ) + assertTrue(validated.isInvalid) + assertEquals( + setOf( + UserValidationError.INVALID_EMAIL_ADDRESS, + UserValidationError.TOO_SHORT_FIRST_NAME, + ), + validated.invalidValueOrThrow.toSet() + ) + } + + @Test + fun testCreateUser_withInvalidEmailAndLastName_returnsInvalid() { + val validated = User.create( + id = ID, + email = "h", + firstName = VALID_NAME, + lastName = "h", + avatar = AVATAR, + ) + assertTrue(validated.isInvalid) + assertEquals( + setOf( + UserValidationError.INVALID_EMAIL_ADDRESS, + UserValidationError.TOO_SHORT_LAST_NAME, + ), + validated.invalidValueOrThrow.toSet() + ) + } + + @Test + fun testCreateUser_withInvalidFirstNameAndLastName_returnsInvalid() { + val validated = User.create( + id = ID, + email = VALID_EMAIL, + firstName = "h", + lastName = "h", + avatar = AVATAR, + ) + assertTrue(validated.isInvalid) + assertEquals( + setOf( + UserValidationError.TOO_SHORT_FIRST_NAME, + UserValidationError.TOO_SHORT_LAST_NAME, + ), + validated.invalidValueOrThrow.toSet() + ) + } + + @Test + fun testCreateUser_withInvalidValues_returnsInvalid() { + val validated = User.create( + id = ID, + email = "h", + firstName = "h", + lastName = "h", + avatar = AVATAR, + ) + assertTrue(validated.isInvalid) + assertEquals( + UserValidationError.values().toSet(), + validated.invalidValueOrThrow.toSet() + ) + } +} diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt index 79406d72..812e5b66 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt @@ -12,6 +12,7 @@ import com.hoc.flowmvi.core_ui.firstChange import com.hoc.flowmvi.core_ui.navigator.IntentProviders import com.hoc.flowmvi.core_ui.textChanges import com.hoc.flowmvi.core_ui.toast +import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.mvi_base.AbstractMviActivity import com.hoc.flowmvi.ui.add.databinding.ActivityAddBinding import com.hoc081098.flowext.mapTo @@ -54,15 +55,15 @@ class AddActivity : Timber.d("viewState=$viewState") addBinding.emailEditText.setErrorIfChanged(viewState.emailChanged) { - if (ValidationError.INVALID_EMAIL_ADDRESS in viewState.errors) "Invalid email" + if (UserValidationError.INVALID_EMAIL_ADDRESS in viewState.errors) "Invalid email" else null } addBinding.firstNameEditText.setErrorIfChanged(viewState.firstNameChanged) { - if (ValidationError.TOO_SHORT_FIRST_NAME in viewState.errors) "Too short first name" + if (UserValidationError.TOO_SHORT_FIRST_NAME in viewState.errors) "Too short first name" else null } addBinding.lastNameEditText.setErrorIfChanged(viewState.lastNameChanged) { - if (ValidationError.TOO_SHORT_LAST_NAME in viewState.errors) "Too short last name" + if (UserValidationError.TOO_SHORT_LAST_NAME in viewState.errors) "Too short last name" else null } 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 6b18b8a3..4aee6710 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,26 +1,17 @@ package com.hoc.flowmvi.ui.add import android.os.Parcelable -import arrow.core.ValidatedNel -import arrow.core.invalidNel -import com.hoc.flowmvi.domain.entity.User -import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserError +import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState import kotlinx.parcelize.Parcelize -enum class ValidationError { - INVALID_EMAIL_ADDRESS, - TOO_SHORT_FIRST_NAME, - TOO_SHORT_LAST_NAME; - - val asInvalidNel: ValidatedNel = invalidNel() -} - @Parcelize data class ViewState( - val errors: Set, + val errors: Set, val isLoading: Boolean, // show error or not val emailChanged: Boolean, @@ -60,7 +51,7 @@ sealed interface ViewIntent : MviIntent { internal sealed interface PartialStateChange { fun reduce(viewState: ViewState): ViewState - data class ErrorsChanged(val errors: Set) : PartialStateChange { + data class ErrorsChanged(val errors: Set) : PartialStateChange { override fun reduce(viewState: ViewState) = if (viewState.errors == errors) viewState else viewState.copy(errors = errors) } 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 2e995900..e0edd2fe 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 @@ -3,8 +3,7 @@ package com.hoc.flowmvi.ui.add import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import arrow.core.orNull -import arrow.core.zip -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst @@ -93,19 +92,17 @@ class AddVM( .shareWhileSubscribed() val userFormFlow = combine( - emailFlow.map { validateEmail(it) }.distinctUntilChanged(), - firstNameFlow.map { validateFirstName(it) }.distinctUntilChanged(), - lastNameFlow.map { validateLastName(it) }.distinctUntilChanged(), - ) { emailValidated, firstNameValidated, lastNameValidated -> - emailValidated.zip(firstNameValidated, lastNameValidated) { email, firstName, lastName -> - User( - firstName = firstName, - email = email, - lastName = lastName, - id = "", - avatar = "" - ) - } + emailFlow, + firstNameFlow, + lastNameFlow, + ) { email, firstName, lastName -> + User.create( + email = email, + firstName = firstName, + lastName = lastName, + id = "", + avatar = "", + ) }.stateWithInitialNullWhileSubscribed() val addUserChanges = filterIsInstance() diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/Validators.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/Validators.kt deleted file mode 100644 index 99b3e528..00000000 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/Validators.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.hoc.flowmvi.ui.add - -import androidx.core.util.PatternsCompat -import arrow.core.ValidatedNel -import arrow.core.validNel - -private const val MIN_LENGTH_FIRST_NAME = 3 -private const val MIN_LENGTH_LAST_NAME = 3 - -internal fun validateFirstName(firstName: String?): ValidatedNel { - if (firstName == null || firstName.length < MIN_LENGTH_FIRST_NAME) { - return ValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel - } - // more validations here - return firstName.validNel() -} - -internal fun validateLastName(lastName: String?): ValidatedNel { - if (lastName == null || lastName.length < MIN_LENGTH_LAST_NAME) { - return ValidationError.TOO_SHORT_LAST_NAME.asInvalidNel - } - // more validations here - return lastName.validNel() -} - -internal fun validateEmail(email: String?): ValidatedNel { - if (email == null || !PatternsCompat.EMAIL_ADDRESS.matcher(email).matches()) { - return ValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel - } - // more validations here - return email.validNel() -} diff --git a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt index df4a76c2..0966642e 100644 --- a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt +++ b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt @@ -5,12 +5,13 @@ import arrow.core.left import arrow.core.right import com.flowmvi.mvi_testing.BaseMviViewModelTest import com.flowmvi.mvi_testing.mapRight -import com.hoc.flowmvi.domain.entity.User -import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserError +import com.hoc.flowmvi.domain.model.UserValidationError +import com.hoc.flowmvi.domain.model.UserValidationError.TOO_SHORT_FIRST_NAME +import com.hoc.flowmvi.domain.model.UserValidationError.TOO_SHORT_LAST_NAME import com.hoc.flowmvi.domain.usecase.AddUserUseCase -import com.hoc.flowmvi.ui.add.ValidationError.TOO_SHORT_FIRST_NAME -import com.hoc.flowmvi.ui.add.ValidationError.TOO_SHORT_LAST_NAME -import com.hoc.flowmvi.ui.add.ValidationError.values +import com.hoc.flowmvi.test_utils.valueOrThrow import io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified @@ -21,7 +22,7 @@ import kotlin.test.Test import kotlin.time.Duration import kotlin.time.ExperimentalTime -private val ALL_ERRORS = values().toSet() +private val ALL_ERRORS = UserValidationError.values().toSet() private const val EMAIL = "hoc081098@gmail.com" private const val NAME = "hoc081098" @@ -268,13 +269,13 @@ class AddVMTest : BaseMviViewModelTest "Server error" UserError.Unexpected -> "Unexpected error" is UserError.UserNotFound -> "User not found" - UserError.ValidationFailed -> "Validation failed" + is UserError.ValidationFailed -> "Validation failed" } } 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 b82e4f21..42d734fa 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,7 +1,8 @@ package com.hoc.flowmvi.ui.main -import com.hoc.flowmvi.domain.entity.User -import com.hoc.flowmvi.domain.repository.UserError +import arrow.core.Either +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState @@ -17,19 +18,19 @@ data class UserItem( constructor(domain: User) : this( id = domain.id, - email = domain.email, + email = domain.email.value, avatar = domain.avatar, - firstName = domain.firstName, - lastName = domain.lastName + firstName = domain.firstName.value, + lastName = domain.lastName.value ) - fun toDomain() = User( + fun toDomain(): Either = User.create( id = id, lastName = lastName, firstName = firstName, avatar = avatar, email = email - ) + ).toEither().mapLeft { UserError.ValidationFailed(it.toSet()) } } sealed interface ViewIntent : MviIntent { 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 c27f48a0..41be5fa9 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 @@ -1,6 +1,7 @@ package com.hoc.flowmvi.ui.main import androidx.lifecycle.viewModelScope +import arrow.core.flatMap import com.hoc.flowmvi.domain.usecase.GetUsersUseCase import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase @@ -123,7 +124,7 @@ class MainVM( flow { userItem .toDomain() - .let { removeUser(it) } + .flatMap { removeUser(it) } .let { emit(it) } } .map { result -> diff --git a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainContractTest.kt b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainContractTest.kt index c01dfba5..415f4592 100644 --- a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainContractTest.kt +++ b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainContractTest.kt @@ -1,6 +1,8 @@ package com.hoc.flowmvi.ui.main -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.test_utils.getOrThrow +import com.hoc.flowmvi.test_utils.valueOrThrow import kotlin.test.Test import kotlin.test.assertEquals @@ -62,35 +64,26 @@ class MainContractTest { @Test fun test_userItem_toDomain() { assertEquals( - UserItem( + User.create( id = "0", email = "test@gmail.com", - avatar = "avatar.png", firstName = "first", - lastName = "last" - ).toDomain(), - User( + lastName = "last", + avatar = "avatar.png", + ).valueOrThrow, + UserItem( id = "0", email = "test@gmail.com", - firstName = "first", - lastName = "last", avatar = "avatar.png", - ) + firstName = "first", + lastName = "last" + ).toDomain().getOrThrow, ) } @Test fun test_userItem_fromDomain() { assertEquals( - UserItem( - domain = User( - id = "0", - email = "test@gmail.com", - firstName = "first", - lastName = "last", - avatar = "avatar.png", - ) - ), UserItem( id = "0", email = "test@gmail.com", @@ -98,6 +91,15 @@ class MainContractTest { firstName = "first", lastName = "last" ), + UserItem( + domain = User.create( + id = "0", + email = "test@gmail.com", + firstName = "first", + lastName = "last", + avatar = "avatar.png", + ).valueOrThrow + ), ) } } 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 c77e9bde..856edc7a 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 @@ -4,8 +4,8 @@ import arrow.core.left import arrow.core.right import com.flowmvi.mvi_testing.BaseMviViewModelTest import com.flowmvi.mvi_testing.mapRight -import com.hoc.flowmvi.domain.entity.User -import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.usecase.GetUsersUseCase import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase diff --git a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/TestData.kt b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/TestData.kt index 5d473401..ce186154 100644 --- a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/TestData.kt +++ b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/TestData.kt @@ -1,29 +1,30 @@ package com.hoc.flowmvi.ui.main -import com.hoc.flowmvi.domain.entity.User +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.test_utils.valueOrThrow internal val USERS = listOf( - User( + User.create( id = "1", email = "email1@gmail.com", firstName = "first1", lastName = "last1", avatar = "1.png" ), - User( + User.create( id = "2", email = "email1@gmail.com", firstName = "first2", lastName = "last2", avatar = "2.png" ), - User( + User.create( id = "3", email = "email1@gmail.com", firstName = "first3", lastName = "last3", avatar = "3.png" ), -) +).map { it.valueOrThrow } internal val USER_ITEMS = USERS.map(::UserItem) 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 8524cd48..a5559176 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 @@ -15,7 +15,7 @@ import com.hoc.flowmvi.core_ui.clicks import com.hoc.flowmvi.core_ui.navigator.IntentProviders import com.hoc.flowmvi.core_ui.queryTextEvents import com.hoc.flowmvi.core_ui.toast -import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.mvi_base.AbstractMviActivity import com.hoc.flowmvi.ui.search.databinding.ActivitySearchBinding import com.hoc081098.viewbindingdelegate.viewBinding @@ -67,7 +67,7 @@ class SearchActivity : UserError.ServerError -> "Server error" UserError.Unexpected -> "Unexpected error" is UserError.UserNotFound -> "User not found" - UserError.ValidationFailed -> "Validation failed" + is UserError.ValidationFailed -> "Validation failed" } } } 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 d970eb30..48c01a30 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,7 +1,7 @@ package com.hoc.flowmvi.ui.search -import com.hoc.flowmvi.domain.entity.User -import com.hoc.flowmvi.domain.repository.UserError +import com.hoc.flowmvi.domain.model.User +import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState @@ -19,7 +19,7 @@ data class UserItem private constructor( fun from(domain: User): UserItem { return UserItem( id = domain.id, - email = domain.email, + email = domain.email.value, avatar = domain.avatar, fullName = "${domain.firstName} ${domain.lastName}", ) diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts index 3fd801bc..e09f4e86 100644 --- a/test-utils/build.gradle.kts +++ b/test-utils/build.gradle.kts @@ -10,6 +10,7 @@ java { dependencies { implementation(deps.coroutines.core) implementation(core) + api(deps.arrow.core) addUnitTest(testImplementation = false) } diff --git a/test-utils/src/main/java/com/hoc/flowmvi/test_utils/utils.kt b/test-utils/src/main/java/com/hoc/flowmvi/test_utils/utils.kt new file mode 100644 index 00000000..1f09c584 --- /dev/null +++ b/test-utils/src/main/java/com/hoc/flowmvi/test_utils/utils.kt @@ -0,0 +1,24 @@ +package com.hoc.flowmvi.test_utils + +import arrow.core.Either +import arrow.core.Validated +import arrow.core.getOrHandle +import arrow.core.identity +import arrow.core.valueOr + +inline val Validated.valueOrThrow: A + get() = valueOr(this::throws) + +inline val Validated.invalidValueOrThrow: E + get() = fold(::identity, this::throws) + +inline val Either.leftOrThrow: L + get() = fold(::identity, this::throws) + +inline val Either.getOrThrow: R + get() = getOrHandle(this::throws) + +@PublishedApi +internal fun Any.throws(it: E): Nothing = + if (it is Throwable) throw it + else error("$this - $it - Should not reach here!")