Route Wolfskin setup through the household profile picker
All checks were successful
review/code-claude Opus review PASS: no blockers, 10 non-blocking notes
review/safety-opus Opus review PASS: no blockers, 10 non-blocking notes
All checks were successful
review/code-claude Opus review PASS: no blockers, 10 non-blocking notes
review/safety-opus Opus review PASS: no blockers, 10 non-blocking notes
This commit is contained in:
parent
2b142af8d1
commit
a5b5e92017
10 changed files with 983 additions and 2 deletions
|
|
@ -0,0 +1,166 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import org.robolectric.Shadows
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import com.github.damontecres.wholphin.MainActivityViewModel
|
||||
import com.github.damontecres.wholphin.MainContent
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.*
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.coVerify
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Exercise the actual MainContent boundary, not a parallel copy of its destination mapping. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class WolfskinSetupNavigationTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")
|
||||
private val repository = mockk<ServerRepository>(relaxed = true)
|
||||
private val navigation = NavigationManager()
|
||||
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
private val stack = mutableStateListOf<SetupDestination>(SetupDestination.Loading)
|
||||
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||
private val model = WolfskinSetupViewModel(repository, navigation, setup, WolfskinSession(), conductarr)
|
||||
private val preferences = mockk<DataStore<AppPreferences>>()
|
||||
private val intentService = mockk<IntentService>()
|
||||
private var previousScreens = true
|
||||
private var previousFixtures = false
|
||||
|
||||
@Before fun configure() {
|
||||
WolfskinScreenPreference.initialize(rule.activity)
|
||||
previousScreens = WolfskinScreenPreference.enabled
|
||||
WolfskinScreenPreference.enabled = true
|
||||
ConductarrPreference.initialize(rule.activity)
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
every { repository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(server)
|
||||
every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||
every { repository.serverDao.getServers() } returns listOf(JellyfinServerUsers(server, listOf(user)))
|
||||
every { setup.navigateTo(any()) } answers { stack[0] = firstArg() }
|
||||
every { preferences.data } returns MutableStateFlow(AppPreferences.getDefaultInstance())
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
}
|
||||
|
||||
@After fun close() {
|
||||
conductarr.close()
|
||||
WolfskinScreenPreference.enabled = previousScreens
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
}
|
||||
|
||||
private fun mount() {
|
||||
val screensaver = mockk<ScreensaverService>(relaxed = true)
|
||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||
rule.setContent {
|
||||
MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
mockk(relaxed = true), screensaver, Modifier,
|
||||
setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation,
|
||||
mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService)
|
||||
|
||||
private fun awaitStage(stage: WolfskinSetupViewModel.Stage) {
|
||||
rule.waitUntil(5_000) {
|
||||
Snapshot.sendApplyNotifications()
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
model.stage == stage
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertOwnPicker() {
|
||||
awaitStage(WolfskinSetupViewModel.Stage.Picker)
|
||||
rule.onNodeWithText("Who’s watching?").assertIsDisplayed()
|
||||
rule.onNodeWithText("Justin").assertIsDisplayed()
|
||||
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
|
||||
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() {
|
||||
runBlocking { appModel().handleAppStart() }
|
||||
mount()
|
||||
assertOwnPicker()
|
||||
// The legacy sign-out-equivalent erased the recent preference, but Room still has the household.
|
||||
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
|
||||
rule.runOnIdle { setup.navigateTo(SetupDestination.ServerList) }
|
||||
assertOwnPicker()
|
||||
rule.runOnIdle { setup.navigateTo(SetupDestination.UserList(server)) }
|
||||
assertOwnPicker()
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Cannot restore sign-in")
|
||||
rule.runOnIdle { rule.activity.lifecycleScope.launch { appModel().handleIntent(Intent()) } }
|
||||
rule.waitUntil(5_000) { stack.last() == SetupDestination.ServerList }
|
||||
assertOwnPicker()
|
||||
coVerify(exactly = 0) { repository.switchServerOrUser() }
|
||||
}
|
||||
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Test fun unreachableSelectionRendersWolfskinRecoveryFrame() {
|
||||
stack[0] = SetupDestination.UserList(server)
|
||||
coEvery { repository.changeUser(server, user) } throws IOException("offline")
|
||||
mount()
|
||||
assertOwnPicker()
|
||||
rule.onNodeWithText("Justin").performClick()
|
||||
awaitStage(WolfskinSetupViewModel.Stage.Unavailable)
|
||||
rule.onNodeWithText("Try again").assertIsDisplayed()
|
||||
rule.onNodeWithText("Sign in").assertIsDisplayed()
|
||||
rule.onNodeWithText(rule.activity.getString(R.string.select_user)).assertDoesNotExist()
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "setup").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "household-unreachable.png"))
|
||||
}
|
||||
|
||||
@Test fun unconfiguredFirstRunOffersUrlInsteadOfStockServerSelector() {
|
||||
coEvery { repository.getMostRecentServer() } returns RestoredSession.None
|
||||
every { repository.serverDao.getServers() } returns emptyList()
|
||||
stack[0] = SetupDestination.ServerList
|
||||
mount()
|
||||
awaitStage(WolfskinSetupViewModel.Stage.Unconfigured)
|
||||
rule.onNodeWithText("Connect your household server").assertIsDisplayed()
|
||||
rule.onNodeWithTag("household-server-url").assertIsDisplayed().performTextInput("https://jellyfin.test")
|
||||
rule.onNodeWithText("Try again").assertIsEnabled()
|
||||
rule.onNodeWithText(rule.activity.getString(R.string.select_server)).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.MainContent
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.test.TestActivity
|
||||
import com.github.damontecres.wholphin.ui.TestModule
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.github.damontecres.wholphin.util.configure
|
||||
import com.github.damontecres.wholphin.util.reset
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.operations.QuickConnectApi
|
||||
import org.jellyfin.sdk.api.operations.SystemApi
|
||||
import org.jellyfin.sdk.api.operations.UserApi
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** The rollout switch must still expose both real upstream selectors, not a second forked copy. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltAndroidTest
|
||||
@Config(application = HiltTestApplication::class, sdk = [34])
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class WolfskinSetupRollbackTest {
|
||||
@get:Rule(order = 0) val hilt = HiltAndroidRule(this)
|
||||
@get:Rule(order = 1) val registerActivity = object : TestWatcher() {
|
||||
override fun starting(description: Description?) {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
shadowOf(context.packageManager).addOrUpdateActivity(ActivityInfo().apply {
|
||||
name = TestActivity::class.java.name
|
||||
packageName = context.packageName
|
||||
})
|
||||
}
|
||||
}
|
||||
@get:Rule(order = 2) val rule = createAndroidComposeRule<TestActivity>()
|
||||
@Inject lateinit var api: ApiClient
|
||||
@Inject lateinit var jellyfin: Jellyfin
|
||||
private var previousScreens = true
|
||||
|
||||
@Before fun setup() {
|
||||
hilt.inject()
|
||||
WholphinDispatchers.configure(TestModule.testDispatcher)
|
||||
WolfskinScreenPreference.initialize(rule.activity)
|
||||
previousScreens = WolfskinScreenPreference.enabled
|
||||
WolfskinScreenPreference.enabled = false
|
||||
val users = mockk<UserApi>()
|
||||
val system = mockk<SystemApi>()
|
||||
val quickConnect = mockk<QuickConnectApi>()
|
||||
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
|
||||
every { api.userApi } returns users
|
||||
every { api.systemApi } returns system
|
||||
every { api.quickConnectApi } returns quickConnect
|
||||
coEvery { users.getPublicUsers() } returns successResponse(emptyList())
|
||||
coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(version = "10.11.11"))
|
||||
coEvery { quickConnect.getQuickConnectEnabled() } returns successResponse(false)
|
||||
}
|
||||
|
||||
@After fun restore() {
|
||||
WolfskinScreenPreference.enabled = previousScreens
|
||||
WholphinDispatchers.reset()
|
||||
}
|
||||
|
||||
@Test fun rollbackRendersBothStockSelectors() {
|
||||
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
|
||||
val screensaver = mockk<ScreensaverService>()
|
||||
every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
|
||||
rule.setContent {
|
||||
MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
mockk(relaxed = true), screensaver)
|
||||
}
|
||||
fun awaitHeading(resource: Int) {
|
||||
rule.waitUntil(5_000) {
|
||||
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
rule.onAllNodes(androidx.compose.ui.test.hasText(rule.activity.getString(resource))).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
rule.onNodeWithText(rule.activity.getString(resource)).assertIsDisplayed()
|
||||
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
|
||||
}
|
||||
awaitHeading(R.string.select_server)
|
||||
rule.runOnIdle { stack[0] = SetupDestination.UserList(JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)) }
|
||||
awaitHeading(R.string.select_user)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.data.AppDatabase
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
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.successResponse
|
||||
import com.github.damontecres.wholphin.ui.TestModule
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.operations.SystemApi
|
||||
import org.jellyfin.sdk.api.operations.UserApi
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.tv.WolfskinSetupViewModel.Stage
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Setup preserves saved credentials, rejects protected-profile bypasses and recovers real auth failures. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class WolfskinSetupTest {
|
||||
private val context get() = ApplicationProvider.getApplicationContext<Application>()
|
||||
private val database by lazy { Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).allowMainThreadQueries().build() }
|
||||
private val recent by lazy { MostRecentServerSharedPreferences(context) }
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null)
|
||||
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-test-token")
|
||||
private val api = mockk<ApiClient>(relaxed = true)
|
||||
private val usersApi = mockk<UserApi>()
|
||||
private val systemApi = mockk<SystemApi>()
|
||||
private val jellyfin by lazy { TestModule.jellyfin(mockk(), context, ClientInfo("Wolfskin test", "test"), DeviceInfo("setup-test", "Setup test")) }
|
||||
private val navigation = NavigationManager()
|
||||
private val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||
private val session = WolfskinSession()
|
||||
private val conductarr = ConductarrSession(MemoryCacheStore())
|
||||
private val repository by lazy { ServerRepository(context, jellyfin, database.serverDao(), api, Dispatchers.IO, recent) }
|
||||
private val model by lazy { WolfskinSetupViewModel(repository, navigation, setup, session, conductarr) }
|
||||
private val dto get() = UserDto(id = user.id, name = user.name, hasPassword = true, hasConfiguredPassword = true, hasConfiguredEasyPassword = false)
|
||||
private var previousFixtures = false
|
||||
|
||||
@Before fun saveHousehold(): Unit = runBlocking {
|
||||
ConductarrPreference.initialize(context)
|
||||
previousFixtures = ConductarrPreference.fixtures
|
||||
ConductarrPreference.updateFixtures(false)
|
||||
recent.clear()
|
||||
database.serverDao().addOrUpdateServer(server)
|
||||
user = database.serverDao().addOrUpdateUser(user)
|
||||
recent.save(CurrentUser(server, user))
|
||||
val client = jellyfin
|
||||
every { api.userApi } returns usersApi
|
||||
every { api.systemApi } returns systemApi
|
||||
every { client.createApi(any(), any(), any(), any(), any()) } returns api
|
||||
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||
coEvery { systemApi.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(id = server.id.toString(), serverName = server.name))
|
||||
}
|
||||
|
||||
@After fun close() {
|
||||
conductarr.close()
|
||||
database.close()
|
||||
ConductarrPreference.updateFixtures(previousFixtures)
|
||||
}
|
||||
|
||||
@Test fun enteringPickerPreservesServerAndUserAndRecoversErasedPreferenceFromRoom() = runBlocking {
|
||||
val saved = recent.get()
|
||||
model.load(null)
|
||||
assertEquals(Stage.Picker, model.stage)
|
||||
assertEquals(user.id.toString(), model.picker!!.person.jellyfinUserId)
|
||||
assertEquals(saved, recent.get())
|
||||
recent.clear()
|
||||
model.load(null)
|
||||
assertEquals(server, model.server)
|
||||
assertEquals(Stage.Picker, model.stage)
|
||||
assertEquals(MostRecentServer.None, recent.get())
|
||||
}
|
||||
|
||||
@Test fun expiredTokenRequiresCredentialsAndSuccessfulLoginRestoresSessionWithoutSecondPicker() = runBlocking {
|
||||
model.load(server)
|
||||
coEvery { usersApi.getCurrentUser() } throws InvalidStatusException(401)
|
||||
model.select(model.picker!!.person)
|
||||
assertEquals(Stage.Login, model.stage)
|
||||
assertEquals(user, model.loginUser)
|
||||
assertNull(repository.current.value)
|
||||
assertTrue(recent.get() is MostRecentServer.ServerAndUser)
|
||||
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(user = dto, serverId = server.id.toString(), accessToken = "renewed-test-token"))
|
||||
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||
model.login(user.name!!, "test-password")
|
||||
assertEquals("renewed-test-token", repository.current.value!!.user.accessToken)
|
||||
assertEquals(user.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
||||
assertFalse(session.state!!.pickerVisible)
|
||||
assertEquals(user.id.toString(), session.state!!.person.jellyfinUserId)
|
||||
}
|
||||
|
||||
@Test fun unreachableServerOffersRetryAndSavedPersonCanSignInWhenItReturns() = runTest {
|
||||
model.load(server)
|
||||
val saved = recent.get()
|
||||
coEvery { usersApi.getCurrentUser() } throws IOException("offline")
|
||||
model.select(model.picker!!.person)
|
||||
assertEquals(Stage.Unavailable, model.stage)
|
||||
assertEquals(saved, recent.get())
|
||||
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
||||
assertEquals(Stage.Loading, model.stage)
|
||||
retry.await()
|
||||
assertEquals(Stage.Picker, model.stage)
|
||||
coEvery { usersApi.getCurrentUser() } returns successResponse(dto)
|
||||
model.select(model.picker!!.person)
|
||||
assertEquals(user.id, repository.current.value!!.user.id)
|
||||
assertFalse(session.state!!.pickerVisible)
|
||||
}
|
||||
|
||||
@Test fun retryCanRemainUnavailableWithoutErasingHousehold() = runTest {
|
||||
database.serverDao().deleteUser(server.id, user.id)
|
||||
val saved = recent.get()
|
||||
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
||||
model.load(null)
|
||||
assertEquals(Stage.Unavailable, model.stage)
|
||||
val retry = async(start = CoroutineStart.UNDISPATCHED) { model.retry() }
|
||||
assertEquals(Stage.Loading, model.stage)
|
||||
retry.await()
|
||||
assertEquals(Stage.Unavailable, model.stage)
|
||||
assertEquals(server, model.server)
|
||||
assertEquals(saved, recent.get())
|
||||
}
|
||||
|
||||
@Test fun pinAndRequireLoginCannotReuseSavedTokenWithoutUnlocking() = runBlocking {
|
||||
user = user.copy(pin = "URDL")
|
||||
database.serverDao().updateUser(user)
|
||||
model.load(server)
|
||||
model.select(model.picker!!.person)
|
||||
assertEquals(user, model.pinUser)
|
||||
assertTrue(model.picker!!.pickerVisible)
|
||||
model.enterPin("WRONG")
|
||||
assertNull(repository.current.value)
|
||||
model.enterPin("URDL")
|
||||
assertEquals(user.id, repository.current.value!!.user.id)
|
||||
user = user.copy(pin = null, requireLogin = true)
|
||||
database.serverDao().updateUser(user)
|
||||
model.load(server)
|
||||
model.select(model.picker!!.person)
|
||||
assertEquals(Stage.Login, model.stage)
|
||||
assertEquals(user, model.loginUser)
|
||||
}
|
||||
|
||||
@Test fun unavailablePublicProfilesKeepHouseholdAndAllowCredentialRecovery() = runBlocking {
|
||||
database.serverDao().deleteUser(server.id, user.id)
|
||||
coEvery { usersApi.getPublicUsers() } throws IOException("offline")
|
||||
model.load(null)
|
||||
assertEquals(Stage.Unavailable, model.stage)
|
||||
assertEquals(server, model.server)
|
||||
model.showLogin(null)
|
||||
coEvery { usersApi.authenticateUserByName(any()) } returns successResponse(AuthenticationResult(
|
||||
user = dto, serverId = server.id.toString(), accessToken = "new-test-token"))
|
||||
model.login(user.name!!, "test-password")
|
||||
assertEquals(user.id, repository.current.value!!.user.id)
|
||||
}
|
||||
|
||||
@Test fun expiredLaunchRecoveryIgnoresOutagesLateIdentitiesAndRollbackMode() = runBlocking {
|
||||
WolfskinScreenPreference.initialize(context)
|
||||
val previous = WolfskinScreenPreference.enabled
|
||||
try {
|
||||
WolfskinScreenPreference.enabled = true
|
||||
val expected = (repository.restoreSavedSession() as com.github.damontecres.wholphin.data.RestoredSession.Success).currentUser
|
||||
var destination: SetupDestination = SetupDestination.AppContent(expected)
|
||||
every { setup.navigateTo(any()) } answers { destination = firstArg() }
|
||||
recoverWolfskinSession(context, IOException("offline"), expected, repository, setup)
|
||||
assertTrue(destination is SetupDestination.AppContent)
|
||||
WolfskinScreenPreference.enabled = false
|
||||
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
||||
assertTrue(destination is SetupDestination.AppContent)
|
||||
WolfskinScreenPreference.enabled = true
|
||||
val other = database.serverDao().addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel"))
|
||||
recent.save(CurrentUser(server, other))
|
||||
repository.restoreSavedSession()
|
||||
recoverWolfskinSession(context, InvalidStatusException(401), expected, repository, setup)
|
||||
assertTrue(destination is SetupDestination.AppContent)
|
||||
recoverWolfskinSession(context, InvalidStatusException(401), repository.current.value, repository, setup)
|
||||
model.load((destination as SetupDestination.UserList).server)
|
||||
assertEquals(Stage.Picker, model.stage)
|
||||
assertTrue(model.picker!!.people.any { it.jellyfinUserId == other.id.toString() })
|
||||
assertEquals(other.id, (recent.get() as MostRecentServer.ServerAndUser).userId)
|
||||
} finally { WolfskinScreenPreference.enabled = previous }
|
||||
}
|
||||
|
||||
@Test fun firstRunDiscoversAndPersistsHouseholdBeforeFetchingPublicProfiles() = runBlocking {
|
||||
database.serverDao().deleteUser(server.id, user.id)
|
||||
database.serverDao().deleteServer(server.id)
|
||||
recent.clear()
|
||||
model.load(null)
|
||||
assertEquals(Stage.Unconfigured, model.stage)
|
||||
coEvery { jellyfin.discovery.getRecommendedServers("bad-url") } returns emptyList()
|
||||
model.connect("bad-url")
|
||||
assertEquals(Stage.Unconfigured, model.stage)
|
||||
assertTrue(database.serverDao().getServers().isEmpty())
|
||||
coEvery { jellyfin.discovery.getRecommendedServers(server.url) } returns listOf(RecommendedServerInfo(
|
||||
address = server.url, responseTime = 1, score = RecommendedServerInfoScore.GREAT, issues = emptyList(),
|
||||
systemInfo = Result.success(PublicSystemInfo(id = server.id.toString(), serverName = server.name, startupWizardCompleted = true))))
|
||||
model.connect(server.url)
|
||||
assertEquals(server.id, database.serverDao().getServers().single().server.id)
|
||||
coEvery { usersApi.getPublicUsers() } returns successResponse(listOf(dto))
|
||||
model.load(model.server)
|
||||
assertEquals(Stage.Picker, model.stage)
|
||||
model.select(model.picker!!.person)
|
||||
assertEquals(Stage.Login, model.stage)
|
||||
assertNull(repository.current.value)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue