Merge pull request #120 from kirmanak/delete-recipe

Add "delete recipe" button
This commit is contained in:
Kirill Kamakin
2022-12-16 21:26:54 +01:00
committed by GitHub
26 changed files with 255 additions and 53 deletions

View File

@@ -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)
}
} }

View File

@@ -19,4 +19,6 @@ interface RecipeRepo {
suspend fun refreshRecipes() suspend fun refreshRecipes()
suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result<Unit> suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result<Unit>
suspend fun deleteRecipe(entity: RecipeSummaryEntity): Result<Unit>
} }

View File

@@ -19,4 +19,6 @@ interface RecipeStorage {
suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity? suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity?
suspend fun updateFavoriteRecipes(favorites: List<String>) suspend fun updateFavoriteRecipes(favorites: List<String>)
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
} }

View File

@@ -80,4 +80,9 @@ class RecipeStorageImpl @Inject constructor(
recipeDao.setNonFavorite(favorites) recipeDao.setNonFavorite(favorites)
} }
} }
override suspend fun deleteRecipe(entity: RecipeSummaryEntity) {
logger.v { "deleteRecipeBySlug() called with: entity = $entity" }
recipeDao.deleteRecipe(entity)
}
} }

View File

@@ -78,11 +78,24 @@ class RecipeRepoImpl @Inject constructor(
): Result<Unit> = runCatchingExceptCancel { ): Result<Unit> = runCatchingExceptCancel {
logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" } logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" }
dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite) dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite)
mediator.onFavoritesChange() val favorites = dataSource.getFavoriteRecipes()
storage.updateFavoriteRecipes(favorites)
pagingSourceFactory.invalidate()
}.onFailure { }.onFailure {
logger.e(it) { "Can't update recipe's is favorite status" } 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 { companion object {
private const val LOAD_PAGE_SIZE = 50 private const val LOAD_PAGE_SIZE = 50
private const val INITIAL_LOAD_PAGE_SIZE = LOAD_PAGE_SIZE * 3 private const val INITIAL_LOAD_PAGE_SIZE = LOAD_PAGE_SIZE * 3

View File

@@ -83,10 +83,4 @@ class RecipesRemoteMediator @Inject constructor(
recipes.size recipes.size
} }
suspend fun onFavoritesChange() {
logger.v { "onFavoritesChange() called" }
val favorites = network.getFavoriteRecipes()
storage.updateFavoriteRecipes(favorites)
pagingSourceFactory.invalidate()
}
} }

View File

@@ -8,4 +8,6 @@ interface RecipeDataSource {
suspend fun getFavoriteRecipes(): List<String> suspend fun getFavoriteRecipes(): List<String>
suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean)
suspend fun deleteRecipe(recipeSlug: String)
} }

View File

@@ -47,6 +47,10 @@ class RecipeViewHolder @AssistedInject constructor(
override val recipeSummaryEntity: RecipeSummaryEntity override val recipeSummaryEntity: RecipeSummaryEntity
) : ClickEvent() ) : ClickEvent()
data class DeleteClick(
override val recipeSummaryEntity: RecipeSummaryEntity
) : ClickEvent()
} }
private val loadingPlaceholder by lazy { private val loadingPlaceholder by lazy {
@@ -62,6 +66,7 @@ class RecipeViewHolder @AssistedInject constructor(
logger.d { "bind: item clicked $entity" } logger.d { "bind: item clicked $entity" }
clickListener(ClickEvent.RecipeClick(entity)) clickListener(ClickEvent.RecipeClick(entity))
} }
binding.favoriteIcon.isVisible = showFavoriteIcon binding.favoriteIcon.isVisible = showFavoriteIcon
binding.favoriteIcon.setOnClickListener { binding.favoriteIcon.setOnClickListener {
clickListener(ClickEvent.FavoriteClick(entity)) clickListener(ClickEvent.FavoriteClick(entity))
@@ -80,6 +85,10 @@ class RecipeViewHolder @AssistedInject constructor(
R.string.view_holder_recipe_non_favorite_content_description R.string.view_holder_recipe_non_favorite_content_description
} }
) )
binding.deleteIcon.setOnClickListener {
clickListener(ClickEvent.DeleteClick(item))
}
} }
} }
} }

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.ui.recipes package gq.kirmanak.mealient.ui.recipes
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.StringRes import androidx.annotation.StringRes
@@ -13,6 +14,7 @@ import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity 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 checkedMenuItemId = R.id.recipes_list
) )
} }
viewModel.showFavoriteIcon.observe(viewLifecycleOwner) { showFavoriteIcon -> collectWhenViewResumed(viewModel.showFavoriteIcon) { showFavoriteIcon ->
setupRecipeAdapter(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() hideKeyboardOnScroll()
} }
@@ -100,6 +108,9 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) {
is RecipeViewHolder.ClickEvent.RecipeClick -> { is RecipeViewHolder.ClickEvent.RecipeClick -> {
onRecipeClicked(it.recipeSummaryEntity) 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) { private fun onFavoriteClick(event: RecipeViewHolder.ClickEvent) {
logger.v { "onFavoriteClick() called with: event = $event" } logger.v { "onFavoriteClick() called with: event = $event" }
viewModel.onFavoriteIconClick(event.recipeSummaryEntity).observe(viewLifecycleOwner) { viewModel.onFavoriteIconClick(event.recipeSummaryEntity).observe(viewLifecycleOwner) {

View File

@@ -2,9 +2,9 @@ package gq.kirmanak.mealient.ui.recipes
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo 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.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.extensions.valueUpdatesOnly
import gq.kirmanak.mealient.logging.Logger 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -23,8 +31,18 @@ class RecipesListViewModel @Inject constructor(
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope) val pagingData: Flow<PagingData<RecipeSummaryEntity>> = recipeRepo.createPager().flow
val showFavoriteIcon = authRepo.isAuthorizedFlow.asLiveData() .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 { init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized -> authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
@@ -49,4 +67,13 @@ class RecipesListViewModel @Inject constructor(
isFavorite = recipeSummaryEntity.isFavorite.not(), isFavorite = recipeSummaryEntity.isFavorite.not(),
).also { emit(it) } ).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)
}
}
} }

View 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>

View File

@@ -38,9 +38,8 @@
app:layout_constraintDimensionRatio="2:1" app:layout_constraintDimensionRatio="2:1"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="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_constraintVertical_chainStyle="packed"
app:layout_goneMarginTop="@dimen/margin_medium"
app:shapeAppearance="?shapeAppearanceCornerMedium" app:shapeAppearance="?shapeAppearanceCornerMedium"
tools:srcCompat="@drawable/placeholder_recipe" /> tools:srcCompat="@drawable/placeholder_recipe" />
@@ -54,6 +53,19 @@
app:layout_constraintEnd_toEndOf="@id/image" app:layout_constraintEnd_toEndOf="@id/image"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/ic_favorite_unfilled" 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> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

View File

@@ -57,4 +57,10 @@
<string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string> <string name="content_description_activity_share_recipe_progress">Индикатор прогресса</string>
<string name="view_holder_recipe_favorite_content_description">Добавлен в избранное</string> <string name="view_holder_recipe_favorite_content_description">Добавлен в избранное</string>
<string name="view_holder_recipe_non_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> </resources>

View File

@@ -50,6 +50,11 @@
<string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string> <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_load_failure_toast_no_connection">no connection</string>
<string name="fragment_recipes_favorite_update_failed">Favorite status update failed</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="menu_navigation_drawer_change_url">Change URL</string>
<string name="search_recipes_hint">Search recipes</string> <string name="search_recipes_hint">Search recipes</string>
<string name="menu_navigation_drawer_header" translatable="false">@string/app_name</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="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_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_non_favorite_content_description">Item is not favorite</string>
<string name="view_holder_recipe_delete_content_description">Delete recipe</string>
</resources> </resources>

View File

@@ -8,9 +8,11 @@ import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_FULL_RECIPE_INFO 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 gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -70,19 +72,25 @@ class RecipeRepoTest : BaseUnitTest() {
@Test @Test
fun `when remove favorite recipe expect correct sequence`() = runTest { fun `when remove favorite recipe expect correct sequence`() = runTest {
coEvery { dataSource.getFavoriteRecipes() } returns listOf("porridge")
subject.updateIsRecipeFavorite("cake", false) subject.updateIsRecipeFavorite("cake", false)
coVerify { coVerify {
dataSource.updateIsRecipeFavorite(eq("cake"), eq(false)) dataSource.updateIsRecipeFavorite(eq("cake"), eq(false))
remoteMediator.onFavoritesChange() dataSource.getFavoriteRecipes()
storage.updateFavoriteRecipes(eq(listOf("porridge")))
pagingSourceFactory.invalidate()
} }
} }
@Test @Test
fun `when add favorite recipe expect correct sequence`() = runTest { fun `when add favorite recipe expect correct sequence`() = runTest {
coEvery { dataSource.getFavoriteRecipes() } returns listOf("porridge", "cake")
subject.updateIsRecipeFavorite("porridge", true) subject.updateIsRecipeFavorite("porridge", true)
coVerify { coVerify {
dataSource.updateIsRecipeFavorite(eq("porridge"), eq(true)) 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()) dataSource.updateIsRecipeFavorite(any(), any())
} throws Unauthorized(IOException()) } throws Unauthorized(IOException())
subject.updateIsRecipeFavorite("porridge", true) subject.updateIsRecipeFavorite("porridge", true)
coVerify(inverse = true) { remoteMediator.onFavoritesChange() } coVerify(inverse = true) { dataSource.getFavoriteRecipes() }
} }
@Test @Test
@@ -100,4 +108,21 @@ class RecipeRepoTest : BaseUnitTest() {
subject.refreshRecipes() subject.refreshRecipes()
coVerify { remoteMediator.updateRecipes(eq(0), eq(150), eq(LoadType.REFRESH)) } 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()) }
}
} }

