@@ -12,7 +12,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "gq.kirmanak.mealient"
|
||||
minSdk 21
|
||||
minSdk 23
|
||||
targetSdk 31
|
||||
versionCode 5
|
||||
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 {
|
||||
// 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"
|
||||
|
||||
|
||||
@@ -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<Boolean>
|
||||
|
||||
|
||||
@@ -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<String?>
|
||||
fun authHeaderObservable(): Flow<String?>
|
||||
|
||||
fun clearAuthData()
|
||||
}
|
||||
@@ -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<AuthService>,
|
||||
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<AuthService>()
|
||||
|
||||
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<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 {
|
||||
val cause = HttpException(response)
|
||||
val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json)
|
||||
throw when (errorDetail?.detail) {
|
||||
"Unauthorized" -> Unauthorized(cause)
|
||||
else -> NotMealie(cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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<GetTokenResponse>
|
||||
}
|
||||
@@ -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<String?> {
|
||||
Timber.v("tokenObservable() called")
|
||||
return sharedPreferences.changesFlow().map { it.first.getStringOrNull(TOKEN_KEY) }
|
||||
override fun authHeaderObservable(): Flow<String?> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
data class GetTokenResponse(@SerialName("access_token") val accessToken: String)
|
||||
@@ -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()
|
||||
|
||||
@@ -7,11 +7,10 @@ import retrofit2.Response
|
||||
import timber.log.Timber
|
||||
import java.io.InputStream
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
inline fun <T, reified R> Response<T>.decodeErrorBodyOrNull(json: Json): R? =
|
||||
errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it) }
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream): T? =
|
||||
runCatching { decodeFromStream<T>(stream) }
|
||||
.onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") }
|
||||
|
||||
@@ -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<Pair<SharedPreferences, String?>> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
|
||||
Timber.v("watchChanges: listener called with key $key")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package gq.kirmanak.mealient.data.network
|
||||
|
||||
interface ServiceFactory<T> {
|
||||
|
||||
fun provideService(baseUrl: String): T
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RecipeService>,
|
||||
) : RecipeDataSource {
|
||||
private var _recipeService: RecipeService? = null
|
||||
|
||||
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getToken(): String = authRepo.requireAuthHeader()
|
||||
}
|
||||
|
||||
@@ -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<GetRecipeSummaryResponse>
|
||||
|
||||
@GET("/api/recipes/{recipe_slug}")
|
||||
suspend fun getRecipe(
|
||||
@Path("recipe_slug") recipeSlug: String
|
||||
@Path("recipe_slug") recipeSlug: String,
|
||||
@Header("Authorization") authHeader: String?,
|
||||
): GetRecipeResponse
|
||||
}
|
||||
@@ -1,36 +1,47 @@
|
||||
package gq.kirmanak.mealient.di
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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<AuthService> {
|
||||
return retrofitBuilder.createServiceFactory()
|
||||
}
|
||||
}
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
|
||||
|
||||
@Binds
|
||||
@IntoSet
|
||||
fun bindAuthInterceptor(authOkHttpInterceptor: AuthOkHttpInterceptor): Interceptor
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
|
||||
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorageImpl
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DisclaimerModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideDisclaimerStorage(disclaimerStorageImpl: DisclaimerStorageImpl): DisclaimerStorage
|
||||
}
|
||||
@@ -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,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.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 {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecipeServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<RecipeService> {
|
||||
return retrofitBuilder.createServiceFactory()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecipePagingSourceFactory(
|
||||
|
||||
@@ -14,7 +14,9 @@ import javax.inject.Singleton
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface UiModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindImageLoader(imageLoaderGlide: ImageLoaderPicasso): ImageLoader
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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<Unit> {
|
||||
val callbackFlow: Flow<Unit> = 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<CharSequence?> = callbackFlow {
|
||||
Timber.v("textChangesFlow() called")
|
||||
val textWatcher = doAfterTextChanged {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<AuthService>
|
||||
|
||||
@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<GetTokenResponse>(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<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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Int, RecipeSummaryEntity>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Int, RecipeSummaryEntity>
|
||||
|
||||
@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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user