Merge pull request #28 from kirmanak/refactoring

Refactoring
This commit is contained in:
Kirill Kamakin
2022-04-03 01:39:27 +05:00
committed by GitHub
42 changed files with 550 additions and 677 deletions

View File

@@ -12,7 +12,7 @@ android {
defaultConfig { defaultConfig {
applicationId "gq.kirmanak.mealient" applicationId "gq.kirmanak.mealient"
minSdk 21 minSdk 23
targetSdk 31 targetSdk 31
versionCode 5 versionCode 5
versionName "0.1.4" versionName "0.1.4"
@@ -70,6 +70,12 @@ android {
} }
} }
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
}
}
dependencies { dependencies {
// https://github.com/material-components/material-components-android // https://github.com/material-components/material-components-android
implementation "com.google.android.material:material:1.5.0" 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 platform("com.squareup.okhttp3:okhttp-bom:4.9.3")
implementation "com.squareup.okhttp3:okhttp" implementation "com.squareup.okhttp3:okhttp"
debugImplementation "com.squareup.okhttp3:logging-interceptor" debugImplementation "com.squareup.okhttp3:logging-interceptor"
testImplementation "com.squareup.okhttp3:mockwebserver"
// https://github.com/Kotlin/kotlinx.serialization/releases // https://github.com/Kotlin/kotlinx.serialization/releases
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
@@ -155,6 +160,9 @@ dependencies {
// https://mvnrepository.com/artifact/com.google.truth/truth // https://mvnrepository.com/artifact/com.google.truth/truth
testImplementation "com.google.truth:truth:1.1.3" testImplementation "com.google.truth:truth:1.1.3"
// https://mockk.io/
testImplementation "io.mockk:mockk:1.12.3"
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases // https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6" implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"

View File

@@ -7,7 +7,11 @@ interface AuthRepo {
suspend fun getBaseUrl(): String? suspend fun getBaseUrl(): String?
suspend fun getToken(): String? suspend fun requireBaseUrl(): String
suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String
fun authenticationStatuses(): Flow<Boolean> fun authenticationStatuses(): Flow<Boolean>

View File

@@ -3,13 +3,13 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AuthStorage { interface AuthStorage {
fun storeAuthData(token: String, baseUrl: String) fun storeAuthData(authHeader: String, baseUrl: String)
suspend fun getBaseUrl(): String? suspend fun getBaseUrl(): String?
suspend fun getToken(): String? suspend fun getAuthHeader(): String?
fun tokenObservable(): Flow<String?> fun authHeaderObservable(): Flow<String?>
fun clearAuthData() fun clearAuthData()
} }

View File

@@ -3,20 +3,18 @@ package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.data.impl.ErrorDetail 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.impl.util.decodeErrorBodyOrNull
import gq.kirmanak.mealient.data.network.ServiceFactory
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.create import retrofit2.Response
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ExperimentalSerializationApi
class AuthDataSourceImpl @Inject constructor( class AuthDataSourceImpl @Inject constructor(
private val retrofitBuilder: RetrofitBuilder, private val authServiceFactory: ServiceFactory<AuthService>,
private val json: Json, private val json: Json,
) : AuthDataSource { ) : AuthDataSource {
@@ -26,13 +24,31 @@ class AuthDataSourceImpl @Inject constructor(
baseUrl: String baseUrl: String
): String { ): String {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val authService = retrofitBuilder.buildRetrofit(baseUrl).create<AuthService>() val authService = authServiceFactory.provideService(baseUrl)
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken")
return accessToken
}
val accessToken = runCatching { private suspend fun sendRequest(
val response = authService.getToken(username, password) authService: AuthService,
Timber.d("authenticate() response is $response") username: String,
if (response.isSuccessful) { password: String
checkNotNull(response.body()).accessToken ): Response<GetTokenResponse> = 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<GetTokenResponse>
): String = if (response.isSuccessful) {
response.body()?.accessToken ?: throw NotMealie(NullPointerException("Body is null"))
} else { } else {
val cause = HttpException(response) val cause = HttpException(response)
val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json) val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json)
@@ -41,16 +57,4 @@ class AuthDataSourceImpl @Inject constructor(
else -> NotMealie(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()
Timber.v("authenticate() returned: $accessToken")
return accessToken
}
} }

View File

@@ -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)
}
}

View File

@@ -14,8 +14,9 @@ import javax.inject.Inject
class AuthRepoImpl @Inject constructor( class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource, private val dataSource: AuthDataSource,
private val storage: AuthStorage private val storage: AuthStorage,
) : AuthRepo { ) : AuthRepo {
override suspend fun authenticate( override suspend fun authenticate(
username: String, username: String,
password: String, password: String,
@@ -24,20 +25,23 @@ class AuthRepoImpl @Inject constructor(
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
val url = parseBaseUrl(baseUrl) val url = parseBaseUrl(baseUrl)
val accessToken = dataSource.authenticate(username, password, url) val accessToken = dataSource.authenticate(username, password, url)
Timber.d("authenticate result is $accessToken") Timber.d("authenticate result is \"$accessToken\"")
storage.storeAuthData(accessToken, url) storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken), url)
} }
override suspend fun getBaseUrl(): String? = storage.getBaseUrl() override suspend fun getBaseUrl(): String? = storage.getBaseUrl()
override suspend fun getToken(): String? { override suspend fun requireBaseUrl(): String =
Timber.v("getToken() called") checkNotNull(getBaseUrl()) { "Base URL is null when it was required" }
return storage.getToken()
} 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<Boolean> { override fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called") Timber.v("authenticationStatuses() called")
return storage.tokenObservable().map { it != null } return storage.authHeaderObservable().map { it != null }
} }
override fun logout() { override fun logout() {
@@ -57,4 +61,7 @@ class AuthRepoImpl @Inject constructor(
throw MalformedUrl(e) throw MalformedUrl(e)
} }
companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
}
} }

View File

@@ -11,9 +11,5 @@ interface AuthService {
suspend fun getToken( suspend fun getToken(
@Field("username") username: String, @Field("username") username: String,
@Field("password") password: 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<GetTokenResponse> ): Response<GetTokenResponse>
} }

View File

@@ -1,29 +1,28 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.impl.util.changesFlow import gq.kirmanak.mealient.data.impl.util.changesFlow
import gq.kirmanak.mealient.data.impl.util.getStringOrNull import gq.kirmanak.mealient.data.impl.util.getStringOrNull
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject 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" private const val BASE_URL_KEY = "BASE_URL"
@ExperimentalCoroutinesApi
class AuthStorageImpl @Inject constructor( class AuthStorageImpl @Inject constructor(
private val sharedPreferences: SharedPreferences private val sharedPreferences: SharedPreferences
) : AuthStorage { ) : AuthStorage {
override fun storeAuthData(token: String, baseUrl: String) { override fun storeAuthData(authHeader: String, baseUrl: String) {
Timber.v("storeAuthData() called with: token = $token, baseUrl = $baseUrl") Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl")
sharedPreferences.edit() sharedPreferences.edit {
.putString(TOKEN_KEY, token) putString(AUTH_HEADER_KEY, authHeader)
.putString(BASE_URL_KEY, baseUrl) putString(BASE_URL_KEY, baseUrl)
.apply() }
} }
override suspend fun getBaseUrl(): String? { override suspend fun getBaseUrl(): String? {
@@ -32,23 +31,23 @@ class AuthStorageImpl @Inject constructor(
return baseUrl return baseUrl
} }
override suspend fun getToken(): String? { override suspend fun getAuthHeader(): String? {
Timber.v("getToken() called") Timber.v("getAuthHeader() called")
val token = sharedPreferences.getStringOrNull(TOKEN_KEY) val token = sharedPreferences.getStringOrNull(AUTH_HEADER_KEY)
Timber.d("getToken: token is $token") Timber.d("getAuthHeader: header is \"$token\"")
return token return token
} }
override fun tokenObservable(): Flow<String?> { override fun authHeaderObservable(): Flow<String?> {
Timber.v("tokenObservable() called") Timber.v("authHeaderObservable() called")
return sharedPreferences.changesFlow().map { it.first.getStringOrNull(TOKEN_KEY) } return sharedPreferences.changesFlow().map { it.first.getStringOrNull(AUTH_HEADER_KEY) }
} }
override fun clearAuthData() { override fun clearAuthData() {
Timber.v("clearAuthData() called") Timber.v("clearAuthData() called")
sharedPreferences.edit() sharedPreferences.edit {
.remove(TOKEN_KEY) remove(AUTH_HEADER_KEY)
.remove(BASE_URL_KEY) remove(BASE_URL_KEY)
.apply() }
} }
} }

View File

@@ -4,7 +4,4 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class GetTokenResponse( data class GetTokenResponse(@SerialName("access_token") val accessToken: String)
@SerialName("access_token") val accessToken: String,
@SerialName("token_type") val tokenType: String
)

View File

@@ -9,11 +9,12 @@ import retrofit2.Retrofit
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ExperimentalSerializationApi
class RetrofitBuilder @Inject constructor( class RetrofitBuilder @Inject constructor(
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val json: Json private val json: Json
) { ) {
@OptIn(ExperimentalSerializationApi::class)
fun buildRetrofit(baseUrl: String): Retrofit { fun buildRetrofit(baseUrl: String): Retrofit {
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl") Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
val contentType = "application/json".toMediaType() val contentType = "application/json".toMediaType()

View File

@@ -7,11 +7,10 @@ import retrofit2.Response
import timber.log.Timber import timber.log.Timber
import java.io.InputStream import java.io.InputStream
@ExperimentalSerializationApi
inline fun <T, reified R> Response<T>.decodeErrorBodyOrNull(json: Json): R? = inline fun <T, reified R> Response<T>.decodeErrorBodyOrNull(json: Json): R? =
errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it) } errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it) }
@ExperimentalSerializationApi @OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream): T? = inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream): T? =
runCatching { decodeFromStream<T>(stream) } runCatching { decodeFromStream<T>(stream) }
.onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") } .onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") }

View File

@@ -17,7 +17,7 @@ suspend fun SharedPreferences.getStringOrNull(key: String) =
suspend fun SharedPreferences.getBooleanOrFalse(key: String) = suspend fun SharedPreferences.getBooleanOrFalse(key: String) =
withContext(Dispatchers.IO) { getBoolean(key, false) } withContext(Dispatchers.IO) { getBoolean(key, false) }
@ExperimentalCoroutinesApi @OptIn(ExperimentalCoroutinesApi::class)
fun SharedPreferences.changesFlow(): Flow<Pair<SharedPreferences, String?>> = callbackFlow { fun SharedPreferences.changesFlow(): Flow<Pair<SharedPreferences, String?>> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
Timber.v("watchChanges: listener called with key $key") Timber.v("watchChanges: listener called with key $key")

View File

@@ -0,0 +1,29 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import timber.log.Timber
inline fun <reified T> RetrofitBuilder.createServiceFactory() =
RetrofitServiceFactory(T::class.java, this)
class RetrofitServiceFactory<T>(
private val serviceClass: Class<T>,
private val retrofitBuilder: RetrofitBuilder,
) : ServiceFactory<T> {
private val cache: MutableMap<String, T> = 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
}
}
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.data.network
interface ServiceFactory<T> {
fun provideService(baseUrl: String): T
}

View File

@@ -12,7 +12,7 @@ import kotlinx.coroutines.CancellationException
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ExperimentalPagingApi @OptIn(ExperimentalPagingApi::class)
class RecipeRepoImpl @Inject constructor( class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator, private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage, private val storage: RecipeStorage,

View File

@@ -11,7 +11,7 @@ import kotlinx.coroutines.CancellationException
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ExperimentalPagingApi @OptIn(ExperimentalPagingApi::class)
class RecipesRemoteMediator @Inject constructor( class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage, private val storage: RecipeStorage,
private val network: RecipeDataSource, private val network: RecipeDataSource,

View File

@@ -1,50 +1,35 @@
package gq.kirmanak.mealient.data.recipes.network package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.auth.AuthRepo 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.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import kotlinx.serialization.ExperimentalSerializationApi
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ExperimentalSerializationApi
class RecipeDataSourceImpl @Inject constructor( class RecipeDataSourceImpl @Inject constructor(
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
private val retrofitBuilder: RetrofitBuilder private val recipeServiceFactory: ServiceFactory<RecipeService>,
) : RecipeDataSource { ) : RecipeDataSource {
private var _recipeService: RecipeService? = null
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> { override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
Timber.v("requestRecipes() called with: start = $start, limit = $limit") Timber.v("requestRecipes() called with: start = $start, limit = $limit")
val service: RecipeService = getRecipeService() val recipeSummary = getRecipeService().getRecipeSummary(start, limit, getToken())
val recipeSummary = service.getRecipeSummary(start, limit)
Timber.v("requestRecipes() returned: $recipeSummary") Timber.v("requestRecipes() returned: $recipeSummary")
return recipeSummary return recipeSummary
} }
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse { override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
Timber.v("requestRecipeInfo() called with: slug = $slug") Timber.v("requestRecipeInfo() called with: slug = $slug")
val service: RecipeService = getRecipeService() val recipeInfo = getRecipeService().getRecipe(slug, getToken())
val recipeInfo = service.getRecipe(slug)
Timber.v("requestRecipeInfo() returned: $recipeInfo") Timber.v("requestRecipeInfo() returned: $recipeInfo")
return recipeInfo return recipeInfo
} }
private suspend fun getRecipeService(): RecipeService { private suspend fun getRecipeService(): RecipeService {
Timber.v("getRecipeService() called") Timber.v("getRecipeService() called")
val cachedService: RecipeService? = _recipeService return recipeServiceFactory.provideService(authRepo.requireBaseUrl())
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
} }
private suspend fun getToken(): String = authRepo.requireAuthHeader()
} }

View File

@@ -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.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@@ -10,11 +11,13 @@ interface RecipeService {
@GET("/api/recipes/summary") @GET("/api/recipes/summary")
suspend fun getRecipeSummary( suspend fun getRecipeSummary(
@Query("start") start: Int, @Query("start") start: Int,
@Query("limit") limit: Int @Query("limit") limit: Int,
@Header("Authorization") authHeader: String?,
): List<GetRecipeSummaryResponse> ): List<GetRecipeSummaryResponse>
@GET("/api/recipes/{recipe_slug}") @GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe( suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String @Path("recipe_slug") recipeSlug: String,
@Header("Authorization") authHeader: String?,
): GetRecipeResponse ): GetRecipeResponse
} }

View File

@@ -1,36 +1,47 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient.di
import android.accounts.AccountManager
import android.content.Context
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl 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.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthService
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import kotlinx.coroutines.ExperimentalCoroutinesApi import gq.kirmanak.mealient.data.impl.RetrofitBuilder
import kotlinx.serialization.ExperimentalSerializationApi import gq.kirmanak.mealient.data.network.ServiceFactory
import okhttp3.Interceptor import gq.kirmanak.mealient.data.network.createServiceFactory
import javax.inject.Singleton
@ExperimentalCoroutinesApi
@ExperimentalSerializationApi
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface AuthModule { interface AuthModule {
companion object {
@Provides
@Singleton
fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<AuthService> {
return retrofitBuilder.createServiceFactory()
}
}
@Binds @Binds
@Singleton
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
@Binds @Binds
@Singleton
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
@Binds @Binds
@Singleton
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
@Binds
@IntoSet
fun bindAuthInterceptor(authOkHttpInterceptor: AuthOkHttpInterceptor): Interceptor
} }

View File

@@ -6,10 +6,13 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorageImpl import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorageImpl
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface DisclaimerModule { interface DisclaimerModule {
@Binds @Binds
@Singleton
fun provideDisclaimerStorage(disclaimerStorageImpl: DisclaimerStorageImpl): DisclaimerStorage fun provideDisclaimerStorage(disclaimerStorageImpl: DisclaimerStorageImpl): DisclaimerStorage
} }

View File

@@ -1,12 +1,14 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient.di
import androidx.paging.ExperimentalPagingApi
import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.InvalidatingPagingSourceFactory
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent 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.RecipeImageLoader
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
@@ -15,27 +17,37 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageLoaderImpl
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import kotlinx.serialization.ExperimentalSerializationApi import gq.kirmanak.mealient.data.recipes.network.RecipeService
import javax.inject.Singleton import javax.inject.Singleton
@ExperimentalPagingApi
@ExperimentalSerializationApi
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface RecipeModule { interface RecipeModule {
@Binds @Binds
@Singleton
fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource
@Binds @Binds
@Singleton
fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage
@Binds @Binds
@Singleton
fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
@Binds @Binds
@Singleton
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
companion object { companion object {
@Provides
@Singleton
fun provideRecipeServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<RecipeService> {
return retrofitBuilder.createServiceFactory()
}
@Provides @Provides
@Singleton @Singleton
fun provideRecipePagingSourceFactory( fun provideRecipePagingSourceFactory(

View File

@@ -14,7 +14,9 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface UiModule { interface UiModule {
@Binds @Binds
@Singleton
fun bindImageLoader(imageLoaderGlide: ImageLoaderPicasso): ImageLoader fun bindImageLoader(imageLoaderGlide: ImageLoaderPicasso): ImageLoader
companion object { companion object {

View File

@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import timber.log.Timber import timber.log.Timber
@ExperimentalCoroutinesApi @OptIn(ExperimentalCoroutinesApi::class)
fun SwipeRefreshLayout.refreshesLiveData(): LiveData<Unit> { fun SwipeRefreshLayout.refreshesLiveData(): LiveData<Unit> {
val callbackFlow: Flow<Unit> = callbackFlow { val callbackFlow: Flow<Unit> = callbackFlow {
val listener = SwipeRefreshLayout.OnRefreshListener { val listener = SwipeRefreshLayout.OnRefreshListener {
@@ -63,7 +63,7 @@ fun AppCompatActivity.setActionBarVisibility(isVisible: Boolean) {
?: Timber.w("setActionBarVisibility: action bar is null") ?: Timber.w("setActionBarVisibility: action bar is null")
} }
@ExperimentalCoroutinesApi @OptIn(ExperimentalCoroutinesApi::class)
fun TextView.textChangesFlow(): Flow<CharSequence?> = callbackFlow { fun TextView.textChangesFlow(): Flow<CharSequence?> = callbackFlow {
Timber.v("textChangesFlow() called") Timber.v("textChangesFlow() called")
val textWatcher = doAfterTextChanged { val textWatcher = doAfterTextChanged {

View File

@@ -16,12 +16,10 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.ui.textChangesFlow import gq.kirmanak.mealient.ui.textChangesFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import timber.log.Timber import timber.log.Timber
@ExperimentalCoroutinesApi
@AndroidEntryPoint @AndroidEntryPoint
class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind) private val binding by viewBinding(FragmentAuthenticationBinding::bind)

View File

@@ -15,11 +15,9 @@ import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import gq.kirmanak.mealient.ui.refreshesLiveData import gq.kirmanak.mealient.ui.refreshesLiveData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import timber.log.Timber import timber.log.Timber
@ExperimentalCoroutinesApi
@AndroidEntryPoint @AndroidEntryPoint
class RecipesFragment : Fragment(R.layout.fragment_recipes) { class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val binding by viewBinding(FragmentRecipesBinding::bind) private val binding by viewBinding(FragmentRecipesBinding::bind)

View File

@@ -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()
}

View File

@@ -1,84 +1,86 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat 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.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_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.AuthImplTestData.body import gq.kirmanak.mealient.test.toJsonResponseBody
import gq.kirmanak.mealient.test.AuthImplTestData.enqueueSuccessfulAuthResponse import io.mockk.MockKAnnotations
import gq.kirmanak.mealient.test.AuthImplTestData.enqueueUnsuccessfulAuthResponse import io.mockk.coEvery
import gq.kirmanak.mealient.test.MockServerTest import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import kotlinx.serialization.ExperimentalSerializationApi import org.junit.Before
import okhttp3.mockwebserver.MockResponse
import org.junit.Test 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<AuthService>
@ExperimentalSerializationApi
@ExperimentalCoroutinesApi
@HiltAndroidTest
class AuthDataSourceImplTest : MockServerTest() {
@Inject
lateinit var subject: AuthDataSourceImpl lateinit var subject: AuthDataSourceImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthDataSourceImpl(authServiceFactory, AppModule.createJson())
}
@Test @Test
fun `when authentication is successful then token is correct`() = runBlocking { fun `when authentication is successful then token is correct`() = runTest {
mockServer.enqueueSuccessfulAuthResponse() val token = authenticate(Response.success(GetTokenResponse(TEST_TOKEN)))
val token = subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
assertThat(token).isEqualTo(TEST_TOKEN) assertThat(token).isEqualTo(TEST_TOKEN)
} }
@Test(expected = Unauthorized::class) @Test(expected = Unauthorized::class)
fun `when authentication isn't successful then throws`(): Unit = runBlocking { fun `when authenticate receives 401 and Unauthorized then throws Unauthorized`() = runTest {
mockServer.enqueueUnsuccessfulAuthResponse() val body = "{\"detail\":\"Unauthorized\"}".toJsonResponseBody()
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) authenticate(Response.error(401, body))
}
@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")
} }
@Test(expected = NotMealie::class) @Test(expected = NotMealie::class)
fun `when authenticate but response empty then NotMealie`(): Unit = runBlocking { fun `when authenticate receives 401 but not Unauthorized then throws NotMealie`() = runTest {
val response = MockResponse().setResponseCode(200) val body = "{\"detail\":\"Something\"}".toJsonResponseBody()
mockServer.enqueue(response) authenticate(Response.error(401, body))
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
} }
@Test(expected = NotMealie::class) @Test(expected = NotMealie::class)
fun `when authenticate but response invalid then NotMealie`(): Unit = runBlocking { fun `when authenticate receives 404 and empty body then throws NotMealie`() = runTest {
val response = MockResponse() authenticate(Response.error(401, "".toJsonResponseBody()))
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("{\"test\": \"test\"")
mockServer.enqueue(response)
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
} }
@Test(expected = NotMealie::class) @Test(expected = NotMealie::class)
fun `when authenticate but response not found then NotMealie`(): Unit = runBlocking { fun `when authenticate receives 200 and null then throws NotMealie`() = runTest {
val response = MockResponse().setResponseCode(404) authenticate(Response.success<GetTokenResponse>(200, null))
mockServer.enqueue(response)
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
} }
@Test(expected = NoServerConnection::class) @Test(expected = NoServerConnection::class)
fun `when authenticate but host not found then NoServerConnection`(): Unit = runBlocking { fun `when authenticate and getToken throws then throws NoServerConnection`() = runTest {
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, "http://test") setUpAuthServiceFactory()
coEvery { authService.getToken(any(), any()) } throws IOException("Server not found")
callAuthenticate()
}
private suspend fun authenticate(response: Response<GetTokenResponse>): 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
} }
} }

View File

@@ -1,55 +1,74 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat 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.MalformedUrl
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.Unauthorized 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_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.AuthImplTestData.enqueueSuccessfulAuthResponse import gq.kirmanak.mealient.test.RobolectricTest
import gq.kirmanak.mealient.test.AuthImplTestData.enqueueUnsuccessfulAuthResponse import io.mockk.MockKAnnotations
import gq.kirmanak.mealient.test.MockServerTest 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.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 org.junit.Test
import javax.inject.Inject
@HiltAndroidTest @OptIn(ExperimentalCoroutinesApi::class)
class AuthRepoImplTest : MockServerTest() { class AuthRepoImplTest : RobolectricTest() {
@Inject
@MockK
lateinit var dataSource: AuthDataSource
@MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage
lateinit var subject: AuthRepoImpl lateinit var subject: AuthRepoImpl
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthRepoImpl(dataSource, storage)
}
@Test @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() assertThat(subject.authenticationStatuses().first()).isFalse()
} }
@Test @Test
fun `when authenticated then first auth status is true`() = runBlocking { fun `when authenticated then first auth status is true`() = runTest {
mockServer.enqueueSuccessfulAuthResponse() coEvery { storage.authHeaderObservable() } returns flowOf(TEST_AUTH_HEADER)
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
assertThat(subject.authenticationStatuses().first()).isTrue() assertThat(subject.authenticationStatuses().first()).isTrue()
} }
@Test(expected = Unauthorized::class) @Test(expected = Unauthorized::class)
fun `when authentication fails then authenticate throws`() = runBlocking { fun `when authentication fails then authenticate throws`() = runTest {
mockServer.enqueueUnsuccessfulAuthResponse() coEvery {
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) 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 @Test
fun `when authenticated then getToken returns token`() = runBlocking { fun `when authenticated then getToken returns token`() = runTest {
mockServer.enqueueSuccessfulAuthResponse() coEvery { storage.getAuthHeader() } returns TEST_AUTH_HEADER
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER)
assertThat(subject.getToken()).isEqualTo(TEST_TOKEN)
} }
@Test @Test
fun `when authenticated then getBaseUrl returns url`() = runBlocking { fun `when authenticated then getBaseUrl returns url`() = runTest {
mockServer.enqueueSuccessfulAuthResponse() coEvery { storage.getBaseUrl() } returns TEST_BASE_URL
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) assertThat(subject.getBaseUrl()).isEqualTo(TEST_BASE_URL)
assertThat(subject.getBaseUrl()).isEqualTo(serverUrl)
} }
@Test(expected = MalformedUrl::class) @Test(expected = MalformedUrl::class)
@@ -76,4 +95,19 @@ class AuthRepoImplTest : MockServerTest() {
fun `when baseUrl is correct then doesn't change`() { fun `when baseUrl is correct then doesn't change`() {
assertThat(subject.parseBaseUrl("https://google.com/")).isEqualTo("https://google.com/") 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() }
}
} }

View File

@@ -2,76 +2,77 @@ package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest 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.AuthImplTestData.TEST_URL
import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@ExperimentalCoroutinesApi @OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class AuthStorageImplTest : HiltRobolectricTest() { class AuthStorageImplTest : HiltRobolectricTest() {
@Inject @Inject
lateinit var subject: AuthStorageImpl lateinit var subject: AuthStorageImpl
@Test @Test
fun `when storing auth data then doesn't throw`() = runBlocking { fun `when storing auth data then doesn't throw`() = runTest {
subject.storeAuthData(TEST_TOKEN, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
} }
@Test @Test
fun `when reading url after storing data then returns url`() = runBlocking { fun `when reading url after storing data then returns url`() = runTest {
subject.storeAuthData(TEST_TOKEN, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
assertThat(subject.getBaseUrl()).isEqualTo(TEST_URL) assertThat(subject.getBaseUrl()).isEqualTo(TEST_URL)
} }
@Test @Test
fun `when reading token after storing data then returns token`() = runBlocking { fun `when reading token after storing data then returns token`() = runTest {
subject.storeAuthData(TEST_TOKEN, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
assertThat(subject.getToken()).isEqualTo(TEST_TOKEN) assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER)
} }
@Test @Test
fun `when reading token without storing data then returns null`() = runBlocking { fun `when reading token without storing data then returns null`() = runTest {
assertThat(subject.getToken()).isNull() assertThat(subject.getAuthHeader()).isNull()
} }
@Test @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() assertThat(subject.getBaseUrl()).isNull()
} }
@Test @Test
fun `when didn't store auth data then first token is null`() = runBlocking { fun `when didn't store auth data then first token is null`() = runTest {
assertThat(subject.tokenObservable().first()).isNull() assertThat(subject.authHeaderObservable().first()).isNull()
} }
@Test @Test
fun `when stored auth data then first token is correct`() = runBlocking { fun `when stored auth data then first token is correct`() = runTest {
subject.storeAuthData(TEST_TOKEN, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
assertThat(subject.tokenObservable().first()).isEqualTo(TEST_TOKEN) assertThat(subject.authHeaderObservable().first()).isEqualTo(TEST_AUTH_HEADER)
} }
@Test @Test
fun `when clearAuthData then first token is null`() = runBlocking { fun `when clearAuthData then first token is null`() = runTest {
subject.storeAuthData(TEST_TOKEN, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
subject.clearAuthData() subject.clearAuthData()
assertThat(subject.tokenObservable().first()).isNull() assertThat(subject.authHeaderObservable().first()).isNull()
} }
@Test @Test
fun `when clearAuthData then getToken returns null`() = runBlocking { fun `when clearAuthData then getToken returns null`() = runTest {
subject.storeAuthData(TEST_TOKEN, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
subject.clearAuthData() subject.clearAuthData()
assertThat(subject.getToken()).isNull() assertThat(subject.getAuthHeader()).isNull()
} }
@Test @Test
fun `when clearAuthData then getBaseUrl returns null`() = runBlocking { fun `when clearAuthData then getBaseUrl returns null`() = runTest {
subject.storeAuthData(TEST_TOKEN, TEST_URL) subject.storeAuthData(TEST_AUTH_HEADER, TEST_URL)
subject.clearAuthData() subject.clearAuthData()
assertThat(subject.getBaseUrl()).isNull() assertThat(subject.getBaseUrl()).isNull()
} }

View File

@@ -3,22 +3,24 @@ package gq.kirmanak.mealient.data.disclaimer
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class DisclaimerStorageImplTest : HiltRobolectricTest() { class DisclaimerStorageImplTest : HiltRobolectricTest() {
@Inject @Inject
lateinit var subject: DisclaimerStorageImpl lateinit var subject: DisclaimerStorageImpl
@Test @Test
fun `when isDisclaimerAccepted initially then false`(): Unit = runBlocking { fun `when isDisclaimerAccepted initially then false`() = runTest {
assertThat(subject.isDisclaimerAccepted()).isFalse() assertThat(subject.isDisclaimerAccepted()).isFalse()
} }
@Test @Test
fun `when isDisclaimerAccepted after accept then true`(): Unit = runBlocking { fun `when isDisclaimerAccepted after accept then true`() = runTest {
subject.acceptDisclaimer() subject.acceptDisclaimer()
assertThat(subject.isDisclaimerAccepted()).isTrue() assertThat(subject.isDisclaimerAccepted()).isTrue()
} }

View File

@@ -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)
}
}

View File

@@ -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_CAKE
import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_PORRIDGE import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_PORRIDGE
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES 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 org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidTest @HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeStorageImplTest : HiltRobolectricTest() { class RecipeStorageImplTest : HiltRobolectricTest() {
@Inject @Inject
@@ -35,7 +37,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
lateinit var appDb: AppDb lateinit var appDb: AppDb
@Test @Test
fun `when saveRecipes then saves tags`(): Unit = runBlocking { fun `when saveRecipes then saves tags`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actualTags = appDb.recipeDao().queryAllTags() val actualTags = appDb.recipeDao().queryAllTags()
assertThat(actualTags).containsExactly( assertThat(actualTags).containsExactly(
@@ -46,7 +48,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @Test
fun `when saveRecipes then saves categories`(): Unit = runBlocking { fun `when saveRecipes then saves categories`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actual = appDb.recipeDao().queryAllCategories() val actual = appDb.recipeDao().queryAllCategories()
assertThat(actual).containsExactly( assertThat(actual).containsExactly(
@@ -57,7 +59,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @Test
fun `when saveRecipes then saves recipes`(): Unit = runBlocking { fun `when saveRecipes then saves recipes`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actualTags = appDb.recipeDao().queryAllRecipes() val actualTags = appDb.recipeDao().queryAllRecipes()
assertThat(actualTags).containsExactly( assertThat(actualTags).containsExactly(
@@ -67,7 +69,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @Test
fun `when saveRecipes then saves category recipes`(): Unit = runBlocking { fun `when saveRecipes then saves category recipes`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actual = appDb.recipeDao().queryAllCategoryRecipes() val actual = appDb.recipeDao().queryAllCategoryRecipes()
assertThat(actual).containsExactly( assertThat(actual).containsExactly(
@@ -79,7 +81,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @Test
fun `when saveRecipes then saves tag recipes`(): Unit = runBlocking { fun `when saveRecipes then saves tag recipes`() = runTest {
subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actual = appDb.recipeDao().queryAllTagRecipes() val actual = appDb.recipeDao().queryAllTagRecipes()
assertThat(actual).containsExactly( assertThat(actual).containsExactly(
@@ -91,7 +93,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
val actual = appDb.recipeDao().queryAllRecipes() val actual = appDb.recipeDao().queryAllRecipes()
@@ -99,7 +101,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
val actual = appDb.recipeDao().queryAllCategoryRecipes() val actual = appDb.recipeDao().queryAllCategoryRecipes()
@@ -110,7 +112,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
val actual = appDb.recipeDao().queryAllTagRecipes() val actual = appDb.recipeDao().queryAllTagRecipes()
@@ -121,7 +123,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.clearAllLocalData() subject.clearAllLocalData()
val actual = appDb.recipeDao().queryAllRecipes() val actual = appDb.recipeDao().queryAllRecipes()
@@ -129,7 +131,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.clearAllLocalData() subject.clearAllLocalData()
val actual = appDb.recipeDao().queryAllCategories() val actual = appDb.recipeDao().queryAllCategories()
@@ -137,7 +139,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(TEST_RECIPE_SUMMARIES)
subject.clearAllLocalData() subject.clearAllLocalData()
val actual = appDb.recipeDao().queryAllTags() val actual = appDb.recipeDao().queryAllTags()
@@ -145,7 +147,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(listOf(RECIPE_SUMMARY_CAKE))
subject.saveRecipeInfo(GET_CAKE_RESPONSE) subject.saveRecipeInfo(GET_CAKE_RESPONSE)
val actual = appDb.recipeDao().queryFullRecipeInfo(1) val actual = appDb.recipeDao().queryFullRecipeInfo(1)
@@ -153,7 +155,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE))
subject.saveRecipeInfo(GET_CAKE_RESPONSE) subject.saveRecipeInfo(GET_CAKE_RESPONSE)
subject.saveRecipeInfo(GET_PORRIDGE_RESPONSE) subject.saveRecipeInfo(GET_PORRIDGE_RESPONSE)
@@ -162,7 +164,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(listOf(RECIPE_SUMMARY_CAKE))
subject.saveRecipeInfo(GET_CAKE_RESPONSE) subject.saveRecipeInfo(GET_CAKE_RESPONSE)
val newRecipe = GET_CAKE_RESPONSE.copy(recipeIngredients = listOf(BREAD_INGREDIENT)) val newRecipe = GET_CAKE_RESPONSE.copy(recipeIngredients = listOf(BREAD_INGREDIENT))
@@ -173,7 +175,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
} }
@Test @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.saveRecipes(listOf(RECIPE_SUMMARY_CAKE))
subject.saveRecipeInfo(GET_CAKE_RESPONSE) subject.saveRecipeInfo(GET_CAKE_RESPONSE)
val newRecipe = GET_CAKE_RESPONSE.copy(recipeInstructions = listOf(MIX_INSTRUCTION)) val newRecipe = GET_CAKE_RESPONSE.copy(recipeInstructions = listOf(MIX_INSTRUCTION))

View File

@@ -1,74 +1,80 @@
package gq.kirmanak.mealient.data.recipes.impl package gq.kirmanak.mealient.data.recipes.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.ui.ImageLoader
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN import io.mockk.MockKAnnotations
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_URL import io.mockk.coEvery
import gq.kirmanak.mealient.test.HiltRobolectricTest import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest @OptIn(ExperimentalCoroutinesApi::class)
class RecipeImageLoaderImplTest : HiltRobolectricTest() { class RecipeImageLoaderImplTest {
@Inject
lateinit var subject: RecipeImageLoaderImpl lateinit var subject: RecipeImageLoaderImpl
@Inject @MockK
lateinit var authStorage: AuthStorage 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 @Test
fun `when url has slash then generated doesn't add new`() = runBlocking { fun `when url has slash then generated doesn't add new`() = runTest {
authStorage.storeAuthData(TEST_TOKEN, "https://google.com/")
val actual = subject.generateImageUrl("cake") val actual = subject.generateImageUrl("cake")
assertThat(actual).isEqualTo("https://google.com/api/media/recipes/cake/images/original.webp") assertThat(actual).isEqualTo("https://google.com/api/media/recipes/cake/images/original.webp")
} }
@Test @Test
fun `when url doesn't have slash then generated adds new`() = runBlocking { fun `when url doesn't have slash then generated adds new`() = runTest {
authStorage.storeAuthData(TEST_TOKEN, "https://google.com")
val actual = subject.generateImageUrl("cake") val actual = subject.generateImageUrl("cake")
assertThat(actual).isEqualTo("https://google.com/api/media/recipes/cake/images/original.webp") assertThat(actual).isEqualTo("https://google.com/api/media/recipes/cake/images/original.webp")
} }
@Test @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") val actual = subject.generateImageUrl("cake")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@Test @Test
fun `when url is blank then generated is null`() = runBlocking { fun `when url is blank then generated is null`() = runTest {
authStorage.storeAuthData(TEST_TOKEN, " ") coEvery { authRepo.getBaseUrl() } returns " "
val actual = subject.generateImageUrl("cake") val actual = subject.generateImageUrl("cake")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@Test @Test
fun `when url is empty then generated is null`() = runBlocking { fun `when url is empty then generated is null`() = runTest {
authStorage.storeAuthData(TEST_TOKEN, "") coEvery { authRepo.getBaseUrl() } returns ""
val actual = subject.generateImageUrl("cake") val actual = subject.generateImageUrl("cake")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@Test @Test
fun `when slug is empty then generated is null`() = runBlocking { fun `when slug is empty then generated is null`() = runTest {
authStorage.storeAuthData(TEST_TOKEN, TEST_URL)
val actual = subject.generateImageUrl("") val actual = subject.generateImageUrl("")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@Test @Test
fun `when slug is blank then generated is null`() = runBlocking { fun `when slug is blank then generated is null`() = runTest {
authStorage.storeAuthData(TEST_TOKEN, TEST_URL)
val actual = subject.generateImageUrl(" ") val actual = subject.generateImageUrl(" ")
assertThat(actual).isNull() assertThat(actual).isNull()
} }
@Test @Test
fun `when slug is null then generated is null`() = runBlocking { fun `when slug is null then generated is null`() = runTest {
authStorage.storeAuthData(TEST_TOKEN, TEST_URL)
val actual = subject.generateImageUrl(null) val actual = subject.generateImageUrl(null)
assertThat(actual).isNull() assertThat(actual).isNull()
} }

View File

@@ -1,53 +1,65 @@
package gq.kirmanak.mealient.data.recipes.impl package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.InvalidatingPagingSourceFactory
import com.google.common.truth.Truth.assertThat 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.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage 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.FULL_CAKE_INFO_ENTITY
import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_CAKE import gq.kirmanak.mealient.test.RecipeImplTestData.GET_CAKE_RESPONSE
import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueSuccessfulGetRecipe import io.mockk.MockKAnnotations
import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueUnsuccessfulRecipeResponse import io.mockk.coEvery
import kotlinx.coroutines.runBlocking 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 org.junit.Test
import javax.inject.Inject
@HiltAndroidTest @OptIn(ExperimentalCoroutinesApi::class)
class RecipeRepoImplTest : MockServerWithAuthTest() { class RecipeRepoImplTest {
@Inject
lateinit var subject: RecipeRepo
@Inject @MockK(relaxUnitFun = true)
lateinit var storage: RecipeStorage lateinit var storage: RecipeStorage
@Inject @MockK
lateinit var appDb: AppDb lateinit var dataSource: RecipeDataSource
@MockK
lateinit var remoteMediator: RecipesRemoteMediator
@MockK
lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>
lateinit var subject: RecipeRepo
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = RecipeRepoImpl(remoteMediator, storage, pagingSourceFactory, dataSource)
}
@Test @Test
fun `when loadRecipeInfo then loads recipe`(): Unit = runBlocking { fun `when loadRecipeInfo then loads recipe`() = runTest {
storage.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns GET_CAKE_RESPONSE
mockServer.enqueueSuccessfulGetRecipe() coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY
val actual = subject.loadRecipeInfo(1, "cake") val actual = subject.loadRecipeInfo(1, "cake")
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
} }
@Test @Test
fun `when loadRecipeInfo then saves to DB`(): Unit = runBlocking { fun `when loadRecipeInfo then saves to DB`() = runTest {
storage.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns GET_CAKE_RESPONSE
mockServer.enqueueSuccessfulGetRecipe() coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY
subject.loadRecipeInfo(1, "cake") subject.loadRecipeInfo(1, "cake")
val actual = appDb.recipeDao().queryFullRecipeInfo(1) coVerify { storage.saveRecipeInfo(eq(GET_CAKE_RESPONSE)) }
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
} }
@Test @Test
fun `when loadRecipeInfo with error then loads from DB`(): Unit = runBlocking { fun `when loadRecipeInfo with error then loads from DB`() = runTest {
storage.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) coEvery { dataSource.requestRecipeInfo(eq("cake")) } throws RuntimeException()
mockServer.enqueueSuccessfulGetRecipe() coEvery { storage.queryRecipeInfo(eq(1)) } returns FULL_CAKE_INFO_ENTITY
subject.loadRecipeInfo(1, "cake")
mockServer.enqueueUnsuccessfulRecipeResponse()
val actual = subject.loadRecipeInfo(1, "cake") val actual = subject.loadRecipeInfo(1, "cake")
assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY)
} }

View File

@@ -3,115 +3,137 @@ package gq.kirmanak.mealient.data.recipes.impl
import androidx.paging.* import androidx.paging.*
import androidx.paging.LoadType.* import androidx.paging.LoadType.*
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.Unauthorized
import gq.kirmanak.mealient.data.AppDb import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.test.MockServerWithAuthTest import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES
import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY import io.mockk.MockKAnnotations
import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_ENTITIES import io.mockk.coEvery
import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueSuccessfulRecipeSummaryResponse import io.mockk.coVerify
import gq.kirmanak.mealient.test.RecipeImplTestData.enqueueUnsuccessfulRecipeResponse import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.runBlocking import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test import org.junit.Test
import javax.inject.Inject
@ExperimentalPagingApi @ExperimentalCoroutinesApi
@HiltAndroidTest @OptIn(ExperimentalPagingApi::class)
class RecipesRemoteMediatorTest : MockServerWithAuthTest() { class RecipesRemoteMediatorTest {
private val pagingConfig = PagingConfig( private val pagingConfig = PagingConfig(
pageSize = 2, pageSize = 2,
prefetchDistance = 5, prefetchDistance = 5,
enablePlaceholders = false enablePlaceholders = false
) )
@Inject
lateinit var subject: RecipesRemoteMediator lateinit var subject: RecipesRemoteMediator
@Inject @MockK(relaxUnitFun = true)
lateinit var appDb: AppDb lateinit var storage: RecipeStorage
@MockK
lateinit var dataSource: RecipeDataSource
@MockK(relaxUnitFun = true)
lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = RecipesRemoteMediator(storage, dataSource, pagingSourceFactory)
}
@Test @Test
fun `when first load with refresh successful then result success`(): Unit = runBlocking { fun `when first load with refresh successful then result success`() = runTest {
mockServer.enqueueSuccessfulRecipeSummaryResponse() coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES
val result = subject.load(REFRESH, pagingState()) val result = subject.load(REFRESH, pagingState())
assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java)
} }
@Test @Test
fun `when first load with refresh successful then recipes stored`(): Unit = runBlocking { fun `when first load with refresh successful then end is reached`() = runTest {
mockServer.enqueueSuccessfulRecipeSummaryResponse() coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES
subject.load(REFRESH, pagingState()) val result = subject.load(REFRESH, pagingState())
val actual = appDb.recipeDao().queryAllRecipes() assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isTrue()
assertThat(actual).containsExactly(
CAKE_RECIPE_SUMMARY_ENTITY,
PORRIDGE_RECIPE_SUMMARY_ENTITY
)
} }
@Test @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()) val result = subject.load(PREPEND, pagingState())
assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java)
} }
@Test @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()) val result = subject.load(PREPEND, pagingState())
assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isTrue() assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isTrue()
} }
@Test @Test
fun `when load successful then lastRequestEnd updated`(): Unit = runBlocking { fun `when load successful then lastRequestEnd updated`() = runTest {
mockServer.enqueueSuccessfulRecipeSummaryResponse() coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES
subject.load(REFRESH, pagingState()) subject.load(REFRESH, pagingState())
val actual = subject.lastRequestEnd val actual = subject.lastRequestEnd
assertThat(actual).isEqualTo(2) assertThat(actual).isEqualTo(2)
} }
@Test @Test
fun `when load fails then lastRequestEnd still 0`(): Unit = runBlocking { fun `when load fails then lastRequestEnd still 0`() = runTest {
mockServer.enqueueUnsuccessfulRecipeResponse() coEvery { dataSource.requestRecipes(eq(0), eq(6)) } throws Unauthorized(RuntimeException())
subject.load(REFRESH, pagingState()) subject.load(REFRESH, pagingState())
val actual = subject.lastRequestEnd val actual = subject.lastRequestEnd
assertThat(actual).isEqualTo(0) assertThat(actual).isEqualTo(0)
} }
@Test @Test
fun `when load fails then result is error`(): Unit = runBlocking { fun `when load fails then result is error`() = runTest {
mockServer.enqueueUnsuccessfulRecipeResponse() coEvery { dataSource.requestRecipes(eq(0), eq(6)) } throws Unauthorized(RuntimeException())
val actual = subject.load(REFRESH, pagingState()) val actual = subject.load(REFRESH, pagingState())
assertThat(actual).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java) assertThat(actual).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java)
} }
@Test @Test
fun `when refresh then request params correct`(): Unit = runBlocking { fun `when refresh then request params correct`() = runTest {
mockServer.enqueueUnsuccessfulRecipeResponse() coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException())
subject.load(REFRESH, pagingState()) subject.load(REFRESH, pagingState())
val actual = mockServer.takeRequest().path coVerify { dataSource.requestRecipes(eq(0), eq(6)) }
assertThat(actual).isEqualTo("/api/recipes/summary?start=0&limit=6")
} }
@Test @Test
fun `when append then request params correct`(): Unit = runBlocking { fun `when append then request params correct`() = runTest {
mockServer.enqueueSuccessfulRecipeSummaryResponse() coEvery { dataSource.requestRecipes(any(), any()) } returns TEST_RECIPE_SUMMARIES
subject.load(REFRESH, pagingState()) subject.load(REFRESH, pagingState())
mockServer.takeRequest()
mockServer.enqueueSuccessfulRecipeSummaryResponse()
subject.load(APPEND, pagingState()) subject.load(APPEND, pagingState())
val actual = mockServer.takeRequest().path coVerify {
assertThat(actual).isEqualTo("/api/recipes/summary?start=2&limit=2") dataSource.requestRecipes(eq(0), eq(6))
dataSource.requestRecipes(eq(2), eq(2))
}
} }
@Test @Test
fun `when append fails then recipes aren't removed`(): Unit = runBlocking { fun `when append fails then recipes aren't removed`() = runTest {
mockServer.enqueueSuccessfulRecipeSummaryResponse() coEvery { dataSource.requestRecipes(any(), any()) } returns TEST_RECIPE_SUMMARIES
subject.load(REFRESH, pagingState()) subject.load(REFRESH, pagingState())
mockServer.takeRequest() coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException())
mockServer.enqueueUnsuccessfulRecipeResponse()
subject.load(APPEND, pagingState()) subject.load(APPEND, pagingState())
val actual = appDb.recipeDao().queryAllRecipes() coVerify {
assertThat(actual).isEqualTo(TEST_RECIPE_ENTITIES) storage.refreshAll(TEST_RECIPE_SUMMARIES)
}
} }
private fun pagingState( private fun pagingState(

View File

@@ -1,35 +1,10 @@
package gq.kirmanak.mealient.test package gq.kirmanak.mealient.test
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import java.nio.charset.Charset
object AuthImplTestData { object AuthImplTestData {
const val TEST_USERNAME = "TEST_USERNAME" const val TEST_USERNAME = "TEST_USERNAME"
const val TEST_PASSWORD = "TEST_PASSWORD" const val TEST_PASSWORD = "TEST_PASSWORD"
const val TEST_BASE_URL = "https://example.com/"
const val TEST_TOKEN = "TEST_TOKEN" const val TEST_TOKEN = "TEST_TOKEN"
const val SUCCESSFUL_AUTH_RESPONSE = const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN"
"{\"access_token\":\"$TEST_TOKEN\",\"token_type\":\"TEST_TOKEN_TYPE\"}"
const val UNSUCCESSFUL_AUTH_RESPONSE =
"{\"detail\":\"Unauthorized\"}"
const val TEST_URL = "TEST_URL" 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)
}
} }

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -11,8 +11,6 @@ import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
object RecipeImplTestData { object RecipeImplTestData {
val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse( val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse(
@@ -43,37 +41,6 @@ object RecipeImplTestData {
val TEST_RECIPE_SUMMARIES = listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE) 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( val CAKE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity(
remoteId = 1, remoteId = 1,
name = "Cake", name = "Cake",
@@ -96,25 +63,7 @@ object RecipeImplTestData {
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
) )
val TEST_RECIPE_ENTITIES = listOf(CAKE_RECIPE_SUMMARY_ENTITY, PORRIDGE_RECIPE_SUMMARY_ENTITY) private val SUGAR_INGREDIENT = GetRecipeIngredientResponse(
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(
title = "Sugar", title = "Sugar",
note = "2 oz of white sugar", note = "2 oz of white sugar",
unit = "", unit = "",
@@ -132,7 +81,7 @@ object RecipeImplTestData {
quantity = 2 quantity = 2
) )
val MILK_INGREDIENT = GetRecipeIngredientResponse( private val MILK_INGREDIENT = GetRecipeIngredientResponse(
title = "Milk", title = "Milk",
note = "2 oz of white milk", note = "2 oz of white milk",
unit = "", unit = "",
@@ -146,12 +95,12 @@ object RecipeImplTestData {
text = "Mix the ingredients" text = "Mix the ingredients"
) )
val BAKE_INSTRUCTION = GetRecipeInstructionResponse( private val BAKE_INSTRUCTION = GetRecipeInstructionResponse(
title = "Bake", title = "Bake",
text = "Bake the ingredients" text = "Bake the ingredients"
) )
val BOIL_INSTRUCTION = GetRecipeInstructionResponse( private val BOIL_INSTRUCTION = GetRecipeInstructionResponse(
title = "Boil", title = "Boil",
text = "Boil the ingredients" text = "Boil the ingredients"
) )
@@ -172,110 +121,6 @@ object RecipeImplTestData {
recipeInstructions = listOf(MIX_INSTRUCTION, BAKE_INSTRUCTION) 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( val GET_PORRIDGE_RESPONSE = GetRecipeResponse(
remoteId = 2, remoteId = 2,
name = "Porridge", name = "Porridge",
@@ -299,19 +144,19 @@ object RecipeImplTestData {
text = "Mix the ingredients", text = "Mix the ingredients",
) )
val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( private val BAKE_CAKE_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
localId = 2, localId = 2,
recipeId = 1, recipeId = 1,
title = "Bake", title = "Bake",
text = "Bake the ingredients", text = "Bake the ingredients",
) )
val CAKE_RECIPE_ENTITY = RecipeEntity( private val CAKE_RECIPE_ENTITY = RecipeEntity(
remoteId = 1, remoteId = 1,
recipeYield = "4 servings" recipeYield = "4 servings"
) )
val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( private val CAKE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
localId = 1, localId = 1,
recipeId = 1, recipeId = 1,
title = "Sugar", title = "Sugar",
@@ -346,12 +191,12 @@ object RecipeImplTestData {
), ),
) )
val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity( private val PORRIDGE_RECIPE_ENTITY_FULL = RecipeEntity(
remoteId = 2, remoteId = 2,
recipeYield = "3 servings" recipeYield = "3 servings"
) )
val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( private val PORRIDGE_MILK_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
localId = 4, localId = 4,
recipeId = 2, recipeId = 2,
title = "Milk", title = "Milk",
@@ -362,7 +207,7 @@ object RecipeImplTestData {
quantity = 3 quantity = 3
) )
val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity( private val PORRIDGE_SUGAR_RECIPE_INGREDIENT_ENTITY = RecipeIngredientEntity(
localId = 3, localId = 3,
recipeId = 2, recipeId = 2,
title = "Sugar", title = "Sugar",
@@ -373,14 +218,14 @@ object RecipeImplTestData {
quantity = 1 quantity = 1
) )
val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( private val PORRIDGE_MIX_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
localId = 3, localId = 3,
recipeId = 2, recipeId = 2,
title = "Mix", title = "Mix",
text = "Mix the ingredients" text = "Mix the ingredients"
) )
val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity( private val PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY = RecipeInstructionEntity(
localId = 4, localId = 4,
recipeId = 2, recipeId = 2,
title = "Boil", title = "Boil",
@@ -399,12 +244,4 @@ object RecipeImplTestData {
PORRIDGE_BOIL_RECIPE_INSTRUCTION_ENTITY, 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)
}
} }

View File

@@ -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

View File

@@ -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())

View File

@@ -3,7 +3,8 @@ package gq.kirmanak.mealient.ui.disclaimer
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.currentTime
@@ -11,18 +12,18 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ExperimentalCoroutinesApi @OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest @HiltAndroidTest
class DisclaimerViewModelTest : HiltRobolectricTest() { class DisclaimerViewModelTest {
@Inject @MockK(relaxUnitFun = true)
lateinit var storage: DisclaimerStorage lateinit var storage: DisclaimerStorage
lateinit var subject: DisclaimerViewModel lateinit var subject: DisclaimerViewModel
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this)
subject = DisclaimerViewModel(storage) subject = DisclaimerViewModel(storage)
} }