View File

@@ -144,27 +144,6 @@ class RecipesRemoteMediatorTest : BaseUnitTest() {
coVerify { storage.refreshAll(TEST_RECIPE_SUMMARY_ENTITIES) } 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 @Test
fun `when recipe update requested but favorite fails expect non-zero updates`() = runTest { fun `when recipe update requested but favorite fails expect non-zero updates`() = runTest {
coEvery { dataSource.getFavoriteRecipes() } throws Unauthorized(IOException()) coEvery { dataSource.getFavoriteRecipes() } throws Unauthorized(IOException())

View File

@@ -5,15 +5,23 @@ import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.BaseUnitTest
import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf 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 kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class RecipesListViewModelTest : BaseUnitTest() { class RecipesListViewModelTest : BaseUnitTest() {
@@ -24,6 +32,12 @@ class RecipesListViewModelTest : BaseUnitTest() {
@MockK(relaxed = true) @MockK(relaxed = true)
lateinit var recipeRepo: RecipeRepo lateinit var recipeRepo: RecipeRepo
@Before
override fun setUp() {
super.setUp()
every { authRepo.isAuthorizedFlow } returns flowOf(true)
}
@Test @Test
fun `when authRepo isAuthorized changes to true expect that recipes are refreshed`() { fun `when authRepo isAuthorized changes to true expect that recipes are refreshed`() {
every { authRepo.isAuthorizedFlow } returns flowOf(false, true) every { authRepo.isAuthorizedFlow } returns flowOf(false, true)
@@ -40,14 +54,12 @@ class RecipesListViewModelTest : BaseUnitTest() {
@Test @Test
fun `when authRepo isAuthorized doesn't change expect that recipes are not refreshed`() { fun `when authRepo isAuthorized doesn't change expect that recipes are not refreshed`() {
every { authRepo.isAuthorizedFlow } returns flowOf(true)
createSubject() createSubject()
coVerify(inverse = true) { recipeRepo.refreshRecipes() } coVerify(inverse = true) { recipeRepo.refreshRecipes() }
} }
@Test @Test
fun `when refreshRecipeInfo succeeds expect successful result`() = runTest { fun `when refreshRecipeInfo succeeds expect successful result`() = runTest {
every { authRepo.isAuthorizedFlow } returns flowOf(true)
val slug = "cake" val slug = "cake"
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit) coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
val actual = createSubject().refreshRecipeInfo(slug).asFlow().first() val actual = createSubject().refreshRecipeInfo(slug).asFlow().first()
@@ -56,7 +68,6 @@ class RecipesListViewModelTest : BaseUnitTest() {
@Test @Test
fun `when refreshRecipeInfo succeeds expect call to repo`() = runTest { fun `when refreshRecipeInfo succeeds expect call to repo`() = runTest {
every { authRepo.isAuthorizedFlow } returns flowOf(true)
val slug = "cake" val slug = "cake"
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit) coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns Result.success(Unit)
createSubject().refreshRecipeInfo(slug).asFlow().first() createSubject().refreshRecipeInfo(slug).asFlow().first()
@@ -65,7 +76,6 @@ class RecipesListViewModelTest : BaseUnitTest() {
@Test @Test
fun `when refreshRecipeInfo fails expect result with error`() = runTest { fun `when refreshRecipeInfo fails expect result with error`() = runTest {
every { authRepo.isAuthorizedFlow } returns flowOf(true)
val slug = "cake" val slug = "cake"
val result = Result.failure<Unit>(RuntimeException()) val result = Result.failure<Unit>(RuntimeException())
coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns result coEvery { recipeRepo.refreshRecipeInfo(eq(slug)) } returns result
@@ -73,5 +83,38 @@ class RecipesListViewModelTest : BaseUnitTest() {
assertThat(actual).isEqualTo(result) 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) private fun createSubject() = RecipesListViewModel(recipeRepo, authRepo, logger)
} }

View File

@@ -15,9 +15,7 @@ import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.Timeout
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ShareRecipeViewModelTest : BaseUnitTest() { class ShareRecipeViewModelTest : BaseUnitTest() {
@@ -27,9 +25,6 @@ class ShareRecipeViewModelTest : BaseUnitTest() {
lateinit var subject: ShareRecipeViewModel lateinit var subject: ShareRecipeViewModel
@get:Rule
val timeoutRule: Timeout = Timeout.seconds(5)
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()

View File

@@ -46,4 +46,7 @@ interface RecipeDao {
@Query("UPDATE recipe_summaries SET is_favorite = 0 WHERE slug NOT IN (:favorites)") @Query("UPDATE recipe_summaries SET is_favorite = 0 WHERE slug NOT IN (:favorites)")
suspend fun setNonFavorite(favorites: List<String>) suspend fun setNonFavorite(favorites: List<String>)
@Delete
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
} }

View File

@@ -46,4 +46,6 @@ interface MealieDataSourceV0 {
suspend fun removeFavoriteRecipe(userId: Int, recipeSlug: String) suspend fun removeFavoriteRecipe(userId: Int, recipeSlug: String)
suspend fun addFavoriteRecipe(userId: Int, recipeSlug: String) suspend fun addFavoriteRecipe(userId: Int, recipeSlug: String)
suspend fun deleteRecipe(slug: String)
} }

View File

@@ -115,4 +115,12 @@ class MealieDataSourceV0Impl @Inject constructor(
logMethod = { "addFavoriteRecipe" }, logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
) )
override suspend fun deleteRecipe(
slug: String
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteRecipe(slug) },
logMethod = { "deleteRecipe" },
logParameters = { "slug = $slug" }
)
} }

View File

@@ -55,4 +55,9 @@ interface MealieServiceV0 {
@Path("userId") userId: Int, @Path("userId") userId: Int,
@Path("recipeSlug") recipeSlug: String @Path("recipeSlug") recipeSlug: String
) )
@DELETE("/api/recipes/{slug}")
suspend fun deleteRecipe(
@Path("slug") slug: String
)
} }

View File

@@ -54,4 +54,5 @@ interface MealieDataSourceV1 {
suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String)
suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) suspend fun addFavoriteRecipe(userId: String, recipeSlug: String)
suspend fun deleteRecipe(slug: String)
} }

View File

@@ -126,5 +126,13 @@ class MealieDataSourceV1Impl @Inject constructor(
logMethod = { "addFavoriteRecipe" }, logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
) )
override suspend fun deleteRecipe(
slug: String
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteRecipe(slug) },
logMethod = { "deleteRecipe" },
logParameters = { "slug = $slug" }
)
} }

View File

@@ -61,4 +61,9 @@ interface MealieServiceV1 {
@Path("userId") userId: String, @Path("userId") userId: String,
@Path("recipeSlug") recipeSlug: String @Path("recipeSlug") recipeSlug: String
) )
@DELETE("/api/recipes/{slug}")
suspend fun deleteRecipe(
@Path("slug") slug: String
)
} }

View File

@@ -7,20 +7,23 @@ import io.mockk.MockKAnnotations
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.rules.Timeout
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
open class BaseUnitTest { open class BaseUnitTest {
@get:Rule @get:Rule(order = 0)
val instantExecutorRule = InstantTaskExecutorRule() val instantExecutorRule = InstantTaskExecutorRule()
@get:Rule(order = 1)
val timeoutRule: Timeout = Timeout.seconds(10)
protected val logger: Logger = FakeLogger() protected val logger: Logger = FakeLogger()
lateinit var dispatchers: AppDispatchers lateinit var dispatchers: AppDispatchers
@@ -30,10 +33,10 @@ open class BaseUnitTest {
MockKAnnotations.init(this) MockKAnnotations.init(this)
Dispatchers.setMain(UnconfinedTestDispatcher()) Dispatchers.setMain(UnconfinedTestDispatcher())
dispatchers = object : AppDispatchers { dispatchers = object : AppDispatchers {
override val io: CoroutineDispatcher = StandardTestDispatcher() override val io: CoroutineDispatcher = UnconfinedTestDispatcher()
override val main: CoroutineDispatcher = StandardTestDispatcher() override val main: CoroutineDispatcher = UnconfinedTestDispatcher()
override val default: CoroutineDispatcher = StandardTestDispatcher() override val default: CoroutineDispatcher = UnconfinedTestDispatcher()
override val unconfined: CoroutineDispatcher = StandardTestDispatcher() override val unconfined: CoroutineDispatcher = UnconfinedTestDispatcher()
} }
} }