BIT-2234: Delete Account Confirmation Screen (Navigation) (#1278)

This commit is contained in:
Ramsey Smith 2024-04-18 10:42:39 -06:00 committed by Álison Fernandes
parent 4ac9d05036
commit 9648f720be
11 changed files with 702 additions and 18 deletions

View file

@ -12,11 +12,15 @@ private const val DELETE_ACCOUNT_ROUTE = "delete_account"
*/
fun NavGraphBuilder.deleteAccountDestination(
onNavigateBack: () -> Unit,
onNavigateToDeleteAccountConfirmation: () -> Unit,
) {
composableWithSlideTransitions(
route = DELETE_ACCOUNT_ROUTE,
) {
DeleteAccountScreen(onNavigateBack = onNavigateBack)
DeleteAccountScreen(
onNavigateBack = onNavigateBack,
onNavigateToDeleteAccountConfirmation = onNavigateToDeleteAccountConfirmation,
)
}
}

View file

@ -54,6 +54,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
fun DeleteAccountScreen(
viewModel: DeleteAccountViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
onNavigateToDeleteAccountConfirmation: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
@ -65,6 +66,10 @@ fun DeleteAccountScreen(
is DeleteAccountEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen -> {
onNavigateToDeleteAccountConfirmation()
}
}
}
@ -149,9 +154,17 @@ fun DeleteAccountScreen(
)
Spacer(modifier = Modifier.height(24.dp))
DeleteAccountButton(
onConfirmationClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(it)) }
onDeleteAccountConfirmDialogClick = remember(viewModel) {
{
viewModel.trySendAction(
DeleteAccountAction.DeleteAccountConfirmDialogClick(it),
)
}
},
onDeleteAccountClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) }
},
isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled,
modifier = Modifier
.semantics { testTag = "DELETE ACCOUNT" }
.fillMaxWidth()
@ -175,7 +188,9 @@ fun DeleteAccountScreen(
@Composable
private fun DeleteAccountButton(
onConfirmationClick: (masterPassword: String) -> Unit,
onDeleteAccountConfirmDialogClick: (masterPassword: String) -> Unit,
onDeleteAccountClick: () -> Unit,
isUnlockWithPasswordEnabled: Boolean,
modifier: Modifier = Modifier,
) {
var showPasswordDialog by remember { mutableStateOf(false) }
@ -183,7 +198,7 @@ private fun DeleteAccountButton(
BitwardenMasterPasswordDialog(
onConfirmClick = {
showPasswordDialog = false
onConfirmationClick(it)
onDeleteAccountConfirmDialogClick(it)
},
onDismissRequest = { showPasswordDialog = false },
)
@ -191,7 +206,13 @@ private fun DeleteAccountButton(
BitwardenErrorButton(
label = stringResource(id = R.string.delete_account),
onClick = { showPasswordDialog = true },
onClick = {
if (isUnlockWithPasswordEnabled) {
showPasswordDialog = true
} else {
onDeleteAccountClick()
}
},
modifier = modifier,
)
}

View file

@ -20,7 +20,7 @@ import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the account security screen.
* View model for the [DeleteAccountScreen].
*/
@HiltViewModel
class DeleteAccountViewModel @Inject constructor(
@ -29,6 +29,10 @@ class DeleteAccountViewModel @Inject constructor(
) : BaseViewModel<DeleteAccountState, DeleteAccountEvent, DeleteAccountAction>(
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState(
dialog = null,
isUnlockWithPasswordEnabled = requireNotNull(authRepository.userStateFlow.value)
.activeAccount
.trustedDevice
?.hasMasterPassword != false,
),
) {
@ -42,15 +46,23 @@ class DeleteAccountViewModel @Inject constructor(
when (action) {
DeleteAccountAction.CancelClick -> handleCancelClick()
DeleteAccountAction.CloseClick -> handleCloseClick()
is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick(action)
is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick()
DeleteAccountAction.AccountDeletionConfirm -> handleAccountDeletionConfirm()
DeleteAccountAction.DismissDialog -> handleDismissDialog()
is DeleteAccountAction.Internal.DeleteAccountComplete -> {
handleDeleteAccountComplete(action)
}
is DeleteAccountAction.DeleteAccountConfirmDialogClick -> {
handleDeleteAccountConfirmDialogClick(action)
}
}
}
private fun handleDeleteAccountClick() {
sendEvent(DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen)
}
private fun handleCancelClick() {
sendEvent(DeleteAccountEvent.NavigateBack)
}
@ -59,7 +71,9 @@ class DeleteAccountViewModel @Inject constructor(
sendEvent(DeleteAccountEvent.NavigateBack)
}
private fun handleDeleteAccountClick(action: DeleteAccountAction.DeleteAccountClick) {
private fun handleDeleteAccountConfirmDialogClick(
action: DeleteAccountAction.DeleteAccountConfirmDialogClick,
) {
mutableStateFlow.update {
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading)
}
@ -103,10 +117,15 @@ class DeleteAccountViewModel @Inject constructor(
/**
* Models state for the Delete Account screen.
*
* @param dialog The dialog for the [DeleteAccountScreen].
* @param isUnlockWithPasswordEnabled Whether or not the user is able to unlock the vault with
* their master password.
*/
@Parcelize
data class DeleteAccountState(
val dialog: DeleteAccountDialog?,
val isUnlockWithPasswordEnabled: Boolean,
) : Parcelable {
/**
@ -144,6 +163,11 @@ sealed class DeleteAccountEvent {
*/
data object NavigateBack : DeleteAccountEvent()
/**
* Navigates to the [DeleteAccountConfirmationScreen].
*/
data object NavigateToDeleteAccountConfirmationScreen : DeleteAccountEvent()
/**
* Displays the [message] in a toast.
*/
@ -169,7 +193,14 @@ sealed class DeleteAccountAction {
/**
* The user has clicked the delete account button.
*/
data class DeleteAccountClick(
data object DeleteAccountClick : DeleteAccountAction()
/**
* The user has clicked the delete account confirmation dialog button.
*
* @param masterPassword The master password of the user.
*/
data class DeleteAccountConfirmDialogClick(
val masterPassword: String,
) : DeleteAccountAction()

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val DELETE_ACCOUNT_CONFIRMATION_ROUTE = "delete_account_confirmation"
/**
* Add delete account confirmation destinations to the nav graph.
*/
fun NavGraphBuilder.deleteAccountConfirmationDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = DELETE_ACCOUNT_CONFIRMATION_ROUTE,
) {
DeleteAccountConfirmationScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the [DeleteAccountConfirmationScreen].
*/
fun NavController.navigateToDeleteAccountConfirmation(navOptions: NavOptions? = null) {
navigate(DELETE_ACCOUNT_CONFIRMATION_ROUTE, navOptions)
}

View file

@ -0,0 +1,129 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
/**
* Displays the delete account confirmation screen.
*/
@Composable
fun DeleteAccountConfirmationScreen(
viewModel: DeleteAccountConfirmationViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
DeleteAccountConfirmationEvent.NavigateBack -> onNavigateBack()
is DeleteAccountConfirmationEvent.ShowToast -> {
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
}
}
DeleteAccountConfirmationDialogs(
dialogState = state.dialog,
onDeleteAccountAcknowledge = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge) }
},
onDismissDialog = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountConfirmationAction.DismissDialog) }
},
)
DeleteAccountConfirmationScaffold(
state = state,
onCloseClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick) }
},
)
}
@Composable
private fun DeleteAccountConfirmationDialogs(
dialogState: DeleteAccountConfirmationState.DeleteAccountConfirmationDialog?,
onDismissDialog: () -> Unit,
onDeleteAccountAcknowledge: () -> Unit,
) {
when (dialogState) {
is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = null,
message = dialogState.message,
),
onDismissRequest = onDeleteAccountAcknowledge,
)
}
is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialogState.title,
message = dialogState.message,
),
onDismissRequest = onDismissDialog,
)
}
is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(dialogState.title),
)
}
null -> Unit
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeleteAccountConfirmationScaffold(
state: DeleteAccountConfirmationState,
onCloseClick: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.verification_code),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = onCloseClick,
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
// TODO finish UI in BIT-2234
}
}
}

