Implement proper loading of recipe summaries

This commit is contained in:
Kirill Kamakin
2021-11-16 22:24:27 +03:00
parent b9f31ebbc7
commit c2129c763e
20 changed files with 505 additions and 111 deletions

View File

@@ -65,6 +65,9 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/constraintlayout // https://developer.android.com/jetpack/androidx/releases/constraintlayout
implementation "androidx.constraintlayout:constraintlayout:2.1.1" 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 // https://developer.android.com/jetpack/androidx/releases/lifecycle
def lifecycle_version = "2.4.0" def lifecycle_version = "2.4.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"

View File

@@ -5,4 +5,6 @@ import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
interface RecipeRepo { interface RecipeRepo {
fun createPager(): Pager<Int, RecipeEntity> fun createPager(): Pager<Int, RecipeEntity>
suspend fun clearLocalData()
} }

View File

@@ -14,7 +14,7 @@ interface RecipeDao {
@Query("SELECT * FROM categories") @Query("SELECT * FROM categories")
suspend fun queryAllCategories(): List<CategoryEntity> suspend fun queryAllCategories(): List<CategoryEntity>
@Query("SELECT * FROM recipes") @Query("SELECT * FROM recipes ORDER BY date_added DESC")
fun queryRecipesByPages(): PagingSource<Int, RecipeEntity> fun queryRecipesByPages(): PagingSource<Int, RecipeEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -41,7 +41,13 @@ interface RecipeDao {
@Query("DELETE FROM recipes") @Query("DELETE FROM recipes")
suspend fun removeAllRecipes() 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> suspend fun queryAllRecipes(): List<RecipeEntity>
@Query("SELECT * FROM category_recipe") @Query("SELECT * FROM category_recipe")

View File

@@ -9,4 +9,6 @@ interface RecipeStorage {
fun queryRecipes(): PagingSource<Int, RecipeEntity> fun queryRecipes(): PagingSource<Int, RecipeEntity>
suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>) suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>)
suspend fun clearAllLocalData()
} }

View File

@@ -99,4 +99,13 @@ class RecipeStorageImpl @Inject constructor(
saveRecipes(recipes) saveRecipes(recipes)
} }
} }
override suspend fun clearAllLocalData() {
Timber.v("clearAllLocalData() called")
db.withTransaction {
recipeDao.removeAllRecipes()
recipeDao.removeAllCategories()
recipeDao.removeAllTags()
}
}
} }

View File

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

View File

@@ -6,17 +6,27 @@ import androidx.paging.PagingConfig
import gq.kirmanak.mealie.data.recipes.RecipeRepo import gq.kirmanak.mealie.data.recipes.RecipeRepo
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ExperimentalPagingApi @ExperimentalPagingApi
class RecipeRepoImpl @Inject constructor( class RecipeRepoImpl @Inject constructor(
private val mediator: RecipesRemoteMediator, private val mediator: RecipesRemoteMediator,
private val storage: RecipeStorage private val storage: RecipeStorage,
private val pagingSourceFactory: RecipePagingSourceFactory
) : RecipeRepo { ) : RecipeRepo {
override fun createPager(): Pager<Int, RecipeEntity> { override fun createPager(): Pager<Int, RecipeEntity> {
val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = false, prefetchDistance = 10) Timber.v("createPager() called")
return Pager(pagingConfig, 0, mediator) { val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = true)
storage.queryRecipes() return Pager(
} config = pagingConfig,
remoteMediator = mediator,
pagingSourceFactory = pagingSourceFactory
)
}
override suspend fun clearLocalData() {
Timber.v("clearLocalData() called")
storage.clearAllLocalData()
} }
} }

View File

