@@ -12,7 +12,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "gq.kirmanak.mealient"
|
applicationId "gq.kirmanak.mealient"
|
||||||
minSdk 21
|
minSdk 23
|
||||||
targetSdk 31
|
targetSdk 31
|
||||||
versionCode 5
|
versionCode 5
|
||||||
versionName "0.1.4"
|
versionName "0.1.4"
|
||||||
@@ -70,6 +70,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||||
|
kotlinOptions {
|
||||||
|
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// https://github.com/material-components/material-components-android
|
// https://github.com/material-components/material-components-android
|
||||||
implementation "com.google.android.material:material:1.5.0"
|
implementation "com.google.android.material:material:1.5.0"
|
||||||
@@ -110,7 +116,6 @@ dependencies {
|
|||||||
implementation platform("com.squareup.okhttp3:okhttp-bom:4.9.3")
|
implementation platform("com.squareup.okhttp3:okhttp-bom:4.9.3")
|
||||||
implementation "com.squareup.okhttp3:okhttp"
|
implementation "com.squareup.okhttp3:okhttp"
|
||||||
debugImplementation "com.squareup.okhttp3:logging-interceptor"
|
debugImplementation "com.squareup.okhttp3:logging-interceptor"
|
||||||
testImplementation "com.squareup.okhttp3:mockwebserver"
|
|
||||||
|
|
||||||
// https://github.com/Kotlin/kotlinx.serialization/releases
|
// https://github.com/Kotlin/kotlinx.serialization/releases
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2"
|
||||||
@@ -155,6 +160,9 @@ dependencies {
|
|||||||
// https://mvnrepository.com/artifact/com.google.truth/truth
|
// https://mvnrepository.com/artifact/com.google.truth/truth
|
||||||
testImplementation "com.google.truth:truth:1.1.3"
|
testImplementation "com.google.truth:truth:1.1.3"
|
||||||
|
|
||||||
|
// https://mockk.io/
|
||||||
|
testImplementation "io.mockk:mockk:1.12.3"
|
||||||
|
|
||||||
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
|
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
|
||||||
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"
|
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,47 @@
|
|||||||
package gq.kirmanak.mealient.di
|
package gq.kirmanak.mealient.di
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import dagger.multibindings.IntoSet
|
|
||||||
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
||||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||||
import gq.kirmanak.mealient.data.auth.AuthStorage
|
import gq.kirmanak.mealient.data.auth.AuthStorage
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
|
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthOkHttpInterceptor
|
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
|
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
|
||||||
|
import gq.kirmanak.mealient.data.auth.impl.AuthService
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
|
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
import okhttp3.Interceptor
|
import gq.kirmanak.mealient.data.network.createServiceFactory
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
|
||||||
@ExperimentalSerializationApi
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface AuthModule {
|
interface AuthModule {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<AuthService> {
|
||||||
|
return retrofitBuilder.createServiceFactory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
|
fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
|
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
|
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
|
||||||
|
|
||||||
@Binds
|
|
||||||
@IntoSet
|
|
||||||
fun bindAuthInterceptor(authOkHttpInterceptor: AuthOkHttpInterceptor): Interceptor
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import dagger.hilt.InstallIn
|
|||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
|
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
|
||||||
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorageImpl
|
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorageImpl
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface DisclaimerModule {
|
interface DisclaimerModule {
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun provideDisclaimerStorage(disclaimerStorageImpl: DisclaimerStorageImpl): DisclaimerStorage
|
fun provideDisclaimerStorage(disclaimerStorageImpl: DisclaimerStorageImpl): DisclaimerStorage
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package gq.kirmanak.mealient.di
|
package gq.kirmanak.mealient.di
|
||||||
|
|
||||||
import androidx.paging.ExperimentalPagingApi
|
|
||||||
import androidx.paging.InvalidatingPagingSourceFactory
|
import androidx.paging.InvalidatingPagingSourceFactory
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import gq.kirmanak.mealient.data.impl.RetrofitBuilder
|
||||||
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
|
import gq.kirmanak.mealient.data.network.createServiceFactory
|
||||||
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
|
import gq.kirmanak.mealient.data.recipes.RecipeImageLoader
|
||||||
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
||||||
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
||||||
@@ -15,27 +17,37 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeImageLoaderImpl
|
|||||||
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
|
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
|
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import gq.kirmanak.mealient.data.recipes.network.RecipeService
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@ExperimentalPagingApi
|
|
||||||
@ExperimentalSerializationApi
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface RecipeModule {
|
interface RecipeModule {
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource
|
fun provideRecipeDataSource(recipeDataSourceImpl: RecipeDataSourceImpl): RecipeDataSource
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage
|
fun provideRecipeStorage(recipeStorageImpl: RecipeStorageImpl): RecipeStorage
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
|
fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
|
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRecipeServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory<RecipeService> {
|
||||||
|
return retrofitBuilder.createServiceFactory()
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRecipePagingSourceFactory(
|
fun provideRecipePagingSourceFactory(
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface UiModule {
|
interface UiModule {
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
@Singleton
|
||||||
fun bindImageLoader(imageLoaderGlide: ImageLoaderPicasso): ImageLoader
|
fun bindImageLoader(imageLoaderGlide: ImageLoaderPicasso): ImageLoader
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -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