Implement proper loading of recipe summaries
This commit is contained in:
@@ -5,4 +5,6 @@ import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
||||
|
||||
interface RecipeRepo {
|
||||
fun createPager(): Pager<Int, RecipeEntity>
|
||||
|
||||
suspend fun clearLocalData()
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface RecipeDao {
|
||||
@Query("SELECT * FROM categories")
|
||||
suspend fun queryAllCategories(): List<CategoryEntity>
|
||||
|
||||
@Query("SELECT * FROM recipes")
|
||||
@Query("SELECT * FROM recipes ORDER BY date_added DESC")
|
||||
fun queryRecipesByPages(): PagingSource<Int, RecipeEntity>
|
||||
|
||||
@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<RecipeEntity>
|
||||
|
||||
@Query("SELECT * FROM category_recipe")
|
||||
|
||||
@@ -9,4 +9,6 @@ interface RecipeStorage {
|
||||
fun queryRecipes(): PagingSource<Int, RecipeEntity>
|
||||
|
||||
suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>)
|
||||
|
||||
suspend fun clearAllLocalData()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int, RecipeEntity> {
|
||||
private val sources: MutableList<PagingSource<Int, RecipeEntity>> = mutableListOf()
|
||||
|
||||
override fun invoke(): PagingSource<Int, RecipeEntity> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<Int, RecipeEntity> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Int, RecipeEntity>() {
|
||||
|
||||
@VisibleForTesting
|
||||
var lastRequestEnd: Int = 0
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, RecipeEntity>
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
package gq.kirmanak.mealie.data.recipes.network
|
||||
|
||||
interface RecipeDataSource {
|
||||
suspend fun requestRecipes(start: Int = 0, end: Int = 9999): List<GetRecipeSummaryResponse>
|
||||
suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse>
|
||||
}
|
||||
@@ -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<GetRecipeSummaryResponse> {
|
||||
Timber.v("requestRecipes() called")
|
||||
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
|
||||
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 {
|
||||
|
||||
@@ -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<GetRecipeSummaryResponse>
|
||||
}
|
||||
@@ -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<Unit> {
|
||||
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 <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.listenToRefreshRequests(
|
||||
refreshLayout: SwipeRefreshLayout
|
||||
) {
|
||||
Timber.v("listenToRefreshRequests() called")
|
||||
refreshLayout.refreshesFlow().collectLatest {
|
||||
Timber.d("Received refresh request")
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class AuthenticationFragment : Fragment() {
|
||||
"URL is empty"
|
||||
} ?: return
|
||||
}
|
||||
lifecycleScope.launchWhenResumed {
|
||||
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
|
||||
runCatching {
|
||||
viewModel.authenticate(email, pass, url)
|
||||
}.onFailure {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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<Int, RecipeEntity> by lazy { recipeRepo.createPager() }
|
||||
val recipeFlow: Flow<PagingData<RecipeEntity>> by lazy { pager.flow.cachedIn(viewModelScope) }
|
||||
val recipeFlow = recipeRepo.createPager().flow
|
||||
|
||||
fun loadRecipeImage(view: ImageView, recipe: RecipeEntity?) {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,20 @@
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.recipes.RecipesFragment">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recipes"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/refresher"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recipes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:listitem="@layout/view_holder_recipe" />
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<PagingSource.LoadResult.Page<Int, RecipeEntity>> = emptyList(),
|
||||
anchorPosition: Int? = null
|
||||
): PagingState<Int, RecipeEntity> = PagingState(
|
||||
pages = pages,
|
||||
anchorPosition = anchorPosition,
|
||||
config = pagingConfig,
|
||||
leadingPlaceholderCount = 0
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user