@@ -1,56 +1,63 @@
package gq.kirmanak.mealie.data.recipes.impl package gq.kirmanak.mealie.data.recipes.impl
import androidx.annotation.VisibleForTesting
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType 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.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource
import kotlinx.coroutines.CancellationException
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ExperimentalPagingApi @ExperimentalPagingApi
class RecipesRemoteMediator @Inject constructor( class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage, private val storage: RecipeStorage,
private val network: RecipeDataSource private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
) : RemoteMediator<Int, RecipeEntity>() { ) : RemoteMediator<Int, RecipeEntity>() {
@VisibleForTesting
var lastRequestEnd: Int = 0
override suspend fun load( override suspend fun load(
loadType: LoadType, loadType: LoadType,
state: PagingState<Int, RecipeEntity> state: PagingState<Int, RecipeEntity>
): MediatorResult { ): MediatorResult {
val pageSize = state.config.pageSize Timber.v("load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state")
val closestPage = state.anchorPosition?.let { state.closestPageToPosition(it) }
val start = when (loadType) { if (loadType == PREPEND) {
REFRESH -> 0 Timber.i("load: early exit, PREPEND isn't supported")
PREPEND -> closestPage?.prevKey ?: 0 return MediatorResult.Success(endOfPaginationReached = true)
APPEND -> closestPage?.nextKey ?: 0
}
val end = when (loadType) {
REFRESH -> pageSize
PREPEND, APPEND -> start + pageSize
} }
val recipes = try { val start = if (loadType == REFRESH) 0 else lastRequestEnd
network.requestRecipes(start = start, end = end) val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
} catch (e: Exception) {
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") Timber.e(e, "Can't load recipes")
return MediatorResult.Error(e) return MediatorResult.Error(e)
} }
try { // After something is inserted into DB the paging sources have to be invalidated
when (loadType) { // But for some reason Room/Paging library don't do it automatically
REFRESH -> storage.refreshAll(recipes) // Here we invalidate them manually.
PREPEND, APPEND -> storage.saveRecipes(recipes) // Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
} pagingSourceFactory.invalidate()
} catch (e: Exception) {
Timber.e(e, "Can't save recipes") Timber.d("load: expectedCount = $limit, received $count")
return MediatorResult.Error(e) lastRequestEnd = start + count
} return MediatorResult.Success(endOfPaginationReached = count < limit)
val expectedCount = end - start
val isEndReached = recipes.size < expectedCount
return MediatorResult.Success(isEndReached)
} }
} }

View File