View file

@ -0,0 +1,149 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the [DeleteAccountConfirmationScreen].
*/
@HiltViewModel
class DeleteAccountConfirmationViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<
DeleteAccountConfirmationState,
DeleteAccountConfirmationEvent,
DeleteAccountConfirmationAction,>(
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountConfirmationState(
dialog = null,
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: DeleteAccountConfirmationAction) {
when (action) {
DeleteAccountConfirmationAction.CloseClick -> handleCloseClick()
DeleteAccountConfirmationAction.DeleteAccountAcknowledge -> {
handleDeleteAccountAcknowledge()
}
DeleteAccountConfirmationAction.DismissDialog -> handleDismissDialog()
}
}
private fun handleCloseClick() {
sendEvent(DeleteAccountConfirmationEvent.NavigateBack)
}
private fun handleDeleteAccountAcknowledge() {
authRepository.clearPendingAccountDeletion()
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
}
/**
* Models state for the [DeleteAccountConfirmationScreen].
*/
@Parcelize
data class DeleteAccountConfirmationState(
val dialog: DeleteAccountConfirmationDialog?,
) : Parcelable {
/**
* Displays a dialog.
*/
sealed class DeleteAccountConfirmationDialog : Parcelable {
/**
* Dialog to confirm to the user that the account has been deleted.
*
* @param message The message for the dialog.
*/
@Parcelize
data class DeleteSuccess(
val message: Text =
R.string.your_account_has_been_permanently_deleted.asText(),
) : DeleteAccountConfirmationDialog()
/**
* Displays the error dialog when deleting an account fails.
*
* @param title The title for the dialog.
* @param message The message for the dialog.
*/
@Parcelize
data class Error(
val title: Text = R.string.an_error_has_occurred.asText(),
val message: Text,
) : DeleteAccountConfirmationDialog()
/**
* Displays the loading dialog when deleting an account.
*
* @param title The title for the dialog.
*/
@Parcelize
data class Loading(
val title: Text = R.string.loading.asText(),
) : DeleteAccountConfirmationDialog()
}
}
/**
* Models events for the [DeleteAccountConfirmationScreen].
*/
sealed class DeleteAccountConfirmationEvent {
/**
* Navigates back.
*/
data object NavigateBack : DeleteAccountConfirmationEvent()
/**
* Displays the [message] in a toast.
*/
data class ShowToast(
val message: Text,
) : DeleteAccountConfirmationEvent()
}
/**
* Models actions for the [DeleteAccountConfirmationScreen].
*/
sealed class DeleteAccountConfirmationAction {
/**
* The user has clicked the close button.
*/
data object CloseClick : DeleteAccountConfirmationAction()
/**
* The user has dismissed the dialog.
*/
data object DismissDialog : DeleteAccountConfirmationAction()
/**
* The user has acknowledged the account deletion.
*/
data object DeleteAccountAcknowledge : DeleteAccountConfirmationAction()
}

View file

@ -8,6 +8,8 @@ import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation.deleteAccountConfirmationDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation.navigateToDeleteAccountConfirmation
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.loginApprovalDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests
@ -97,7 +99,15 @@ fun NavGraphBuilder.vaultUnlockedGraph(
)
},
)
deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
deleteAccountDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToDeleteAccountConfirmation = {
navController.navigateToDeleteAccountConfirmation()
},
)
deleteAccountConfirmationDestination(
onNavigateBack = { navController.popBackStack() },
)
loginApprovalDestination(onNavigateBack = { navController.popBackStack() })
pendingRequestsDestination(
onNavigateBack = { navController.popBackStack() },

View file

@ -26,6 +26,7 @@ import org.junit.Test
class DeleteAccountScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToDeleteAccountConfirmationScreenCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<DeleteAccountEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -39,6 +40,9 @@ class DeleteAccountScreenTest : BaseComposeTest() {
composeTestRule.setContent {
DeleteAccountScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToDeleteAccountConfirmation = {
onNavigateToDeleteAccountConfirmationScreenCalled = true
},
viewModel = viewModel,
)
}
@ -50,6 +54,13 @@ class DeleteAccountScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Test
@Suppress("MaxLineLength")
fun `NavigateToDeleteAccountConfirmationScreen should call onNavigateToDeleteAccountConfirmationScreenCalled`() {
mutableEventFlow.tryEmit(DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen)
assertTrue(onNavigateToDeleteAccountConfirmationScreenCalled)
}
@Test
fun `cancel click should emit CancelClick`() {
composeTestRule.onNodeWithText("Cancel").performScrollTo().performClick()
@ -195,11 +206,16 @@ class DeleteAccountScreenTest : BaseComposeTest() {
.assertDoesNotExist()
verify {
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(password))
viewModel.trySendAction(
DeleteAccountAction.DeleteAccountConfirmDialogClick(
password,
),
)
}
}
}
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
dialog = null,
isUnlockWithPasswordEnabled = true,
)

