BIT-896 Allow user to log out (#129)

This commit is contained in:
Andrew Haisting 2023-10-18 13:55:46 -05:00 committed by Álison Fernandes
parent 6af438a264
commit aafd32fbc3
21 changed files with 581 additions and 41 deletions

View file

@ -36,6 +36,11 @@ interface AuthRepository {
captchaToken: String?, captchaToken: String?,
): LoginResult ): LoginResult
/**
* Log out the current user.
*/
fun logout()
/** /**
* Set the value of [captchaTokenResultFlow]. * Set the value of [captchaTokenResultFlow].
*/ */

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -88,6 +89,10 @@ class AuthRepositoryImpl @Inject constructor(
}, },
) )
override fun logout() {
mutableAuthStateFlow.update { AuthState.Unauthenticated }
}
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) { override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
mutableCaptchaTokenFlow.tryEmit(tokenResult) mutableCaptchaTokenFlow.tryEmit(tokenResult)
} }

View file

@ -12,7 +12,7 @@ import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestinations
import com.x8bit.bitwarden.ui.auth.feature.login.loginDestinations import com.x8bit.bitwarden.ui.auth.feature.login.loginDestinations
import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin
private const val AUTH_ROUTE: String = "auth" const val AUTH_ROUTE: String = "auth"
/** /**
* Add auth destinations to the nav graph. * Add auth destinations to the nav graph.

View file

@ -5,15 +5,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authDestinations import com.x8bit.bitwarden.ui.auth.feature.auth.authDestinations
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuth import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuth
import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash
import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestinations import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestinations
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlocked import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlocked
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedDestinations import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedDestinations
@ -39,7 +42,20 @@ fun RootNavScreen(
) { ) {
splashDestinations() splashDestinations()
authDestinations(navController) authDestinations(navController)
vaultUnlockedDestinations() vaultUnlockedDestinations(navController)
}
val targetRoute = when (state) {
RootNavState.Auth -> AUTH_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
RootNavState.VaultUnlocked -> VAULT_UNLOCKED_ROUTE
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
// Don't navigate if we are already at the correct root. This notably happens during process
// death. In this case, the NavHost already restores state, so we don't have to navigate.
if (currentRoute == targetRoute) {
return
} }
// When state changes, navigate to different root navigation state // When state changes, navigate to different root navigation state
@ -47,10 +63,10 @@ fun RootNavScreen(
// When changing root navigation state, pop everything else off the back stack: // When changing root navigation state, pop everything else off the back stack:
popUpTo(navController.graph.id) { popUpTo(navController.graph.id) {
inclusive = false inclusive = false
saveState = true saveState = false
} }
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = false
} }
when (state) { when (state) {
@ -59,3 +75,20 @@ fun RootNavScreen(
RootNavState.VaultUnlocked -> navController.navigateToVaultUnlocked(rootNavOptions) RootNavState.VaultUnlocked -> navController.navigateToVaultUnlocked(rootNavOptions)
} }
} }
/**
* Helper method that returns the highest level route for the given [NavDestination].
*
* As noted above, this can be removed after upgrading to latest compose navigation, since
* the nav args can prevent us from having to do this check.
*/
@Suppress("ReturnCount")
private fun NavDestination?.rootLevelRoute(): String? {
if (this == null) {
return null
}
if (parent?.route == null) {
return route
}
return parent.rootLevelRoute()
}

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
private const val ACCOUNT_SECURITY_ROUTE = "account_security"
/**
* Add settings destinations to the nav graph.
*/
fun NavGraphBuilder.accountSecurityDestination(
onNavigateBack: () -> Unit,
) {
composable(ACCOUNT_SECURITY_ROUTE) {
AccountSecurityScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the account security screen.
*/
fun NavController.navigateToAccountSecurity(navOptions: NavOptions? = null) {
navigate(ACCOUNT_SECURITY_ROUTE, navOptions)
}

View file

@ -0,0 +1,83 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowTopAppBar
/**
* Displays the account security screen.
*/
@Composable
fun AccountSecurityScreen(
onNavigateBack: () -> Unit,
viewModel: AccountSecurityViewModel = hiltViewModel(),
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
AccountSecurityEvent.NavigateBack -> onNavigateBack.invoke()
}
}
Column(
Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.surface),
) {
BitwardenOverflowTopAppBar(
title = stringResource(id = R.string.account),
navigationIcon = painterResource(id = R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.BackClick) }
},
dropdownMenuItemContent = {},
)
Spacer(Modifier.height(8.dp))
AccountSecurityRow(
text = R.string.log_out.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.LogoutClick) }
},
)
}
}
@Composable
private fun AccountSecurityRow(
text: Text,
onClick: () -> Unit,
) {
Text(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.fillMaxWidth(),
text = text(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}

View file

@ -0,0 +1,46 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* View model for the account security screen.
*/
@HiltViewModel
class AccountSecurityViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : BaseViewModel<Unit, AccountSecurityEvent, AccountSecurityAction>(
initialState = Unit,
) {
override fun handleAction(action: AccountSecurityAction): Unit = when (action) {
AccountSecurityAction.LogoutClick -> authRepository.logout()
AccountSecurityAction.BackClick -> sendEvent(AccountSecurityEvent.NavigateBack)
}
}
/**
* Models events for the account security screen.
*/
sealed class AccountSecurityEvent {
/**
* Navigate back.
*/
data object NavigateBack : AccountSecurityEvent()
}
/**
* Models actions for the account security screen.
*/
sealed class AccountSecurityAction {
/**
* User clicked back button.
*/
data object BackClick : AccountSecurityAction()
/**
* User clicked log out.
*/
data object LogoutClick : AccountSecurityAction()
}

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
const val SETTINGS_ROUTE: String = "settings"
/**
* Add settings destinations to the nav graph.
*/
fun NavGraphBuilder.settingsDestinations(
onNavigateToAccountSecurity: () -> Unit,
) {
composable(SETTINGS_ROUTE) {
SettingsScreen(onNavigateToAccountSecurity = onNavigateToAccountSecurity)
}
}
/**
* Navigate to the settings screen screen.
*/
fun NavController.navigateToSettings(navOptions: NavOptions? = null) {
navigate(SETTINGS_ROUTE, navOptions)
}

View file

@ -0,0 +1,77 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
/**
* Displays the settings screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateToAccountSecurity: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity.invoke()
}
}
Column(
Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.surface),
) {
BitwardenMediumTopAppBar(
title = stringResource(id = R.string.settings),
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
)
SettingsRow(
text = R.string.account.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(SettingsAction.AccountSecurityClick) }
},
)
}
}
@Composable
private fun SettingsRow(
text: Text,
onClick: () -> Unit,
) {
Text(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.fillMaxWidth(),
text = text(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}

View file

@ -0,0 +1,41 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* View model for the settings screen.
*/
@HiltViewModel
class SettingsViewModel @Inject constructor() : BaseViewModel<Unit, SettingsEvent, SettingsAction>(
initialState = Unit,
) {
override fun handleAction(action: SettingsAction): Unit = when (action) {
SettingsAction.AccountSecurityClick -> handleAccountSecurityClick()
}
private fun handleAccountSecurityClick() {
sendEvent(SettingsEvent.NavigateAccountSecurity)
}
}
/**
* Models events for the settings screen.
*/
sealed class SettingsEvent {
/**
* Navigate to the account security screen.
*/
data object NavigateAccountSecurity : SettingsEvent()
}
/**
* Models actions for the settings screen.
*/
sealed class SettingsAction {
/**
* User clicked account security.
*/
data object AccountSecurityClick : SettingsAction()
}

View file

@ -2,12 +2,15 @@ package com.x8bit.bitwarden.ui.platform.feature.vaultunlocked
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.navigation import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.platform.feature.settings.accountSecurityDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToAccountSecurity
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
private const val VAULT_UNLOCKED_ROUTE: String = "VaultUnlocked" const val VAULT_UNLOCKED_ROUTE: String = "VaultUnlocked"
/** /**
* Navigate to the vault unlocked screen. * Navigate to the vault unlocked screen.
@ -19,11 +22,16 @@ fun NavController.navigateToVaultUnlocked(navOptions: NavOptions? = null) {
/** /**
* Add vault unlocked destinations to the root nav graph. * Add vault unlocked destinations to the root nav graph.
*/ */
fun NavGraphBuilder.vaultUnlockedDestinations() { fun NavGraphBuilder.vaultUnlockedDestinations(navController: NavHostController) {
navigation( navigation(
startDestination = VAULT_UNLOCKED_NAV_BAR_ROUTE, startDestination = VAULT_UNLOCKED_NAV_BAR_ROUTE,
route = VAULT_UNLOCKED_ROUTE, route = VAULT_UNLOCKED_ROUTE,
) { ) {
vaultUnlockedNavBarDestination() vaultUnlockedNavBarDestination(
onNavigateToAccountSecurity = { navController.navigateToAccountSecurity() },
)
accountSecurityDestination(
onNavigateBack = { navController.popBackStack() },
)
} }
} }

View file

@ -20,8 +20,12 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null)
/** /**
* Add vault unlocked destination to the root nav graph. * Add vault unlocked destination to the root nav graph.
*/ */
fun NavGraphBuilder.vaultUnlockedNavBarDestination() { fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToAccountSecurity: () -> Unit,
) {
composable(VAULT_UNLOCKED_NAV_BAR_ROUTE) { composable(VAULT_UNLOCKED_NAV_BAR_ROUTE) {
VaultUnlockedNavBarScreen() VaultUnlockedNavBarScreen(
onNavigateToAccountSecurity = onNavigateToAccountSecurity,
)
} }
} }

View file

@ -28,6 +28,9 @@ import androidx.navigation.navOptions
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.PlaceholderComposable import com.x8bit.bitwarden.ui.platform.components.PlaceholderComposable
import com.x8bit.bitwarden.ui.platform.feature.settings.SETTINGS_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettings
import com.x8bit.bitwarden.ui.platform.feature.settings.settingsDestinations
import com.x8bit.bitwarden.ui.tools.feature.generator.GENERATOR_ROUTE import com.x8bit.bitwarden.ui.tools.feature.generator.GENERATOR_ROUTE
import com.x8bit.bitwarden.ui.tools.feature.generator.generatorDestination import com.x8bit.bitwarden.ui.tools.feature.generator.generatorDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGenerator import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGenerator
@ -41,6 +44,7 @@ import kotlinx.parcelize.Parcelize
*/ */
@Composable @Composable
fun VaultUnlockedNavBarScreen( fun VaultUnlockedNavBarScreen(
onNavigateToAccountSecurity: () -> Unit,
viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(), viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
) { ) {
@ -68,6 +72,7 @@ fun VaultUnlockedNavBarScreen(
} }
VaultUnlockedNavBarScaffold( VaultUnlockedNavBarScaffold(
navController = navController, navController = navController,
onNavigateToAccountSecurity = onNavigateToAccountSecurity,
generatorTabClickedAction = { generatorTabClickedAction = {
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
}, },
@ -89,6 +94,7 @@ fun VaultUnlockedNavBarScreen(
@Composable @Composable
@Suppress("LongMethod") @Suppress("LongMethod")
private fun VaultUnlockedNavBarScaffold( private fun VaultUnlockedNavBarScaffold(
onNavigateToAccountSecurity: () -> Unit,
navController: NavHostController, navController: NavHostController,
vaultTabClickedAction: () -> Unit, vaultTabClickedAction: () -> Unit,
sendTabClickedAction: () -> Unit, sendTabClickedAction: () -> Unit,
@ -162,7 +168,9 @@ private fun VaultUnlockedNavBarScaffold(
vaultDestination() vaultDestination()
sendDestination() sendDestination()
generatorDestination() generatorDestination()
settingsDestination() settingsDestinations(
onNavigateToAccountSecurity = onNavigateToAccountSecurity,
)
} }
} }
} }
@ -301,32 +309,3 @@ private fun NavController.navigateToSend(navOptions: NavOptions? = null) {
navigate(SEND_ROUTE, navOptions) navigate(SEND_ROUTE, navOptions)
} }
// #endregion Send // #endregion Send
// #region Settings
/**
* TODO: move to settings package (BIT-147)
*/
private const val SETTINGS_ROUTE = "settings"
/**
* Add settings destination to the nav graph.
*
* TODO: move to settings package (BIT-147)
*/
private fun NavGraphBuilder.settingsDestination() {
composable(SETTINGS_ROUTE) {
PlaceholderComposable(text = "Settings")
}
}
/**
* Navigate to the generator screen. Note this will only work if generator screen was added
* via [settingsDestination].
*
* TODO: move to settings package (BIT-147)
*
*/
private fun NavController.navigateToSettings(navOptions: NavOptions? = null) {
navigate(SETTINGS_ROUTE, navOptions)
}
// #endregion Settings

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="25dp"
android:viewportWidth="24"
android:viewportHeight="25">
<path
android:pathData="M20,11.103H7.83L13.42,5.513L12,4.103L4,12.103L12,20.103L13.41,18.693L7.83,13.103H20V11.103Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -202,6 +202,31 @@ class AuthRepositoryTest {
} }
} }
@Test
fun `logout should change AuthState to be Unauthenticated`() = runTest {
// First login:
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
)
}
.returns(Result.success(GetTokenResponseJson.Success(accessToken = ACCESS_TOKEN)))
every { authInterceptor.authToken = ACCESS_TOKEN } returns Unit
repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
// Then call logout:
repository.authStateFlow.test {
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem())
repository.logout()
assertEquals(AuthState.Unauthenticated, awaitItem())
}
}
companion object { companion object {
private const val EMAIL = "test@test.com" private const val EMAIL = "test@test.com"
private const val PASSWORD = "password" private const val PASSWORD = "password"

View file

@ -19,10 +19,10 @@ class RootNavScreenTest : BaseComposeTest() {
// When changing root navigation state, pop everything else off the back stack: // When changing root navigation state, pop everything else off the back stack:
popUpTo(fakeNavHostController.graphId) { popUpTo(fakeNavHostController.graphId) {
inclusive = false inclusive = false
saveState = true saveState = false
} }
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = false
} }
@Test @Test

View file

@ -0,0 +1,62 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
class AccountSecurityScreenTest : BaseComposeTest() {
@Test
fun `on Log out click should send LogoutClick`() {
val viewModel: AccountSecurityViewModel = mockk {
every { eventFlow } returns emptyFlow()
every { trySendAction(AccountSecurityAction.LogoutClick) } returns Unit
}
composeTestRule.setContent {
AccountSecurityScreen(
viewModel = viewModel,
onNavigateBack = { },
)
}
composeTestRule.onNodeWithText("Log out").performClick()
verify { viewModel.trySendAction(AccountSecurityAction.LogoutClick) }
}
@Test
fun `on back click should send BackClick`() {
val viewModel: AccountSecurityViewModel = mockk {
every { eventFlow } returns emptyFlow()
every { trySendAction(AccountSecurityAction.BackClick) } returns Unit
}
composeTestRule.setContent {
AccountSecurityScreen(
viewModel = viewModel,
onNavigateBack = { },
)
}
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(AccountSecurityAction.BackClick) }
}
@Test
fun `on NavigateAccountSecurity should call onNavigateToAccountSecurity`() {
var haveCalledNavigateBack = false
val viewModel = mockk<AccountSecurityViewModel> {
every { eventFlow } returns flowOf(AccountSecurityEvent.NavigateBack)
}
composeTestRule.setContent {
AccountSecurityScreen(
viewModel = viewModel,
onNavigateBack = { haveCalledNavigateBack = true },
)
}
assert(haveCalledNavigateBack)
}
}

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AccountSecurityViewModelTest : BaseViewModelTest() {
@Test
fun `on BackClick should emit NavigateBack`() = runTest {
val viewModel = AccountSecurityViewModel(
authRepository = mockk(),
)
viewModel.eventFlow.test {
viewModel.trySendAction(AccountSecurityAction.BackClick)
assertEquals(AccountSecurityEvent.NavigateBack, awaitItem())
}
}
@Test
fun `on LogoutClick should call logout`() = runTest {
val authRepository: AuthRepository = mockk {
every { logout() } returns Unit
}
val viewModel = AccountSecurityViewModel(
authRepository = authRepository,
)
viewModel.trySendAction(AccountSecurityAction.LogoutClick)
verify { authRepository.logout() }
}
}

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
class SettingsScreenTest : BaseComposeTest() {
@Test
fun `on account row click should emit AccountSecurityClick`() {
val viewModel = mockk<SettingsViewModel> {
every { eventFlow } returns emptyFlow()
every { trySendAction(SettingsAction.AccountSecurityClick) } returns Unit
}
composeTestRule.setContent {
SettingsScreen(
viewModel = viewModel,
onNavigateToAccountSecurity = { },
)
}
composeTestRule.onNodeWithText("Account").performClick()
verify { viewModel.trySendAction(SettingsAction.AccountSecurityClick) }
}
@Test
fun `on NavigateAccountSecurity should call onNavigateToAccountSecurity`() {
var haveCalledNavigateToAccountSecurity = false
val viewModel = mockk<SettingsViewModel> {
every { eventFlow } returns flowOf(SettingsEvent.NavigateAccountSecurity)
}
composeTestRule.setContent {
SettingsScreen(
viewModel = viewModel,
onNavigateToAccountSecurity = {
haveCalledNavigateToAccountSecurity = true
},
)
}
assert(haveCalledNavigateToAccountSecurity)
}
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SettingsViewModelTest : BaseViewModelTest() {
@Test
fun `on AccountSecurityClick should emit NavigateAccountSecurity`() = runTest {
val viewModel = SettingsViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SettingsAction.AccountSecurityClick)
assertEquals(SettingsEvent.NavigateAccountSecurity, awaitItem())
}
}
}

View file

@ -32,6 +32,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
onNodeWithText("My vault").performClick() onNodeWithText("My vault").performClick()
@ -52,6 +53,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -73,6 +75,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
onNodeWithText("Send").performClick() onNodeWithText("Send").performClick()
@ -93,6 +96,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -114,6 +118,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
onNodeWithText("Generator").performClick() onNodeWithText("Generator").performClick()
@ -134,6 +139,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -155,6 +161,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
onNodeWithText("Settings").performClick() onNodeWithText("Settings").performClick()
@ -175,6 +182,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToAccountSecurity = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }