From d3456cea70db70662c1823d4d06231a26eab0d89 Mon Sep 17 00:00:00 2001 From: rahulsainani Date: Tue, 15 Apr 2025 21:03:27 +0200 Subject: [PATCH] Use ktor and kotlinX serialization --- app/build.gradle.kts | 3 +- .../com/fernandocejas/sample/AllFeatures.kt | 15 + .../sample/AndroidApplication.kt | 2 - .../sample/core/di/CoreModule.kt | 23 -- .../sample/core/{Core.kt => di/Feature.kt} | 18 +- .../sample/core/navigation/Navigator.kt | 12 +- .../sample/core/network/ApiResponse.kt | 56 +++ .../sample/core/network/HttpClientX.kt | 34 ++ .../sample/core/network/NetworkHandler.kt | 1 - .../sample/core/network/NetworkModule.kt | 54 +++ .../sample/features/auth/Auth.kt | 2 +- .../sample/features/auth/di/AuthModule.kt | 5 - .../sample/features/login/Login.kt | 2 +- .../features/movies/data/MovieEntity.kt | 9 +- .../sample/features/movies/data/MoviesApi.kt | 34 -- .../features/movies/data/MoviesRepository.kt | 70 ++-- .../features/movies/data/MoviesService.kt | 31 +- .../sample/features/movies/{ => di}/Movies.kt | 4 +- .../sample/core/network/HttpClientXTest.kt | 177 +++++++++ .../movies/data/MoviesRepositoryTest.kt | 349 +++++++++++------- .../movies/interactor/GetMovieDetailsTest.kt | 29 +- .../movies/interactor/GetMoviesTest.kt | 31 +- .../fernandocejas/sample/matchers/Android.kt | 3 +- gradle/libs.versions.toml | 3 +- 24 files changed, 664 insertions(+), 303 deletions(-) create mode 100644 app/src/main/kotlin/com/fernandocejas/sample/AllFeatures.kt delete mode 100644 app/src/main/kotlin/com/fernandocejas/sample/core/di/CoreModule.kt rename app/src/main/kotlin/com/fernandocejas/sample/core/{Core.kt => di/Feature.kt} (64%) create mode 100644 app/src/main/kotlin/com/fernandocejas/sample/core/network/ApiResponse.kt create mode 100644 app/src/main/kotlin/com/fernandocejas/sample/core/network/HttpClientX.kt create mode 100644 app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkModule.kt delete mode 100644 app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesApi.kt rename app/src/main/kotlin/com/fernandocejas/sample/features/movies/{ => di}/Movies.kt (91%) create mode 100644 app/src/test/kotlin/com/fernandocejas/sample/core/network/HttpClientXTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 017a601a..139217bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,6 +22,7 @@ plugins { alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.ksp) alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) } val appConfig = AppConfig() @@ -99,7 +100,6 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.converter.gson) // Compose // @see: https://developer.android.google.cn/develop/ui/compose/setup?hl=en#kotlin_1 @@ -130,6 +130,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.robolectric) + testImplementation(libs.ktor.client.mock) // UI tests dependencies androidTestImplementation(composeBom) diff --git a/app/src/main/kotlin/com/fernandocejas/sample/AllFeatures.kt b/app/src/main/kotlin/com/fernandocejas/sample/AllFeatures.kt new file mode 100644 index 00000000..c1d1d38c --- /dev/null +++ b/app/src/main/kotlin/com/fernandocejas/sample/AllFeatures.kt @@ -0,0 +1,15 @@ +package com.fernandocejas.sample + +import com.fernandocejas.sample.core.navigation.navigationFeature +import com.fernandocejas.sample.core.network.networkFeature +import com.fernandocejas.sample.features.auth.authFeature +import com.fernandocejas.sample.features.login.loginFeature +import com.fernandocejas.sample.features.movies.di.moviesFeature + +fun allFeatures() = listOf( + networkFeature(), + authFeature(), + loginFeature(), + moviesFeature(), + navigationFeature(), +) diff --git a/app/src/main/kotlin/com/fernandocejas/sample/AndroidApplication.kt b/app/src/main/kotlin/com/fernandocejas/sample/AndroidApplication.kt index 2ca8aa35..7c05915b 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/AndroidApplication.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/AndroidApplication.kt @@ -16,8 +16,6 @@ package com.fernandocejas.sample import android.app.Application -import com.fernandocejas.sample.core.allFeatures -import com.fernandocejas.sample.core.di.coreModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.GlobalContext.startKoin diff --git a/app/src/main/kotlin/com/fernandocejas/sample/core/di/CoreModule.kt b/app/src/main/kotlin/com/fernandocejas/sample/core/di/CoreModule.kt deleted file mode 100644 index 3f434298..00000000 --- a/app/src/main/kotlin/com/fernandocejas/sample/core/di/CoreModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.fernandocejas.sample.core.di - -import com.fernandocejas.sample.core.navigation.Navigator -import com.fernandocejas.sample.core.network.NetworkHandler -import okhttp3.OkHttpClient -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.module -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory - -val coreModule = module { - singleOf(::retrofit) - singleOf(::NetworkHandler) - singleOf(::Navigator) -} - -private fun retrofit(): Retrofit { - return Retrofit.Builder() - .baseUrl("https://raw.githubusercontent.com/android10/Sample-Data/master/Android-CleanArchitecture-Kotlin/") - .client(OkHttpClient.Builder().build()) - .addConverterFactory(GsonConverterFactory.create()) - .build() -} diff --git a/app/src/main/kotlin/com/fernandocejas/sample/core/Core.kt b/app/src/main/kotlin/com/fernandocejas/sample/core/di/Feature.kt similarity index 64% rename from app/src/main/kotlin/com/fernandocejas/sample/core/Core.kt rename to app/src/main/kotlin/com/fernandocejas/sample/core/di/Feature.kt index 5b61f028..99f196e6 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/core/Core.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/core/di/Feature.kt @@ -1,9 +1,5 @@ -package com.fernandocejas.sample.core +package com.fernandocejas.sample.core.di -import com.fernandocejas.sample.core.di.coreModule -import com.fernandocejas.sample.features.auth.authFeature -import com.fernandocejas.sample.features.login.loginFeature -import com.fernandocejas.sample.features.movies.moviesFeature import org.koin.core.module.Module /** @@ -45,15 +41,3 @@ interface Feature { */ // fun databaseTables(): List = emptyList() } - -private fun coreFeature() = object : Feature { - override fun name() = "core" - override fun diModule() = coreModule -} - -fun allFeatures() = listOf( - coreFeature(), - authFeature(), - loginFeature(), - moviesFeature(), -) diff --git a/app/src/main/kotlin/com/fernandocejas/sample/core/navigation/Navigator.kt b/app/src/main/kotlin/com/fernandocejas/sample/core/navigation/Navigator.kt index 8e63a506..b60863da 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/core/navigation/Navigator.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/core/navigation/Navigator.kt @@ -21,10 +21,14 @@ import android.content.Intent import android.net.Uri import android.view.View import androidx.fragment.app.FragmentActivity +import com.fernandocejas.sample.core.di.Feature import com.fernandocejas.sample.core.extension.emptyString import com.fernandocejas.sample.features.auth.credentials.Authenticator import com.fernandocejas.sample.features.movies.ui.MovieView import com.fernandocejas.sample.features.movies.ui.MoviesActivity +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module class Navigator(private val authenticator: Authenticator) { @@ -83,4 +87,10 @@ class Navigator(private val authenticator: Authenticator) { class Extras(val transitionSharedElement: View) } - +// temporary solution to compile till Navigator is deleted +fun navigationFeature() = object : Feature { + override fun name() = "navigation" + override fun diModule() = module { + singleOf(::Navigator) + } +} diff --git a/app/src/main/kotlin/com/fernandocejas/sample/core/network/ApiResponse.kt b/app/src/main/kotlin/com/fernandocejas/sample/core/network/ApiResponse.kt new file mode 100644 index 00000000..9b7b3b68 --- /dev/null +++ b/app/src/main/kotlin/com/fernandocejas/sample/core/network/ApiResponse.kt @@ -0,0 +1,56 @@ +package com.fernandocejas.sample.core.network + +import com.fernandocejas.sample.core.functional.Either +import com.fernandocejas.sample.core.functional.toLeft +import com.fernandocejas.sample.core.functional.toRight + +sealed class ApiResponse { + /** + * Represents successful network responses (2xx). + */ + data class Success(val body: T) : ApiResponse() + + sealed class Error : ApiResponse() { + /** + * Represents server (50x) and client (40x) errors. + */ + data class HttpError(val code: Int, val errorBody: E?) : Error() + + /** + * Represent IOExceptions and connectivity issues. + */ + data object NetworkError : Error() + + /** + * Represent SerializationExceptions. + */ + data object SerializationError : Error() + } +} + +// Side Effect helpers +inline fun ApiResponse.onSuccess(block: (T) -> Unit): ApiResponse { + if (this is ApiResponse.Success) { + block(body) + } + return this +} + +fun ApiResponse.toEither(): Either { + return when (this) { + is ApiResponse.Success -> body.toRight() + is ApiResponse.Error.HttpError -> errorBody.toLeft() + is ApiResponse.Error.NetworkError -> null.toLeft() + is ApiResponse.Error.SerializationError -> null.toLeft() + } +} + +fun ApiResponse.toEither( + successTransform: (T) -> D, + errorTransform: (ApiResponse.Error) -> F, +): Either { + return when (this) { + is ApiResponse.Success -> successTransform(body).toRight() + is ApiResponse.Error -> errorTransform(this).toLeft() + } +} diff --git a/app/src/main/kotlin/com/fernandocejas/sample/core/network/HttpClientX.kt b/app/src/main/kotlin/com/fernandocejas/sample/core/network/HttpClientX.kt new file mode 100644 index 00000000..a603405b --- /dev/null +++ b/app/src/main/kotlin/com/fernandocejas/sample/core/network/HttpClientX.kt @@ -0,0 +1,34 @@ +package com.fernandocejas.sample.core.network + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.ResponseException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.serialization.JsonConvertException +import kotlinx.io.IOException + +suspend inline fun HttpClient.safeRequest( + block: HttpRequestBuilder.() -> Unit, +): ApiResponse = + try { + val response = request { block() } + ApiResponse.Success(response.body()) + } catch (e: ClientRequestException) { + ApiResponse.Error.HttpError(e.response.status.value, e.errorBody()) + } catch (e: ServerResponseException) { + ApiResponse.Error.HttpError(e.response.status.value, e.errorBody()) + } catch (e: IOException) { + ApiResponse.Error.NetworkError + } catch (e: JsonConvertException) { + ApiResponse.Error.SerializationError + } + +suspend inline fun ResponseException.errorBody(): E? = + try { + response.body() + } catch (e: JsonConvertException) { + null + } diff --git a/app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkHandler.kt b/app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkHandler.kt index 1a257f49..929587cd 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkHandler.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkHandler.kt @@ -17,7 +17,6 @@ package com.fernandocejas.sample.core.network import android.content.Context import android.net.NetworkCapabilities -import android.os.Build import com.fernandocejas.sample.core.extension.connectivityManager /** diff --git a/app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkModule.kt b/app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkModule.kt new file mode 100644 index 00000000..1328f361 --- /dev/null +++ b/app/src/main/kotlin/com/fernandocejas/sample/core/network/NetworkModule.kt @@ -0,0 +1,54 @@ +package com.fernandocejas.sample.core.network + +import co.touchlab.kermit.Logger +import com.fernandocejas.sample.core.di.Feature +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.cache.HttpCache +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.ContentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import io.ktor.client.plugins.logging.Logger as KtorLogger + +fun networkFeature() = object : Feature { + override fun name() = "network" + override fun diModule() = networkModule +} + +private val networkModule = module { + singleOf(::NetworkHandler) + single { json } + single { client } +} + +private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false +} + +private val client = HttpClient(OkHttp) { + engine { + config { + followRedirects(true) + } + } + install(HttpCache) + install(HttpTimeout) + install(ContentNegotiation) { + json(json, ContentType.Text.Plain) + } + install(Logging) { + logger = object : KtorLogger { + override fun log(message: String) { + Logger.withTag("HTTP").d { "\uD83C\uDF10 $message" } + } + } + level = LogLevel.HEADERS + } +} diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/auth/Auth.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/auth/Auth.kt index 0d67816d..4bcd9d04 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/auth/Auth.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/features/auth/Auth.kt @@ -1,6 +1,6 @@ package com.fernandocejas.sample.features.auth -import com.fernandocejas.sample.core.Feature +import com.fernandocejas.sample.core.di.Feature import com.fernandocejas.sample.features.auth.credentials.Authenticator import org.koin.core.module.dsl.singleOf import org.koin.dsl.module diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/auth/di/AuthModule.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/auth/di/AuthModule.kt index 40b18e11..93895a9d 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/auth/di/AuthModule.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/features/auth/di/AuthModule.kt @@ -1,13 +1,8 @@ package com.fernandocejas.sample.features.auth.di -import com.fernandocejas.sample.core.navigation.Navigator -import com.fernandocejas.sample.core.network.NetworkHandler import com.fernandocejas.sample.features.auth.credentials.Authenticator -import okhttp3.OkHttpClient import org.koin.core.module.dsl.singleOf import org.koin.dsl.module -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory val authModule = module { singleOf(::Authenticator) diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/login/Login.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/login/Login.kt index 4a7e73e6..2acf5b77 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/login/Login.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/features/login/Login.kt @@ -1,6 +1,6 @@ package com.fernandocejas.sample.features.login -import com.fernandocejas.sample.core.Feature +import com.fernandocejas.sample.core.di.Feature import org.koin.dsl.module fun loginFeature() = object : Feature { diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MovieEntity.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MovieEntity.kt index 2fd1c041..64b5c90a 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MovieEntity.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MovieEntity.kt @@ -16,7 +16,10 @@ package com.fernandocejas.sample.features.movies.data import com.fernandocejas.sample.features.movies.interactor.Movie +import kotlinx.serialization.Serializable -data class MovieEntity(private val id: Int, private val poster: String) { - fun toMovie() = Movie(id, poster) -} +@Serializable +data class MovieEntity(val id: Int, val poster: String) + + +fun MovieEntity.toMovie() = Movie(id, poster) diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesApi.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesApi.kt deleted file mode 100644 index 926851e5..00000000 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesApi.kt +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (C) 2020 Fernando Cejas Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.fernandocejas.sample.features.movies.data - -import retrofit2.Call -import retrofit2.http.GET -import retrofit2.http.Path - -internal interface MoviesApi { - companion object { - private const val PARAM_MOVIE_ID = "movieId" - private const val MOVIES = "movies.json" - private const val MOVIE_DETAILS = "movie_0{$PARAM_MOVIE_ID}.json" - } - - @GET(MOVIES) - fun movies(): Call> - - @GET(MOVIE_DETAILS) - fun movieDetails(@Path(PARAM_MOVIE_ID) movieId: Int): Call -} diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepository.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepository.kt index 6acab472..56775053 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepository.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepository.kt @@ -19,57 +19,59 @@ import com.fernandocejas.sample.core.failure.Failure import com.fernandocejas.sample.core.failure.Failure.NetworkConnection import com.fernandocejas.sample.core.failure.Failure.ServerError import com.fernandocejas.sample.core.functional.Either -import com.fernandocejas.sample.core.functional.Either.Left -import com.fernandocejas.sample.core.functional.Either.Right +import com.fernandocejas.sample.core.functional.toLeft +import com.fernandocejas.sample.core.network.ApiResponse import com.fernandocejas.sample.core.network.NetworkHandler +import com.fernandocejas.sample.core.network.toEither import com.fernandocejas.sample.features.movies.interactor.Movie import com.fernandocejas.sample.features.movies.interactor.MovieDetails -import retrofit2.Call interface MoviesRepository { - fun movies(): Either> - fun movieDetails(movieId: Int): Either + suspend fun movies(): Either> + suspend fun movieDetails(movieId: Int): Either class Network( private val networkHandler: NetworkHandler, private val service: MoviesService ) : MoviesRepository { - override fun movies(): Either> { + override suspend fun movies(): Either> { return when (networkHandler.isNetworkAvailable()) { - true -> request( - service.movies(), - { it.map { movieEntity -> movieEntity.toMovie() } }, - emptyList() - ) - false -> Left(NetworkConnection) - } - } + true -> { + service.movies() + .toEither( + successTransform = { it.map { movieEntity -> movieEntity.toMovie() } }, + errorTransform = { + when (it) { + is ApiResponse.Error.HttpError<*> -> ServerError + is ApiResponse.Error.NetworkError -> NetworkConnection + is ApiResponse.Error.SerializationError -> ServerError + } + }, + ) + } - override fun movieDetails(movieId: Int): Either { - return when (networkHandler.isNetworkAvailable()) { - true -> request( - service.movieDetails(movieId), - { it.toMovieDetails() }, - MovieDetailsEntity.empty - ) - false -> Left(NetworkConnection) + false -> NetworkConnection.toLeft() } } - private fun request( - call: Call, - transform: (T) -> R, - default: T - ): Either { - return try { - val response = call.execute() - when (response.isSuccessful) { - true -> Right(transform((response.body() ?: default))) - false -> Left(ServerError) + override suspend fun movieDetails(movieId: Int): Either { + return when (networkHandler.isNetworkAvailable()) { + true -> { + return service.movieDetails(movieId) + .toEither( + successTransform = { it.toMovieDetails() }, + errorTransform = { + when (it) { + is ApiResponse.Error.HttpError<*> -> ServerError + is ApiResponse.Error.NetworkError -> NetworkConnection + is ApiResponse.Error.SerializationError -> ServerError + } + }, + ) } - } catch (exception: Throwable) { - Left(ServerError) + + false -> NetworkConnection.toLeft() } } } diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesService.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesService.kt index 2e9ad3dc..2ec95697 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesService.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/data/MoviesService.kt @@ -15,11 +15,32 @@ */ package com.fernandocejas.sample.features.movies.data -import retrofit2.Retrofit +import com.fernandocejas.sample.core.network.ApiResponse +import com.fernandocejas.sample.core.network.safeRequest +import io.ktor.client.HttpClient +import io.ktor.client.request.url +import io.ktor.http.ContentType +import io.ktor.http.contentType -class MoviesService(retrofit: Retrofit) : MoviesApi { - private val moviesApi by lazy { retrofit.create(MoviesApi::class.java) } +class MoviesService( + private val httpClient: HttpClient, +) { - override fun movies() = moviesApi.movies() - override fun movieDetails(movieId: Int) = moviesApi.movieDetails(movieId) + suspend fun movies(): ApiResponse, Unit> = + httpClient.safeRequest, Unit> { + url(BASE_URL + MOVIES) + contentType(ContentType.Text.Plain) + } + + suspend fun movieDetails(movieId: Int): ApiResponse = + httpClient.safeRequest { + url(BASE_URL + "movie_0${movieId}.json") + contentType(ContentType.Text.Plain) + } + + companion object { + private const val MOVIES = "movies.json" + private const val BASE_URL = + "https://raw.githubusercontent.com/android10/Sample-Data/master/Android-CleanArchitecture-Kotlin/" + } } diff --git a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/Movies.kt b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/di/Movies.kt similarity index 91% rename from app/src/main/kotlin/com/fernandocejas/sample/features/movies/Movies.kt rename to app/src/main/kotlin/com/fernandocejas/sample/features/movies/di/Movies.kt index 418f9108..4954fab9 100644 --- a/app/src/main/kotlin/com/fernandocejas/sample/features/movies/Movies.kt +++ b/app/src/main/kotlin/com/fernandocejas/sample/features/movies/di/Movies.kt @@ -1,6 +1,6 @@ -package com.fernandocejas.sample.features.movies +package com.fernandocejas.sample.features.movies.di -import com.fernandocejas.sample.core.Feature +import com.fernandocejas.sample.core.di.Feature import com.fernandocejas.sample.features.movies.data.MoviesRepository import com.fernandocejas.sample.features.movies.data.MoviesService import com.fernandocejas.sample.features.movies.interactor.GetMovieDetails diff --git a/app/src/test/kotlin/com/fernandocejas/sample/core/network/HttpClientXTest.kt b/app/src/test/kotlin/com/fernandocejas/sample/core/network/HttpClientXTest.kt new file mode 100644 index 00000000..2c4b02e0 --- /dev/null +++ b/app/src/test/kotlin/com/fernandocejas/sample/core/network/HttpClientXTest.kt @@ -0,0 +1,177 @@ +package com.fernandocejas.sample.core.network + +import io.kotest.matchers.equals.shouldBeEqual +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.url +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.io.IOException +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.Test + +class HttpClientXTest { + + @Test + fun `map to ApiResponse Success when client returns OK`() = runTest { + val tested = FakeApi(httpClient(okEngine)) + + val expected = ApiResponse.Success(FakeApi.FakeData()) + val actual = tested.endpoint() + + actual shouldBeEqual expected + } + + @Test + fun `map to ApiResponse HttpError with code when client returns 40x`() = runTest { + val tested = FakeApi(httpClient(badRequestEngine)) + + val expected = ApiResponse.Error.HttpError(400, FakeApi.FakeError("bad request")) + val actual = tested.endpoint() + + actual shouldBeEqual expected + } + + @Test + fun `map to ApiResponse HttpError with code and body when client returns 50x`() = runTest { + val tested = FakeApi(httpClient(serverErrorEngine)) + + val expected = ApiResponse.Error.HttpError(500, FakeApi.FakeError("internal server error")) + val actual = tested.endpoint() + + actual shouldBeEqual expected + } + + @Test + fun `map to ApiResponse HttpError with code and null body when client returns 50x and error doesn't match contract`() = + runTest { + val tested = FakeApi(httpClient(malformedServerErrorEngine)) + + val expected = ApiResponse.Error.HttpError(500, null) + val actual = tested.endpoint() + + actual shouldBeEqual expected + } + + @Test + fun `map to ApiResponse NetworkError when there is no internet`() = runTest { + val tested = FakeApi(httpClient(networkErrorEngine)) + + val expected = ApiResponse.Error.NetworkError + val actual = tested.endpoint() + + actual shouldBeEqual expected + } + + @Test + fun `map to ApiResponse SerialisationError when data format doesn't match`() = runTest { + val tested = FakeApi(httpClient(serialisationErrorEngine)) + + val expected = ApiResponse.Error.SerializationError + val actual = tested.endpoint() + + actual shouldBeEqual expected + } + + private fun httpClient(engine: MockEngine) = HttpClient(engine) { + expectSuccess = true + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + } + ) + } + } + + private class FakeApi( + private val client: HttpClient, + ) { + + suspend fun endpoint(): ApiResponse = + client.safeRequest { + url("https://movies.com/api") + contentType(ContentType.Application.Json) + } + + @Serializable + data class FakeData( + val id: Int = 1, + val name: String = "Movie", + ) + + @Serializable + data class FakeError( + val message: String + ) + } + + private val headers = headersOf(HttpHeaders.ContentType, "application/json") + + private val serialisationErrorEngine = MockEngine { + respond( + content = "just a string", + status = HttpStatusCode.OK, + headers = headers, + ) + } + + private val badRequestEngine = MockEngine { + respond( + content = """ + { + "message": "bad request" + } + """.trimIndent(), + status = HttpStatusCode.BadRequest, + headers = headers, + ) + } + + private val serverErrorEngine = MockEngine { + respond( + content = """ + { + "message": "internal server error" + } + """.trimIndent(), + status = HttpStatusCode.InternalServerError, + headers = headers, + ) + } + + private val malformedServerErrorEngine = MockEngine { + respond( + content = """ + { + "incorrect_field_name": "error" + } + """.trimIndent(), + status = HttpStatusCode.InternalServerError, + headers = headers, + ) + } + + private val networkErrorEngine = MockEngine { throw IOException("No internet") } + + private val okEngine = MockEngine { + respond( + content = """ + { + "id": 1, + "name": "Movie" + } + """.trimIndent(), + status = HttpStatusCode.OK, + headers = headers, + ) + } +} diff --git a/app/src/test/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepositoryTest.kt b/app/src/test/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepositoryTest.kt index f9cf116f..75fd138d 100644 --- a/app/src/test/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepositoryTest.kt +++ b/app/src/test/kotlin/com/fernandocejas/sample/features/movies/data/MoviesRepositoryTest.kt @@ -15,184 +15,269 @@ */ package com.fernandocejas.sample.features.movies.data -import com.fernandocejas.sample.UnitTest -import com.fernandocejas.sample.core.extension.empty -import com.fernandocejas.sample.core.failure.Failure import com.fernandocejas.sample.core.failure.Failure.NetworkConnection import com.fernandocejas.sample.core.failure.Failure.ServerError -import com.fernandocejas.sample.core.functional.Either -import com.fernandocejas.sample.core.functional.Either.Right +import com.fernandocejas.sample.core.functional.toLeft +import com.fernandocejas.sample.core.functional.toRight +import com.fernandocejas.sample.core.network.ApiResponse import com.fernandocejas.sample.core.network.NetworkHandler -import com.fernandocejas.sample.features.movies.data.MovieDetailsEntity -import com.fernandocejas.sample.features.movies.data.MovieEntity -import com.fernandocejas.sample.features.movies.data.MoviesRepository import com.fernandocejas.sample.features.movies.data.MoviesRepository.Network -import com.fernandocejas.sample.features.movies.data.MoviesService import com.fernandocejas.sample.features.movies.interactor.Movie import com.fernandocejas.sample.features.movies.interactor.MovieDetails import io.kotest.matchers.equals.shouldBeEqual -import io.kotest.matchers.should -import io.kotest.matchers.shouldBe -import io.kotest.matchers.types.beInstanceOf -import io.mockk.Called -import io.mockk.every +import io.mockk.coEvery import io.mockk.mockk -import io.mockk.verify -import org.junit.Before +import kotlinx.coroutines.test.runTest import org.junit.Test -import retrofit2.Call -import retrofit2.Response -class MoviesRepositoryTest : UnitTest() { - - private lateinit var networkRepository: MoviesRepository.Network +class MoviesRepositoryTest { private val networkHandler: NetworkHandler = mockk() private val service: MoviesService = mockk() - - private val moviesCall: Call> = mockk() - private val moviesResponse: Response> = mockk() - private val movieDetailsCall: Call = mockk() - private val movieDetailsResponse: Response = mockk() - - @Before - fun setUp() { - networkRepository = Network(networkHandler, service) - } + private val networkRepository = Network(networkHandler, service) + +// private val moviesCall: Call> = mockk() +// private val moviesResponse: Response> = mockk() +// private val movieDetailsCall: Call = mockk() +// private val movieDetailsResponse: Response = mockk() + +// @Test +// fun `should return empty list by default`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { moviesResponse.body() } returns null +// every { moviesResponse.isSuccessful } returns true +// every { moviesCall.execute() } returns moviesResponse +// every { service.movies() } returns moviesCall +// +// val movies = networkRepository.movies() +// +// movies shouldBeEqual Right(emptyList()) +// verify(exactly = 1) { service.movies() } +// } @Test - fun `should return empty list by default`() { - every { networkHandler.isNetworkAvailable() } returns true - every { moviesResponse.body() } returns null - every { moviesResponse.isSuccessful } returns true - every { moviesCall.execute() } returns moviesResponse - every { service.movies() } returns moviesCall + fun `movies returns list on success when network is available`() = runTest { + // Given + val movieEntities = listOf(MovieEntity(1, "poster")) + coEvery { networkHandler.isNetworkAvailable() } returns true + coEvery { service.movies() } returns ApiResponse.Success(movieEntities) - val movies = networkRepository.movies() + // When + val result = networkRepository.movies() - movies shouldBeEqual Right(emptyList()) - verify(exactly = 1) { service.movies() } + // Then + result shouldBeEqual listOf(Movie(1, "poster")).toRight() } - @Test - fun `should get movie list from service`() { - every { networkHandler.isNetworkAvailable() } returns true - every { moviesResponse.body() } returns listOf(MovieEntity(1, "poster")) - every { moviesResponse.isSuccessful } returns true - every { moviesCall.execute() } returns moviesResponse - every { service.movies() } returns moviesCall - - val movies = networkRepository.movies() - - movies shouldBeEqual Right(listOf(Movie(1, "poster"))) - verify(exactly = 1) { service.movies() } - } +// @Test +// fun `should get movie list from service`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { moviesResponse.body() } returns listOf(MovieEntity(1, "poster")) +// every { moviesResponse.isSuccessful } returns true +// every { moviesCall.execute() } returns moviesResponse +// every { service.movies() } returns moviesCall +// +// val movies = networkRepository.movies() +// +// movies shouldBeEqual Right(listOf(Movie(1, "poster"))) +// verify(exactly = 1) { service.movies() } +// } @Test - fun `movies service should return network failure when no connection`() { - every { networkHandler.isNetworkAvailable() } returns false + fun `movies returns NetworkConnection when network is not available`() = runTest { + // Given + coEvery { networkHandler.isNetworkAvailable() } returns false - val movies = networkRepository.movies() + // When + val result = networkRepository.movies() - movies should beInstanceOf>>() - movies.isLeft shouldBeEqual true - movies.fold({ failure -> failure should beInstanceOf() }, {}) - verify { service wasNot Called } + // Then + result shouldBeEqual NetworkConnection.toLeft() } - @Test - fun `movies service should return server error if no successful response`() { - every { networkHandler.isNetworkAvailable() } returns true - every { moviesResponse.isSuccessful } returns false - every { moviesCall.execute() } returns moviesResponse - every { service.movies() } returns moviesCall +// @Test +// fun `movies service should return network failure when no connection`() { +// every { networkHandler.isNetworkAvailable() } returns false +// +// val movies = networkRepository.movies() +// +// movies should beInstanceOf>>() +// movies.isLeft shouldBeEqual true +// movies.fold({ failure -> failure should beInstanceOf() }, {}) +// verify { service wasNot Called } +// } - val movies = networkRepository.movies() - - movies.isLeft shouldBeEqual true - movies.fold({ failure -> failure should beInstanceOf() }, {}) - } @Test - fun `movies request should catch exceptions`() { - every { networkHandler.isNetworkAvailable() } returns true - every { moviesCall.execute() } returns moviesResponse - every { service.movies() } returns moviesCall + fun `movies returns ServerError on HTTP error`() = runTest { + // Given + coEvery { networkHandler.isNetworkAvailable() } returns true + coEvery { service.movies() } returns ApiResponse.Error.HttpError(500, errorBody = null) - val movies = networkRepository.movies() + // When + val result = networkRepository.movies() - movies.isLeft shouldBe true - movies.fold({ failure -> failure should beInstanceOf() }, {}) + // Then + result shouldBeEqual ServerError.toLeft() } +// @Test +// fun `movies service should return server error if no successful response`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { moviesResponse.isSuccessful } returns false +// every { moviesCall.execute() } returns moviesResponse +// every { service.movies() } returns moviesCall +// +// val movies = networkRepository.movies() +// +// movies.isLeft shouldBeEqual true +// movies.fold({ failure -> failure should beInstanceOf() }, {}) +// } + +// @Test +// fun `movies request should catch exceptions`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { moviesCall.execute() } returns moviesResponse +// every { service.movies() } returns moviesCall +// +// val movies = networkRepository.movies() +// +// movies.isLeft shouldBe true +// movies.fold({ failure -> failure should beInstanceOf() }, {}) +// } + +// @Test +// fun `should return empty movie details by default`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { movieDetailsResponse.body() } returns null +// every { movieDetailsResponse.isSuccessful } returns true +// every { movieDetailsCall.execute() } returns movieDetailsResponse +// every { service.movieDetails(1) } returns movieDetailsCall +// +// val movieDetails = networkRepository.movieDetails(1) +// +// movieDetails shouldBeEqual Right(MovieDetails.empty) +// verify(exactly = 1) { service.movieDetails(1) } +// } + @Test - fun `should return empty movie details by default`() { - every { networkHandler.isNetworkAvailable() } returns true - every { movieDetailsResponse.body() } returns null - every { movieDetailsResponse.isSuccessful } returns true - every { movieDetailsCall.execute() } returns movieDetailsResponse - every { service.movieDetails(1) } returns movieDetailsCall + fun `movieDetails returns details on success when network is available`() = runTest { + // Given + val responseEntity = MovieDetailsEntity( + id = 1, + title = "title", + poster = "desc", + summary = "summary", + cast = "cast", + director = "director", + year = 0, + trailer = "trailer" + ) + val expected = MovieDetails( + id = 1, + title = "title", + poster = "desc", + summary = "summary", + cast = "cast", + director = "director", + year = 0, + trailer = "trailer" + ) - val movieDetails = networkRepository.movieDetails(1) + coEvery { networkHandler.isNetworkAvailable() } returns true + coEvery { service.movieDetails(1) } returns ApiResponse.Success(responseEntity) - movieDetails shouldBeEqual Right(MovieDetails.empty) - verify(exactly = 1) { service.movieDetails(1) } - } + // When + val result = networkRepository.movieDetails(1) - @Test - fun `should get movie details from service`() { - every { networkHandler.isNetworkAvailable() } returns true - every { movieDetailsResponse.body() } returns - MovieDetailsEntity(8, "title", String.empty(), String.empty(), - String.empty(), String.empty(), 0, String.empty()) - every { movieDetailsResponse.isSuccessful } returns true - every { movieDetailsCall.execute() } returns movieDetailsResponse - every { service.movieDetails(1) } returns movieDetailsCall - - val movieDetails = networkRepository.movieDetails(1) - - movieDetails shouldBeEqual Right( - MovieDetails(8, "title", String.empty(), - String.empty(), String.empty(), String.empty(), 0, String.empty()) - ) - verify(exactly = 1) { service.movieDetails(1) } + // Then + result shouldBeEqual expected.toRight() } +// @Test +// fun `should get movie details from service`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { movieDetailsResponse.body() } returns +// MovieDetailsEntity( +// 8, "title", String.empty(), String.empty(), +// String.empty(), String.empty(), 0, String.empty() +// ) +// every { movieDetailsResponse.isSuccessful } returns true +// every { movieDetailsCall.execute() } returns movieDetailsResponse +// every { service.movieDetails(1) } returns movieDetailsCall +// +// val movieDetails = networkRepository.movieDetails(1) +// +// movieDetails shouldBeEqual Right( +// MovieDetails( +// 8, "title", String.empty(), +// String.empty(), String.empty(), String.empty(), 0, String.empty() +// ) +// ) +// verify(exactly = 1) { service.movieDetails(1) } +// } + @Test - fun `movie details service should return network failure when no connection`() { - every { networkHandler.isNetworkAvailable() } returns false + fun `movieDetails returns NetworkConnection when network is not available`() = runTest { + // Given + coEvery { networkHandler.isNetworkAvailable() } returns false - val movieDetails = networkRepository.movieDetails(1) + // When + val result = networkRepository.movieDetails(1) - movieDetails.isLeft shouldBeEqual true - movieDetails.fold({ failure -> failure should beInstanceOf() }, {}) - verify { service wasNot Called } + // Then + result shouldBeEqual NetworkConnection.toLeft() } - @Test - fun `movie details service should return server error if no successful response`() { - every { networkHandler.isNetworkAvailable() } returns true - every { movieDetailsResponse.body() } returns null - every { movieDetailsResponse.isSuccessful } returns false - every { movieDetailsCall.execute() } returns movieDetailsResponse - every { service.movieDetails(1) } returns movieDetailsCall - - val movieDetails = networkRepository.movieDetails(1) - - movieDetails should beInstanceOf>() - movieDetails.isLeft shouldBeEqual true - movieDetails.fold({ failure -> failure should beInstanceOf() }, {}) - } +// @Test +// fun `movie details service should return network failure when no connection`() { +// every { networkHandler.isNetworkAvailable() } returns false +// +// val movieDetails = networkRepository.movieDetails(1) +// +// movieDetails.isLeft shouldBeEqual true +// movieDetails.fold({ failure -> failure should beInstanceOf() }, {}) +// verify { service wasNot Called } +// } @Test - fun `movie details request should catch exceptions`() { - every { networkHandler.isNetworkAvailable() } returns true - every { movieDetailsCall.execute() } returns movieDetailsResponse - every { service.movieDetails(1) } returns movieDetailsCall + fun `movie details returns ServerError on HTTP error`() = runTest { + // Given + coEvery { networkHandler.isNetworkAvailable() } returns true + coEvery { service.movieDetails(1) } returns + ApiResponse.Error.HttpError(code = 500, errorBody = null) - val movieDetails = networkRepository.movieDetails(1) + // When + val result = networkRepository.movieDetails(1) - movieDetails.isLeft shouldBeEqual true - movieDetails.fold({ failure -> failure should beInstanceOf() }, {}) + // Then + result shouldBeEqual ServerError.toLeft() } + +// @Test +// fun `movie details service should return server error if no successful response`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { movieDetailsResponse.body() } returns null +// every { movieDetailsResponse.isSuccessful } returns false +// every { movieDetailsCall.execute() } returns movieDetailsResponse +// every { service.movieDetails(1) } returns movieDetailsCall +// +// val movieDetails = networkRepository.movieDetails(1) +// +// movieDetails should beInstanceOf>() +// movieDetails.isLeft shouldBeEqual true +// movieDetails.fold({ failure -> failure should beInstanceOf() }, {}) +// } + +// @Test +// fun `movie details request should catch exceptions`() { +// every { networkHandler.isNetworkAvailable() } returns true +// every { movieDetailsCall.execute() } returns movieDetailsResponse +// every { service.movieDetails(1) } returns movieDetailsCall +// +// val movieDetails = networkRepository.movieDetails(1) +// +// movieDetails.isLeft shouldBeEqual true +// movieDetails.fold({ failure -> failure should beInstanceOf() }, {}) +// } } diff --git a/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMovieDetailsTest.kt b/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMovieDetailsTest.kt index d83fc62b..8312c4f1 100644 --- a/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMovieDetailsTest.kt +++ b/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMovieDetailsTest.kt @@ -15,33 +15,26 @@ */ package com.fernandocejas.sample.features.movies.interactor -import com.fernandocejas.sample.UnitTest -import com.fernandocejas.sample.core.functional.Either.Right +import com.fernandocejas.sample.core.functional.toRight import com.fernandocejas.sample.features.movies.data.MoviesRepository -import io.mockk.every +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk -import io.mockk.verify -import kotlinx.coroutines.runBlocking -import org.junit.Before +import kotlinx.coroutines.test.runTest import org.junit.Test -class GetMovieDetailsTest : UnitTest() { - - private lateinit var getMovieDetails: GetMovieDetails +class GetMovieDetailsTest { private val moviesRepository: MoviesRepository = mockk() - - @Before - fun setUp() { - getMovieDetails = GetMovieDetails(moviesRepository) - every { moviesRepository.movieDetails(MOVIE_ID) } returns Right(MovieDetails.empty) - } + private val getMovieDetails = GetMovieDetails(moviesRepository) @Test - fun `should get data from repository`() { - runBlocking { getMovieDetails.run(GetMovieDetails.Params(MOVIE_ID)) } + fun `should get data from repository`() = runTest { + coEvery { moviesRepository.movieDetails(MOVIE_ID) } returns MovieDetails.empty.toRight() + + getMovieDetails.run(GetMovieDetails.Params(MOVIE_ID)) - verify(exactly = 1) { moviesRepository.movieDetails(MOVIE_ID) } + coVerify(exactly = 1) { moviesRepository.movieDetails(MOVIE_ID) } } companion object { diff --git a/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMoviesTest.kt b/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMoviesTest.kt index 5079a98d..826b6d54 100644 --- a/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMoviesTest.kt +++ b/app/src/test/kotlin/com/fernandocejas/sample/features/movies/interactor/GetMoviesTest.kt @@ -15,35 +15,26 @@ */ package com.fernandocejas.sample.features.movies.interactor -import com.fernandocejas.sample.UnitTest -import com.fernandocejas.sample.core.functional.Either.Right +import com.fernandocejas.sample.core.functional.toRight import com.fernandocejas.sample.core.interactor.UseCase import com.fernandocejas.sample.features.movies.data.MoviesRepository -import com.fernandocejas.sample.features.movies.interactor.GetMovies -import com.fernandocejas.sample.features.movies.interactor.Movie -import io.mockk.every +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk -import io.mockk.verify -import kotlinx.coroutines.runBlocking -import org.junit.Before +import kotlinx.coroutines.test.runTest import org.junit.Test -class GetMoviesTest : UnitTest() { - - private lateinit var getMovies: GetMovies +class GetMoviesTest { private val moviesRepository: MoviesRepository = mockk() - - @Before - fun setUp() { - getMovies = GetMovies(moviesRepository) - every { moviesRepository.movies() } returns Right(listOf(Movie.empty)) - } + private val getMovies = GetMovies(moviesRepository) @Test - fun `should get data from repository`() { - runBlocking { getMovies.run(UseCase.None()) } + fun `should get data from repository`() = runTest { + coEvery { moviesRepository.movies() } returns listOf(Movie.empty).toRight() + + getMovies.run(UseCase.None()) - verify(exactly = 1) { moviesRepository.movies() } + coVerify(exactly = 1) { moviesRepository.movies() } } } diff --git a/app/src/test/kotlin/com/fernandocejas/sample/matchers/Android.kt b/app/src/test/kotlin/com/fernandocejas/sample/matchers/Android.kt index 438a214c..2e22724d 100644 --- a/app/src/test/kotlin/com/fernandocejas/sample/matchers/Android.kt +++ b/app/src/test/kotlin/com/fernandocejas/sample/matchers/Android.kt @@ -1,5 +1,6 @@ package com.fernandocejas.sample.matchers +import android.app.Activity import androidx.appcompat.app.AppCompatActivity import io.kotest.matchers.string.shouldBeEqualIgnoringCase import org.robolectric.Robolectric @@ -8,7 +9,7 @@ import kotlin.reflect.KClass infix fun KClass.shouldNavigateTo( - nextActivity: KClass + nextActivity: KClass ): () -> Unit = { val originActivity = Robolectric.buildActivity(this.java).get() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 352c31e8..91210769 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,6 @@ appCompat = "1.7.0" activityCompose = "1.10.1" lifecycleViewmodelCompose = "2.8.7" koinAndroid = "3.5.6" -converterGson = "2.9.0" coil = "3.1.0" kermit = "2.0.4" kotlinxSerializationJson = "1.8.1" @@ -57,7 +56,6 @@ androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinAndroid" } koin-androidx-compose-navigation = { module = "io.insert-koin:koin-androidx-compose-navigation", version.ref = "koinAndroid" } @@ -80,6 +78,7 @@ kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" junit = { module = "junit:junit", version.ref = "junit" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +ktor-client-mock ={ module = "io.ktor:ktor-client-mock", version.ref = "ktor" } # main module ui test dependencies --- androidx-runner = { module = "androidx.test:runner", version.ref = "runner" }