@@ -1,5 +1,5 @@
package gq.kirmanak.mealie.data.recipes.network package gq.kirmanak.mealie.data.recipes.network
interface RecipeDataSource { interface RecipeDataSource {
suspend fun requestRecipes(start: Int = 0, end: Int = 9999): List<GetRecipeSummaryResponse> suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse>
} }

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealie.data.recipes.network 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.auth.AuthRepo
import gq.kirmanak.mealie.data.impl.RetrofitBuilder
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -13,10 +13,12 @@ class RecipeDataSourceImpl @Inject constructor(
) : RecipeDataSource { ) : RecipeDataSource {
private var _recipeService: RecipeService? = null private var _recipeService: RecipeService? = null
override suspend fun requestRecipes(start: Int, end: Int): List<GetRecipeSummaryResponse> { override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
Timber.v("requestRecipes() called") Timber.v("requestRecipes() called with: start = $start, limit = $limit")
val service: RecipeService = getRecipeService() 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 { private suspend fun getRecipeService(): RecipeService {

View File

@@ -7,6 +7,6 @@ interface RecipeService {
@GET("/api/recipes/summary") @GET("/api/recipes/summary")
suspend fun getRecipeSummary( suspend fun getRecipeSummary(
@Query("start") start: Int, @Query("start") start: Int,
@Query("end") end: Int @Query("limit") limit: Int
): List<GetRecipeSummaryResponse> ): List<GetRecipeSummaryResponse>
} }

View File

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

View File

@@ -75,7 +75,7 @@ class AuthenticationFragment : Fragment() {
"URL is empty" "URL is empty"
} ?: return } ?: return
} }
lifecycleScope.launchWhenResumed { viewLifecycleOwner.lifecycleScope.launchWhenResumed {
runCatching { runCatching {
viewModel.authenticate(email, pass, url) viewModel.authenticate(email, pass, url)
}.onFailure { }.onFailure {

View File

@@ -1,15 +1,19 @@
package gq.kirmanak.mealie.ui.auth package gq.kirmanak.mealie.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealie.data.auth.AuthRepo import gq.kirmanak.mealie.data.auth.AuthRepo
import gq.kirmanak.mealie.data.recipes.RecipeRepo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthenticationViewModel @Inject constructor( class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo
) : ViewModel() { ) : ViewModel() {
init { init {
Timber.v("constructor called") Timber.v("constructor called")
@@ -28,5 +32,6 @@ class AuthenticationViewModel @Inject constructor(
fun logout() { fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
authRepo.logout() authRepo.logout()
viewModelScope.launch { recipeRepo.clearLocalData() }
} }
} }

View File

@@ -3,24 +3,19 @@ package gq.kirmanak.mealie.ui.recipes
import android.widget.ImageView import android.widget.ImageView
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealie.data.recipes.RecipeImageLoader import gq.kirmanak.mealie.data.recipes.RecipeImageLoader
import gq.kirmanak.mealie.data.recipes.RecipeRepo import gq.kirmanak.mealie.data.recipes.RecipeRepo
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RecipeViewModel @Inject constructor( class RecipeViewModel @Inject constructor(
private val recipeRepo: RecipeRepo, recipeRepo: RecipeRepo,
private val recipeImageLoader: RecipeImageLoader private val recipeImageLoader: RecipeImageLoader
) : ViewModel() { ) : ViewModel() {
private val pager: Pager<Int, RecipeEntity> by lazy { recipeRepo.createPager() } val recipeFlow = recipeRepo.createPager().flow
val recipeFlow: Flow<PagingData<RecipeEntity>> by lazy { pager.flow.cachedIn(viewModelScope) }
fun loadRecipeImage(view: ImageView, recipe: RecipeEntity?) { fun loadRecipeImage(view: ImageView, recipe: RecipeEntity?) {
viewModelScope.launch { viewModelScope.launch {

View File

@@ -11,10 +11,14 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealie.databinding.FragmentRecipesBinding import gq.kirmanak.mealie.databinding.FragmentRecipesBinding
import gq.kirmanak.mealie.ui.SwipeRefreshLayoutHelper.listenToRefreshRequests
import gq.kirmanak.mealie.ui.auth.AuthenticationViewModel import gq.kirmanak.mealie.ui.auth.AuthenticationViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber import timber.log.Timber
@ExperimentalCoroutinesApi
@AndroidEntryPoint @AndroidEntryPoint
class RecipesFragment : Fragment() { class RecipesFragment : Fragment() {
private var _binding: FragmentRecipesBinding? = null private var _binding: FragmentRecipesBinding? = null
@@ -58,12 +62,26 @@ class RecipesFragment : Fragment() {
private fun setupRecipeAdapter() { private fun setupRecipeAdapter() {
Timber.v("setupRecipeAdapter() called") Timber.v("setupRecipeAdapter() called")
binding.recipes.layoutManager = LinearLayoutManager(requireContext()) binding.recipes.layoutManager = LinearLayoutManager(requireContext())
val recipesPagingAdapter = RecipesPagingAdapter(viewModel) val adapter = RecipesPagingAdapter(viewModel)
binding.recipes.adapter = recipesPagingAdapter binding.recipes.adapter = adapter
lifecycleScope.launchWhenResumed { viewLifecycleOwner.lifecycleScope.launchWhenResumed {
viewModel.recipeFlow.collectLatest { viewModel.recipeFlow.collect {
Timber.d("setupRecipeAdapter: received update") Timber.d("Received update")
recipesPagingAdapter.submitData(it) 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")
} }
} }
} }

View File

@@ -6,12 +6,20 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.recipes.RecipesFragment"> tools:context=".ui.recipes.RecipesFragment">
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/recipes" android:id="@+id/refresher"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

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

View File

@@ -4,10 +4,11 @@ import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealie.data.MealieDb import gq.kirmanak.mealie.data.MealieDb
import gq.kirmanak.mealie.data.auth.impl.HiltRobolectricTest 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.coroutines.runBlocking
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@@ -46,30 +47,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
fun `when saveRecipes then saves recipes`(): Unit = runBlocking { fun `when saveRecipes then saves recipes`(): Unit = runBlocking {
subject.saveRecipes(TEST_RECIPE_SUMMARIES) subject.saveRecipes(TEST_RECIPE_SUMMARIES)
val actualTags = mealieDb.recipeDao().queryAllRecipes() val actualTags = mealieDb.recipeDao().queryAllRecipes()
assertThat(actualTags).containsExactly( assertThat(actualTags).containsExactly(CAKE_RECIPE_ENTITY, PORRIDGE_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")
),
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"),
)
)
} }
@Test @Test
@@ -96,33 +74,59 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
) )
} }
companion object { @Test
private val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse( fun `when refreshAll then old recipes aren't preserved`(): Unit = runBlocking {
remoteId = 1, subject.saveRecipes(TEST_RECIPE_SUMMARIES)
name = "Cake", subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
slug = "cake", val actual = mealieDb.recipeDao().queryAllRecipes()
image = "86", assertThat(actual).containsExactly(
description = "A tasty cake", CAKE_RECIPE_ENTITY.copy(localId = 3),
recipeCategories = listOf("dessert", "tasty"),
tags = listOf("gluten", "allergic"),
rating = 4,
dateAdded = LocalDate.parse("2021-11-13"),
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
) )
}
private val RECIPE_SUMMARY_PORRIDGE = GetRecipeSummaryResponse( @Test
remoteId = 2, fun `when refreshAll then old category recipes aren't preserved`(): Unit = runBlocking {
name = "Porridge", subject.saveRecipes(TEST_RECIPE_SUMMARIES)
slug = "porridge", subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
image = "89", val actual = mealieDb.recipeDao().queryAllCategoryRecipes()
description = "A tasty porridge", assertThat(actual).containsExactly(
recipeCategories = listOf("porridge", "tasty"), CategoryRecipeEntity(categoryId = 1, recipeId = 3),
tags = listOf("gluten", "milk"), CategoryRecipeEntity(categoryId = 2, recipeId = 3),
rating = 5,
dateAdded = LocalDate.parse("2021-11-12"),
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
) )
}
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()
} }
} }

View File

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