From c2129c763eda15fd1873fbecfe317fd47edae1f7 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 16 Nov 2021 22:24:27 +0300 Subject: [PATCH] Implement proper loading of recipe summaries --- app/build.gradle | 3 + .../mealie/data/recipes/RecipeRepo.kt | 2 + .../mealie/data/recipes/db/RecipeDao.kt | 10 +- .../mealie/data/recipes/db/RecipeStorage.kt | 2 + .../data/recipes/db/RecipeStorageImpl.kt | 9 ++ .../recipes/impl/RecipePagingSourceFactory.kt | 32 ++++ .../data/recipes/impl/RecipeRepoImpl.kt | 20 ++- .../recipes/impl/RecipesRemoteMediator.kt | 61 ++++---- .../data/recipes/network/RecipeDataSource.kt | 2 +- .../recipes/network/RecipeDataSourceImpl.kt | 10 +- .../data/recipes/network/RecipeService.kt | 2 +- .../mealie/ui/SwipeRefreshLayoutHelper.kt | 42 ++++++ .../mealie/ui/auth/AuthenticationFragment.kt | 2 +- .../mealie/ui/auth/AuthenticationViewModel.kt | 7 +- .../mealie/ui/recipes/RecipeViewModel.kt | 9 +- .../mealie/ui/recipes/RecipesFragment.kt | 30 +++- app/src/main/res/layout/fragment_recipes.xml | 18 ++- .../mealie/data/recipes/RecipeImplTestData.kt | 111 ++++++++++++++ .../data/recipes/db/RecipeStorageImplTest.kt | 106 +++++++------- .../recipes/impl/RecipesRemoteMediatorTest.kt | 138 ++++++++++++++++++ 20 files changed, 505 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipePagingSourceFactory.kt create mode 100644 app/src/main/java/gq/kirmanak/mealie/ui/SwipeRefreshLayoutHelper.kt create mode 100644 app/src/test/java/gq/kirmanak/mealie/data/recipes/RecipeImplTestData.kt create mode 100644 app/src/test/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediatorTest.kt diff --git a/app/build.gradle b/app/build.gradle index bdd9164..90cab58 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -65,6 +65,9 @@ dependencies { // https://developer.android.com/jetpack/androidx/releases/constraintlayout implementation "androidx.constraintlayout:constraintlayout:2.1.1" + // https://developer.android.com/jetpack/androidx/releases/swiperefreshlayout + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" + // https://developer.android.com/jetpack/androidx/releases/lifecycle def lifecycle_version = "2.4.0" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt index c53fb2e..f448144 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/RecipeRepo.kt @@ -5,4 +5,6 @@ import gq.kirmanak.mealie.data.recipes.db.RecipeEntity interface RecipeRepo { fun createPager(): Pager + + suspend fun clearLocalData() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt index 3d674d8..0d9f07d 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeDao.kt @@ -14,7 +14,7 @@ interface RecipeDao { @Query("SELECT * FROM categories") suspend fun queryAllCategories(): List - @Query("SELECT * FROM recipes") + @Query("SELECT * FROM recipes ORDER BY date_added DESC") fun queryRecipesByPages(): PagingSource @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -41,7 +41,13 @@ interface RecipeDao { @Query("DELETE FROM recipes") suspend fun removeAllRecipes() - @Query("SELECT * FROM recipes") + @Query("DELETE FROM tags") + suspend fun removeAllTags() + + @Query("DELETE FROM categories") + suspend fun removeAllCategories() + + @Query("SELECT * FROM recipes ORDER BY date_updated DESC") suspend fun queryAllRecipes(): List @Query("SELECT * FROM category_recipe") diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt index 98e8db8..52e7c92 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorage.kt @@ -9,4 +9,6 @@ interface RecipeStorage { fun queryRecipes(): PagingSource suspend fun refreshAll(recipes: List) + + suspend fun clearAllLocalData() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt index 887f490..bcfead0 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImpl.kt @@ -99,4 +99,13 @@ class RecipeStorageImpl @Inject constructor( saveRecipes(recipes) } } + + override suspend fun clearAllLocalData() { + Timber.v("clearAllLocalData() called") + db.withTransaction { + recipeDao.removeAllRecipes() + recipeDao.removeAllCategories() + recipeDao.removeAllTags() + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipePagingSourceFactory.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipePagingSourceFactory.kt new file mode 100644 index 0000000..1a95465 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipePagingSourceFactory.kt @@ -0,0 +1,32 @@ +package gq.kirmanak.mealie.data.recipes.impl + +import androidx.paging.PagingSource +import gq.kirmanak.mealie.data.recipes.db.RecipeEntity +import gq.kirmanak.mealie.data.recipes.db.RecipeStorage +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RecipePagingSourceFactory @Inject constructor( + private val recipeStorage: RecipeStorage +) : () -> PagingSource { + private val sources: MutableList> = mutableListOf() + + override fun invoke(): PagingSource { + Timber.v("invoke() called") + val newSource = recipeStorage.queryRecipes() + sources.add(newSource) + return newSource + } + + fun invalidate() { + Timber.v("invalidate() called") + for (source in sources) { + if (!source.invalid) { + source.invalidate() + } + } + sources.removeAll { it.invalid } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt index 41bc3c2..1a86131 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipeRepoImpl.kt @@ -6,17 +6,27 @@ import androidx.paging.PagingConfig import gq.kirmanak.mealie.data.recipes.RecipeRepo import gq.kirmanak.mealie.data.recipes.db.RecipeEntity import gq.kirmanak.mealie.data.recipes.db.RecipeStorage +import timber.log.Timber import javax.inject.Inject @ExperimentalPagingApi class RecipeRepoImpl @Inject constructor( private val mediator: RecipesRemoteMediator, - private val storage: RecipeStorage + private val storage: RecipeStorage, + private val pagingSourceFactory: RecipePagingSourceFactory ) : RecipeRepo { override fun createPager(): Pager { - val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = false, prefetchDistance = 10) - return Pager(pagingConfig, 0, mediator) { - storage.queryRecipes() - } + Timber.v("createPager() called") + val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = true) + return Pager( + config = pagingConfig, + remoteMediator = mediator, + pagingSourceFactory = pagingSourceFactory + ) + } + + override suspend fun clearLocalData() { + Timber.v("clearLocalData() called") + storage.clearAllLocalData() } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediator.kt index 45adb9f..69256f2 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediator.kt @@ -1,56 +1,63 @@ package gq.kirmanak.mealie.data.recipes.impl +import androidx.annotation.VisibleForTesting import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType -import androidx.paging.LoadType.* +import androidx.paging.LoadType.PREPEND +import androidx.paging.LoadType.REFRESH import androidx.paging.PagingState import androidx.paging.RemoteMediator import gq.kirmanak.mealie.data.recipes.db.RecipeEntity import gq.kirmanak.mealie.data.recipes.db.RecipeStorage import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource +import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject @ExperimentalPagingApi class RecipesRemoteMediator @Inject constructor( private val storage: RecipeStorage, - private val network: RecipeDataSource + private val network: RecipeDataSource, + private val pagingSourceFactory: RecipePagingSourceFactory, ) : RemoteMediator() { + @VisibleForTesting + var lastRequestEnd: Int = 0 + override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { - val pageSize = state.config.pageSize - val closestPage = state.anchorPosition?.let { state.closestPageToPosition(it) } - val start = when (loadType) { - REFRESH -> 0 - PREPEND -> closestPage?.prevKey ?: 0 - APPEND -> closestPage?.nextKey ?: 0 - } - val end = when (loadType) { - REFRESH -> pageSize - PREPEND, APPEND -> start + pageSize + Timber.v("load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state") + + if (loadType == PREPEND) { + Timber.i("load: early exit, PREPEND isn't supported") + return MediatorResult.Success(endOfPaginationReached = true) } - val recipes = try { - network.requestRecipes(start = start, end = end) - } catch (e: Exception) { + val start = if (loadType == REFRESH) 0 else lastRequestEnd + val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize + + val count: Int = try { + val recipes = network.requestRecipes(start, limit) + if (loadType == REFRESH) storage.refreshAll(recipes) + else storage.saveRecipes(recipes) + recipes.size + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { Timber.e(e, "Can't load recipes") return MediatorResult.Error(e) } - try { - when (loadType) { - REFRESH -> storage.refreshAll(recipes) - PREPEND, APPEND -> storage.saveRecipes(recipes) - } - } catch (e: Exception) { - Timber.e(e, "Can't save recipes") - return MediatorResult.Error(e) - } - val expectedCount = end - start - val isEndReached = recipes.size < expectedCount - return MediatorResult.Success(isEndReached) + // After something is inserted into DB the paging sources have to be invalidated + // But for some reason Room/Paging library don't do it automatically + // Here we invalidate them manually. + // Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858 + pagingSourceFactory.invalidate() + + Timber.d("load: expectedCount = $limit, received $count") + lastRequestEnd = start + count + return MediatorResult.Success(endOfPaginationReached = count < limit) } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSource.kt index 188b8f8..90aeb78 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSource.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSource.kt @@ -1,5 +1,5 @@ package gq.kirmanak.mealie.data.recipes.network interface RecipeDataSource { - suspend fun requestRecipes(start: Int = 0, end: Int = 9999): List + suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSourceImpl.kt index f3814b1..5cea55d 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeDataSourceImpl.kt @@ -1,7 +1,7 @@ package gq.kirmanak.mealie.data.recipes.network -import gq.kirmanak.mealie.data.impl.RetrofitBuilder import gq.kirmanak.mealie.data.auth.AuthRepo +import gq.kirmanak.mealie.data.impl.RetrofitBuilder import kotlinx.serialization.ExperimentalSerializationApi import timber.log.Timber import javax.inject.Inject @@ -13,10 +13,12 @@ class RecipeDataSourceImpl @Inject constructor( ) : RecipeDataSource { private var _recipeService: RecipeService? = null - override suspend fun requestRecipes(start: Int, end: Int): List { - Timber.v("requestRecipes() called") + override suspend fun requestRecipes(start: Int, limit: Int): List { + Timber.v("requestRecipes() called with: start = $start, limit = $limit") val service: RecipeService = getRecipeService() - return service.getRecipeSummary(start, end) + val recipeSummary = service.getRecipeSummary(start, limit) + Timber.v("requestRecipes() returned: $recipeSummary") + return recipeSummary } private suspend fun getRecipeService(): RecipeService { diff --git a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt index 6cfa483..9628cf0 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/recipes/network/RecipeService.kt @@ -7,6 +7,6 @@ interface RecipeService { @GET("/api/recipes/summary") suspend fun getRecipeSummary( @Query("start") start: Int, - @Query("end") end: Int + @Query("limit") limit: Int ): List } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/SwipeRefreshLayoutHelper.kt b/app/src/main/java/gq/kirmanak/mealie/ui/SwipeRefreshLayoutHelper.kt new file mode 100644 index 0000000..7d200ac --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealie/ui/SwipeRefreshLayoutHelper.kt @@ -0,0 +1,42 @@ +package gq.kirmanak.mealie.ui + +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collectLatest +import timber.log.Timber + +@ExperimentalCoroutinesApi +object SwipeRefreshLayoutHelper { + private fun SwipeRefreshLayout.refreshesFlow(): Flow { + Timber.v("refreshesFlow() called") + return callbackFlow { + val listener = SwipeRefreshLayout.OnRefreshListener { + Timber.v("Refresh requested") + trySendBlocking(Unit).onFailure { Timber.e(it, "Can't send refresh callback") } + } + Timber.v("Adding refresh request listener") + setOnRefreshListener(listener) + awaitClose { + Timber.v("Removing refresh request listener") + setOnRefreshListener(null) + } + } + } + + suspend fun PagingDataAdapter.listenToRefreshRequests( + refreshLayout: SwipeRefreshLayout + ) { + Timber.v("listenToRefreshRequests() called") + refreshLayout.refreshesFlow().collectLatest { + Timber.d("Received refresh request") + refresh() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt index e8e73e0..964aa6c 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt @@ -75,7 +75,7 @@ class AuthenticationFragment : Fragment() { "URL is empty" } ?: return } - lifecycleScope.launchWhenResumed { + viewLifecycleOwner.lifecycleScope.launchWhenResumed { runCatching { viewModel.authenticate(email, pass, url) }.onFailure { diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt index 47c1d80..e0d7ba2 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt @@ -1,15 +1,19 @@ package gq.kirmanak.mealie.ui.auth import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealie.data.auth.AuthRepo +import gq.kirmanak.mealie.data.recipes.RecipeRepo import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel class AuthenticationViewModel @Inject constructor( - private val authRepo: AuthRepo + private val authRepo: AuthRepo, + private val recipeRepo: RecipeRepo ) : ViewModel() { init { Timber.v("constructor called") @@ -28,5 +32,6 @@ class AuthenticationViewModel @Inject constructor( fun logout() { Timber.v("logout() called") authRepo.logout() + viewModelScope.launch { recipeRepo.clearLocalData() } } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewModel.kt index 3020093..ce07683 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipeViewModel.kt @@ -3,24 +3,19 @@ package gq.kirmanak.mealie.ui.recipes import android.widget.ImageView import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingData -import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealie.data.recipes.RecipeImageLoader import gq.kirmanak.mealie.data.recipes.RecipeRepo import gq.kirmanak.mealie.data.recipes.db.RecipeEntity -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RecipeViewModel @Inject constructor( - private val recipeRepo: RecipeRepo, + recipeRepo: RecipeRepo, private val recipeImageLoader: RecipeImageLoader ) : ViewModel() { - private val pager: Pager by lazy { recipeRepo.createPager() } - val recipeFlow: Flow> by lazy { pager.flow.cachedIn(viewModelScope) } + val recipeFlow = recipeRepo.createPager().flow fun loadRecipeImage(view: ImageView, recipe: RecipeEntity?) { viewModelScope.launch { diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt index 39780da..41de13d 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/recipes/RecipesFragment.kt @@ -11,10 +11,14 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealie.databinding.FragmentRecipesBinding +import gq.kirmanak.mealie.ui.SwipeRefreshLayoutHelper.listenToRefreshRequests import gq.kirmanak.mealie.ui.auth.AuthenticationViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest import timber.log.Timber +@ExperimentalCoroutinesApi @AndroidEntryPoint class RecipesFragment : Fragment() { private var _binding: FragmentRecipesBinding? = null @@ -58,12 +62,26 @@ class RecipesFragment : Fragment() { private fun setupRecipeAdapter() { Timber.v("setupRecipeAdapter() called") binding.recipes.layoutManager = LinearLayoutManager(requireContext()) - val recipesPagingAdapter = RecipesPagingAdapter(viewModel) - binding.recipes.adapter = recipesPagingAdapter - lifecycleScope.launchWhenResumed { - viewModel.recipeFlow.collectLatest { - Timber.d("setupRecipeAdapter: received update") - recipesPagingAdapter.submitData(it) + val adapter = RecipesPagingAdapter(viewModel) + binding.recipes.adapter = adapter + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + viewModel.recipeFlow.collect { + Timber.d("Received update") + adapter.submitData(it) + } + } + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + adapter.listenToRefreshRequests(binding.refresher) + } + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + adapter.onPagesUpdatedFlow.collect { + Timber.d("Pages have been updated") + binding.refresher.isRefreshing = false + } + } + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + adapter.loadStateFlow.collect { + Timber.d("New load state: $it") } } } diff --git a/app/src/main/res/layout/fragment_recipes.xml b/app/src/main/res/layout/fragment_recipes.xml index bce4175..9f83dca 100644 --- a/app/src/main/res/layout/fragment_recipes.xml +++ b/app/src/main/res/layout/fragment_recipes.xml @@ -6,12 +6,20 @@ android:layout_height="match_parent" tools:context=".ui.recipes.RecipesFragment"> - + app:layout_constraintTop_toTopOf="parent"> + + + + \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealie/data/recipes/RecipeImplTestData.kt b/app/src/test/java/gq/kirmanak/mealie/data/recipes/RecipeImplTestData.kt new file mode 100644 index 0000000..37649f9 --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealie/data/recipes/RecipeImplTestData.kt @@ -0,0 +1,111 @@ +package gq.kirmanak.mealie.data.recipes + +import gq.kirmanak.mealie.data.recipes.db.RecipeEntity +import gq.kirmanak.mealie.data.recipes.network.GetRecipeSummaryResponse +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer + +object RecipeImplTestData { + val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse( + remoteId = 1, + name = "Cake", + slug = "cake", + image = "86", + description = "A tasty cake", + recipeCategories = listOf("dessert", "tasty"), + tags = listOf("gluten", "allergic"), + rating = 4, + dateAdded = LocalDate.parse("2021-11-13"), + dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"), + ) + + val RECIPE_SUMMARY_PORRIDGE = GetRecipeSummaryResponse( + remoteId = 2, + name = "Porridge", + slug = "porridge", + image = "89", + description = "A tasty porridge", + recipeCategories = listOf("porridge", "tasty"), + tags = listOf("gluten", "milk"), + rating = 5, + dateAdded = LocalDate.parse("2021-11-12"), + dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), + ) + + val TEST_RECIPE_SUMMARIES = listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE) + + const val RECIPE_SUMMARY_SUCCESSFUL = """[ + { + "id": 1, + "name": "Cake", + "slug": "cake", + "image": "86", + "description": "A tasty cake", + "recipeCategory": ["dessert", "tasty"], + "tags": ["gluten", "allergic"], + "rating": 4, + "dateAdded": "2021-11-13", + "dateUpdated": "2021-11-13T15:30:13" + }, + { + "id": 2, + "name": "Porridge", + "slug": "porridge", + "image": "89", + "description": "A tasty porridge", + "recipeCategory": ["porridge", "tasty"], + "tags": ["gluten", "milk"], + "rating": 5, + "dateAdded": "2021-11-12", + "dateUpdated": "2021-10-13T17:35:23" + } + ]""" + + const val RECIPE_SUMMARY_UNSUCCESSFUL = """ + {"detail":"Unauthorized"} + """ + + val CAKE_RECIPE_ENTITY = RecipeEntity( + localId = 1, + remoteId = 1, + name = "Cake", + slug = "cake", + image = "86", + description = "A tasty cake", + rating = 4, + dateAdded = LocalDate.parse("2021-11-13"), + dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13") + ) + + val PORRIDGE_RECIPE_ENTITY = RecipeEntity( + localId = 2, + remoteId = 2, + name = "Porridge", + slug = "porridge", + image = "89", + description = "A tasty porridge", + rating = 5, + dateAdded = LocalDate.parse("2021-11-12"), + dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), + ) + + val TEST_RECIPE_ENTITIES = listOf(CAKE_RECIPE_ENTITY, PORRIDGE_RECIPE_ENTITY) + + fun MockWebServer.enqueueSuccessfulRecipeSummaryResponse() { + val response = MockResponse() + .setBody(RECIPE_SUMMARY_SUCCESSFUL) + .setHeader("Content-Type", "application/json") + .setResponseCode(200) + enqueue(response) + } + + fun MockWebServer.enqueueUnsuccessfulRecipeSummaryResponse() { + val response = MockResponse() + .setBody(RECIPE_SUMMARY_UNSUCCESSFUL) + .setHeader("Content-Type", "application/json") + .setResponseCode(401) + enqueue(response) + } +} \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImplTest.kt index 0c1af36..74a0f74 100644 --- a/app/src/test/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealie/data/recipes/db/RecipeStorageImplTest.kt @@ -4,10 +4,11 @@ import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import gq.kirmanak.mealie.data.MealieDb import gq.kirmanak.mealie.data.auth.impl.HiltRobolectricTest -import gq.kirmanak.mealie.data.recipes.network.GetRecipeSummaryResponse +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.CAKE_RECIPE_ENTITY +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.PORRIDGE_RECIPE_ENTITY +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.RECIPE_SUMMARY_CAKE +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.TEST_RECIPE_SUMMARIES import kotlinx.coroutines.runBlocking -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime import org.junit.Test import javax.inject.Inject @@ -46,30 +47,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { fun `when saveRecipes then saves recipes`(): Unit = runBlocking { subject.saveRecipes(TEST_RECIPE_SUMMARIES) val actualTags = mealieDb.recipeDao().queryAllRecipes() - assertThat(actualTags).containsExactly( - RecipeEntity( - localId = 1, - remoteId = 1, - name = "Cake", - slug = "cake", - image = "86", - description = "A tasty cake", - rating = 4, - dateAdded = LocalDate.parse("2021-11-13"), - dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13") - ), - RecipeEntity( - localId = 2, - remoteId = 2, - name = "Porridge", - slug = "porridge", - image = "89", - description = "A tasty porridge", - rating = 5, - dateAdded = LocalDate.parse("2021-11-12"), - dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), - ) - ) + assertThat(actualTags).containsExactly(CAKE_RECIPE_ENTITY, PORRIDGE_RECIPE_ENTITY) } @Test @@ -96,33 +74,59 @@ class RecipeStorageImplTest : HiltRobolectricTest() { ) } - companion object { - private val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse( - remoteId = 1, - name = "Cake", - slug = "cake", - image = "86", - description = "A tasty cake", - recipeCategories = listOf("dessert", "tasty"), - tags = listOf("gluten", "allergic"), - rating = 4, - dateAdded = LocalDate.parse("2021-11-13"), - dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"), + @Test + fun `when refreshAll then old recipes aren't preserved`(): Unit = runBlocking { + subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) + val actual = mealieDb.recipeDao().queryAllRecipes() + assertThat(actual).containsExactly( + CAKE_RECIPE_ENTITY.copy(localId = 3), ) + } - private val RECIPE_SUMMARY_PORRIDGE = GetRecipeSummaryResponse( - remoteId = 2, - name = "Porridge", - slug = "porridge", - image = "89", - description = "A tasty porridge", - recipeCategories = listOf("porridge", "tasty"), - tags = listOf("gluten", "milk"), - rating = 5, - dateAdded = LocalDate.parse("2021-11-12"), - dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), + @Test + fun `when refreshAll then old category recipes aren't preserved`(): Unit = runBlocking { + subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) + val actual = mealieDb.recipeDao().queryAllCategoryRecipes() + assertThat(actual).containsExactly( + CategoryRecipeEntity(categoryId = 1, recipeId = 3), + CategoryRecipeEntity(categoryId = 2, recipeId = 3), ) + } - private val TEST_RECIPE_SUMMARIES = listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE) + @Test + fun `when refreshAll then old tag recipes aren't preserved`(): Unit = runBlocking { + subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) + val actual = mealieDb.recipeDao().queryAllTagRecipes() + assertThat(actual).containsExactly( + TagRecipeEntity(tagId = 1, recipeId = 3), + TagRecipeEntity(tagId = 2, recipeId = 3), + ) + } + + @Test + fun `when clearAllLocalData then recipes aren't preserved`(): Unit = runBlocking { + subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.clearAllLocalData() + val actual = mealieDb.recipeDao().queryAllRecipes() + assertThat(actual).isEmpty() + } + + @Test + fun `when clearAllLocalData then categories aren't preserved`(): Unit = runBlocking { + subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.clearAllLocalData() + val actual = mealieDb.recipeDao().queryAllCategories() + assertThat(actual).isEmpty() + } + + @Test + fun `when clearAllLocalData then tags aren't preserved`(): Unit = runBlocking { + subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.clearAllLocalData() + val actual = mealieDb.recipeDao().queryAllTags() + assertThat(actual).isEmpty() } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediatorTest.kt b/app/src/test/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediatorTest.kt new file mode 100644 index 0000000..c4a118d --- /dev/null +++ b/app/src/test/java/gq/kirmanak/mealie/data/recipes/impl/RecipesRemoteMediatorTest.kt @@ -0,0 +1,138 @@ +package gq.kirmanak.mealie.data.recipes.impl + +import androidx.paging.* +import androidx.paging.LoadType.* +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import gq.kirmanak.mealie.data.MealieDb +import gq.kirmanak.mealie.data.auth.AuthRepo +import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_PASSWORD +import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_USERNAME +import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.enqueueSuccessfulAuthResponse +import gq.kirmanak.mealie.data.auth.impl.MockServerTest +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.CAKE_RECIPE_ENTITY +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.PORRIDGE_RECIPE_ENTITY +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.TEST_RECIPE_ENTITIES +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.enqueueSuccessfulRecipeSummaryResponse +import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.enqueueUnsuccessfulRecipeSummaryResponse +import gq.kirmanak.mealie.data.recipes.db.RecipeEntity +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import javax.inject.Inject + +@ExperimentalPagingApi +@HiltAndroidTest +class RecipesRemoteMediatorTest : MockServerTest() { + private val pagingConfig = PagingConfig( + pageSize = 2, + prefetchDistance = 5, + enablePlaceholders = false + ) + + @Inject + lateinit var subject: RecipesRemoteMediator + + @Inject + lateinit var authRepo: AuthRepo + + @Inject + lateinit var mealieDb: MealieDb + + @Before + fun authenticate(): Unit = runBlocking { + mockServer.enqueueSuccessfulAuthResponse() + authRepo.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) + mockServer.takeRequest() + } + + @Test + fun `when first load with refresh successful then result success`(): Unit = runBlocking { + mockServer.enqueueSuccessfulRecipeSummaryResponse() + val result = subject.load(REFRESH, pagingState()) + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + } + + @Test + fun `when first load with refresh successful then recipes stored`(): Unit = runBlocking { + mockServer.enqueueSuccessfulRecipeSummaryResponse() + subject.load(REFRESH, pagingState()) + val actual = mealieDb.recipeDao().queryAllRecipes() + assertThat(actual).containsExactly(CAKE_RECIPE_ENTITY, PORRIDGE_RECIPE_ENTITY) + } + + @Test + fun `when load state prepend then success`(): Unit = runBlocking { + val result = subject.load(PREPEND, pagingState()) + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + } + + @Test + fun `when load state prepend then end is reached`(): Unit = runBlocking { + val result = subject.load(PREPEND, pagingState()) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isTrue() + } + + @Test + fun `when load successful then lastRequestEnd updated`(): Unit = runBlocking { + mockServer.enqueueSuccessfulRecipeSummaryResponse() + subject.load(REFRESH, pagingState()) + val actual = subject.lastRequestEnd + assertThat(actual).isEqualTo(2) + } + + @Test + fun `when load fails then lastRequestEnd still 0`(): Unit = runBlocking { + mockServer.enqueueUnsuccessfulRecipeSummaryResponse() + subject.load(REFRESH, pagingState()) + val actual = subject.lastRequestEnd + assertThat(actual).isEqualTo(0) + } + + @Test + fun `when load fails then result is error`(): Unit = runBlocking { + mockServer.enqueueUnsuccessfulRecipeSummaryResponse() + val actual = subject.load(REFRESH, pagingState()) + assertThat(actual).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java) + } + + @Test + fun `when refresh then request params correct`(): Unit = runBlocking { + mockServer.enqueueUnsuccessfulRecipeSummaryResponse() + subject.load(REFRESH, pagingState()) + val actual = mockServer.takeRequest().path + assertThat(actual).isEqualTo("/api/recipes/summary?start=0&limit=6") + } + + @Test + fun `when append then request params correct`(): Unit = runBlocking { + mockServer.enqueueSuccessfulRecipeSummaryResponse() + subject.load(REFRESH, pagingState()) + mockServer.takeRequest() + mockServer.enqueueSuccessfulRecipeSummaryResponse() + subject.load(APPEND, pagingState()) + val actual = mockServer.takeRequest().path + assertThat(actual).isEqualTo("/api/recipes/summary?start=2&limit=2") + } + + @Test + fun `when append fails then recipes aren't removed`(): Unit = runBlocking { + mockServer.enqueueSuccessfulRecipeSummaryResponse() + subject.load(REFRESH, pagingState()) + mockServer.takeRequest() + mockServer.enqueueUnsuccessfulRecipeSummaryResponse() + subject.load(APPEND, pagingState()) + val actual = mealieDb.recipeDao().queryAllRecipes() + assertThat(actual).isEqualTo(TEST_RECIPE_ENTITIES) + } + + private fun pagingState( + pages: List> = emptyList(), + anchorPosition: Int? = null + ): PagingState = PagingState( + pages = pages, + anchorPosition = anchorPosition, + config = pagingConfig, + leadingPlaceholderCount = 0 + ) +} \ No newline at end of file