diff --git a/app/build.gradle b/app/build.gradle index 1271b99..32c033a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,6 +70,12 @@ android { } } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" + } +} + dependencies { // https://github.com/material-components/material-components-android implementation "com.google.android.material:material:1.5.0" @@ -110,7 +116,6 @@ dependencies { implementation platform("com.squareup.okhttp3:okhttp-bom:4.9.3") implementation "com.squareup.okhttp3:okhttp" debugImplementation "com.squareup.okhttp3:logging-interceptor" - testImplementation "com.squareup.okhttp3:mockwebserver" // https://github.com/Kotlin/kotlinx.serialization/releases implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" @@ -155,6 +160,9 @@ dependencies { // https://mvnrepository.com/artifact/com.google.truth/truth testImplementation "com.google.truth:truth:1.1.3" + // https://mockk.io/ + testImplementation "io.mockk:mockk:1.12.3" + // https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6" diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt index 5240fe8..794e69c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt @@ -7,7 +7,11 @@ interface AuthRepo { suspend fun getBaseUrl(): String? - suspend fun getToken(): String? + suspend fun requireBaseUrl(): String + + suspend fun getAuthHeader(): String? + + suspend fun requireAuthHeader(): String fun authenticationStatuses(): Flow diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt index 79d46cc..88cd966 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt @@ -3,13 +3,13 @@ package gq.kirmanak.mealient.data.auth import kotlinx.coroutines.flow.Flow interface AuthStorage { - fun storeAuthData(token: String, baseUrl: String) + fun storeAuthData(authHeader: String, baseUrl: String) suspend fun getBaseUrl(): String? - suspend fun getToken(): String? + suspend fun getAuthHeader(): String? - fun tokenObservable(): Flow + fun authHeaderObservable(): Flow fun clearAuthData() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt index 6cc7060..c587045 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt @@ -3,20 +3,18 @@ package gq.kirmanak.mealient.data.auth.impl import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.data.impl.ErrorDetail -import gq.kirmanak.mealient.data.impl.RetrofitBuilder import gq.kirmanak.mealient.data.impl.util.decodeErrorBodyOrNull +import gq.kirmanak.mealient.data.network.ServiceFactory import kotlinx.coroutines.CancellationException -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import retrofit2.HttpException -import retrofit2.create +import retrofit2.Response import timber.log.Timber import javax.inject.Inject -@ExperimentalSerializationApi class AuthDataSourceImpl @Inject constructor( - private val retrofitBuilder: RetrofitBuilder, + private val authServiceFactory: ServiceFactory, private val json: Json, ) : AuthDataSource { @@ -26,31 +24,37 @@ class AuthDataSourceImpl @Inject constructor( baseUrl: String ): String { Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") - val authService = retrofitBuilder.buildRetrofit(baseUrl).create() - - val accessToken = runCatching { - val response = authService.getToken(username, password) - Timber.d("authenticate() response is $response") - if (response.isSuccessful) { - checkNotNull(response.body()).accessToken - } else { - val cause = HttpException(response) - val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json) - throw when (errorDetail?.detail) { - "Unauthorized" -> Unauthorized(cause) - else -> NotMealie(cause) - } - } - }.onFailure { - Timber.e(it, "authenticate: getToken failed") - throw when (it) { - is CancellationException, is AuthenticationError -> it - is SerializationException, is IllegalStateException -> NotMealie(it) - else -> NoServerConnection(it) - } - }.getOrThrow() - + val authService = authServiceFactory.provideService(baseUrl) + val response = sendRequest(authService, username, password) + val accessToken = parseToken(response) Timber.v("authenticate() returned: $accessToken") return accessToken } + + private suspend fun sendRequest( + authService: AuthService, + username: String, + password: String + ): Response = try { + authService.getToken(username, password) + } catch (e: Throwable) { + throw when (e) { + is CancellationException -> e + is SerializationException -> NotMealie(e) + else -> NoServerConnection(e) + } + } + + private fun parseToken( + response: Response + ): String = if (response.isSuccessful) { + response.body()?.accessToken ?: throw NotMealie(NullPointerException("Body is null")) + } else { + val cause = HttpException(response) + val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json) + throw when (errorDetail?.detail) { + "Unauthorized" -> Unauthorized(cause) + else -> NotMealie(cause) + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthOkHttpInterceptor.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthOkHttpInterceptor.kt deleted file mode 100644 index e6f9570..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthOkHttpInterceptor.kt +++ /dev/null @@ -1,29 +0,0 @@ -package gq.kirmanak.mealient.data.auth.impl - -import gq.kirmanak.mealient.data.auth.AuthStorage -import kotlinx.coroutines.runBlocking -import okhttp3.Interceptor -import okhttp3.Response -import timber.log.Timber -import javax.inject.Inject - -const val AUTHORIZATION_HEADER = "Authorization" - -class AuthOkHttpInterceptor @Inject constructor( - private val authStorage: AuthStorage -) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - Timber.v("intercept() called with: chain = $chain") - val token = runBlocking { authStorage.getToken() } - Timber.d("intercept: token = $token") - val request = if (token.isNullOrBlank()) { - chain.request() - } else { - chain.request() - .newBuilder() - .addHeader(AUTHORIZATION_HEADER, "Bearer $token") - .build() - } - return chain.proceed(request) - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt index f981b92..20f0867 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt @@ -14,8 +14,9 @@ import javax.inject.Inject class AuthRepoImpl @Inject constructor( private val dataSource: AuthDataSource, - private val storage: AuthStorage + private val storage: AuthStorage, ) : AuthRepo { + override suspend fun authenticate( username: String, password: String, @@ -24,20 +25,23 @@ class AuthRepoImpl @Inject constructor( Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") val url = parseBaseUrl(baseUrl) val accessToken = dataSource.authenticate(username, password, url) - Timber.d("authenticate result is $accessToken") - storage.storeAuthData(accessToken, url) + Timber.d("authenticate result is \"$accessToken\"") + storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken), url) } override suspend fun getBaseUrl(): String? = storage.getBaseUrl() - override suspend fun getToken(): String? { - Timber.v("getToken() called") - return storage.getToken() - } + override suspend fun requireBaseUrl(): String = + checkNotNull(getBaseUrl()) { "Base URL is null when it was required" } + + override suspend fun getAuthHeader(): String? = storage.getAuthHeader() + + override suspend fun requireAuthHeader(): String = + checkNotNull(getAuthHeader()) { "Auth header is null when it was required" } override fun authenticationStatuses(): Flow { Timber.v("authenticationStatuses() called") - return storage.tokenObservable().map { it != null } + return storage.authHeaderObservable().map { it != null } } override fun logout() { @@ -57,4 +61,7 @@ class AuthRepoImpl @Inject constructor( throw MalformedUrl(e) } + companion object { + private const val AUTH_HEADER_FORMAT = "Bearer %s" + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthService.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthService.kt index e6b910c..6fe068b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthService.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthService.kt @@ -11,9 +11,5 @@ interface AuthService { suspend fun getToken( @Field("username") username: String, @Field("password") password: String, - @Field("grant_type") grantType: String? = null, - @Field("scope") scope: String? = null, - @Field("client_id") clientId: String? = null, - @Field("client_secret") clientSecret: String? = null ): Response } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt index 75fcc2a..5399314 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt @@ -1,29 +1,28 @@ package gq.kirmanak.mealient.data.auth.impl import android.content.SharedPreferences +import androidx.core.content.edit import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.impl.util.changesFlow import gq.kirmanak.mealient.data.impl.util.getStringOrNull -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject -private const val TOKEN_KEY = "AUTH_TOKEN" +private const val AUTH_HEADER_KEY = "AUTH_TOKEN" private const val BASE_URL_KEY = "BASE_URL" -@ExperimentalCoroutinesApi class AuthStorageImpl @Inject constructor( private val sharedPreferences: SharedPreferences ) : AuthStorage { - override fun storeAuthData(token: String, baseUrl: String) { - Timber.v("storeAuthData() called with: token = $token, baseUrl = $baseUrl") - sharedPreferences.edit() - .putString(TOKEN_KEY, token) - .putString(BASE_URL_KEY, baseUrl) - .apply() + override fun storeAuthData(authHeader: String, baseUrl: String) { + Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl") + sharedPreferences.edit { + putString(AUTH_HEADER_KEY, authHeader) + putString(BASE_URL_KEY, baseUrl) + } } override suspend fun getBaseUrl(): String? { @@ -32,23 +31,23 @@ class AuthStorageImpl @Inject constructor( return baseUrl } - override suspend fun getToken(): String? { - Timber.v("getToken() called") - val token = sharedPreferences.getStringOrNull(TOKEN_KEY) - Timber.d("getToken: token is $token") + override suspend fun getAuthHeader(): String? { + Timber.v("getAuthHeader() called") + val token = sharedPreferences.getStringOrNull(AUTH_HEADER_KEY) + Timber.d("getAuthHeader: header is \"$token\"") return token } - override fun tokenObservable(): Flow { - Timber.v("tokenObservable() called") - return sharedPreferences.changesFlow().map { it.first.getStringOrNull(TOKEN_KEY) } + override fun authHeaderObservable(): Flow { + Timber.v("authHeaderObservable() called") + return sharedPreferences.changesFlow().map { it.first.getStringOrNull(AUTH_HEADER_KEY) } } override fun clearAuthData() { Timber.v("clearAuthData() called") - sharedPreferences.edit() - .remove(TOKEN_KEY) - .remove(BASE_URL_KEY) - .apply() + sharedPreferences.edit { + remove(AUTH_HEADER_KEY) + remove(BASE_URL_KEY) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/GetTokenResponse.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/GetTokenResponse.kt index a73e4cf..029e76e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/GetTokenResponse.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/GetTokenResponse.kt @@ -4,7 +4,4 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class GetTokenResponse( - @SerialName("access_token") val accessToken: String, - @SerialName("token_type") val tokenType: String -) \ No newline at end of file +data class GetTokenResponse(@SerialName("access_token") val accessToken: String) \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/impl/RetrofitBuilder.kt b/app/src/main/java/gq/kirmanak/mealient/data/impl/RetrofitBuilder.kt index b8fb26e..a22242a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/impl/RetrofitBuilder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/impl/RetrofitBuilder.kt @@ -9,11 +9,12 @@ import retrofit2.Retrofit import timber.log.Timber import javax.inject.Inject -@ExperimentalSerializationApi class RetrofitBuilder @Inject constructor( private val okHttpClient: OkHttpClient, private val json: Json ) { + + @OptIn(ExperimentalSerializationApi::class) fun buildRetrofit(baseUrl: String): Retrofit { Timber.v("buildRetrofit() called with: baseUrl = $baseUrl") val contentType = "application/json".toMediaType() diff --git a/app/src/main/java/gq/kirmanak/mealient/data/impl/util/NetworkExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/data/impl/util/NetworkExtensions.kt index bd276e3..c226ace 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/impl/util/NetworkExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/impl/util/NetworkExtensions.kt @@ -7,11 +7,10 @@ import retrofit2.Response import timber.log.Timber import java.io.InputStream -@ExperimentalSerializationApi inline fun Response.decodeErrorBodyOrNull(json: Json): R? = errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull(it) } -@ExperimentalSerializationApi +@OptIn(ExperimentalSerializationApi::class) inline fun Json.decodeFromStreamOrNull(stream: InputStream): T? = runCatching { decodeFromStream(stream) } .onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/impl/util/SharedPrefExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/data/impl/util/SharedPrefExtensions.kt index c2ce6fd..46b5df5 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/impl/util/SharedPrefExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/impl/util/SharedPrefExtensions.kt @@ -17,7 +17,7 @@ suspend fun SharedPreferences.getStringOrNull(key: String) = suspend fun SharedPreferences.getBooleanOrFalse(key: String) = withContext(Dispatchers.IO) { getBoolean(key, false) } -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) fun SharedPreferences.changesFlow(): Flow> = callbackFlow { val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> Timber.v("watchChanges: listener called with key $key") diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt new file mode 100644 index 0000000..17e973f --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt @@ -0,0 +1,29 @@ +package gq.kirmanak.mealient.data.network + +import gq.kirmanak.mealient.data.impl.RetrofitBuilder +import timber.log.Timber + +inline fun RetrofitBuilder.createServiceFactory() = + RetrofitServiceFactory(T::class.java, this) + +class RetrofitServiceFactory( + private val serviceClass: Class, + private val retrofitBuilder: RetrofitBuilder, +) : ServiceFactory { + + private val cache: MutableMap = mutableMapOf() + + @Synchronized + override fun provideService(baseUrl: String): T { + Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}") + val cached = cache[baseUrl] + return if (cached == null) { + Timber.d("provideService: cache is empty, creating new") + val new = retrofitBuilder.buildRetrofit(baseUrl).create(serviceClass) + cache[baseUrl] = new + new + } else { + cached + } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt new file mode 100644 index 0000000..1ae70d8 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.data.network + +interface ServiceFactory { + + fun provideService(baseUrl: String): T +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 5454820..b8926f8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject -@ExperimentalPagingApi +@OptIn(ExperimentalPagingApi::class) class RecipeRepoImpl @Inject constructor( private val mediator: RecipesRemoteMediator, private val storage: RecipeStorage, diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt index e8a6e43..7c0b5c3 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject -@ExperimentalPagingApi +@OptIn(ExperimentalPagingApi::class) class RecipesRemoteMediator @Inject constructor( private val storage: RecipeStorage, private val network: RecipeDataSource, diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt index d90efd0..6a11cde 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt @@ -1,50 +1,35 @@ package gq.kirmanak.mealient.data.recipes.network import gq.kirmanak.mealient.data.auth.AuthRepo -import gq.kirmanak.mealient.data.impl.RetrofitBuilder +import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse -import kotlinx.serialization.ExperimentalSerializationApi import timber.log.Timber import javax.inject.Inject -@ExperimentalSerializationApi class RecipeDataSourceImpl @Inject constructor( private val authRepo: AuthRepo, - private val retrofitBuilder: RetrofitBuilder + private val recipeServiceFactory: ServiceFactory, ) : RecipeDataSource { - private var _recipeService: RecipeService? = null override suspend fun requestRecipes(start: Int, limit: Int): List { Timber.v("requestRecipes() called with: start = $start, limit = $limit") - val service: RecipeService = getRecipeService() - val recipeSummary = service.getRecipeSummary(start, limit) + val recipeSummary = getRecipeService().getRecipeSummary(start, limit, getToken()) Timber.v("requestRecipes() returned: $recipeSummary") return recipeSummary } override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse { Timber.v("requestRecipeInfo() called with: slug = $slug") - val service: RecipeService = getRecipeService() - val recipeInfo = service.getRecipe(slug) + val recipeInfo = getRecipeService().getRecipe(slug, getToken()) Timber.v("requestRecipeInfo() returned: $recipeInfo") return recipeInfo } private suspend fun getRecipeService(): RecipeService { Timber.v("getRecipeService() called") - val cachedService: RecipeService? = _recipeService - val service: RecipeService = if (cachedService == null) { - val baseUrl = checkNotNull(authRepo.getBaseUrl()) { "Base url is null" } - val token = checkNotNull(authRepo.getToken()) { "Token is null" } - Timber.d("requestRecipes: baseUrl = $baseUrl, token = $token") - val retrofit = retrofitBuilder.buildRetrofit(baseUrl) - val createdService = retrofit.create(RecipeService::class.java) - _recipeService = createdService - createdService - } else { - cachedService - } - return service + return recipeServiceFactory.provideService(authRepo.requireBaseUrl()) } -} \ No newline at end of file + + private suspend fun getToken(): String = authRepo.requireAuthHeader() +} diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt index 56d3791..19a64b9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeService.kt @@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.recipes.network import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.Path import retrofit2.http.Query @@ -10,11 +11,13 @@ interface RecipeService { @GET("/api/recipes/summary") suspend fun getRecipeSummary( @Query("start") start: Int, - @Query("limit") limit: Int + @Query("limit") limit: Int, + @Header("Authorization") authHeader: String?, ): List @GET("/api/recipes/{recipe_slug}") suspend fun getRecipe( - @Path("recipe_slug") recipeSlug: String + @Path("recipe_slug") recipeSlug: String, + @Header("Authorization") authHeader: String?, ): GetRecipeResponse } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt index d401112..4507430 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -2,25 +2,34 @@ package gq.kirmanak.mealient.di import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoSet import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl -import gq.kirmanak.mealient.data.auth.impl.AuthOkHttpInterceptor import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl +import gq.kirmanak.mealient.data.auth.impl.AuthService import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.serialization.ExperimentalSerializationApi -import okhttp3.Interceptor +import gq.kirmanak.mealient.data.impl.RetrofitBuilder +import gq.kirmanak.mealient.data.network.ServiceFactory +import gq.kirmanak.mealient.data.network.createServiceFactory +import javax.inject.Singleton -@ExperimentalCoroutinesApi -@ExperimentalSerializationApi @Module @InstallIn(SingletonComponent::class) interface AuthModule { + + companion object { + + @Provides + @Singleton + fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory { + return retrofitBuilder.createServiceFactory() + } + } + @Binds fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource @@ -29,8 +38,4 @@ interface AuthModule { @Binds fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo - - @Binds - @IntoSet - fun bindAuthInterceptor(authOkHttpInterceptor: AuthOkHttpInterceptor): Interceptor } diff --git a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt index 2b83c14..8986e09 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt @@ -1,12 +1,14 @@ package gq.kirmanak.mealient.di -import androidx.paging.ExperimentalPagingApi import androidx.paging.InvalidatingPagingSourceFactory import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import gq.kirmanak.mealient.data.impl.RetrofitBuilder +import gq.kirmanak.mealient.data.network.ServiceFactory +import gq.kirmanak.mealient.data.network.createServiceFactory import gq.kirmanak.mealient.data.recipes.RecipeImageLoader import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.db.RecipeStorage @@ -15,11 +17,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageLoaderImpl import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl -import kotlinx.serialization.ExperimentalSerializationApi +import gq.kirmanak.mealient.data.recipes.network.RecipeService import javax.inject.Singleton -@ExperimentalPagingApi -@ExperimentalSerializationApi @Module @InstallIn(SingletonComponent::class) interface RecipeModule { @@ -36,6 +36,13 @@ interface RecipeModule { fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader companion object { + + @Provides + @Singleton + fun provideRecipeServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory { + return retrofitBuilder.createServiceFactory() + } + @Provides @Singleton fun provideRecipePagingSourceFactory( diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt index 1b886a6..035ccc4 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import timber.log.Timber -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) fun SwipeRefreshLayout.refreshesLiveData(): LiveData { val callbackFlow: Flow = callbackFlow { val listener = SwipeRefreshLayout.OnRefreshListener { @@ -63,7 +63,7 @@ fun AppCompatActivity.setActionBarVisibility(isVisible: Boolean) { ?: Timber.w("setActionBarVisibility: action bar is null") } -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) fun TextView.textChangesFlow(): Flow = callbackFlow { Timber.v("textChangesFlow() called") val textWatcher = doAfterTextChanged { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt index 3215279..e95d28a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt @@ -16,12 +16,10 @@ import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealient.ui.textChangesFlow -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import timber.log.Timber -@ExperimentalCoroutinesApi @AndroidEntryPoint class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { private val binding by viewBinding(FragmentAuthenticationBinding::bind) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index ecb1696..f4df870 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -15,11 +15,9 @@ import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.FragmentRecipesBinding import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel import gq.kirmanak.mealient.ui.refreshesLiveData -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import timber.log.Timber -@ExperimentalCoroutinesApi @AndroidEntryPoint class RecipesFragment : Fragment(R.layout.fragment_recipes) { private val binding by viewBinding(FragmentRecipesBinding::bind) diff --git a/app/src/release/java/gq/kirmanak/mealient/di/ReleaseModule.kt b/app/src/release/java/gq/kirmanak/mealient/di/ReleaseModule.kt new file mode 100644 index 0000000..40b35e3 --- /dev/null +++ b/app/src/release/java/gq/kirmanak/mealient/di/ReleaseModule.kt @@ -0,0 +1,20 @@ +package gq.kirmanak.mealient.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.Interceptor +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ReleaseModule { + + // Release version of the application doesn't have any interceptors but this Set + // is required by Dagger, so an empty Set is provided here + // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) + @Provides + @Singleton + fun provideInterceptors(): Set<@JvmSuppressWildcards Interceptor> = emptySet() +} diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt index 7485268..8c51447 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImplTest.kt @@ -1,84 +1,86 @@ package gq.kirmanak.mealient.data.auth.impl import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* +import gq.kirmanak.mealient.data.network.ServiceFactory +import gq.kirmanak.mealient.di.AppModule +import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME -import gq.kirmanak.mealient.test.AuthImplTestData.body -import gq.kirmanak.mealient.test.AuthImplTestData.enqueueSuccessfulAuthResponse -import gq.kirmanak.mealient.test.AuthImplTestData.enqueueUnsuccessfulAuthResponse -import gq.kirmanak.mealient.test.MockServerTest +import gq.kirmanak.mealient.test.toJsonResponseBody +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.ExperimentalSerializationApi -import okhttp3.mockwebserver.MockResponse +import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test -import javax.inject.Inject +import retrofit2.Response +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthDataSourceImplTest { + @MockK + lateinit var authService: AuthService + + @MockK + lateinit var authServiceFactory: ServiceFactory -@ExperimentalSerializationApi -@ExperimentalCoroutinesApi -@HiltAndroidTest -class AuthDataSourceImplTest : MockServerTest() { - @Inject lateinit var subject: AuthDataSourceImpl + @Before + fun setUp() { + MockKAnnotations.init(this) + subject = AuthDataSourceImpl(authServiceFactory, AppModule.createJson()) + } + @Test - fun `when authentication is successful then token is correct`() = runBlocking { - mockServer.enqueueSuccessfulAuthResponse() - val token = subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) + fun `when authentication is successful then token is correct`() = runTest { + val token = authenticate(Response.success(GetTokenResponse(TEST_TOKEN))) assertThat(token).isEqualTo(TEST_TOKEN) } @Test(expected = Unauthorized::class) - fun `when authentication isn't successful then throws`(): Unit = runBlocking { - mockServer.enqueueUnsuccessfulAuthResponse() - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) - } - - @Test - fun `when authentication is requested then body is correct`() = runBlocking { - mockServer.enqueueSuccessfulAuthResponse() - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) - val body = mockServer.takeRequest().body() - assertThat(body).isEqualTo("username=$TEST_USERNAME&password=$TEST_PASSWORD") - } - - @Test - fun `when authentication is requested then path is correct`() = runBlocking { - mockServer.enqueueSuccessfulAuthResponse() - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) - val path = mockServer.takeRequest().path - assertThat(path).isEqualTo("/api/auth/token") + fun `when authenticate receives 401 and Unauthorized then throws Unauthorized`() = runTest { + val body = "{\"detail\":\"Unauthorized\"}".toJsonResponseBody() + authenticate(Response.error(401, body)) } @Test(expected = NotMealie::class) - fun `when authenticate but response empty then NotMealie`(): Unit = runBlocking { - val response = MockResponse().setResponseCode(200) - mockServer.enqueue(response) - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) + fun `when authenticate receives 401 but not Unauthorized then throws NotMealie`() = runTest { + val body = "{\"detail\":\"Something\"}".toJsonResponseBody() + authenticate(Response.error(401, body)) } @Test(expected = NotMealie::class) - fun `when authenticate but response invalid then NotMealie`(): Unit = runBlocking { - val response = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("{\"test\": \"test\"") - mockServer.enqueue(response) - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) + fun `when authenticate receives 404 and empty body then throws NotMealie`() = runTest { + authenticate(Response.error(401, "".toJsonResponseBody())) } @Test(expected = NotMealie::class) - fun `when authenticate but response not found then NotMealie`(): Unit = runBlocking { - val response = MockResponse().setResponseCode(404) - mockServer.enqueue(response) - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) + fun `when authenticate receives 200 and null then throws NotMealie`() = runTest { + authenticate(Response.success(200, null)) } @Test(expected = NoServerConnection::class) - fun `when authenticate but host not found then NoServerConnection`(): Unit = runBlocking { - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, "http://test") + fun `when authenticate and getToken throws then throws NoServerConnection`() = runTest { + setUpAuthServiceFactory() + coEvery { authService.getToken(any(), any()) } throws IOException("Server not found") + callAuthenticate() + } + + private suspend fun authenticate(response: Response): String { + setUpAuthServiceFactory() + coEvery { authService.getToken(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns response + return callAuthenticate() + } + + private suspend fun callAuthenticate() = + subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL) + + private fun setUpAuthServiceFactory() { + every { authServiceFactory.provideService(eq(TEST_BASE_URL)) } returns authService } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt index 330518d..66b0457 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt @@ -1,55 +1,74 @@ package gq.kirmanak.mealient.data.auth.impl import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest +import gq.kirmanak.mealient.data.auth.AuthDataSource +import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.MalformedUrl import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.Unauthorized +import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER +import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME -import gq.kirmanak.mealient.test.AuthImplTestData.enqueueSuccessfulAuthResponse -import gq.kirmanak.mealient.test.AuthImplTestData.enqueueUnsuccessfulAuthResponse -import gq.kirmanak.mealient.test.MockServerTest +import gq.kirmanak.mealient.test.RobolectricTest +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test -import javax.inject.Inject -@HiltAndroidTest -class AuthRepoImplTest : MockServerTest() { - @Inject +@OptIn(ExperimentalCoroutinesApi::class) +class AuthRepoImplTest : RobolectricTest() { + + @MockK + lateinit var dataSource: AuthDataSource + + @MockK(relaxUnitFun = true) + lateinit var storage: AuthStorage + lateinit var subject: AuthRepoImpl + @Before + fun setUp() { + MockKAnnotations.init(this) + subject = AuthRepoImpl(dataSource, storage) + } + @Test - fun `when not authenticated then first auth status is false`() = runBlocking { + fun `when not authenticated then first auth status is false`() = runTest { + coEvery { storage.authHeaderObservable() } returns flowOf(null) assertThat(subject.authenticationStatuses().first()).isFalse() } @Test - fun `when authenticated then first auth status is true`() = runBlocking { - mockServer.enqueueSuccessfulAuthResponse() - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) + fun `when authenticated then first auth status is true`() = runTest { + coEvery { storage.authHeaderObservable() } returns flowOf(TEST_AUTH_HEADER) assertThat(subject.authenticationStatuses().first()).isTrue() } @Test(expected = Unauthorized::class) - fun `when authentication fails then authenticate throws`() = runBlocking { - mockServer.enqueueUnsuccessfulAuthResponse() - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) + fun `when authentication fails then authenticate throws`() = runTest { + coEvery { + dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL)) + } throws Unauthorized(RuntimeException()) + subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL) } @Test - fun `when authenticated then getToken returns token`() = runBlocking { - mockServer.enqueueSuccessfulAuthResponse() - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) - assertThat(subject.getToken()).isEqualTo(TEST_TOKEN) + fun `when authenticated then getToken returns token`() = runTest { + coEvery { storage.getAuthHeader() } returns TEST_AUTH_HEADER + assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER) } @Test - fun `when authenticated then getBaseUrl returns url`() = runBlocking { - mockServer.enqueueSuccessfulAuthResponse() - subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) - assertThat(subject.getBaseUrl()).isEqualTo(serverUrl) + fun `when authenticated then getBaseUrl returns url`() = runTest { + coEvery { storage.getBaseUrl() } returns TEST_BASE_URL + assertThat(subject.getBaseUrl()).isEqualTo(TEST_BASE_URL) } @Test(expected = MalformedUrl::class) @@ -76,4 +95,19 @@ class AuthRepoImplTest : MockServerTest() { fun `when baseUrl is correct then doesn't change`() { assertThat(subject.parseBaseUrl("https://google.com/")).isEqualTo("https://google.com/") } + + @Test + fun `when authenticated successfully then stores token and url`() = runTest { + coEvery { + dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD), eq(TEST_BASE_URL)) + } returns TEST_TOKEN + subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL) + verify { storage.storeAuthData(TEST_AUTH_HEADER, TEST_BASE_URL) } + } + + @Test + fun `when logout then clearAuthData is called`() = runTest { + subject.logout() + verify { storage.clearAuthData() } + } } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt index 5ac3050..6de0d15 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImplTest.kt @@ -2,76 +2,77 @@ package gq.kirmanak.mealient.data.auth.impl import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN +import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_URL import gq.kirmanak.mealient.test.HiltRobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Test import javax.inject.Inject -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest class AuthStorageImplTest : HiltRobolectricTest() { + @Inject lateinit var subject: AuthStorageImpl @Test - fun `when storing auth data then doesn't throw`() = runBlocking { - subject.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when storing auth data then doesn't throw`() = runTest { + subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) } @Test - fun `when reading url after storing data then returns url`() = runBlocking { - subject.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when reading url after storing data then returns url`() = runTest { + subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) assertThat(subject.getBaseUrl()).isEqualTo(TEST_URL) } @Test - fun `when reading token after storing data then returns token`() = runBlocking { - subject.storeAuthData(TEST_TOKEN, TEST_URL) - assertThat(subject.getToken()).isEqualTo(TEST_TOKEN) + fun `when reading token after storing data then returns token`() = runTest { + subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) + assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER) } @Test - fun `when reading token without storing data then returns null`() = runBlocking { - assertThat(subject.getToken()).isNull() + fun `when reading token without storing data then returns null`() = runTest { + assertThat(subject.getAuthHeader()).isNull() } @Test - fun `when reading url without storing data then returns null`() = runBlocking { + fun `when reading url without storing data then returns null`() = runTest { assertThat(subject.getBaseUrl()).isNull() } @Test - fun `when didn't store auth data then first token is null`() = runBlocking { - assertThat(subject.tokenObservable().first()).isNull() + fun `when didn't store auth data then first token is null`() = runTest { + assertThat(subject.authHeaderObservable().first()).isNull() } @Test - fun `when stored auth data then first token is correct`() = runBlocking { - subject.storeAuthData(TEST_TOKEN, TEST_URL) - assertThat(subject.tokenObservable().first()).isEqualTo(TEST_TOKEN) + fun `when stored auth data then first token is correct`() = runTest { + subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) + assertThat(subject.authHeaderObservable().first()).isEqualTo(TEST_AUTH_HEADER) } @Test - fun `when clearAuthData then first token is null`() = runBlocking { - subject.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when clearAuthData then first token is null`() = runTest { + subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.clearAuthData() - assertThat(subject.tokenObservable().first()).isNull() + assertThat(subject.authHeaderObservable().first()).isNull() } @Test - fun `when clearAuthData then getToken returns null`() = runBlocking { - subject.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when clearAuthData then getToken returns null`() = runTest { + subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.clearAuthData() - assertThat(subject.getToken()).isNull() + assertThat(subject.getAuthHeader()).isNull() } @Test - fun `when clearAuthData then getBaseUrl returns null`() = runBlocking { - subject.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when clearAuthData then getBaseUrl returns null`() = runTest { + subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL) subject.clearAuthData() assertThat(subject.getBaseUrl()).isNull() } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImplTest.kt index 0eeafd4..80a5bb5 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImplTest.kt @@ -3,22 +3,24 @@ package gq.kirmanak.mealient.data.disclaimer import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import gq.kirmanak.mealient.test.HiltRobolectricTest -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Test import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest class DisclaimerStorageImplTest : HiltRobolectricTest() { @Inject lateinit var subject: DisclaimerStorageImpl @Test - fun `when isDisclaimerAccepted initially then false`(): Unit = runBlocking { + fun `when isDisclaimerAccepted initially then false`() = runTest { assertThat(subject.isDisclaimerAccepted()).isFalse() } @Test - fun `when isDisclaimerAccepted after accept then true`(): Unit = runBlocking { + fun `when isDisclaimerAccepted after accept then true`() = runTest { subject.acceptDisclaimer() assertThat(subject.isDisclaimerAccepted()).isTrue() } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/impl/OkHttpBuilderTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/impl/OkHttpBuilderTest.kt deleted file mode 100644 index 87d795d..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/data/impl/OkHttpBuilderTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package gq.kirmanak.mealient.data.impl - -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import gq.kirmanak.mealient.data.auth.AuthStorage -import gq.kirmanak.mealient.data.auth.impl.AUTHORIZATION_HEADER -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_URL -import gq.kirmanak.mealient.test.MockServerTest -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.mockwebserver.MockResponse -import org.junit.Test -import javax.inject.Inject - -@HiltAndroidTest -class OkHttpBuilderTest : MockServerTest() { - - @Inject - lateinit var subject: OkHttpBuilder - - @Inject - lateinit var authStorage: AuthStorage - - @Test - fun `when token null then no auth header`() { - val client = subject.buildOkHttp() - val header = sendRequestAndExtractAuthHeader(client) - assertThat(header).isNull() - } - - @Test - fun `when token isn't null then auth header contains token`() { - authStorage.storeAuthData(TEST_TOKEN, TEST_URL) - val client = subject.buildOkHttp() - val header = sendRequestAndExtractAuthHeader(client) - assertThat(header).isEqualTo("Bearer $TEST_TOKEN") - } - - private fun sendRequestAndExtractAuthHeader(client: OkHttpClient): String? { - mockServer.enqueue(MockResponse()) - val request = Request.Builder().url(serverUrl).get().build() - client.newCall(request).execute() - return mockServer.takeRequest().getHeader(AUTHORIZATION_HEADER) - } -} \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt index 3b829a4..0ca38fe 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt @@ -21,11 +21,13 @@ import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTI import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_CAKE import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_PORRIDGE import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Test import javax.inject.Inject @HiltAndroidTest +@OptIn(ExperimentalCoroutinesApi::class) class RecipeStorageImplTest : HiltRobolectricTest() { @Inject @@ -35,7 +37,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { lateinit var appDb: AppDb @Test - fun `when saveRecipes then saves tags`(): Unit = runBlocking { + fun `when saveRecipes then saves tags`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) val actualTags = appDb.recipeDao().queryAllTags() assertThat(actualTags).containsExactly( @@ -46,7 +48,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipes then saves categories`(): Unit = runBlocking { + fun `when saveRecipes then saves categories`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) val actual = appDb.recipeDao().queryAllCategories() assertThat(actual).containsExactly( @@ -57,7 +59,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipes then saves recipes`(): Unit = runBlocking { + fun `when saveRecipes then saves recipes`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) val actualTags = appDb.recipeDao().queryAllRecipes() assertThat(actualTags).containsExactly( @@ -67,7 +69,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipes then saves category recipes`(): Unit = runBlocking { + fun `when saveRecipes then saves category recipes`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) val actual = appDb.recipeDao().queryAllCategoryRecipes() assertThat(actual).containsExactly( @@ -79,7 +81,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipes then saves tag recipes`(): Unit = runBlocking { + fun `when saveRecipes then saves tag recipes`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) val actual = appDb.recipeDao().queryAllTagRecipes() assertThat(actual).containsExactly( @@ -91,7 +93,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when refreshAll then old recipes aren't preserved`(): Unit = runBlocking { + fun `when refreshAll then old recipes aren't preserved`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) val actual = appDb.recipeDao().queryAllRecipes() @@ -99,7 +101,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when refreshAll then old category recipes aren't preserved`(): Unit = runBlocking { + fun `when refreshAll then old category recipes aren't preserved`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) val actual = appDb.recipeDao().queryAllCategoryRecipes() @@ -110,7 +112,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when refreshAll then old tag recipes aren't preserved`(): Unit = runBlocking { + fun `when refreshAll then old tag recipes aren't preserved`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) val actual = appDb.recipeDao().queryAllTagRecipes() @@ -121,7 +123,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when clearAllLocalData then recipes aren't preserved`(): Unit = runBlocking { + fun `when clearAllLocalData then recipes aren't preserved`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.clearAllLocalData() val actual = appDb.recipeDao().queryAllRecipes() @@ -129,7 +131,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when clearAllLocalData then categories aren't preserved`(): Unit = runBlocking { + fun `when clearAllLocalData then categories aren't preserved`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.clearAllLocalData() val actual = appDb.recipeDao().queryAllCategories() @@ -137,7 +139,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when clearAllLocalData then tags aren't preserved`(): Unit = runBlocking { + fun `when clearAllLocalData then tags aren't preserved`() = runTest { subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.clearAllLocalData() val actual = appDb.recipeDao().queryAllTags() @@ -145,7 +147,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipeInfo then saves recipe info`(): Unit = runBlocking { + fun `when saveRecipeInfo then saves recipe info`() = runTest { subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) subject.saveRecipeInfo(GET_CAKE_RESPONSE) val actual = appDb.recipeDao().queryFullRecipeInfo(1) @@ -153,7 +155,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipeInfo with two then saves second`(): Unit = runBlocking { + fun `when saveRecipeInfo with two then saves second`() = runTest { subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE)) subject.saveRecipeInfo(GET_CAKE_RESPONSE) subject.saveRecipeInfo(GET_PORRIDGE_RESPONSE) @@ -162,7 +164,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipeInfo secondly then overwrites ingredients`(): Unit = runBlocking { + fun `when saveRecipeInfo secondly then overwrites ingredients`() = runTest { subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) subject.saveRecipeInfo(GET_CAKE_RESPONSE) val newRecipe = GET_CAKE_RESPONSE.copy(recipeIngredients = listOf(BREAD_INGREDIENT)) @@ -173,7 +175,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { } @Test - fun `when saveRecipeInfo secondly then overwrites instructions`(): Unit = runBlocking { + fun `when saveRecipeInfo secondly then overwrites instructions`() = runTest { subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) subject.saveRecipeInfo(GET_CAKE_RESPONSE) val newRecipe = GET_CAKE_RESPONSE.copy(recipeInstructions = listOf(MIX_INSTRUCTION)) diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImplTest.kt index 8a0ada1..2f4b2a2 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImplTest.kt @@ -1,74 +1,80 @@ package gq.kirmanak.mealient.data.recipes.impl import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import gq.kirmanak.mealient.data.auth.AuthStorage -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_URL -import gq.kirmanak.mealient.test.HiltRobolectricTest -import kotlinx.coroutines.runBlocking +import gq.kirmanak.mealient.data.auth.AuthRepo +import gq.kirmanak.mealient.ui.ImageLoader +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test -import javax.inject.Inject -@HiltAndroidTest -class RecipeImageLoaderImplTest : HiltRobolectricTest() { - @Inject +@OptIn(ExperimentalCoroutinesApi::class) +class RecipeImageLoaderImplTest { lateinit var subject: RecipeImageLoaderImpl - @Inject - lateinit var authStorage: AuthStorage + @MockK + lateinit var authRepo: AuthRepo + + @MockK + lateinit var imageLoader: ImageLoader + + @Before + fun setUp() { + MockKAnnotations.init(this) + subject = RecipeImageLoaderImpl(imageLoader, authRepo) + coEvery { authRepo.getBaseUrl() } returns "https://google.com/" + } @Test - fun `when url has slash then generated doesn't add new`() = runBlocking { - authStorage.storeAuthData(TEST_TOKEN, "https://google.com/") + fun `when url has slash then generated doesn't add new`() = runTest { val actual = subject.generateImageUrl("cake") assertThat(actual).isEqualTo("https://google.com/api/media/recipes/cake/images/original.webp") } @Test - fun `when url doesn't have slash then generated adds new`() = runBlocking { - authStorage.storeAuthData(TEST_TOKEN, "https://google.com") + fun `when url doesn't have slash then generated adds new`() = runTest { val actual = subject.generateImageUrl("cake") assertThat(actual).isEqualTo("https://google.com/api/media/recipes/cake/images/original.webp") } @Test - fun `when url is null then generated is null`() = runBlocking { + fun `when url is null then generated is null`() = runTest { + coEvery { authRepo.getBaseUrl() } returns null val actual = subject.generateImageUrl("cake") assertThat(actual).isNull() } @Test - fun `when url is blank then generated is null`() = runBlocking { - authStorage.storeAuthData(TEST_TOKEN, " ") + fun `when url is blank then generated is null`() = runTest { + coEvery { authRepo.getBaseUrl() } returns " " val actual = subject.generateImageUrl("cake") assertThat(actual).isNull() } @Test - fun `when url is empty then generated is null`() = runBlocking { - authStorage.storeAuthData(TEST_TOKEN, "") + fun `when url is empty then generated is null`() = runTest { + coEvery { authRepo.getBaseUrl() } returns "" val actual = subject.generateImageUrl("cake") assertThat(actual).isNull() } @Test - fun `when slug is empty then generated is null`() = runBlocking { - authStorage.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when slug is empty then generated is null`() = runTest { val actual = subject.generateImageUrl("") assertThat(actual).isNull() } @Test - fun `when slug is blank then generated is null`() = runBlocking { - authStorage.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when slug is blank then generated is null`() = runTest { val actual = subject.generateImageUrl(" ") assertThat(actual).isNull() } @Test - fun `when slug is null then generated is null`() = runBlocking { - authStorage.storeAuthData(TEST_TOKEN, TEST_URL) + fun `when slug is null then generated is null`() = runTest { val actual = subject.generateImageUrl(null) assertThat(actual).isNull() } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImplTest.kt index 195318d..d982668 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImplTest.kt @@ -1,53 +1,65 @@ package gq.kirmanak.mealient.data.recipes.impl +import androidx.paging.InvalidatingPagingSourceFactory import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import gq.kirmanak.mealient.data.AppDb import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.db.RecipeStorage -import gq.kirmanak.mealient.test.MockServerWithAuthTest +import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity +import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY -import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_CAKE -import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueSuccessfulGetRecipe -import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueUnsuccessfulRecipeResponse -import kotlinx.coroutines.runBlocking +import gq.kirmanak.mealient.test.RecipeImplTestData.GET_CAKE_RESPONSE +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test -import javax.inject.Inject -@HiltAndroidTest -class RecipeRepoImplTest : MockServerWithAuthTest() { - @Inject - lateinit var subject: RecipeRepo +@OptIn(ExperimentalCoroutinesApi::class) +class RecipeRepoImplTest { - @Inject + @MockK(relaxUnitFun = true) lateinit var storage: RecipeStorage - @Inject - lateinit var appDb: AppDb + @MockK + lateinit var dataSource: RecipeDataSource + + @MockK + lateinit var remoteMediator: RecipesRemoteMediator + + @MockK + lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory + + lateinit var subject: RecipeRepo + + @Before + fun setUp() { + MockKAnnotations.init(this) + subject = RecipeRepoImpl(remoteMediator, storage, pagingSourceFactory, dataSource) + } @Test - fun `when loadRecipeInfo then loads recipe`(): Unit = runBlocking { - storage.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) - mockServer.enqueueSuccessfulGetRecipe() + fun `when loadRecipeInfo then loads recipe`() = runTest { + coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns GET_CAKE_RESPONSE + coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY val actual = subject.loadRecipeInfo(1, "cake") assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) } @Test - fun `when loadRecipeInfo then saves to DB`(): Unit = runBlocking { - storage.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) - mockServer.enqueueSuccessfulGetRecipe() + fun `when loadRecipeInfo then saves to DB`() = runTest { + coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns GET_CAKE_RESPONSE + coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY subject.loadRecipeInfo(1, "cake") - val actual = appDb.recipeDao().queryFullRecipeInfo(1) - assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) + coVerify { storage.saveRecipeInfo(eq(GET_CAKE_RESPONSE)) } } @Test - fun `when loadRecipeInfo with error then loads from DB`(): Unit = runBlocking { - storage.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) - mockServer.enqueueSuccessfulGetRecipe() - subject.loadRecipeInfo(1, "cake") - mockServer.enqueueUnsuccessfulRecipeResponse() + fun `when loadRecipeInfo with error then loads from DB`() = runTest { + coEvery { dataSource.requestRecipeInfo(eq("cake")) } throws RuntimeException() + coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY val actual = subject.loadRecipeInfo(1, "cake") assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt index 57732fd..18dd7aa 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt @@ -3,115 +3,137 @@ package gq.kirmanak.mealient.data.recipes.impl import androidx.paging.* import androidx.paging.LoadType.* import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import gq.kirmanak.mealient.data.AppDb +import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.Unauthorized +import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity -import gq.kirmanak.mealient.test.MockServerWithAuthTest -import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY -import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY -import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_ENTITIES -import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueSuccessfulRecipeSummaryResponse -import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueUnsuccessfulRecipeResponse -import kotlinx.coroutines.runBlocking +import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource +import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test -import javax.inject.Inject -@ExperimentalPagingApi -@HiltAndroidTest -class RecipesRemoteMediatorTest : MockServerWithAuthTest() { +@ExperimentalCoroutinesApi +@OptIn(ExperimentalPagingApi::class) +class RecipesRemoteMediatorTest { private val pagingConfig = PagingConfig( pageSize = 2, prefetchDistance = 5, enablePlaceholders = false ) - @Inject lateinit var subject: RecipesRemoteMediator - @Inject - lateinit var appDb: AppDb + @MockK(relaxUnitFun = true) + lateinit var storage: RecipeStorage + + @MockK + lateinit var dataSource: RecipeDataSource + + @MockK(relaxUnitFun = true) + lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory + + @Before + fun setUp() { + MockKAnnotations.init(this) + subject = RecipesRemoteMediator(storage, dataSource, pagingSourceFactory) + } @Test - fun `when first load with refresh successful then result success`(): Unit = runBlocking { - mockServer.enqueueSuccessfulRecipeSummaryResponse() + fun `when first load with refresh successful then result success`() = runTest { + coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES val result = subject.load(REFRESH, pagingState()) assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) } @Test - fun `when first load with refresh successful then recipes stored`(): Unit = runBlocking { - mockServer.enqueueSuccessfulRecipeSummaryResponse() - subject.load(REFRESH, pagingState()) - val actual = appDb.recipeDao().queryAllRecipes() - assertThat(actual).containsExactly( - CAKE_RECIPE_SUMMARY_ENTITY, - PORRIDGE_RECIPE_SUMMARY_ENTITY - ) + fun `when first load with refresh successful then end is reached`() = runTest { + coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES + val result = subject.load(REFRESH, pagingState()) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isTrue() } @Test - fun `when load state prepend then success`(): Unit = runBlocking { + fun `when first load with refresh successful then invalidate called`() = runTest { + coEvery { dataSource.requestRecipes(any(), any()) } returns TEST_RECIPE_SUMMARIES + subject.load(REFRESH, pagingState()) + verify { pagingSourceFactory.invalidate() } + } + + @Test + fun `when first load with refresh successful then recipes stored`() = runTest { + coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES + subject.load(REFRESH, pagingState()) + coVerify { storage.refreshAll(eq(TEST_RECIPE_SUMMARIES)) } + } + + @Test + fun `when load state prepend then success`() = runTest { val result = subject.load(PREPEND, pagingState()) assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) } @Test - fun `when load state prepend then end is reached`(): Unit = runBlocking { + fun `when load state prepend then end is reached`() = runTest { val result = subject.load(PREPEND, pagingState()) assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isTrue() } @Test - fun `when load successful then lastRequestEnd updated`(): Unit = runBlocking { - mockServer.enqueueSuccessfulRecipeSummaryResponse() + fun `when load successful then lastRequestEnd updated`() = runTest { + coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES subject.load(REFRESH, pagingState()) val actual = subject.lastRequestEnd assertThat(actual).isEqualTo(2) } @Test - fun `when load fails then lastRequestEnd still 0`(): Unit = runBlocking { - mockServer.enqueueUnsuccessfulRecipeResponse() + fun `when load fails then lastRequestEnd still 0`() = runTest { + coEvery { dataSource.requestRecipes(eq(0), eq(6)) } throws Unauthorized(RuntimeException()) subject.load(REFRESH, pagingState()) val actual = subject.lastRequestEnd assertThat(actual).isEqualTo(0) } @Test - fun `when load fails then result is error`(): Unit = runBlocking { - mockServer.enqueueUnsuccessfulRecipeResponse() + fun `when load fails then result is error`() = runTest { + coEvery { dataSource.requestRecipes(eq(0), eq(6)) } throws Unauthorized(RuntimeException()) val actual = subject.load(REFRESH, pagingState()) assertThat(actual).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java) } @Test - fun `when refresh then request params correct`(): Unit = runBlocking { - mockServer.enqueueUnsuccessfulRecipeResponse() + fun `when refresh then request params correct`() = runTest { + coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException()) subject.load(REFRESH, pagingState()) - val actual = mockServer.takeRequest().path - assertThat(actual).isEqualTo("/api/recipes/summary?start=0&limit=6") + coVerify { dataSource.requestRecipes(eq(0), eq(6)) } } @Test - fun `when append then request params correct`(): Unit = runBlocking { - mockServer.enqueueSuccessfulRecipeSummaryResponse() + fun `when append then request params correct`() = runTest { + coEvery { dataSource.requestRecipes(any(), any()) } returns TEST_RECIPE_SUMMARIES subject.load(REFRESH, pagingState()) - mockServer.takeRequest() - mockServer.enqueueSuccessfulRecipeSummaryResponse() subject.load(APPEND, pagingState()) - val actual = mockServer.takeRequest().path - assertThat(actual).isEqualTo("/api/recipes/summary?start=2&limit=2") + coVerify { + dataSource.requestRecipes(eq(0), eq(6)) + dataSource.requestRecipes(eq(2), eq(2)) + } } @Test - fun `when append fails then recipes aren't removed`(): Unit = runBlocking { - mockServer.enqueueSuccessfulRecipeSummaryResponse() + fun `when append fails then recipes aren't removed`() = runTest { + coEvery { dataSource.requestRecipes(any(), any()) } returns TEST_RECIPE_SUMMARIES subject.load(REFRESH, pagingState()) - mockServer.takeRequest() - mockServer.enqueueUnsuccessfulRecipeResponse() + coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException()) subject.load(APPEND, pagingState()) - val actual = appDb.recipeDao().queryAllRecipes() - assertThat(actual).isEqualTo(TEST_RECIPE_ENTITIES) + coVerify { + storage.refreshAll(TEST_RECIPE_SUMMARIES) + } } private fun pagingState( diff --git a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt index 26d68e8..feb4866 100644 --- a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt +++ b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt @@ -1,35 +1,10 @@ package gq.kirmanak.mealient.test -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import java.nio.charset.Charset - object AuthImplTestData { const val TEST_USERNAME = "TEST_USERNAME" const val TEST_PASSWORD = "TEST_PASSWORD" + const val TEST_BASE_URL = "https://example.com/" const val TEST_TOKEN = "TEST_TOKEN" - const val SUCCESSFUL_AUTH_RESPONSE = - "{\"access_token\":\"$TEST_TOKEN\",\"token_type\":\"TEST_TOKEN_TYPE\"}" - const val UNSUCCESSFUL_AUTH_RESPONSE = - "{\"detail\":\"Unauthorized\"}" + const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN" const val TEST_URL = "TEST_URL" - - fun RecordedRequest.body() = body.readString(Charset.defaultCharset()) - - fun MockWebServer.enqueueUnsuccessfulAuthResponse() { - val response = MockResponse() - .setBody(UNSUCCESSFUL_AUTH_RESPONSE) - .setHeader("Content-Type", "application/json") - .setResponseCode(401) - enqueue(response) - } - - fun MockWebServer.enqueueSuccessfulAuthResponse() { - val response = MockResponse() - .setBody(SUCCESSFUL_AUTH_RESPONSE) - .setHeader("Content-Type", "application/json") - .setResponseCode(200) - enqueue(response) - } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/test/MockServerTest.kt b/app/src/test/java/gq/kirmanak/mealient/test/MockServerTest.kt deleted file mode 100644 index c92c7ab..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/test/MockServerTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package gq.kirmanak.mealient.test - -import okhttp3.mockwebserver.MockWebServer -import org.junit.After -import org.junit.Before - -abstract class MockServerTest : HiltRobolectricTest() { - lateinit var mockServer: MockWebServer - lateinit var serverUrl: String - - @Before - fun startMockServer() { - mockServer = MockWebServer().apply { - start() - } - serverUrl = mockServer.url("/").toString() - } - - @After - fun stopMockServer() { - mockServer.shutdown() - } -} \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/test/MockServerWithAuthTest.kt b/app/src/test/java/gq/kirmanak/mealient/test/MockServerWithAuthTest.kt deleted file mode 100644 index 05e7532..0000000 --- a/app/src/test/java/gq/kirmanak/mealient/test/MockServerWithAuthTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package gq.kirmanak.mealient.test - -import gq.kirmanak.mealient.data.auth.AuthRepo -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD -import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME -import gq.kirmanak.mealient.test.AuthImplTestData.enqueueSuccessfulAuthResponse -import kotlinx.coroutines.runBlocking -import org.junit.Before -import javax.inject.Inject - -abstract class MockServerWithAuthTest : MockServerTest() { - @Inject - lateinit var authRepo: AuthRepo - - @Before - fun authenticate(): Unit = runBlocking { - mockServer.enqueueSuccessfulAuthResponse() - authRepo.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) - mockServer.takeRequest() - } -} \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt b/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt index 19c385d..487cb8a 100644 --- a/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt +++ b/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt @@ -11,8 +11,6 @@ import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer object RecipeImplTestData { val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse( @@ -43,37 +41,6 @@ object RecipeImplTestData { val TEST_RECIPE_SUMMARIES = listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE) - const val RECIPE_SUMMARY_SUCCESSFUL = """[ - { - "id": 1, - "name": "Cake", - "slug": "cake", - "image": "86", - "description": "A tasty cake", - "recipeCategory": ["dessert", "tasty"], - "tags": ["gluten", "allergic"], - "rating": 4, - "dateAdded": "2021-11-13", - "dateUpdated": "2021-11-13T15:30:13" - }, - { - "id": 2, - "name": "Porridge", - "slug": "porridge", - "image": "89", - "description": "A tasty porridge", - "recipeCategory": ["porridge", "tasty"], - "tags": ["gluten", "milk"], - "rating": 5, - "dateAdded": "2021-11-12", - "dateUpdated": "2021-10-13T17:35:23" - } - ]""" - - const val RECIPE_SUMMARY_UNSUCCESSFUL = """ - {"detail":"Unauthorized"} - """ - val CAKE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity( remoteId = 1, name = "Cake", @@ -96,25 +63,7 @@ object RecipeImplTestData { dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), ) - val TEST_RECIPE_ENTITIES = listOf(CAKE_RECIPE_SUMMARY_ENTITY, PORRIDGE_RECIPE_SUMMARY_ENTITY) - - fun MockWebServer.enqueueSuccessfulRecipeSummaryResponse() { - val response = MockResponse() - .setBody(RECIPE_SUMMARY_SUCCESSFUL) - .setHeader("Content-Type", "application/json") - .setResponseCode(200) - enqueue(response) - } - - fun MockWebServer.enqueueUnsuccessfulRecipeResponse() { - val response = MockResponse() - .setBody(RECIPE_SUMMARY_UNSUCCESSFUL) - .setHeader("Content-Type", "application/json") - .setResponseCode(401) - enqueue(response) - } - - val SUGAR_INGREDIENT = GetRecipeIngredientResponse( + private val SUGAR_INGREDIENT = GetRecipeIngredientResponse( title = "Sugar", note = "2 oz of white sugar", unit = "", @@ -132,7 +81,7 @@ object RecipeImplTestData { quantity = 2 ) - val MILK_INGREDIENT = GetRecipeIngredientResponse( + private val MILK_INGREDIENT = GetRecipeIngredientResponse( title = "Milk", note = "2 oz of white milk", unit = "", @@ -146,12 +95,12 @@ object RecipeImplTestData { text = "Mix the ingredients" ) - val BAKE_INSTRUCTION = GetRecipeInstructionResponse( + private val BAKE_INSTRUCTION = GetRecipeInstructionResponse( title = "Bake", text = "Bake the ingredients" ) - val BOIL_INSTRUCTION = GetRecipeInstructionResponse( + private val BOIL_INSTRUCTION = GetRecipeInstructionResponse( title = "Boil", text = "Boil the ingredients" ) @@ -172,110 +121,6 @@ object RecipeImplTestData { recipeInstructions = listOf(MIX_INSTRUCTION, BAKE_INSTRUCTION) ) - val GET_CAKE_RESPONSE_BODY = """ - { - "id": 1, - "name": "Cake", - "slug": "cake", - "image": "86", - "description": "A tasty cake", - "recipeCategory": ["dessert", "tasty"], - "tags": ["gluten", "allergic"], - "rating": 4, - "dateAdded": "2021-11-13", - "dateUpdated": "2021-11-13T15:30:13", - "recipeYield": "4 servings", - "recipeIngredient": [ - { - "title": "Sugar", - "note": "2 oz of white sugar", - "unit": null, - "food": null, - "disableAmount": true, - "quantity": 1 - }, - { - "title": "Bread", - "note": "2 oz of white bread", - "unit": null, - "food": null, - "disableAmount": false, - "quantity": 2 - } - ], - "recipeInstructions": [ - { - "title": "Mix", - "text": "Mix the ingredients" - }, - { - "title": "Bake", - "text": "Bake the ingredients" - } - ], - "nutrition": { - "calories": "100", - "fatContent": "20", - "proteinContent": "30", - "carbohydrateContent": "40", - "fiberContent": "50", - "sodiumContent": "23", - "sugarContent": "53" - }, - "tools": [], - "totalTime": "12 hours", - "prepTime": "1 hour", - "performTime": "4 hours", - "settings": { - "public": true, - "showNutrition": true, - "showAssets": true, - "landscapeView": true, - "disableComments": false, - "disableAmount": false - }, - "assets": [], - "notes": [ - { - "title": "Note title", - "text": "Note text" - }, - { - "title": "Second note", - "text": "Second note text" - } - ], - "orgURL": null, - "extras": {}, - "comments": [ - { - "text": "A new comment", - "id": 1, - "uuid": "476ebc15-f794-4eda-8380-d77bba47f839", - "recipeSlug": "test-recipe", - "dateAdded": "2021-11-19T22:13:23.862459", - "user": { - "id": 1, - "username": "kirmanak", - "admin": true - } - }, - { - "text": "A second comment", - "id": 2, - "uuid": "20498eba-9639-4acd-ba0a-4829ee06915a", - "recipeSlug": "test-recipe", - "dateAdded": "2021-11-19T22:13:29.912314", - "user": { - "id": 1, - "username": "kirmanak", - "admin": true - } - } - ] - } - """.trimIndent() - val GET_PORRIDGE_RESPONSE = GetRecipeResponse( remoteId = 2, name = "Porridge", @@ -299,19 +144,19 @@ object RecipeImplTestData { text = "Mix the ingredients", ) - val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( + private val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( localId = 2, recipeId = 1, title = "Bake", text = "Bake the ingredients", ) - val CAKE_RECIPE_ENTITY = RecipeEntity( + private val CAKE_RECIPE_ENTITY = RecipeEntity( remoteId = 1, recipeYield = "4 servings" ) - val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( + private val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( localId = 1, recipeId = 1, title = "Sugar", @@ -346,12 +191,12 @@ object RecipeImplTestData { ), ) - val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity( + private val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity( remoteId = 2, recipeYield = "3 servings" ) - val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( + private val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( localId = 4, recipeId = 2, title = "Milk", @@ -362,7 +207,7 @@ object RecipeImplTestData { quantity = 3 ) - val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( + private val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( localId = 3, recipeId = 2, title = "Sugar", @@ -373,14 +218,14 @@ object RecipeImplTestData { quantity = 1 ) - val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( + private val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( localId = 3, recipeId = 2, title = "Mix", text = "Mix the ingredients" ) - val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( + private val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( localId = 4, recipeId = 2, title = "Boil", @@ -399,12 +244,4 @@ object RecipeImplTestData { PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY, ) ) - - fun MockWebServer.enqueueSuccessfulGetRecipe() { - val response = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(GET_CAKE_RESPONSE_BODY) - enqueue(response) - } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/test/RobolectricTest.kt b/app/src/test/java/gq/kirmanak/mealient/test/RobolectricTest.kt new file mode 100644 index 0000000..5fc79bf --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/test/RobolectricTest.kt @@ -0,0 +1,10 @@ +package gq.kirmanak.mealient.test + +import android.app.Application +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class, manifest = Config.NONE) +abstract class RobolectricTest \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/test/TestExtensions.kt b/app/src/test/java/gq/kirmanak/mealient/test/TestExtensions.kt new file mode 100644 index 0000000..b80b093 --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealient/test/TestExtensions.kt @@ -0,0 +1,6 @@ +package gq.kirmanak.mealient.test + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody + +fun String.toJsonResponseBody() = toResponseBody("application/json".toMediaType()) \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModelTest.kt index cd279d5..aff879a 100644 --- a/app/src/test/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModelTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModelTest.kt @@ -3,7 +3,8 @@ package gq.kirmanak.mealient.ui.disclaimer import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage -import gq.kirmanak.mealient.test.HiltRobolectricTest +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.take import kotlinx.coroutines.test.currentTime @@ -11,18 +12,18 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit -import javax.inject.Inject -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest -class DisclaimerViewModelTest : HiltRobolectricTest() { - @Inject +class DisclaimerViewModelTest { + @MockK(relaxUnitFun = true) lateinit var storage: DisclaimerStorage lateinit var subject: DisclaimerViewModel @Before fun setUp() { + MockKAnnotations.init(this) subject = DisclaimerViewModel(storage) }