From df2dd3e7ca6a56d095ced9a0ea446b7c7227c3af Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Fri, 22 May 2026 11:19:26 -0500 Subject: [PATCH] Fix: suggestions get stuck on loading for hours/days (#1364) ## Description This should fix the issue where suggestions would sometimes get stuck loading. My best guess is that they were getting stuck because the app thought it was still loading, even though the background job was just waiting. This should fix that by making each library kick off its own suggestions job when needed. ### Related issues Fixes #1065 ### Testing Emulator and nvidia shield ## Screenshots N/A no ui changes ## AI or LLM usage Used codex to help diagnosis the bug. Also added unit tests and more debug logging for the suggestions system using ai. Wrote the fix myself. --------- Co-authored-by: Damontecres --- .../wholphin/services/LatestNextUpWorker.kt | 2 +- .../wholphin/services/SuggestionService.kt | 106 ++++++++++-- .../wholphin/services/SuggestionsWorker.kt | 153 ++++++++++++++---- .../LatestNextUpSchedulerServiceTest.kt | 68 ++++++++ .../services/SuggestionServiceTest.kt | 115 ++++++++++--- .../services/SuggestionsWorkerTest.kt | 78 ++++++++- 6 files changed, 452 insertions(+), 70 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt index 328346f3..5c562cd1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -97,7 +97,7 @@ class LatestNextUpSchedulerService serverRepository.current.observe(activity) { user -> Timber.v("New user %s", user?.user?.id) if (user == null) { - workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) + workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) } else { activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { scheduleWork(user.user.id, user.server.id) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt index 0fe32179..e6d1b0db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -1,10 +1,16 @@ package com.github.damontecres.wholphin.services import androidx.lifecycle.asFlow +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager +import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.util.GetItemsRequestHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -52,36 +58,106 @@ class SuggestionService val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) val cachedSuggestions = cache.get(userId, parentId, itemKind) if (cachedSuggestions == null) { + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, itemKind) + Timber.d( + "No cached suggestions for parent=%s kind=%s; scheduling one-time suggestions refresh", + parentId, + itemKind, + ) + enqueueSuggestionsWork(user, parentId, itemKind, workName) workManager - .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) + .getWorkInfosForUniqueWorkFlow(workName) .map { workInfos -> + cache.get(userId, parentId, itemKind)?.let { + Timber.d( + "Loaded cached suggestions after work update for parent=%s kind=%s count=%d", + parentId, + itemKind, + it.ids.size, + ) + return@map it.toResource(parentId, itemKind) + } + val states = workInfos.map { it.state }.distinct() + Timber.v( + "Observed suggestions work states for parent=%s kind=%s states=%s", + parentId, + itemKind, + states, + ) val isActive = workInfos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty } - } else if (cachedSuggestions.ids.isEmpty()) { - flowOf(SuggestionsResource.Empty) } else { + Timber.v( + "Using cached suggestions for parent=%s kind=%s count=%d", + parentId, + itemKind, + cachedSuggestions.ids.size, + ) flow { - try { - emit( - SuggestionsResource.Success( - fetchItemsByIds(cachedSuggestions.ids, itemKind), - ), - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.e(e, "Failed to fetch items") - emit(SuggestionsResource.Empty) - } + emit(cachedSuggestions.toResource(parentId, itemKind)) } } } } + private fun enqueueSuggestionsWork( + user: JellyfinUser, + parentId: UUID, + itemKind: BaseItemKind, + workName: String, + ) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInputData( + workDataOf( + SuggestionsWorker.PARAM_USER_ID to user.id.toString(), + SuggestionsWorker.PARAM_SERVER_ID to user.serverId.toString(), + SuggestionsWorker.PARAM_PARENT_ID to parentId.toString(), + SuggestionsWorker.PARAM_ITEM_KIND to itemKind.serialName, + ), + ).addTag(SuggestionsWorker.getOnDemandWorkTag(user.id, parentId, itemKind)) + .build() + + workManager.enqueueUniqueWork( + workName, + ExistingWorkPolicy.REPLACE, + request, + ) + Timber.d( + "Enqueued on-demand suggestions for parent=%s kind=%s", + parentId, + itemKind, + ) + } + + private suspend fun CachedSuggestions.toResource( + parentId: UUID, + itemKind: BaseItemKind, + ): SuggestionsResource { + if (ids.isEmpty()) { + Timber.d("Cached suggestions are empty for parent=%s kind=%s", parentId, itemKind) + return SuggestionsResource.Empty + } + return try { + SuggestionsResource.Success(fetchItemsByIds(ids, itemKind)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to fetch items") + SuggestionsResource.Empty + } + } + private suspend fun fetchItemsByIds( ids: List, itemKind: BaseItemKind, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index 4560f2d9..800d31fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -48,9 +48,19 @@ class SuggestionsWorker private val cache: SuggestionsCache, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { - Timber.d("Start") val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + val requestedParentId = inputData.getString(PARAM_PARENT_ID)?.toUUIDOrNull() + val requestedItemKind = + inputData + .getString(PARAM_ITEM_KIND) + ?.let { runCatching { BaseItemKind.fromName(it) }.getOrNull() } + val mode = if (requestedParentId == null && requestedItemKind == null) "periodic" else "on-demand" + Timber.d("Starting SuggestionsWorker mode=%s", mode) + if ((requestedParentId == null) != (requestedItemKind == null)) { + Timber.w("Invalid on-demand suggestions input parent=%s kind=%s", requestedParentId, requestedItemKind) + return Result.failure() + } if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { var currentUser = serverRepository.current.value @@ -71,51 +81,70 @@ class SuggestionsWorker .takeIf { it > 0 } ?: AppPreference.HomePageItems.defaultValue.toInt() + val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") + if (requestedParentId != null && requestedItemKind != null) { + Timber.d( + "Fetching on-demand suggestions for parent=%s kind=%s", + requestedParentId, + requestedItemKind, + ) + fetchAndCacheSuggestions( + context, + requestedParentId, + userId, + requestedItemKind, + itemsPerRow, + ) + Timber.d( + "Completed on-demand suggestions for parent=%s kind=%s", + requestedParentId, + requestedItemKind, + ) + return Result.success() + } + val views = api.userViewsApi .getUserViews(userId = userId) .content.items .orEmpty() if (views.isEmpty()) { + Timber.d("No user views found for periodic suggestions refresh") return Result.success() } + val supportedViews = + views.mapNotNull { view -> + getTypeForCollection(view.collectionType)?.let { + SuggestionsView(view.id, it) + } + } + val skippedCount = views.size - supportedViews.size + Timber.d( + "Refreshing periodic suggestions for %d supported views; skipped=%d", + supportedViews.size, + skippedCount, + ) val results = supervisorScope { - val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") - views - .mapNotNull { view -> - val itemKind = - getTypeForCollection(view.collectionType) - ?: return@mapNotNull null + supportedViews + .map { view -> async(Dispatchers.IO) { runCatching { - Timber.v("Fetching suggestions for view %s", view.id) - val suggestions = - fetchSuggestions( - context, - view.id, - userId, - itemKind, - itemsPerRow, - ) - ensureActive() - val newIds = suggestions.map { it.id } - val cachedIds = cache.get(userId, view.id, itemKind)?.ids - if (cachedIds == newIds) { - Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id) - return@runCatching - } - cache.put( - userId, + Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind) + fetchAndCacheSuggestions( + context, view.id, - itemKind, - newIds, + userId, + view.itemKind, + itemsPerRow, ) + ensureActive() }.onFailure { e -> Timber.e( e, - "Failed to fetch suggestions for view %s", + "Failed to fetch suggestions for parent=%s kind=%s", view.id, + view.itemKind, ) } } @@ -130,7 +159,10 @@ class SuggestionsWorker Timber.d("Completed with $successCount successes and $failureCount failures") return Result.success() } catch (ex: ApiClientException) { - Timber.w(ex, "SuggestionsWorker ApiClientException, will retry") + Timber.w(ex, "SuggestionsWorker ApiClientException, mode=%s", mode) + if (mode == "on-demand") { + return Result.failure() + } return Result.retry() } catch (e: Exception) { Timber.e(e, "SuggestionsWorker failed") @@ -138,6 +170,50 @@ class SuggestionsWorker } } + private suspend fun fetchAndCacheSuggestions( + coroutineContext: CoroutineContext, + parentId: UUID, + userId: UUID, + itemKind: BaseItemKind, + itemsPerRow: Int, + ) { + val suggestions = + fetchSuggestions( + coroutineContext, + parentId, + userId, + itemKind, + itemsPerRow, + ) + val newIds = suggestions.map { it.id } + val cachedIds = cache.get(userId, parentId, itemKind)?.ids + if (cachedIds == newIds) { + Timber.v( + "Suggestions unchanged for parent=%s kind=%s count=%d; skipping cache write", + parentId, + itemKind, + newIds.size, + ) + return + } + cache.put( + userId, + parentId, + itemKind, + newIds, + ) + if (newIds.isEmpty()) { + Timber.d("Cached empty suggestions for parent=%s kind=%s", parentId, itemKind) + } else { + Timber.d( + "Cached %d suggestions for parent=%s kind=%s", + newIds.size, + parentId, + itemKind, + ) + } + } + private suspend fun fetchSuggestions( coroutineContext: CoroutineContext, parentId: UUID, @@ -270,6 +346,20 @@ class SuggestionsWorker const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val PARAM_USER_ID = "userId" const val PARAM_SERVER_ID = "serverId" + const val PARAM_PARENT_ID = "parentId" + const val PARAM_ITEM_KIND = "itemKind" + + fun getOnDemandWorkName( + userId: UUID, + parentId: UUID, + itemKind: BaseItemKind, + ): String = "$WORK_NAME.onDemand.$userId.$parentId.${itemKind.serialName}" + + fun getOnDemandWorkTag( + userId: UUID, + parentId: UUID, + itemKind: BaseItemKind, + ): String = "suggestions:$userId:$parentId:${itemKind.serialName}" fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = when (collectionType) { @@ -278,4 +368,9 @@ class SuggestionsWorker else -> null } } + + private data class SuggestionsView( + val id: UUID, + val itemKind: BaseItemKind, + ) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt new file mode 100644 index 00000000..2f30db77 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt @@ -0,0 +1,68 @@ +package com.github.damontecres.wholphin.services + +import androidx.appcompat.app.AppCompatActivity +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.MutableLiveData +import androidx.work.WorkManager +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LatestNextUpSchedulerServiceTest { + @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private val currentLiveData = MutableLiveData() + private val mockActivity = mockk(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockWorkManager = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(mockk(relaxed = true)) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { mockActivity.lifecycle } returns lifecycleRegistry + every { mockServerRepository.current } returns currentLiveData + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun createService() = + LatestNextUpSchedulerService( + context = mockActivity, + serverRepository = mockServerRepository, + workManager = mockWorkManager, + ).also { + it.dispatcher = testDispatcher + } + + @Test + fun cancels_latestNextUp_work_when_user_null() = + runTest { + createService() + + currentLiveData.value = null + advanceUntilIdle() + + verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) } + verify(exactly = 0) { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index f8fc8445..57e14ec1 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import com.github.damontecres.wholphin.data.ServerRepository @@ -63,13 +65,15 @@ class SuggestionServiceTest { workManager = mockWorkManager, ) - private fun mockQueryResult(items: List): Response = - mockk { - every { content } returns - mockk { - every { this@mockk.items } returns items - } + private fun mockQueryResult(items: List): Response { + val queryResult = + mockk { + every { this@mockk.items } returns items + } + return mockk { + every { content } returns queryResult } + } private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser = JellyfinUser( @@ -81,12 +85,21 @@ class SuggestionServiceTest { private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk { every { this@mockk.state } returns state } + private fun mockEnqueueOnDemandWork() { + every { + mockWorkManager.enqueueUniqueWork( + any(), + any(), + any(), + ) + } returns mockk(relaxed = true) + } + @Test fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = runTest { val currentUser = MutableLiveData(null) every { mockServerRepository.currentUser } returns currentUser - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) val service = createService() val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first() @@ -95,22 +108,31 @@ class SuggestionServiceTest { } @Test - fun maps_active_work_states_to_Loading() = + fun maps_active_onDemand_work_states_to_Loading() = runTest { listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state -> val userId = UUID.randomUUID() val parentId = UUID.randomUUID() val currentUser = MutableLiveData(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) every { mockServerRepository.currentUser } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(listOf(mockWorkInfo(state))) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() assertEquals(SuggestionsResource.Loading, result) + verify { + mockWorkManager.enqueueUniqueWork( + workName, + ExistingWorkPolicy.REPLACE, + any(), + ) + } } } @@ -121,10 +143,12 @@ class SuggestionServiceTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() val currentUser = MutableLiveData(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) every { mockServerRepository.currentUser } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(listOf(mockWorkInfo(state))) val service = createService() @@ -140,15 +164,55 @@ class SuggestionServiceTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() val currentUser = MutableLiveData(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) every { mockServerRepository.currentUser } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() assertEquals(SuggestionsResource.Empty, result) + verify { + mockWorkManager.enqueueUniqueWork( + workName, + ExistingWorkPolicy.REPLACE, + any(), + ) + } + } + + @Test + fun getSuggestionsFlow_returnsSuccess_whenOnDemandWorkCachesItems() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) + val cachedId = UUID.randomUUID() + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany + listOf(null, CachedSuggestions(listOf(cachedId))) + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.SUCCEEDED))) + + val dto = + mockk(relaxed = true) { + every { id } returns cachedId + every { type } returns BaseItemKind.MOVIE + } + io.mockk.mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertTrue(result is SuggestionsResource.Success) + assertEquals(cachedId, (result as SuggestionsResource.Success).items.single().id) } @Test @@ -160,33 +224,42 @@ class SuggestionServiceTest { every { mockServerRepository.currentUser } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns - flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() assertEquals(SuggestionsResource.Empty, result) verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + verify(exactly = 0) { + mockWorkManager.enqueueUniqueWork( + any(), + any(), + any(), + ) + } } @Test - fun getSuggestionsFlow_returnsLoading_whenCacheMissing_andWorkIsEnqueued() = + fun getSuggestionsFlow_ignores_periodic_work_whenCacheMissing() = runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() val currentUser = MutableLiveData(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) every { mockServerRepository.currentUser } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - assertEquals(SuggestionsResource.Loading, result) - verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + assertEquals(SuggestionsResource.Empty, result) + verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } + verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } } @Test @@ -196,9 +269,11 @@ class SuggestionServiceTest { val libraryId = UUID.randomUUID() val otherLibraryId = UUID.randomUUID() val currentUser = MutableLiveData(mockUser(userId)) + val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE) every { mockServerRepository.currentUser } returns currentUser - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + mockEnqueueOnDemandWork() + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null coEvery { @@ -250,8 +325,6 @@ class SuggestionServiceTest { io.mockk.mockkObject(GetItemsRequestHandler) coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) - val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() @@ -282,8 +355,6 @@ class SuggestionServiceTest { io.mockk.mockkObject(GetItemsRequestHandler) coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error") - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) - val service = createService() val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index 5679777e..4b5a9450 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -55,6 +55,8 @@ class SuggestionsWorkerTest { private fun createWorker( userId: UUID? = testUserId, serverId: UUID? = testServerId, + parentId: UUID? = null, + itemKind: BaseItemKind? = null, ): SuggestionsWorker { val inputData = Data @@ -62,6 +64,8 @@ class SuggestionsWorkerTest { .apply { userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) } serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) } + parentId?.let { putString(SuggestionsWorker.PARAM_PARENT_ID, it.toString()) } + itemKind?.let { putString(SuggestionsWorker.PARAM_ITEM_KIND, it.serialName) } }.build() every { mockWorkerParams.inputData } returns inputData return SuggestionsWorker( @@ -90,6 +94,17 @@ class SuggestionsWorkerTest { } } + @Test + fun returns_failure_on_partial_onDemand_input() = + runTest { + listOf( + createWorker(parentId = UUID.randomUUID()), + createWorker(itemKind = BaseItemKind.MOVIE), + ).forEach { worker -> + assertEquals(ListenableWorker.Result.failure(), worker.doWork()) + } + } + @Test fun restores_session_when_api_not_configured() = runTest { @@ -111,6 +126,21 @@ class SuggestionsWorkerTest { assertEquals(ListenableWorker.Result.success(), result) } + @Test + fun onDemand_work_caches_only_requested_library() = + runTest { + val parentId = UUID.randomUUID() + every { mockPreferences.data } returns flowOf(mockPrefs()) + mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList()) + + val result = createWorker(parentId = parentId, itemKind = BaseItemKind.MOVIE).doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 0) { mockUserViewsApi.getUserViews(userId = any()) } + coVerify { mockCache.put(testUserId, parentId, BaseItemKind.MOVIE, emptyList()) } + } + @Test fun caches_suggestions_for_supported_types() = runTest { @@ -136,6 +166,43 @@ class SuggestionsWorkerTest { } } + @Test + fun caches_empty_suggestions_for_supported_types() = + runTest { + val viewId = UUID.randomUUID() + val view = + mockk(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns CollectionType.MOVIES + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList()) + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, emptyList()) } + } + + @Test + fun skips_unsupported_collection_types() = + runTest { + val view = + mockk(relaxed = true) { + every { id } returns UUID.randomUUID() + every { this@mockk.collectionType } returns CollectionType.MUSIC + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify(exactly = 0) { mockCache.put(any(), any(), any(), any()) } + } + @Test fun fetches_contextual_suggestions_when_genres_available() = runTest { @@ -227,7 +294,12 @@ class SuggestionsWorkerTest { } } -fun mockQueryResult(items: List = emptyList()) = - mockk>(relaxed = true) { - every { content } returns mockk { every { this@mockk.items } returns items } +fun mockQueryResult(items: List = emptyList()): org.jellyfin.sdk.api.client.Response { + val queryResult = + mockk { + every { this@mockk.items } returns items + } + return mockk(relaxed = true) { + every { content } returns queryResult } +}