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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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"
} ?: return
}
lifecycleScope.launchWhenResumed {
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
runCatching {
viewModel.authenticate(email, pass, url)
}.onFailure {

View File

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

View File

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

View File

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

View File

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

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

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