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