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

View file

@ -20,7 +20,7 @@ import javax.inject.Inject
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
/** /**
* View model for the account security screen. * View model for the [DeleteAccountScreen].
*/ */
@HiltViewModel @HiltViewModel
class DeleteAccountViewModel @Inject constructor( class DeleteAccountViewModel @Inject constructor(
@ -29,6 +29,10 @@ class DeleteAccountViewModel @Inject constructor(
) : BaseViewModel<DeleteAccountState, DeleteAccountEvent, DeleteAccountAction>( ) : BaseViewModel<DeleteAccountState, DeleteAccountEvent, DeleteAccountAction>(
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState( initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState(
dialog = null, dialog = null,
isUnlockWithPasswordEnabled = requireNotNull(authRepository.userStateFlow.value)
.activeAccount
.trustedDevice
?.hasMasterPassword != false,
), ),
) { ) {
@ -42,15 +46,23 @@ class DeleteAccountViewModel @Inject constructor(
when (action) { when (action) {
DeleteAccountAction.CancelClick -> handleCancelClick() DeleteAccountAction.CancelClick -> handleCancelClick()
DeleteAccountAction.CloseClick -> handleCloseClick() DeleteAccountAction.CloseClick -> handleCloseClick()
is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick(action) is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick()
DeleteAccountAction.AccountDeletionConfirm -> handleAccountDeletionConfirm() DeleteAccountAction.AccountDeletionConfirm -> handleAccountDeletionConfirm()
DeleteAccountAction.DismissDialog -> handleDismissDialog() DeleteAccountAction.DismissDialog -> handleDismissDialog()
is DeleteAccountAction.Internal.DeleteAccountComplete -> { is DeleteAccountAction.Internal.DeleteAccountComplete -> {
handleDeleteAccountComplete(action) handleDeleteAccountComplete(action)
} }
is DeleteAccountAction.DeleteAccountConfirmDialogClick -> {
handleDeleteAccountConfirmDialogClick(action)
}
} }
} }
private fun handleDeleteAccountClick() {
sendEvent(DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen)
}
private fun handleCancelClick() { private fun handleCancelClick() {
sendEvent(DeleteAccountEvent.NavigateBack) sendEvent(DeleteAccountEvent.NavigateBack)
} }
@ -59,7 +71,9 @@ class DeleteAccountViewModel @Inject constructor(
sendEvent(DeleteAccountEvent.NavigateBack) sendEvent(DeleteAccountEvent.NavigateBack)
} }
private fun handleDeleteAccountClick(action: DeleteAccountAction.DeleteAccountClick) { private fun handleDeleteAccountConfirmDialogClick(
action: DeleteAccountAction.DeleteAccountConfirmDialogClick,
) {
mutableStateFlow.update { mutableStateFlow.update {
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading) it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading)
} }
@ -103,10 +117,15 @@ class DeleteAccountViewModel @Inject constructor(
/** /**
* Models state for the Delete Account screen. * 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 @Parcelize
data class DeleteAccountState( data class DeleteAccountState(
val dialog: DeleteAccountDialog?, val dialog: DeleteAccountDialog?,
val isUnlockWithPasswordEnabled: Boolean,
) : Parcelable { ) : Parcelable {
/** /**
@ -144,6 +163,11 @@ sealed class DeleteAccountEvent {
*/ */
data object NavigateBack : DeleteAccountEvent() data object NavigateBack : DeleteAccountEvent()
/**
* Navigates to the [DeleteAccountConfirmationScreen].
*/
data object NavigateToDeleteAccountConfirmationScreen : DeleteAccountEvent()
/** /**
* Displays the [message] in a toast. * Displays the [message] in a toast.
*/ */
@ -169,7 +193,14 @@ sealed class DeleteAccountAction {
/** /**
* The user has clicked the delete account button. * 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, val masterPassword: String,
) : DeleteAccountAction() ) : 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.search.searchDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination 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.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.loginApprovalDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests 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() }) loginApprovalDestination(onNavigateBack = { navController.popBackStack() })
pendingRequestsDestination( pendingRequestsDestination(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },

View file

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

View file

@ -5,6 +5,8 @@ import app.cash.turbine.test
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository 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.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.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery import io.mockk.coEvery
@ -14,18 +16,36 @@ import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class DeleteAccountViewModelTest : BaseViewModelTest() { class DeleteAccountViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE)
private val authRepo: AuthRepository = mockk(relaxed = true) private val authRepo: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
@Test @Test
fun `initial state should be correct when not set`() { 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) val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals(
DEFAULT_STATE.copy(isUnlockWithPasswordEnabled = false),
viewModel.stateFlow.value,
)
} }
@Test @Test
@ -56,13 +76,18 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
} }
@Test @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 { runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
val masterPassword = "ckasb kcs ja" val masterPassword = "ckasb kcs ja"
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword)) viewModel.trySendAction(
DeleteAccountAction.DeleteAccountConfirmDialogClick(
masterPassword,
),
)
assertEquals( assertEquals(
DEFAULT_STATE.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess), 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 @Test
fun `on DeleteAccountClick should update dialog state when deleteAccount fails`() = runTest { fun `on DeleteAccountClick should update dialog state when deleteAccount fails`() = runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
val masterPassword = "ckasb kcs ja" val masterPassword = "ckasb kcs ja"
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Error coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Error
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword)) viewModel.trySendAction(DeleteAccountAction.DeleteAccountConfirmDialogClick(masterPassword))
assertEquals( assertEquals(
DEFAULT_STATE.copy( 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( private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
dialog = null, 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)