View file

@ -5,6 +5,8 @@ import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
@ -14,18 +16,36 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class DeleteAccountViewModelTest : BaseViewModelTest() {
private val authRepo: AuthRepository = mockk(relaxed = true)
private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE)
private val authRepo: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
@Test
fun `initial state should be correct when not set`() {
mutableUserStateFlow.update { currentState ->
currentState.copy(
accounts = currentState.accounts.map { account ->
account.copy(
trustedDevice = account.trustedDevice?.copy(
hasMasterPassword = false,
),
)
},
)
}
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
DEFAULT_STATE.copy(isUnlockWithPasswordEnabled = false),
viewModel.stateFlow.value,
)
}
@Test
@ -56,13 +76,18 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
}
@Test
fun `on DeleteAccountClick should update dialog state when delete account succeeds`() =
@Suppress("MaxLineLength")
fun `on DeleteAccountConfirmDialogClick should update dialog state when delete account succeeds`() =
runTest {
val viewModel = createViewModel()
val masterPassword = "ckasb kcs ja"
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
viewModel.trySendAction(
DeleteAccountAction.DeleteAccountConfirmDialogClick(
masterPassword,
),
)
assertEquals(
DEFAULT_STATE.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess),
@ -74,13 +99,32 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
}
}
@Test
@Suppress("MaxLineLength")
fun `on DeleteAccountClick should emit NavigateToDeleteAccountConfirmationScreen`() =
runTest {
val viewModel = createViewModel(
state = DEFAULT_STATE.copy(
isUnlockWithPasswordEnabled = false,
),
)
viewModel.eventFlow.test {
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick)
assertEquals(
DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen,
awaitItem(),
)
}
}
@Test
fun `on DeleteAccountClick should update dialog state when deleteAccount fails`() = runTest {
val viewModel = createViewModel()
val masterPassword = "ckasb kcs ja"
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Error
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
viewModel.trySendAction(DeleteAccountAction.DeleteAccountConfirmDialogClick(masterPassword))
assertEquals(
DEFAULT_STATE.copy(
@ -135,6 +179,34 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
)
}
private val DEFAULT_USER_STATE: UserState = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = UserState.TrustedDevice(
isDeviceTrusted = true,
hasMasterPassword = true,
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasResetPasswordPermission = true,
),
),
),
)
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
dialog = null,
isUnlockWithPasswordEnabled = true,
)

