Simplify network layer (#175)

* Use Ktor for network requests

* Remove V0 version

* Remove Retrofit dependency

* Fix url

* Update versions of dependencies

* Revert kotlinx-datetime

Due to https://github.com/Kotlin/kotlinx-datetime/issues/304

* Rename leftovers

* Remove OkHttp

* Remove unused manifest

* Remove unused Hilt module

* Fix building empty image URLs

* Use OkHttp as engine for Ktor

* Reduce visibility of internal classes

* Fix first set up test

* Store only auth token, not header

* Remove UnitInfo/FoodInfo/VersionInfo/NewShoppingListItemInfo

* Remove RecipeSummaryInfo and ShoppingListsInfo

* Remove FullShoppingListInfo

* Remove ParseRecipeURLInfo

* Remove FullRecipeInfo

* Sign out if access token does not work

* Rename getVersionInfo method

* Update version name
This commit is contained in:
Kirill Kamakin
2023-11-05 15:01:19 +01:00
committed by GitHub
parent 888783bf14
commit 5ed1acb678
144 changed files with 1216 additions and 2796 deletions

View File

@@ -16,8 +16,8 @@ plugins {
android {
defaultConfig {
applicationId = "gq.kirmanak.mealient"
versionCode = 30
versionName = "0.4.1"
versionCode = 31
versionName = "0.4.2"
testInstrumentationRunner = "gq.kirmanak.mealient.MealientTestRunner"
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
@@ -55,7 +55,7 @@ android {
namespace = "gq.kirmanak.mealient"
packagingOptions {
packaging {
resources.excludes += "DebugProbesKt.bin"
}

View File

@@ -5,11 +5,15 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
val versionV1Response = MockResponse().setResponseCode(200).setBody(
"""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}"""
)
val versionV1Response = MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"production":true,"version":"v1.0.0beta-5","demoStatus":false,"allowSignup":true}""")
val notFoundResponse = MockResponse().setResponseCode(404).setBody("""{"detail":"Not found"}"""")
val notFoundResponse = MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"Not found"}"""")
fun MockWebServer.dispatch(block: (String, RecordedRequest) -> MockResponse) {
dispatcher = object : Dispatcher() {

View File

@@ -9,7 +9,7 @@ interface AuthRepo : ShoppingListsAuthRepo {
suspend fun authenticate(email: String, password: String)
suspend fun getAuthHeader(): String?
suspend fun getAuthToken(): String?
suspend fun logout()
}

View File

@@ -4,9 +4,9 @@ import kotlinx.coroutines.flow.Flow
interface AuthStorage {
val authHeaderFlow: Flow<String?>
val authTokenFlow: Flow<String?>
suspend fun setAuthHeader(authHeader: String?)
suspend fun setAuthToken(authToken: String?)
suspend fun getAuthHeader(): String?
suspend fun getAuthToken(): String?
}

View File

@@ -1,32 +1,19 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenRequestV1
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.CreateApiTokenRequest
import javax.inject.Inject
class AuthDataSourceImpl @Inject constructor(
private val serverInfoRepo: ServerInfoRepo,
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
private val dataSource: MealieDataSource,
) : AuthDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion()
override suspend fun authenticate(
username: String,
password: String,
): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.authenticate(username, password)
ServerVersion.V1 -> v1Source.authenticate(username, password)
override suspend fun authenticate(username: String, password: String): String {
return dataSource.authenticate(username, password)
}
override suspend fun createApiToken(name: String): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.createApiToken(CreateApiTokenRequestV0(name)).token
ServerVersion.V1 -> v1Source.createApiToken(CreateApiTokenRequestV1(name)).token
override suspend fun createApiToken(name: String): String {
return dataSource.createApiToken(CreateApiTokenRequest(name)).token
}
}

View File

@@ -4,6 +4,7 @@ import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.datasource.SignOutHandler
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -13,28 +14,29 @@ class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
private val logger: Logger,
private val signOutHandler: SignOutHandler,
) : AuthRepo, AuthenticationProvider {
override val isAuthorizedFlow: Flow<Boolean>
get() = authStorage.authHeaderFlow.map { it != null }
get() = authStorage.authTokenFlow.map { it != null }
override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" }
val token = authDataSource.authenticate(email, password)
authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(token))
authStorage.setAuthToken(token)
val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setAuthHeader(AUTH_HEADER_FORMAT.format(apiToken))
authStorage.setAuthToken(apiToken)
}
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
override suspend fun getAuthToken(): String? = authStorage.getAuthToken()
override suspend fun logout() {
logger.v { "logout() called" }
authStorage.setAuthHeader(null)
authStorage.setAuthToken(null)
signOutHandler.signOut()
}
companion object {
private const val AUTH_HEADER_FORMAT = "Bearer %s"
private const val API_TOKEN_NAME = "Mealient"
}
}

View File

@@ -22,15 +22,15 @@ class AuthStorageImpl @Inject constructor(
private val logger: Logger,
) : AuthStorage {
override val authHeaderFlow: Flow<String?>
override val authTokenFlow: Flow<String?>
get() = sharedPreferences
.prefsChangeFlow(logger) { getString(AUTH_HEADER_KEY, null) }
.prefsChangeFlow(logger) { getString(AUTH_TOKEN_KEY, null) }
.distinctUntilChanged()
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
override suspend fun setAuthHeader(authHeader: String?) = putString(AUTH_HEADER_KEY, authHeader)
override suspend fun setAuthToken(authToken: String?) = putString(AUTH_TOKEN_KEY, authToken)
override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY)
override suspend fun getAuthToken(): String? = getString(AUTH_TOKEN_KEY)
private suspend fun putString(
key: String,
@@ -48,6 +48,6 @@ class AuthStorageImpl @Inject constructor(
companion object {
@VisibleForTesting
const val AUTH_HEADER_KEY = "authHeader"
const val AUTH_TOKEN_KEY = "authToken"
}
}

View File

@@ -1,16 +1,10 @@
package gq.kirmanak.mealient.data.baseurl
import kotlinx.coroutines.flow.Flow
interface ServerInfoRepo {
suspend fun getUrl(): String?
suspend fun getVersion(): ServerVersion
suspend fun tryBaseURL(baseURL: String): Result<Unit>
fun versionUpdates(): Flow<ServerVersion>
}

View File

@@ -1,11 +1,7 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class ServerInfoRepoImpl @Inject constructor(
@@ -20,47 +16,18 @@ class ServerInfoRepoImpl @Inject constructor(
return result
}
override suspend fun getVersion(): ServerVersion {
var version = serverInfoStorage.getServerVersion()
val serverVersion = if (version == null) {
logger.d { "getVersion: version is null, requesting" }
version = versionDataSource.getVersionInfo().version
val result = determineServerVersion(version)
serverInfoStorage.storeServerVersion(version)
result
} else {
determineServerVersion(version)
}
logger.v { "getVersion() returned: $serverVersion from $version" }
return serverVersion
}
private fun determineServerVersion(version: String): ServerVersion = when {
version.startsWith("v0") -> ServerVersion.V0
version.startsWith("v1") -> ServerVersion.V1
else -> {
logger.w { "Unknown server version: $version" }
ServerVersion.V1
}
}
override suspend fun tryBaseURL(baseURL: String): Result<Unit> {
val oldVersion = serverInfoStorage.getServerVersion()
val oldBaseUrl = serverInfoStorage.getBaseURL()
serverInfoStorage.storeBaseURL(baseURL)
return runCatchingExceptCancel {
serverInfoStorage.storeBaseURL(baseURL)
val version = versionDataSource.getVersionInfo().version
serverInfoStorage.storeServerVersion(version)
}.onFailure {
serverInfoStorage.storeBaseURL(oldBaseUrl, oldVersion)
try {
versionDataSource.requestVersion()
} catch (e: Throwable) {
serverInfoStorage.storeBaseURL(oldBaseUrl)
return Result.failure(e)
}
return Result.success(Unit)
}
override fun versionUpdates(): Flow<ServerVersion> {
return serverInfoStorage
.serverVersionUpdates()
.filterNotNull()
.map { determineServerVersion(it) }
}
}

View File

@@ -1,18 +1,9 @@
package gq.kirmanak.mealient.data.baseurl
import kotlinx.coroutines.flow.Flow
interface ServerInfoStorage {
suspend fun getBaseURL(): String?
suspend fun storeBaseURL(baseURL: String)
suspend fun storeBaseURL(baseURL: String?)
suspend fun storeBaseURL(baseURL: String?, version: String?)
suspend fun storeServerVersion(version: String)
suspend fun getServerVersion(): String?
fun serverVersionUpdates(): Flow<String?>
}

View File

@@ -1,3 +0,0 @@
package gq.kirmanak.mealient.data.baseurl
enum class ServerVersion { V0, V1 }

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource.models.VersionResponse
interface VersionDataSource {
suspend fun getVersionInfo(): VersionInfo
suspend fun requestVersion(): VersionResponse
}

View File

@@ -1,37 +1,14 @@
package gq.kirmanak.mealient.data.baseurl
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.model_mapper.ModelMapper
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.VersionResponse
import javax.inject.Inject
class VersionDataSourceImpl @Inject constructor(
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
private val modelMapper: ModelMapper,
private val dataSource: MealieDataSource,
) : VersionDataSource {
override suspend fun getVersionInfo(): VersionInfo {
val responses = coroutineScope {
val v0Deferred = async {
runCatchingExceptCancel { modelMapper.toVersionInfo(v0Source.getVersionInfo()) }
}
val v1Deferred = async {
runCatchingExceptCancel { modelMapper.toVersionInfo(v1Source.getVersionInfo()) }
}
listOf(v0Deferred, v1Deferred).awaitAll()
}
val firstSuccess = responses.firstNotNullOfOrNull { it.getOrNull() }
if (firstSuccess == null) {
throw responses.firstNotNullOf { it.exceptionOrNull() }
} else {
return firstSuccess
}
override suspend fun requestVersion(): VersionResponse {
return dataSource.getVersionInfo()
}
}

View File

@@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.baseurl.impl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class ServerInfoStorageImpl @Inject constructor(
@@ -13,45 +12,16 @@ class ServerInfoStorageImpl @Inject constructor(
private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey
private val serverVersionKey: Preferences.Key<String>
get() = preferencesStorage.serverVersionKey
override suspend fun getBaseURL(): String? = getValue(baseUrlKey)
override suspend fun storeBaseURL(baseURL: String) {
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
preferencesStorage.removeValues(serverVersionKey)
}
override suspend fun storeBaseURL(baseURL: String?, version: String?) {
when {
baseURL == null -> {
preferencesStorage.removeValues(baseUrlKey, serverVersionKey)
}
version != null -> {
preferencesStorage.storeValues(
Pair(baseUrlKey, baseURL), Pair(serverVersionKey, version)
)
}
else -> {
preferencesStorage.removeValues(serverVersionKey)
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
}
override suspend fun storeBaseURL(baseURL: String?) {
if (baseURL == null) {
preferencesStorage.removeValues(baseUrlKey)
} else {
preferencesStorage.storeValues(Pair(baseUrlKey, baseURL))
}
}
override suspend fun getServerVersion(): String? = getValue(serverVersionKey)
override suspend fun storeServerVersion(version: String) {
preferencesStorage.storeValues(Pair(serverVersionKey, version))
}
override fun serverVersionUpdates(): Flow<String?> {
return preferencesStorage.valueUpdates(serverVersionKey)
}
private suspend fun <T> getValue(key: Preferences.Key<T>): T? = preferencesStorage.getValue(key)
}

View File

@@ -0,0 +1,32 @@
package gq.kirmanak.mealient.data.migration
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import gq.kirmanak.mealient.datastore.DataStoreModule
import javax.inject.Inject
import javax.inject.Named
class From30MigrationExecutor @Inject constructor(
@Named(DataStoreModule.ENCRYPTED) private val sharedPreferences: SharedPreferences,
private val dataStore: DataStore<Preferences>,
) : MigrationExecutor {
override val migratingFrom: Int = 30
override suspend fun executeMigration() {
dataStore.edit { prefs ->
prefs -= stringPreferencesKey("serverVersion")
}
val authHeader = sharedPreferences.getString("authHeader", null)
if (authHeader != null) {
sharedPreferences.edit {
val authToken = authHeader.removePrefix("Bearer ")
putString("authToken", authToken)
}
}
}
}

View File

@@ -1,92 +1,58 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.add.AddRecipeDataSource
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.share.ParseRecipeDataSource
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource.models.AddRecipeInfo
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.RecipeSummaryInfo
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.model_mapper.ModelMapper
import javax.inject.Inject
class MealieDataSourceWrapper @Inject constructor(
private val serverInfoRepo: ServerInfoRepo,
private val v0Source: MealieDataSourceV0,
private val v1Source: MealieDataSourceV1,
private val dataSource: MealieDataSource,
private val modelMapper: ModelMapper,
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
private suspend fun getVersion(): ServerVersion = serverInfoRepo.getVersion()
override suspend fun addRecipe(recipe: AddRecipeInfo): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.addRecipe(modelMapper.toV0Request(recipe))
ServerVersion.V1 -> {
val slug = v1Source.createRecipe(modelMapper.toV1CreateRequest(recipe))
v1Source.updateRecipe(slug, modelMapper.toV1UpdateRequest(recipe))
slug
}
override suspend fun addRecipe(recipe: AddRecipeInfo): String {
val slug = dataSource.createRecipe(modelMapper.toCreateRequest(recipe))
dataSource.updateRecipe(slug, modelMapper.toUpdateRequest(recipe))
return slug
}
override suspend fun requestRecipes(
start: Int,
limit: Int,
): List<RecipeSummaryInfo> = when (getVersion()) {
ServerVersion.V0 -> {
v0Source.requestRecipes(start, limit).map { modelMapper.toRecipeSummaryInfo(it) }
}
ServerVersion.V1 -> {
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
val page = start / limit + 1
v1Source.requestRecipes(page, limit).map { modelMapper.toRecipeSummaryInfo(it) }
): List<GetRecipeSummaryResponse> {
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
val page = start / limit + 1
return dataSource.requestRecipes(page, limit)
}
override suspend fun requestRecipe(slug: String): GetRecipeResponse {
return dataSource.requestRecipeInfo(slug)
}
override suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLRequest): String {
return dataSource.parseRecipeFromURL(parseRecipeURLInfo)
}
override suspend fun getFavoriteRecipes(): List<String> {
return dataSource.requestUserInfo().favoriteRecipes
}
override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) {
val userId = dataSource.requestUserInfo().id
if (isFavorite) {
dataSource.addFavoriteRecipe(userId, recipeSlug)
} else {
dataSource.removeFavoriteRecipe(userId, recipeSlug)
}
}
override suspend fun requestRecipeInfo(slug: String): FullRecipeInfo = when (getVersion()) {
ServerVersion.V0 -> modelMapper.toFullRecipeInfo(v0Source.requestRecipeInfo(slug))
ServerVersion.V1 -> modelMapper.toFullRecipeInfo(v1Source.requestRecipeInfo(slug))
}
override suspend fun parseRecipeFromURL(
parseRecipeURLInfo: ParseRecipeURLInfo,
): String = when (getVersion()) {
ServerVersion.V0 -> v0Source.parseRecipeFromURL(modelMapper.toV0Request(parseRecipeURLInfo))
ServerVersion.V1 -> v1Source.parseRecipeFromURL(modelMapper.toV1Request(parseRecipeURLInfo))
}
override suspend fun getFavoriteRecipes(): List<String> = when (getVersion()) {
ServerVersion.V0 -> v0Source.requestUserInfo().favoriteRecipes
ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes
}
override suspend fun updateIsRecipeFavorite(
recipeSlug: String,
isFavorite: Boolean
) = when (getVersion()) {
ServerVersion.V0 -> {
val userId = v0Source.requestUserInfo().id
if (isFavorite) {
v0Source.addFavoriteRecipe(userId, recipeSlug)
} else {
v0Source.removeFavoriteRecipe(userId, recipeSlug)
}
}
ServerVersion.V1 -> {
val userId = v1Source.requestUserInfo().id
if (isFavorite) {
v1Source.addFavoriteRecipe(userId, recipeSlug)
} else {
v1Source.removeFavoriteRecipe(userId, recipeSlug)
}
}
}
override suspend fun deleteRecipe(recipeSlug: String) = when (getVersion()) {
ServerVersion.V0 -> v0Source.deleteRecipe(recipeSlug)
ServerVersion.V1 -> v1Source.deleteRecipe(recipeSlug)
override suspend fun deleteRecipe(recipeSlug: String) {
dataSource.deleteRecipe(recipeSlug)
}
}

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.recipes.impl
import android.net.Uri
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.logging.Logger
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject
class RecipeImageUrlProviderImpl @Inject constructor(
@@ -15,9 +15,11 @@ class RecipeImageUrlProviderImpl @Inject constructor(
slug?.takeUnless { it.isBlank() } ?: return null
val imagePath = IMAGE_PATH_FORMAT.format(slug)
val baseUrl = serverInfoRepo.getUrl()?.takeUnless { it.isEmpty() }
val result = baseUrl?.toHttpUrlOrNull()
?.newBuilder()
?.addPathSegments(imagePath)
val result = baseUrl
?.takeUnless { it.isBlank() }
?.let { Uri.parse(it) }
?.buildUpon()
?.path(imagePath)
?.build()
?.toString()
logger.v { "getRecipeImageUrl() returned: $result" }

View File

@@ -45,7 +45,7 @@ class RecipeRepoImpl @Inject constructor(
override suspend fun refreshRecipeInfo(recipeSlug: String): Result<Unit> {
logger.v { "refreshRecipeInfo() called with: recipeSlug = $recipeSlug" }
return runCatchingExceptCancel {
val info = dataSource.requestRecipeInfo(recipeSlug)
val info = dataSource.requestRecipe(recipeSlug)
val entity = modelMapper.toRecipeEntity(info)
val ingredients = info.recipeIngredients.map {
modelMapper.toRecipeIngredientEntity(it, entity.remoteId)

View File

@@ -1,12 +1,12 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.datasource.models.FullRecipeInfo
import gq.kirmanak.mealient.datasource.models.RecipeSummaryInfo
import gq.kirmanak.mealient.datasource.models.GetRecipeResponse
import gq.kirmanak.mealient.datasource.models.GetRecipeSummaryResponse
interface RecipeDataSource {
suspend fun requestRecipes(start: Int, limit: Int): List<RecipeSummaryInfo>
suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(slug: String): FullRecipeInfo
suspend fun requestRecipe(slug: String): GetRecipeResponse
suspend fun getFavoriteRecipes(): List<String>

View File

@@ -1,8 +1,8 @@
package gq.kirmanak.mealient.data.share
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
interface ParseRecipeDataSource {
suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLInfo): String
suspend fun parseRecipeFromURL(parseRecipeURLInfo: ParseRecipeURLRequest): String
}

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealient.data.share
import androidx.core.util.PatternsCompat
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@@ -15,7 +15,7 @@ class ShareRecipeRepoImpl @Inject constructor(
val matcher = PatternsCompat.WEB_URL.matcher(url)
require(matcher.find()) { "Can't find URL in the text" }
val urlString = matcher.group()
val request = ParseRecipeURLInfo(url = urlString, includeTags = true)
val request = ParseRecipeURLRequest(url = urlString, includeTags = true)
return parseRecipeDataSource.parseRecipeFromURL(request)
}
}

View File

@@ -7,8 +7,6 @@ interface PreferencesStorage {
val baseUrlKey: Preferences.Key<String>
val serverVersionKey: Preferences.Key<String>
val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
val lastExecutedMigrationVersionKey: Preferences.Key<Int>

View File

@@ -24,8 +24,6 @@ class PreferencesStorageImpl @Inject constructor(
override val baseUrlKey = stringPreferencesKey("baseUrl")
override val serverVersionKey = stringPreferencesKey("serverVersion")
override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted")
override val lastExecutedMigrationVersionKey: Preferences.Key<Int> =

View File

@@ -6,6 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.migration.From24AuthMigrationExecutor
import gq.kirmanak.mealient.data.migration.From30MigrationExecutor
import gq.kirmanak.mealient.data.migration.MigrationDetector
import gq.kirmanak.mealient.data.migration.MigrationDetectorImpl
import gq.kirmanak.mealient.data.migration.MigrationExecutor
@@ -18,6 +19,10 @@ interface MigrationModule {
@IntoSet
fun bindFrom24AuthMigrationExecutor(from24AuthMigrationExecutor: From24AuthMigrationExecutor): MigrationExecutor
@Binds
@IntoSet
fun bindFrom30MigrationExecutor(impl: From30MigrationExecutor): MigrationExecutor
@Binds
fun bindMigrationDetector(migrationDetectorImpl: MigrationDetectorImpl): MigrationDetector
}

View File

@@ -109,7 +109,6 @@ class MainActivity : BaseActivity<MainActivityBinding>(
when (itemId) {
R.id.logout -> menuItem.isVisible = uiState.canShowLogout
R.id.login -> menuItem.isVisible = uiState.canShowLogin
R.id.shopping_lists -> menuItem.isVisible = uiState.v1MenuItemsVisible
}
menuItem.isChecked = itemId == checkedMenuItem
}

View File

@@ -8,7 +8,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger
@@ -47,10 +46,6 @@ class MainActivityViewModel @Inject constructor(
.onEach { isAuthorized -> updateUiState { it.copy(isAuthorized = isAuthorized) } }
.launchIn(viewModelScope)
serverInfoRepo.versionUpdates()
.onEach { version -> updateUiState { it.copy(v1MenuItemsVisible = version == ServerVersion.V1) } }
.launchIn(viewModelScope)
viewModelScope.launch {
_startDestination.value = when {
!disclaimerStorage.isDisclaimerAccepted() -> {

View File

@@ -14,7 +14,6 @@
<item
android:id="@+id/shopping_lists"
android:visible="false"
android:checkable="true"
android:icon="@drawable/ic_shopping_cart"
android:title="@string/menu_navigation_drawer_shopping_lists" />

View File

@@ -9,15 +9,16 @@ import gq.kirmanak.mealient.datastore_test.PORRIDGE_RECIPE_DRAFT
import gq.kirmanak.mealient.model_mapper.ModelMapper
import gq.kirmanak.mealient.model_mapper.ModelMapperImpl
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeRepoTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)

View File

@@ -4,62 +4,59 @@ import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.SignOutHandler
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_API_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V0
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AuthRepoImplTest : BaseUnitTest() {
@MockK
lateinit var dataSource: AuthDataSource
@MockK
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage
@MockK(relaxUnitFun = true)
lateinit var signOutHandler: SignOutHandler
lateinit var subject: AuthRepo
@Before
override fun setUp() {
super.setUp()
subject = AuthRepoImpl(storage, dataSource, logger)
subject = AuthRepoImpl(storage, dataSource, logger, signOutHandler)
}
@Test
fun `when isAuthorizedFlow then reads from storage`() = runTest {
every { storage.authHeaderFlow } returns flowOf("", null, "header")
every { storage.authTokenFlow } returns flowOf("", null, "header")
assertThat(subject.isAuthorizedFlow.toList()).isEqualTo(listOf(true, false, true))
}
@Test
fun `when authenticate successfully then saves to storage`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { dataSource.authenticate(any(), any()) } returns TEST_TOKEN
coEvery { dataSource.createApiToken(any()) } returns TEST_API_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerify {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
storage.setAuthHeader(TEST_AUTH_HEADER)
storage.setAuthToken(TEST_TOKEN)
dataSource.createApiToken(eq("Mealient"))
storage.setAuthHeader(TEST_API_AUTH_HEADER)
storage.setAuthToken(TEST_API_TOKEN)
}
confirmVerified(storage)
}
@@ -74,7 +71,7 @@ class AuthRepoImplTest : BaseUnitTest() {
@Test
fun `when logout expect header removal`() = runTest {
subject.logout()
coVerify { storage.setAuthHeader(null) }
coVerify { storage.setAuthToken(null) }
confirmVerified(storage)
}
}

View File

@@ -7,18 +7,16 @@ import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_HEADER_KEY
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_TOKEN_KEY
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.HiltRobolectricTest
import io.mockk.MockKAnnotations
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class AuthStorageImplTest : HiltRobolectricTest() {
@@ -39,12 +37,12 @@ class AuthStorageImplTest : HiltRobolectricTest() {
@Test
fun `when authHeaderFlow is observed then sends value immediately`() = runTest {
sharedPreferences.edit(commit = true) { putString(AUTH_HEADER_KEY, TEST_AUTH_HEADER) }
assertThat(subject.authHeaderFlow.first()).isEqualTo(TEST_AUTH_HEADER)
sharedPreferences.edit(commit = true) { putString(AUTH_TOKEN_KEY, TEST_TOKEN) }
assertThat(subject.authTokenFlow.first()).isEqualTo(TEST_TOKEN)
}
@Test
fun `when authHeader is observed then sends null if nothing saved`() = runTest {
assertThat(subject.authHeaderFlow.first()).isEqualTo(null)
assertThat(subject.authTokenFlow.first()).isEqualTo(null)
}
}

View File

@@ -1,21 +1,15 @@
package gq.kirmanak.mealient.data.baseurl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.datasource.models.VersionInfo
import gq.kirmanak.mealient.datasource_test.VERSION_INFO_V0
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class ServerInfoRepoTest : BaseUnitTest() {
private lateinit var subject: ServerInfoRepo
@@ -54,104 +48,10 @@ class ServerInfoRepoTest : BaseUnitTest() {
@Test
fun `when tryBaseURL succeeds expect call to storage`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns null
coEvery { dataSource.getVersionInfo() } returns VersionInfo(TEST_VERSION)
subject.tryBaseURL(TEST_BASE_URL)
coVerify {
storage.storeBaseURL(eq(TEST_BASE_URL))
dataSource.getVersionInfo()
storage.storeServerVersion(TEST_VERSION)
}
}
@Test
fun `when tryBaseURL fails expect call to storage`() = runTest {
coEvery { storage.getServerVersion() } returns "serverVersion"
coEvery { storage.getBaseURL() } returns "baseUrl"
coEvery { dataSource.getVersionInfo() } throws IOException()
subject.tryBaseURL(TEST_BASE_URL)
coVerify {
storage.storeBaseURL(eq(TEST_BASE_URL))
dataSource.getVersionInfo()
storage.storeBaseURL(eq("baseUrl"), eq("serverVersion"))
}
}
@Test
fun `when storage is empty expect getVersion to call data source`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VERSION_INFO_V0
subject.getVersion()
coVerify { dataSource.getVersionInfo() }
}
@Test
fun `when storage is empty and data source has value expect getVersion to save it`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo(TEST_VERSION)
subject.getVersion()
coVerify { storage.storeServerVersion(TEST_VERSION) }
}
@Test
fun `when data source has invalid value expect getVersion to return v1`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v2.0.0")
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V1)
}
@Test
fun `when data source has invalid value expect getVersion to save value`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v2.0.0")
subject.getVersion()
coVerify { storage.storeServerVersion("v2.0.0") }
}
@Test
fun `when storage has value expect getVersion to not get URL`() = runTest {
coEvery { storage.getServerVersion() } returns TEST_VERSION
subject.getVersion()
coVerify(inverse = true) { storage.getBaseURL() }
}
@Test
fun `when storage has value expect getVersion to not call data source`() = runTest {
coEvery { storage.getServerVersion() } returns TEST_VERSION
subject.getVersion()
coVerify(inverse = true) { dataSource.getVersionInfo() }
}
@Test
fun `when storage has v0 value expect getVersion to return parsed`() = runTest {
coEvery { storage.getServerVersion() } returns "v0.5.6"
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V0)
}
@Test
fun `when storage has v1 value expect getVersion to return parsed`() = runTest {
coEvery { storage.getServerVersion() } returns "v1.0.0-beta05"
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V1)
}
@Test
fun `when data source has valid v0 value expect getVersion to return it`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v0.5.6")
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V0)
}
@Test
fun `when data source has valid v1 value expect getVersion to return it`() = runTest {
coEvery { storage.getServerVersion() } returns null
coEvery { storage.getBaseURL() } returns TEST_BASE_URL
coEvery { dataSource.getVersionInfo() } returns VersionInfo("v1.0.0-beta05")
assertThat(subject.getVersion()).isEqualTo(ServerVersion.V1)
}
}

View File

@@ -5,18 +5,15 @@ import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ServerInfoStorageTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
@@ -25,14 +22,12 @@ class ServerInfoStorageTest : BaseUnitTest() {
lateinit var subject: ServerInfoStorage
private val baseUrlKey = stringPreferencesKey("baseUrlKey")
private val serverVersionKey = stringPreferencesKey("serverVersionKey")
@Before
override fun setUp() {
super.setUp()
subject = ServerInfoStorageImpl(preferencesStorage)
every { preferencesStorage.baseUrlKey } returns baseUrlKey
every { preferencesStorage.serverVersionKey } returns serverVersionKey
}
@Test
@@ -49,30 +44,11 @@ class ServerInfoStorageTest : BaseUnitTest() {
@Test
fun `when storeBaseURL expect call to preferences storage`() = runTest {
subject.storeBaseURL(TEST_BASE_URL, TEST_VERSION)
subject.storeBaseURL(TEST_BASE_URL)
coVerify {
preferencesStorage.storeValues(
eq(Pair(baseUrlKey, TEST_BASE_URL)),
eq(Pair(serverVersionKey, TEST_VERSION)),
)
}
}
@Test
fun `when preference storage is empty expect getServerVersion return null`() = runTest {
coEvery { preferencesStorage.getValue(eq(serverVersionKey)) } returns null
assertThat(subject.getServerVersion()).isNull()
}
@Test
fun `when preference storage has value expect getServerVersion return value`() = runTest {
coEvery { preferencesStorage.getValue(eq(serverVersionKey)) } returns TEST_VERSION
assertThat(subject.getServerVersion()).isEqualTo(TEST_VERSION)
}
@Test
fun `when storeServerVersion then calls preferences storage`() = runTest {
subject.storeServerVersion(TEST_VERSION)
coVerify { preferencesStorage.storeValues(eq(Pair(serverVersionKey, TEST_VERSION))) }
}
}

View File

@@ -3,12 +3,10 @@ package gq.kirmanak.mealient.data.disclaimer
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class DisclaimerStorageImplTest : HiltRobolectricTest() {

View File

@@ -12,14 +12,12 @@ import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class From24AuthMigrationExecutorTest : HiltRobolectricTest() {

View File

@@ -9,11 +9,9 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MigrationDetectorImplTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)

View File

@@ -2,50 +2,35 @@ package gq.kirmanak.mealient.data.network
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0
import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1
import gq.kirmanak.mealient.datasource.MealieDataSource
import gq.kirmanak.mealient.datasource_test.PORRIDGE_ADD_RECIPE_INFO
import gq.kirmanak.mealient.datasource_test.PORRIDGE_ADD_RECIPE_REQUEST_V0
import gq.kirmanak.mealient.datasource_test.PORRIDGE_CREATE_RECIPE_REQUEST_V1
import gq.kirmanak.mealient.datasource_test.PORRIDGE_FULL_RECIPE_INFO
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_RESPONSE_V1
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1
import gq.kirmanak.mealient.datasource_test.PORRIDGE_UPDATE_RECIPE_REQUEST_V1
import gq.kirmanak.mealient.datasource_test.RECIPE_SUMMARY_PORRIDGE_V0
import gq.kirmanak.mealient.datasource_test.RECIPE_SUMMARY_PORRIDGE_V1
import gq.kirmanak.mealient.datasource_test.PORRIDGE_CREATE_RECIPE_REQUEST
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_RESPONSE
import gq.kirmanak.mealient.datasource_test.PORRIDGE_RECIPE_SUMMARY_RESPONSE
import gq.kirmanak.mealient.datasource_test.PORRIDGE_UPDATE_RECIPE_REQUEST
import gq.kirmanak.mealient.datasource_test.RECIPE_SUMMARY_PORRIDGE
import gq.kirmanak.mealient.model_mapper.ModelMapper
import gq.kirmanak.mealient.model_mapper.ModelMapperImpl
import gq.kirmanak.mealient.test.AuthImplTestData.FAVORITE_RECIPES_LIST
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V0
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V1
import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V0
import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V1
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifySequence
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class MealieDataSourceWrapperTest : BaseUnitTest() {
@MockK
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK(relaxUnitFun = true)
lateinit var v0Source: MealieDataSourceV0
@MockK(relaxUnitFun = true)
lateinit var v1Source: MealieDataSourceV1
lateinit var dataSource: MealieDataSource
private val modelMapper: ModelMapper = ModelMapperImpl()
@@ -54,111 +39,68 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
@Before
override fun setUp() {
super.setUp()
subject = MealieDataSourceWrapper(serverInfoRepo, v0Source, v1Source, modelMapper)
coEvery { v0Source.requestUserInfo() } returns USER_INFO_V0
coEvery { v1Source.requestUserInfo() } returns USER_INFO_V1
subject = MealieDataSourceWrapper(dataSource, modelMapper)
coEvery { dataSource.requestUserInfo() } returns USER_INFO
}
@Test
fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest {
fun `when requestRecipeInfo expect a valid network call`() = runTest {
val slug = "porridge"
coEvery { v1Source.requestRecipeInfo(eq(slug)) } returns PORRIDGE_RECIPE_RESPONSE_V1
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
coEvery { dataSource.requestRecipeInfo(eq(slug)) } returns PORRIDGE_RECIPE_RESPONSE
coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
val actual = subject.requestRecipeInfo(slug)
val actual = subject.requestRecipe(slug)
coVerify { v1Source.requestRecipeInfo(eq(slug)) }
coVerify { dataSource.requestRecipeInfo(eq(slug)) }
assertThat(actual).isEqualTo(PORRIDGE_FULL_RECIPE_INFO)
assertThat(actual).isEqualTo(PORRIDGE_RECIPE_RESPONSE)
}
@Test
fun `when server version v1 expect requestRecipes to call v1`() = runTest {
fun `when requestRecipes expect valid network request`() = runTest {
coEvery {
v1Source.requestRecipes(any(), any())
} returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V1)
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
dataSource.requestRecipes(any(), any())
} returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE)
coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
val actual = subject.requestRecipes(40, 10)
val page = 5 // 0-9 (1), 10-19 (2), 20-29 (3), 30-39 (4), 40-49 (5)
val perPage = 10
coVerify {
v1Source.requestRecipes(eq(page), eq(perPage))
dataSource.requestRecipes(eq(page), eq(perPage))
}
assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V1))
}
@Test
fun `when server version v0 expect requestRecipes to call v0`() = runTest {
coEvery {
v0Source.requestRecipes(any(), any())
} returns listOf(PORRIDGE_RECIPE_SUMMARY_RESPONSE_V0)
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
val start = 40
val limit = 10
val actual = subject.requestRecipes(start, limit)
coVerify {
v0Source.requestRecipes(eq(start), eq(limit))
}
assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE_V0))
assertThat(actual).isEqualTo(listOf(RECIPE_SUMMARY_PORRIDGE))
}
@Test(expected = IOException::class)
fun `when request fails expect addRecipe to rethrow`() = runTest {
coEvery { v0Source.addRecipe(any()) } throws IOException()
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
fun `when request fails expect createRecipe to rethrow`() = runTest {
coEvery { dataSource.createRecipe(any()) } throws IOException()
coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO)
}
@Test
fun `when server version v0 expect addRecipe to call v0`() = runTest {
fun `when create recipe expect createRecipe to call in sequence`() = runTest {
val slug = "porridge"
coEvery { v0Source.addRecipe(any()) } returns slug
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
val actual = subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO)
coVerify {
v0Source.addRecipe(
eq(PORRIDGE_ADD_RECIPE_REQUEST_V0),
)
}
assertThat(actual).isEqualTo(slug)
}
@Test
fun `when server version v1 expect addRecipe to call v1`() = runTest {
val slug = "porridge"
coEvery { v1Source.createRecipe(any()) } returns slug
coEvery { dataSource.createRecipe(any()) } returns slug
coEvery {
v1Source.updateRecipe(any(), any())
} returns PORRIDGE_RECIPE_RESPONSE_V1
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
dataSource.updateRecipe(any(), any())
} returns PORRIDGE_RECIPE_RESPONSE
coEvery { authRepo.getAuthToken() } returns TEST_TOKEN
val actual = subject.addRecipe(PORRIDGE_ADD_RECIPE_INFO)
coVerifySequence {
v1Source.createRecipe(
eq(PORRIDGE_CREATE_RECIPE_REQUEST_V1),
dataSource.createRecipe(
eq(PORRIDGE_CREATE_RECIPE_REQUEST),
)
v1Source.updateRecipe(
dataSource.updateRecipe(
eq(slug),
eq(PORRIDGE_UPDATE_RECIPE_REQUEST_V1),
eq(PORRIDGE_UPDATE_RECIPE_REQUEST),
)
}
@@ -166,68 +108,31 @@ class MealieDataSourceWrapperTest : BaseUnitTest() {
}
@Test
fun `when remove favorite recipe info with v0 expect correct sequence`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
fun `when remove favorite recipe info expect correct sequence`() = runTest {
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false)
coVerify {
v0Source.requestUserInfo()
v0Source.removeFavoriteRecipe(eq(3), eq("cake"))
dataSource.requestUserInfo()
dataSource.removeFavoriteRecipe(eq("userId"), eq("cake"))
}
}
@Test
fun `when remove favorite recipe info with v1 expect correct sequence`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false)
coVerify {
v1Source.requestUserInfo()
v1Source.removeFavoriteRecipe(eq("userId"), eq("cake"))
}
}
@Test
fun `when add favorite recipe info with v0 expect correct sequence`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
fun `when add favorite recipe info expect correct sequence`() = runTest {
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true)
coVerify {
v0Source.requestUserInfo()
v0Source.addFavoriteRecipe(eq(3), eq("cake"))
dataSource.requestUserInfo()
dataSource.addFavoriteRecipe(eq("userId"), eq("cake"))
}
}
@Test
fun `when add favorite recipe info with v1 expect correct sequence`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true)
coVerify {
v1Source.requestUserInfo()
v1Source.addFavoriteRecipe(eq("userId"), eq("cake"))
}
}
@Test
fun `when get favorite recipes with v1 expect correct call`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
fun `when get favorite recipes expect correct call`() = runTest {
subject.getFavoriteRecipes()
coVerify { v1Source.requestUserInfo() }
coVerify { dataSource.requestUserInfo() }
}
@Test
fun `when get favorite recipes with v0 expect correct call`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
subject.getFavoriteRecipes()
coVerify { v0Source.requestUserInfo() }
}
@Test
fun `when get favorite recipes with v1 expect correct result`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1
assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST)
}
@Test
fun `when get favorite recipes with v0 expect correct result`() = runTest {
coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0
fun `when get favorite recipes expect correct result`() = runTest {
assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST)
}
}

View File

@@ -1,27 +1,26 @@
package gq.kirmanak.mealient.data.recipes.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeImageUrlProviderImplTest : BaseUnitTest() {
@HiltAndroidTest
class RecipeImageUrlProviderImplTest : HiltRobolectricTest() {
@Inject
lateinit var subject: RecipeImageUrlProvider
@MockK
lateinit var serverInfoRepo: ServerInfoRepo
@Inject
lateinit var serverInfoStorage: ServerInfoStorage
@Before
override fun setUp() {
super.setUp()
subject = RecipeImageUrlProviderImpl(serverInfoRepo, logger)
fun setUp() {
prepareBaseURL("https://google.com/")
}
@@ -76,7 +75,7 @@ class RecipeImageUrlProviderImplTest : BaseUnitTest() {
assertThat(actual).isNull()
}
private fun prepareBaseURL(baseURL: String?) {
coEvery { serverInfoRepo.getUrl() } returns baseURL
private fun prepareBaseURL(baseURL: String?) = runBlocking {
serverInfoStorage.storeBaseURL(baseURL)
}
}

View File

@@ -9,13 +9,11 @@ import gq.kirmanak.mealient.database.TEST_RECIPE_SUMMARY_ENTITIES
import gq.kirmanak.mealient.database.recipe.RecipeStorage
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() {
@Inject

View File

@@ -13,7 +13,7 @@ import gq.kirmanak.mealient.database.FULL_CAKE_INFO_ENTITY
import gq.kirmanak.mealient.database.MIX_CAKE_RECIPE_INSTRUCTION_ENTITY
import gq.kirmanak.mealient.database.recipe.RecipeStorage
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
import gq.kirmanak.mealient.datasource_test.CAKE_FULL_RECIPE_INFO
import gq.kirmanak.mealient.datasource_test.CAKE_RECIPE_RESPONSE
import gq.kirmanak.mealient.model_mapper.ModelMapper
import gq.kirmanak.mealient.model_mapper.ModelMapperImpl
import gq.kirmanak.mealient.test.BaseUnitTest
@@ -22,13 +22,11 @@ import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeRepoTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
@@ -69,7 +67,7 @@ class RecipeRepoTest : BaseUnitTest() {
@Test
fun `when refreshRecipeInfo expect call to storage`() = runTest {
coEvery { dataSource.requestRecipeInfo(eq("cake")) } returns CAKE_FULL_RECIPE_INFO
coEvery { dataSource.requestRecipe(eq("cake")) } returns CAKE_RECIPE_RESPONSE
subject.refreshRecipeInfo("cake")
coVerify {
storage.saveRecipeInfo(

View File

@@ -16,13 +16,11 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.io.IOException
@ExperimentalCoroutinesApi
@OptIn(ExperimentalPagingApi::class)
class RecipesRemoteMediatorTest : BaseUnitTest() {

View File

@@ -1,15 +1,13 @@
package gq.kirmanak.mealient.data.share
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLInfo
import gq.kirmanak.mealient.datasource.models.ParseRecipeURLRequest
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ShareRecipeRepoImplTest : BaseUnitTest() {
@@ -32,7 +30,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
fun `when url is correct expect saveRecipeByURL saves it`() = runTest {
subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/")
val expected = ParseRecipeURLInfo(
val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/",
includeTags = true
)
@@ -42,7 +40,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
fun `when url has prefix expect saveRecipeByURL removes it`() = runTest {
subject.saveRecipeByURL("My favorite recipe: https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/")
val expected = ParseRecipeURLInfo(
val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/",
includeTags = true
)
@@ -52,7 +50,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
fun `when url has suffix expect saveRecipeByURL removes it`() = runTest {
subject.saveRecipeByURL("https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe")
val expected = ParseRecipeURLInfo(
val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie",
includeTags = true
)
@@ -62,7 +60,7 @@ class ShareRecipeRepoImplTest : BaseUnitTest() {
@Test
fun `when url has prefix and suffix expect saveRecipeByURL removes them`() = runTest {
subject.saveRecipeByURL("Actually, https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie/ is my favorite recipe")
val expected = ParseRecipeURLInfo(
val expected = ParseRecipeURLRequest(
url = "https://www.allrecipes.com/recipe/215447/dads-leftover-turkey-pot-pie",
includeTags = true
)

View File

@@ -3,13 +3,11 @@ package gq.kirmanak.mealient.data.storage
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class PreferencesStorageImplTest : HiltRobolectricTest() {

View File

@@ -1,22 +1,14 @@
package gq.kirmanak.mealient.test
import gq.kirmanak.mealient.data.baseurl.ServerVersion
import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0
import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1
import gq.kirmanak.mealient.datasource.models.GetUserInfoResponse
object AuthImplTestData {
const val TEST_USERNAME = "TEST_USERNAME"
const val TEST_PASSWORD = "TEST_PASSWORD"
const val TEST_BASE_URL = "https://example.com"
const val TEST_TOKEN = "TEST_TOKEN"
const val TEST_AUTH_HEADER = "Bearer TEST_TOKEN"
const val TEST_API_TOKEN = "TEST_API_TOKEN"
const val TEST_API_AUTH_HEADER = "Bearer TEST_API_TOKEN"
const val TEST_VERSION = "v0.5.6"
val TEST_SERVER_VERSION_V0 = ServerVersion.V0
val TEST_SERVER_VERSION_V1 = ServerVersion.V1
val FAVORITE_RECIPES_LIST = listOf("cake", "porridge")
val USER_INFO_V1 = GetUserInfoResponseV1("userId", FAVORITE_RECIPES_LIST)
val USER_INFO_V0 = GetUserInfoResponseV0(3, FAVORITE_RECIPES_LIST)
val USER_INFO = GetUserInfoResponse("userId", FAVORITE_RECIPES_LIST)
}

View File

@@ -45,7 +45,6 @@ class MainActivityViewModelTest : BaseUnitTest() {
every { activityUiStateController.getUiStateFlow() } returns MutableStateFlow(
ActivityUiState()
)
coEvery { serverInfoRepo.versionUpdates() } returns emptyFlow()
subject = MainActivityViewModel(
authRepo = authRepo,
logger = logger,

View File

@@ -7,7 +7,6 @@ import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
@@ -16,7 +15,6 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AddRecipeViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)

View File

@@ -7,12 +7,10 @@ import gq.kirmanak.mealient.database.FULL_CAKE_INFO_ENTITY
import gq.kirmanak.mealient.test.BaseUnitTest
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RecipeInfoViewModelTest : BaseUnitTest() {
@MockK

View File

@@ -9,7 +9,6 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
@@ -17,7 +16,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ShareRecipeViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)