From 4b927e6b7b138337615373b6df770c50177372b7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 21 Aug 2026 15:04:07 -0400 Subject: [PATCH] Fix Next Up channel launch sign in (#1886) ## Description When parsing intents, not every path for authentication was checked, so launching via the "Next Up" OS channel wouldn't check for a profile PIN. This PR fixes that. Additionally, the "Next Up" OS channel launch wasn't checking the correct parameter to ensure the back stack was written properly. ### Related issues Fixes #1883 ### Testing I manually test on the emulator with these test cases: - No profile PIN, cold start - No profile PIN, hot start - Profile PIN, cold start - Profile PIN, hot start - No profile PIN, launch via "Next Up" channel - No profile PIN, launch via intent - Profile PIN, launch via "Next Up" channel - Profile PIN, launch via intent Also added unit tests for intent-based and regular launching which covers those cases and more. ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 13 +- .../wholphin/services/IntentService.kt | 84 ++-- .../wholphin/services/IntentServiceTest.kt | 222 ++++++++++ .../test/TestMainActivityViewModel.kt | 415 ++++++++++++++++++ .../damontecres/wholphin/test/TestUtils.kt | 36 ++ 5 files changed, 736 insertions(+), 34 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 9fa76ea0..0baaefdc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -439,9 +439,16 @@ class MainActivityViewModel val result = intent?.let { intentService.parseIntent(intent) } when (result) { is IntentResult.Error -> { - setupNavigationManager.navigateTo(SetupDestination.ServerList) + val current = serverRepository.current.value + val destination = + if (current != null) { + SetupDestination.UserList(current.server) + } else { + SetupDestination.ServerList + } + setupNavigationManager.navigateTo(destination) Timber.e("Error parsing intent: %s", result.message) - showToast(context, "Invalid intent: ${result.message}") + showToast(context, result.message) return@withLock } @@ -505,7 +512,7 @@ class MainActivityViewModel prefs.currentUserId?.toUUIDOrNull(), ) if (current != null) { - if (current.user.hasPin || current.user.requireLogin) { + if (current.user.isProtected) { setupNavigationManager.navigateTo( SetupDestination.UserList( current.server, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index 95343b47..a37a0ed2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -29,49 +29,26 @@ class IntentService Timber.v("Intent data: %s", intent.data) val action = intent.action ?: intent.data?.host if ( - intent.getStringParam("type") == null && + intent.getStringParam(INTENT_ITEM_TYPE) == null && (action == Intent.ACTION_MAIN || action.isNullOrBlank()) ) { + // Normal app launch return IntentResult.NoOp } - val userId = intent.getStringParam("userId")?.toUUIDOrNull() - val serverId = intent.getStringParam("serverId")?.toUUIDOrNull() - if (userId != null && serverId != null) { - Timber.v("Intent switches user") - val user = - serverRepository.serverDao.getUser(serverId, userId) - ?: return IntentResult.Error("User not found") - if (!user.isProtected) { - serverRepository.restoreSession(serverId, userId) - } else { - return IntentResult.Error("Cannot switch to specified user") - } - } else if (serverRepository.currentUser == null) { - Timber.v("No current user, so restoring last") - val appPrefs = userPreferencesService.flow.first().appPreferences - if (appPrefs.signInAutomatically) { - val userId = appPrefs.currentUserId.toUUIDOrNull() - val serverId = appPrefs.currentServerId.toUUIDOrNull() - if (userId != null && serverId != null) { - serverRepository.restoreSession(serverId, userId) - ?: return IntentResult.Error("Error restoring user") - } else { - return IntentResult.Error("Could not auto sign-in, specify a server & user") - } - } else { - return IntentResult.Error("Auto sign-in not enabled, specify a server & user") - } + val result = prepare(intent) + if (result != null) { + return result } - if (intent.getStringParam("type") != null) { + // Existence of this key implies launching from Play Next channel + if (intent.getStringParam(INTENT_ITEM_TYPE) != null) { return getDestinationFromChannel(intent)?.let { IntentResult.Target( destinations = listOf(it), addHomeToBackStack = false, ) - } - ?: IntentResult.Error("Invalid parameters") + } ?: IntentResult.Error("Invalid parameters") } if (action == Intent.ACTION_SEARCH || action == "search") { @@ -130,6 +107,49 @@ class IntentService return IntentResult.Target(destinations) } + internal suspend fun prepare(intent: Intent): IntentResult? { + val appPrefs = userPreferencesService.flow.first().appPreferences + + val userId = intent.getStringParam(INTENT_USER_ID)?.toUUIDOrNull() + val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() + return if (userId != null && serverId != null) { + Timber.v("Intent switches user") + val user = serverRepository.serverDao.getUser(serverId, userId) + if (user != null && !user.isProtected) { + serverRepository.restoreSession(serverId, userId) + ?: IntentResult.Error("Error restoring user") + null + } else { + IntentResult.Error("Cannot switch to specified user") + } + } else { + val profileProtected = + serverRepository.current.value + ?.user + ?.isProtected == true + if (appPrefs.signInAutomatically && !profileProtected) { + Timber.v("No current user, so restoring last") + val userId = appPrefs.currentUserId?.toUUIDOrNull() + val serverId = appPrefs.currentServerId?.toUUIDOrNull() + + if (userId != null && serverId != null) { + val current = + serverRepository.restoreSession(serverId, userId) + ?: return IntentResult.Error("Error restoring user") + if (current.user.isProtected) { + IntentResult.Error("Could not auto sign-in, user is protected") + } else { + null + } + } else { + IntentResult.Error("Could not auto sign-in, specify a server & user") + } + } else { + IntentResult.Error("Auto sign-in not enabled or user is protected") + } + } + } + private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key) private fun Intent.getLongParam(key: String) = @@ -173,6 +193,8 @@ class IntentService const val INTENT_EPISODE_NUMBER = "epNum" const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + const val INTENT_SERVER_ID = "serverId" + const val INTENT_USER_ID = "userId" } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt new file mode 100644 index 00000000..17c6a313 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -0,0 +1,222 @@ +package com.github.damontecres.wholphin.services + +import android.content.Intent +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.server +import com.github.damontecres.wholphin.test.user +import com.github.damontecres.wholphin.ui.toServerString +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.UUID +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IntentServiceTest { + private val api: ApiClient = mockk() + private val serverRepository: ServerRepository = mockk() + private val userPreferencesService: UserPreferencesService = mockk() + + lateinit var intentService: IntentService + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setup() { + intentService = IntentService(api, serverRepository, userPreferencesService) + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { userPreferencesService.flow } returns + flow { + emit( + UserPreferences( + AppPreferences.getDefaultInstance().update(block), + null, + ), + ) + } + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertNull(result) + + coVerify { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign disabled in with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = false + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow(null) +// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = Intent() + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified with unprotected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertNull(result) + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } + + @Test + fun `Test user specified does not exist`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null + coEvery { serverRepository.restoreSession(serverId, userId) } returns null + + val intent = + Intent().apply { + putExtra(IntentService.INTENT_SERVER_ID, serverId.toServerString()) + putExtra(IntentService.INTENT_USER_ID, userId.toServerString()) + } + val result = intentService.prepare(intent) + assertTrue(result is IntentResult.Error) + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt new file mode 100644 index 00000000..556a2fc0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt @@ -0,0 +1,415 @@ +package com.github.damontecres.wholphin.test + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.datastore.core.DataStore +import com.github.damontecres.wholphin.MainActivityViewModel +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.services.AppUpgradeHandler +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TestMainActivityViewModel { + private val testDispatcher = StandardTestDispatcher() + + private val context: Context = mockk() + private val preferences: DataStore = mockk() + private val serverRepository: ServerRepository = mockk() + private val setupNavigationManager: SetupNavigationManager = mockk(relaxed = true) + private val navigationManager: NavigationManager = mockk(relaxed = true) + private val deviceProfileService: DeviceProfileService = mockk() + private val backdropService: BackdropService = mockk(relaxed = true) + private val appUpgradeHandler: AppUpgradeHandler = mockk() + private val intentService: IntentService = mockk() + private val serverDao: JellyfinServerDao = mockk() + + val viewModel = + MainActivityViewModel( + context, + preferences, + serverRepository, + setupNavigationManager, + navigationManager, + deviceProfileService, + backdropService, + appUpgradeHandler, + intentService, + ) + + private val serverId = UUID.randomUUID() + private val userId = UUID.randomUUID() + private val currentUser = currentUser(serverId, userId) + + private val protectedCurrentUser = + CurrentUser( + server(serverId), + user(serverId, userId).copy(pin = "1234"), + ) + + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + every { appUpgradeHandler.needUpgrade() } returns false + every { appUpgradeHandler.copySubfont(any()) } returns Unit + every { serverRepository.serverDao } returns serverDao + coEvery { serverDao.getServer(serverId) } returns + JellyfinServerUsers( + currentUser.server, + // User list is unused + emptyList(), + ) + mockkStatic(Toast::class) + val mockToast = mockk() + every { Toast.makeText(any(), any(), any()) } returns mockToast + every { mockToast.show() } returns Unit + } + + @After + fun tearDown() { + WholphinDispatchers.reset() + unmockkStatic(Toast::class) + } + + private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { + every { preferences.data } returns + flow { + emit( + AppPreferences.getDefaultInstance().update(block), + ) + } + } + + @Test + fun `Test auto sign in with unprotected profile`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + viewModel.appStart(null) + advanceUntilIdle() + + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + } + + @Test + fun `Test auto sign disabled in with current server`() = + runTest { + setupPreferences { + signInAutomatically = false + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) +// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + + viewModel.appStart(null) + advanceUntilIdle() + + verify(exactly = 1) { serverDao.getServer(serverId) } + coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) } + val args = mutableListOf() + verify { setupNavigationManager.navigateTo(capture(args)) } + assertEquals(2, args.size) + args[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, hot load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + verify { serverDao.getServer(serverId) } + val args = mutableListOf() + verify { setupNavigationManager.navigateTo(capture(args)) } + assertEquals(2, args.size) + args[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) + } + args[1].let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, cold load, with protected profile`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test auto sign in, no current user`() = + runTest { + setupPreferences { + signInAutomatically = true + currentServerId = "" + currentUserId = "" + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(null, null) } returns null + + viewModel.appStart(null) + advanceUntilIdle() + + coVerify(exactly = 1) { serverRepository.restoreSession(null, null) } + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } + + @Test + fun `Test intent result is noop`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + } + + @Test + fun `Test intent result is error, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + destination as SetupDestination.UserList + assertEquals(currentUser.server, destination.server) + } + } + + @Test + fun `Test intent result is error, does not have server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } + + @Test + fun `Test intent result is target, has server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + // Note: navigation manager internally handles Home destination, so it won't be in this list + assertEquals(1, backstack.size) + backstack[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + } + } + + @Test + fun `Test intent result is target, has server, no home backstack`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(currentUser) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = false, + ) + val backstack = mutableListOf() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + destination as SetupDestination.AppContent + assertEquals(currentUser, destination.current) + } + assertEquals(1, backstack.size) + backstack[0].let { destination -> + assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + } + } + + @Test + fun `Test intent result is target, has no server`() = + runTest(testDispatcher) { + setupPreferences { + signInAutomatically = true + currentServerId = serverId.toServerString() + currentUserId = userId.toServerString() + } + every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { intentService.parseIntent(any()) } returns + IntentResult.Target( + destinations = listOf(Destination.Favorites), + addHomeToBackStack = true, + ) + val backstack = mutableListOf() + every { navigationManager.backStack } returns backstack + + viewModel.appStart(Intent()) + advanceUntilIdle() + + val slot = slot() + verify { setupNavigationManager.navigateTo(capture(slot)) } + verify(exactly = 0) { navigationManager.reloadHome() } + slot.captured.let { destination -> + assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 87a44416..21841c25 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -1,11 +1,15 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.ui.isNotNullOrBlank import io.mockk.MockKMatcherScope import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair +import org.jellyfin.sdk.model.api.UserDto fun MockKMatcherScope.nonBlankString() = match { it.isNotNullOrBlank() } @@ -75,3 +79,35 @@ fun playlist( seriesId = null, genreItems = genres, ) + +fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11") + +fun user( + serverId: UUID, + userId: UUID = UUID.randomUUID(), +) = JellyfinUser( + rowId = 1, + id = userId, + serverId = serverId, + name = "test-user", + accessToken = "token", + pin = null, +) + +fun userDto(userId: UUID) = + UserDto( + id = userId, + name = "test-user", + serverName = "test server", + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = false, + ) + +fun currentUser( + serverId: UUID = UUID.randomUUID(), + userId: UUID = UUID.randomUUID(), +) = CurrentUser( + server(serverId), + user(userId), +)