View file

@ -0,0 +1,129 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class DeleteAccountConfirmationScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<DeleteAccountConfirmationEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<DeleteAccountConfirmationViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
DeleteAccountConfirmationScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(DeleteAccountConfirmationEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `loading dialog presence should update with dialog state`() {
composeTestRule
.onAllNodesWithText("Loading")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
DEFAULT_STATE.copy(
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
)
}
composeTestRule
.onAllNodesWithText("Loading")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `error dialog presence should update with dialog state`() {
val message = "hello world"
composeTestRule
.onAllNodesWithText(message)
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
DEFAULT_STATE.copy(
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
message = message.asText(),
),
)
}
composeTestRule
.onAllNodesWithText(message)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `delete success dialog presence should update with dialog state`() {
val message = "Your account has been permanently deleted"
composeTestRule
.onAllNodesWithText(message)
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
DEFAULT_STATE.copy(
dialog =
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
)
}
composeTestRule
.onAllNodesWithText(message)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `delete success dialog dismiss should emit DeleteAccountAction`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(
dialog =
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
)
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge)
}
}
}
private val DEFAULT_STATE: DeleteAccountConfirmationState =
DeleteAccountConfirmationState(dialog = null)

View file

@ -0,0 +1,93 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() {
private val authRepo: AuthRepository = mockk(relaxed = true)
@Test
fun `initial state should be correct when not set`() {
val viewModel = createViewModel(state = null)
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
message = "Hello".asText(),
),
)
val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value)
}
@Test
fun `on CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick)
assertEquals(DeleteAccountConfirmationEvent.NavigateBack, awaitItem())
}
}
@Test
fun `DeleteAccountAcknowledge should clear dialog and call clearPendingAccountDeletion`() =
runTest {
every { authRepo.clearPendingAccountDeletion() } just runs
val state = DEFAULT_STATE.copy(
dialog =
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
)
val viewModel = createViewModel(state = state)
viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge)
assertEquals(
DEFAULT_STATE,
viewModel.stateFlow.value,
)
verify {
authRepo.clearPendingAccountDeletion()
}
}
@Test
fun `on DismissDialog should clear dialog state`() = runTest {
val state = DEFAULT_STATE.copy(
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
message = "Hello".asText(),
),
)
val viewModel = createViewModel(state = state)
viewModel.trySendAction(DeleteAccountConfirmationAction.DismissDialog)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
private fun createViewModel(
authenticationRepository: AuthRepository = authRepo,
state: DeleteAccountConfirmationState? = null,
): DeleteAccountConfirmationViewModel = DeleteAccountConfirmationViewModel(
authRepository = authenticationRepository,
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
}
private val DEFAULT_STATE: DeleteAccountConfirmationState =
DeleteAccountConfirmationState(dialog = null)