Major code refactoring
Main goals are: 1. Ability to use mocks in unit tests instead of having to setup mock web server as if it was an integration test. 2. Cache Retrofit services in memory 3. Make it easier to read 4. Use OptIn where possible instead of propagating Experimental* annotations everywhere
This commit is contained in:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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,31 +24,37 @@ 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 = runCatching {
|
val accessToken = parseToken(response)
|
||||||
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()
|
|
||||||
|
|
||||||
Timber.v("authenticate() returned: $accessToken")
|
Timber.v("authenticate() returned: $accessToken")
|
||||||
return 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(
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
@@ -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()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -2,25 +2,34 @@ package gq.kirmanak.mealient.di
|
|||||||
|
|
||||||
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.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
|
||||||
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
|
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
|
||||||
|
|
||||||
@@ -29,8 +38,4 @@ interface AuthModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
|
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
|
||||||
|
|
||||||
@Binds
|
|
||||||
@IntoSet
|
|
||||||
fun bindAuthInterceptor(authOkHttpInterceptor: AuthOkHttpInterceptor): Interceptor
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +17,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageLoaderImpl
|
|||||||
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
|
import gq.kirmanak.mealient.data.recipes.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 {
|
||||||
@@ -36,6 +36,13 @@ interface RecipeModule {
|
|||||||
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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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))
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user