Merge pull request #120 from kirmanak/delete-recipe
Add "delete recipe" button
This commit is contained in:
@@ -90,4 +90,9 @@ class MealieDataSourceWrapper @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteRecipe(recipeSlug: String) = when (getVersion()) {
|
||||
ServerVersion.V0 -> v0Source.deleteRecipe(recipeSlug)
|
||||
ServerVersion.V1 -> v1Source.deleteRecipe(recipeSlug)
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,6 @@ interface RecipeRepo {
|
||||
suspend fun refreshRecipes()
|
||||
|
||||
suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result<Unit>
|
||||
|
||||
suspend fun deleteRecipe(entity: RecipeSummaryEntity): Result<Unit>
|
||||
}
|
||||
@@ -19,4 +19,6 @@ interface RecipeStorage {
|
||||
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity?
|
||||
|
||||
suspend fun updateFavoriteRecipes(favorites: List<String>)
|
||||
|
||||
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
|
||||
}
|
||||
@@ -80,4 +80,9 @@ class RecipeStorageImpl @Inject constructor(
|
||||
recipeDao.setNonFavorite(favorites)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteRecipe(entity: RecipeSummaryEntity) {
|
||||
logger.v { "deleteRecipeBySlug() called with: entity = $entity" }
|
||||
recipeDao.deleteRecipe(entity)
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,24 @@ class RecipeRepoImpl @Inject constructor(
|
||||
): Result<Unit> = runCatchingExceptCancel {
|
||||
logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" }
|
||||
dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite)
|
||||
mediator.onFavoritesChange()
|
||||
val favorites = dataSource.getFavoriteRecipes()
|
||||
storage.updateFavoriteRecipes(favorites)
|
||||
pagingSourceFactory.invalidate()
|
||||
}.onFailure {
|
||||
logger.e(it) { "Can't update recipe's is favorite status" }
|
||||
}
|
||||
|
||||
override suspend fun deleteRecipe(
|
||||
entity: RecipeSummaryEntity
|
||||
): Result<Unit> = runCatchingExceptCancel {
|
||||
logger.v { "deleteRecipe() called with: entity = $entity" }
|
||||
dataSource.deleteRecipe(entity.slug)
|
||||
storage.deleteRecipe(entity)
|
||||
pagingSourceFactory.invalidate()
|
||||
}.onFailure {
|
||||
logger.e(it) { "Can't delete recipe" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LOAD_PAGE_SIZE = 50
|
||||
private const val INITIAL_LOAD_PAGE_SIZE = LOAD_PAGE_SIZE * 3
|
||||
|
||||
@@ -83,10 +83,4 @@ class RecipesRemoteMediator @Inject constructor(
|
||||
recipes.size
|
||||
}
|
||||
|
||||
suspend fun onFavoritesChange() {
|
||||
logger.v { "onFavoritesChange() called" }
|
||||
val favorites = network.getFavoriteRecipes()
|
||||
storage.updateFavoriteRecipes(favorites)
|
||||
pagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,6 @@ interface RecipeDataSource {
|
||||
suspend fun getFavoriteRecipes(): List<String>
|
||||
|
||||
suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean)
|
||||
|
||||
suspend fun deleteRecipe(recipeSlug: String)
|
||||
}
|
||||
@@ -47,6 +47,10 @@ class RecipeViewHolder @AssistedInject constructor(
|
||||
override val recipeSummaryEntity: RecipeSummaryEntity
|
||||
) : ClickEvent()
|
||||
|
||||
data class DeleteClick(
|
||||
override val recipeSummaryEntity: RecipeSummaryEntity
|
||||
) : ClickEvent()
|
||||
|
||||
}
|
||||
|
||||
private val loadingPlaceholder by lazy {
|
||||
@@ -62,6 +66,7 @@ class RecipeViewHolder @AssistedInject constructor(
|
||||
logger.d { "bind: item clicked $entity" }
|
||||
clickListener(ClickEvent.RecipeClick(entity))
|
||||
}
|
||||
|
||||
binding.favoriteIcon.isVisible = showFavoriteIcon
|
||||
binding.favoriteIcon.setOnClickListener {
|
||||
clickListener(ClickEvent.FavoriteClick(entity))
|
||||
@@ -80,6 +85,10 @@ class RecipeViewHolder @AssistedInject constructor(
|
||||
R.string.view_holder_recipe_non_favorite_content_description
|
||||
}
|
||||
)
|
||||
|
||||
binding.deleteIcon.setOnClickListener {
|
||||
clickListener(ClickEvent.DeleteClick(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gq.kirmanak.mealient.ui.recipes
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
@@ -13,6 +14,7 @@ import androidx.paging.LoadState
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import by.kirich1409.viewbindingdelegate.viewBinding
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import gq.kirmanak.mealient.R
|
||||
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||
@@ -55,9 +57,15 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
|
||||
checkedMenuItemId = R.id.recipes_list
|
||||
)
|
||||
}
|
||||
viewModel.showFavoriteIcon.observe(viewLifecycleOwner) { showFavoriteIcon ->
|
||||
collectWhenViewResumed(viewModel.showFavoriteIcon) { showFavoriteIcon ->
|
||||
setupRecipeAdapter(showFavoriteIcon)
|
||||
}
|
||||
collectWhenViewResumed(viewModel.deleteRecipeResult) {
|
||||
logger.d { "Delete recipe result is $it" }
|
||||
if (it.isFailure) {
|
||||
showLongToast(R.string.fragment_recipes_delete_recipe_failed)
|
||||
}
|
||||
}
|
||||
hideKeyboardOnScroll()
|
||||
}
|
||||
|
||||
@@ -100,6 +108,9 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
|
||||
is RecipeViewHolder.ClickEvent.RecipeClick -> {
|
||||
onRecipeClicked(it.recipeSummaryEntity)
|
||||
}
|
||||
is RecipeViewHolder.ClickEvent.DeleteClick -> {
|
||||
onDeleteClick(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +150,27 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDeleteClick(event: RecipeViewHolder.ClickEvent) {
|
||||
logger.v { "onDeleteClick() called with: event = $event" }
|
||||
val entity = event.recipeSummaryEntity
|
||||
val message = getString(
|
||||
R.string.fragment_recipes_delete_recipe_confirm_dialog_message, entity.name
|
||||
)
|
||||
val onPositiveClick = DialogInterface.OnClickListener { _, _ ->
|
||||
viewModel.onDeleteConfirm(entity)
|
||||
}
|
||||
val positiveBtnResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_positive_btn
|
||||
val titleResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_title
|
||||
val negativeBtnResId = R.string.fragment_recipes_delete_recipe_confirm_dialog_negative_btn
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(titleResId)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(positiveBtnResId, onPositiveClick)
|
||||
.setNegativeButton(negativeBtnResId) { _, _ -> }
|
||||
.show()
|
||||
|
||||
}
|
||||
|
||||
private fun onFavoriteClick(event: RecipeViewHolder.ClickEvent) {
|
||||
logger.v { "onFavoriteClick() called with: event = $event" }
|
||||
viewModel.onFavoriteIconClick(event.recipeSummaryEntity).observe(viewLifecycleOwner) {
|
||||
|
||||
@@ -2,9 +2,9 @@ package gq.kirmanak.mealient.ui.recipes
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||
@@ -12,8 +12,16 @@ import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
||||
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||
import gq.kirmanak.mealient.extensions.valueUpdatesOnly
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -23,8 +31,18 @@ class RecipesListViewModel @Inject constructor(
|
||||
private val logger: Logger,
|
||||
) : ViewModel() {
|
||||
|
||||
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
||||
val showFavoriteIcon = authRepo.isAuthorizedFlow.asLiveData()
|
||||
val pagingData: Flow<PagingData<RecipeSummaryEntity>> = recipeRepo.createPager().flow
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val showFavoriteIcon: StateFlow<Boolean> = authRepo.isAuthorizedFlow
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
private val _deleteRecipeResult = MutableSharedFlow<Result<Unit>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val deleteRecipeResult: SharedFlow<Result<Unit>> get() = _deleteRecipeResult
|
||||
|
||||
init {
|
||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
|
||||
@@ -49,4 +67,13 @@ class RecipesListViewModel @Inject constructor(
|
||||
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
||||
).also { emit(it) }
|
||||
}
|
||||
|
||||
fun onDeleteConfirm(recipeSummaryEntity: RecipeSummaryEntity) {
|
||||
logger.v { "onDeleteConfirm() called with: recipeSummaryEntity = $recipeSummaryEntity" }
|
||||
viewModelScope.launch {
|
||||
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
|
||||
logger.d { "onDeleteConfirm: delete result is $result" }
|
||||
_deleteRecipeResult.emit(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_delete.xml
Normal file
10
app/src/main/res/drawable/ic_delete.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M7,21Q6.175,21 5.588,20.413Q5,19.825 5,19V6H4V4H9V3H15V4H20V6H19V19Q19,19.825 18.413,20.413Q17.825,21 17,21ZM17,6H7V19Q7,19 7,19Q7,19 7,19H17Q17,19 17,19Q17,19 17,19ZM9,17H11V8H9ZM13,17H15V8H13ZM7,6V19Q7,19 7,19Q7,19 7,19Q7,19 7,19Q7,19 7,19Z" />
|
||||
</vector>
|
||||
@@ -38,9 +38,8 @@
|
||||
app:layout_constraintDimensionRatio="2:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/favorite_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/delete_icon"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:layout_goneMarginTop="@dimen/margin_medium"
|
||||
app:shapeAppearance="?shapeAppearanceCornerMedium"
|
||||
tools:srcCompat="@drawable/placeholder_recipe" />
|
||||
|
||||
@@ -54,6 +53,19 @@
|
||||
app:layout_constraintEnd_toEndOf="@id/image"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:srcCompat="@drawable/ic_favorite_unfilled"
|
||||
tools:visibility="gone" />
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/delete_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/margin_medium"
|
||||
android:layout_marginVertical="@dimen/margin_small"
|
||||
android:contentDescription="@string/view_holder_recipe_delete_content_description"
|
||||
app:layout_constraintBottom_toTopOf="@+id/image"
|
||||
app:layout_constraintEnd_toStartOf="@id/favorite_icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_goneMarginEnd="0dp"
|
||||
app:srcCompat="@drawable/ic_delete" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -57,4 +57,10 @@
|
||||
<string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string>
|
||||
<string name="view_holder_recipe_favorite_content_description">Добавлен в избранное</string>
|
||||
<string name="view_holder_recipe_non_favorite_content_description">Не добавлен в избранное</string>
|
||||
<string name="fragment_recipes_delete_recipe_failed">Не удалось удалить рецепт</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Удалить рецепт</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Вы уверены, что хотите удалить %1$s? Удаление необратимо.</string>
|
||||
<string name="view_holder_recipe_delete_content_description">Удалить рецепт</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Подтвердить</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Отмена</string>
|
||||
</resources>
|
||||
@@ -50,6 +50,11 @@
|
||||
<string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string>
|
||||
<string name="fragment_recipes_load_failure_toast_no_connection">no connection</string>
|
||||
<string name="fragment_recipes_favorite_update_failed">Favorite status update failed</string>
|
||||
<string name="fragment_recipes_delete_recipe_failed">Recipe removal failed</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_title">Delete recipe</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Are you sure you want to delete %1$s? This cannot be undone.</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirm</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</string>
|
||||
<string name="menu_navigation_drawer_change_url">Change URL</string>
|
||||
<string name="search_recipes_hint">Search recipes</string>
|
||||
<string name="menu_navigation_drawer_header" translatable="false">@string/app_name</string>
|
||||
@@ -60,4 +65,5 @@
|
||||
<string name="content_description_activity_share_recipe_progress">Progress indicator</string>
|
||||
<string name="view_holder_recipe_favorite_content_description">Item is favorite</string>
|
||||
<string name="view_holder_recipe_non_favorite_content_description">Item is not favorite</string>
|
||||
<string name="view_holder_recipe_delete_content_description">Delete recipe</string>
|
||||
</resources>
|
||||
@@ -8,9 +8,11 @@ import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
||||
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
|
||||
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_FULL_RECIPE_INFO
|
||||
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY
|
||||
import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -70,19 +72,25 @@ class RecipeRepoTest : BaseUnitTest() {
|
||||
|
||||
@Test
|
||||
fun `when remove favorite recipe expect correct sequence`() = runTest {
|
||||
coEvery { dataSource.getFavoriteRecipes() } returns listOf("porridge")
|
||||
subject.updateIsRecipeFavorite("cake", false)
|
||||
coVerify {
|
||||
dataSource.updateIsRecipeFavorite(eq("cake"), eq(false))
|
||||
remoteMediator.onFavoritesChange()
|
||||
dataSource.getFavoriteRecipes()
|
||||
storage.updateFavoriteRecipes(eq(listOf("porridge")))
|
||||
pagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when add favorite recipe expect correct sequence`() = runTest {
|
||||
coEvery { dataSource.getFavoriteRecipes() } returns listOf("porridge", "cake")
|
||||
subject.updateIsRecipeFavorite("porridge", true)
|
||||
coVerify {
|
||||
dataSource.updateIsRecipeFavorite(eq("porridge"), eq(true))
|
||||
remoteMediator.onFavoritesChange()
|
||||
dataSource.getFavoriteRecipes()
|
||||
storage.updateFavoriteRecipes(eq(listOf("porridge", "cake")))
|
||||
pagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +100,7 @@ class RecipeRepoTest : BaseUnitTest() {
|
||||
dataSource.updateIsRecipeFavorite(any(), any())
|
||||
} throws Unauthorized(IOException())
|
||||
subject.updateIsRecipeFavorite("porridge", true)
|
||||
coVerify(inverse = true) { remoteMediator.onFavoritesChange() }
|
||||
coVerify(inverse = true) { dataSource.getFavoriteRecipes() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -100,4 +108,21 @@ class RecipeRepoTest : BaseUnitTest() {
|
||||
subject.refreshRecipes()
|
||||
coVerify { remoteMediator.updateRecipes(eq(0), eq(150), eq(LoadType.REFRESH)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when delete recipe expect correct sequence`() = runTest {
|
||||
subject.deleteRecipe(CAKE_RECIPE_SUMMARY_ENTITY)
|
||||
coVerifyOrder {
|
||||
dataSource.deleteRecipe(eq("cake"))
|
||||
storage.deleteRecipe(eq(CAKE_RECIPE_SUMMARY_ENTITY))
|
||||
pagingSourceFactory.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when delete recipe remotely fails expect it isn't deleted locally`() = runTest {
|
||||
coEvery { dataSource.deleteRecipe(any()) } throws Unauthorized(IOException())
|
||||
subject.deleteRecipe(CAKE_RECIPE_SUMMARY_ENTITY)
|
||||
coVerify(inverse = true) { storage.deleteRecipe(any()) }
|
||||
}
|
||||
}
|
||||
@@ -144,27 +144,6 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
|
||||
coVerify { storage.refreshAll(TEST_RECIPE_SUMMARY_ENTITIES) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when favorites change expect network call`() = runTest {
|
||||
coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge")
|
||||
subject.onFavoritesChange()
|
||||
coVerify { dataSource.getFavoriteRecipes() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when favorites change expect storage update`() = runTest {
|
||||
coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge")
|
||||
subject.onFavoritesChange()
|
||||
coVerify { storage.updateFavoriteRecipes(eq(listOf("cake", "porridge"))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when favorites change expect factory invalidation`() = runTest {
|
||||
coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge")
|
||||
subject.onFavoritesChange()
|
||||
coVerify { pagingSourceFactory.invalidate() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when recipe update requested but favorite fails expect non-zero updates`() = runTest {
|
||||
coEvery { dataSource.getFavoriteRecipes() } throws Unauthorized(IOException())
|
||||
|
||||
@@ -5,15 +5,23 @@ import com.google.common.truth.Truth.assertThat
|
||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
||||
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RecipesListViewModelTest : BaseUnitTest() {
|
||||
@@ -24,6 +32,12 @@ class RecipesListViewModelTest : BaseUnitTest() {
|
||||
@MockK(relaxed = true)
|
||||
lateinit var recipeRepo: RecipeRepo
|
||||
|
||||
@Before
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
every { authRepo.isAuthorizedFlow } returns flowOf(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when authRepo isAuthorized changes to true expect that recipes are refreshed`() {
|
||||
every { authRepo.isAuthorizedFlow } returns flowOf(false, true)
|
||||
@@ -40,14 +54,12 @@ class RecipesListViewModelTest : BaseUnitTest() {
|
||||
|
||||
@Test
|
||||
fun `when authRepo isAuthorized doesn't change expect that recipes are not refreshed`() {
|
||||
every { authRepo.isAuthorizedFlow } returns flowOf(true)
|
||||
createSubject()
|
||||
coVerify(inverse = true) { recipeRepo.refreshRecipes() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when refreshRecipeInfo succeeds expect successful result`() = runTest {
|
||||
every { authRepo.isAuthorizedFlow } returns flowOf(true)
|
||||
val slug = "cake"
|
||||
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
|
||||
val actual = createSubject().refreshRecipeInfo(slug).asFlow().first()
|
||||
@@ -56,7 +68,6 @@ class RecipesListViewModelTest : BaseUnitTest() {
|
||||
|
||||
@Test
|
||||
fun `when refreshRecipeInfo succeeds expect call to repo`() = runTest {
|
||||
every { authRepo.isAuthorizedFlow } returns flowOf(true)
|
||||
val slug = "cake"
|
||||
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
|
||||
createSubject().refreshRecipeInfo(slug).asFlow().first()
|
||||
@@ -65,7 +76,6 @@ class RecipesListViewModelTest : BaseUnitTest() {
|
||||
|
||||
@Test
|
||||
fun `when refreshRecipeInfo fails expect result with error`() = runTest {
|
||||
every { authRepo.isAuthorizedFlow } returns flowOf(true)
|
||||
val slug = "cake"
|
||||
val result = Result.failure<Unit>(RuntimeException())
|
||||
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns result
|
||||
@@ -73,5 +83,38 @@ class RecipesListViewModelTest : BaseUnitTest() {
|
||||
assertThat(actual).isEqualTo(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when delete recipe expect successful result in flow`() = runTest {
|
||||
coEvery { recipeRepo.deleteRecipe(any()) } returns Result.success(Unit)
|
||||
val subject = createSubject()
|
||||
val results = runTestAndCollectFlow(subject.deleteRecipeResult) {
|
||||
subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY)
|
||||
}
|
||||
assertThat(results.single().isSuccess).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when delete recipe expect failed result in flow`() = runTest {
|
||||
coEvery { recipeRepo.deleteRecipe(any()) } returns Result.failure(IOException())
|
||||
val subject = createSubject()
|
||||
val results = runTestAndCollectFlow(subject.deleteRecipeResult) {
|
||||
subject.onDeleteConfirm(CAKE_RECIPE_SUMMARY_ENTITY)
|
||||
}
|
||||
assertThat(results.single().isFailure).isTrue()
|
||||
}
|
||||
|
||||
private inline fun <T> TestScope.runTestAndCollectFlow(
|
||||
flow: Flow<T>,
|
||||
block: () -> Unit,
|
||||
): List<T> {
|
||||
val results = mutableListOf<T>()
|
||||
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
flow.toList(results)
|
||||
}
|
||||
block()
|
||||
collectJob.cancel()
|
||||
return results
|
||||
}
|
||||
|
||||
private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger)
|
||||
}
|
||||
@@ -15,9 +15,7 @@ import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.Timeout
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ShareRecipeViewModelTest : BaseUnitTest() {
|
||||
@@ -27,9 +25,6 @@ class ShareRecipeViewModelTest : BaseUnitTest() {
|
||||
|
||||
lateinit var subject: ShareRecipeViewModel
|
||||
|
||||
@get:Rule
|
||||
val timeoutRule: Timeout = Timeout.seconds(5)
|
||||
|
||||
@Before
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
|
||||
@@ -46,4 +46,7 @@ interface RecipeDao {
|
||||
|
||||
@Query("UPDATE recipe_summaries SET is_favorite = 0 WHERE slug NOT IN (:favorites)")
|
||||
suspend fun setNonFavorite(favorites: List<String>)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
|
||||
}
|
||||
@@ -46,4 +46,6 @@ interface MealieDataSourceV0 {
|
||||
suspend fun removeFavoriteRecipe(userId: Int, recipeSlug: String)
|
||||
|
||||
suspend fun addFavoriteRecipe(userId: Int, recipeSlug: String)
|
||||
|
||||
suspend fun deleteRecipe(slug: String)
|
||||
}
|
||||
@@ -115,4 +115,12 @@ class MealieDataSourceV0Impl @Inject constructor(
|
||||
logMethod = { "addFavoriteRecipe" },
|
||||
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
||||
)
|
||||
|
||||
override suspend fun deleteRecipe(
|
||||
slug: String
|
||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||
block = { service.deleteRecipe(slug) },
|
||||
logMethod = { "deleteRecipe" },
|
||||
logParameters = { "slug = $slug" }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,4 +55,9 @@ interface MealieServiceV0 {
|
||||
@Path("userId") userId: Int,
|
||||
@Path("recipeSlug") recipeSlug: String
|
||||
)
|
||||
|
||||
@DELETE("/api/recipes/{slug}")
|
||||
suspend fun deleteRecipe(
|
||||
@Path("slug") slug: String
|
||||
)
|
||||
}
|
||||
@@ -54,4 +54,5 @@ interface MealieDataSourceV1 {
|
||||
suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String)
|
||||
|
||||
suspend fun addFavoriteRecipe(userId: String, recipeSlug: String)
|
||||
suspend fun deleteRecipe(slug: String)
|
||||
}
|
||||
@@ -126,5 +126,13 @@ class MealieDataSourceV1Impl @Inject constructor(
|
||||
logMethod = { "addFavoriteRecipe" },
|
||||
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
||||
)
|
||||
|
||||
override suspend fun deleteRecipe(
|
||||
slug: String
|
||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||
block = { service.deleteRecipe(slug) },
|
||||
logMethod = { "deleteRecipe" },
|
||||
logParameters = { "slug = $slug" }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,4 +61,9 @@ interface MealieServiceV1 {
|
||||
@Path("userId") userId: String,
|
||||
@Path("recipeSlug") recipeSlug: String
|
||||
)
|
||||
|
||||
@DELETE("/api/recipes/{slug}")
|
||||
suspend fun deleteRecipe(
|
||||
@Path("slug") slug: String
|
||||
)
|
||||
}
|
||||
@@ -7,20 +7,23 @@ import io.mockk.MockKAnnotations
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.rules.Timeout
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
open class BaseUnitTest {
|
||||
|
||||
@get:Rule
|
||||
@get:Rule(order = 0)
|
||||
val instantExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val timeoutRule: Timeout = Timeout.seconds(10)
|
||||
|
||||
protected val logger: Logger = FakeLogger()
|
||||
|
||||
lateinit var dispatchers: AppDispatchers
|
||||
@@ -30,10 +33,10 @@ open class BaseUnitTest {
|
||||
MockKAnnotations.init(this)
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
dispatchers = object : AppDispatchers {
|
||||
override val io: CoroutineDispatcher = StandardTestDispatcher()
|
||||
override val main: CoroutineDispatcher = StandardTestDispatcher()
|
||||
override val default: CoroutineDispatcher = StandardTestDispatcher()
|
||||
override val unconfined: CoroutineDispatcher = StandardTestDispatcher()
|
||||
override val io: CoroutineDispatcher = UnconfinedTestDispatcher()
|
||||
override val main: CoroutineDispatcher = UnconfinedTestDispatcher()
|
||||
override val default: CoroutineDispatcher = UnconfinedTestDispatcher()
|
||||
override val unconfined: CoroutineDispatcher = UnconfinedTestDispatcher()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user