mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-639: Restore items from trash (#735)
This commit is contained in:
parent
8a16672b4d
commit
0422d3fdd8
12 changed files with 448 additions and 2 deletions
|
@ -53,4 +53,12 @@ interface CiphersApi {
|
|||
suspend fun softDeleteCipher(
|
||||
@Path("cipherId") cipherId: String,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
* Restores a cipher.
|
||||
*/
|
||||
@PUT("ciphers/{cipherId}/restore")
|
||||
suspend fun restoreCipher(
|
||||
@Path("cipherId") cipherId: String,
|
||||
): Result<Unit>
|
||||
}
|
||||
|
|
|
@ -39,4 +39,9 @@ interface CiphersService {
|
|||
* Attempt to soft delete a cipher.
|
||||
*/
|
||||
suspend fun softDeleteCipher(cipherId: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Attempt to restore a cipher.
|
||||
*/
|
||||
suspend fun restoreCipher(cipherId: String): Result<Unit>
|
||||
}
|
||||
|
|
|
@ -50,4 +50,7 @@ class CiphersServiceImpl constructor(
|
|||
|
||||
override suspend fun softDeleteCipher(cipherId: String): Result<Unit> =
|
||||
ciphersApi.softDeleteCipher(cipherId = cipherId)
|
||||
|
||||
override suspend fun restoreCipher(cipherId: String): Result<Unit> =
|
||||
ciphersApi.restoreCipher(cipherId = cipherId)
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
|||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
||||
|
@ -187,6 +188,14 @@ interface VaultRepository : VaultLockManager {
|
|||
cipherView: CipherView,
|
||||
): DeleteCipherResult
|
||||
|
||||
/**
|
||||
* Attempt to restore a cipher.
|
||||
*/
|
||||
suspend fun restoreCipher(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
): RestoreCipherResult
|
||||
|
||||
/**
|
||||
* Attempt to update a cipher.
|
||||
*/
|
||||
|
|
|
@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
|||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
||||
|
@ -486,6 +487,33 @@ class VaultRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun restoreCipher(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
): RestoreCipherResult {
|
||||
val userId = requireNotNull(activeUserId)
|
||||
return ciphersService
|
||||
.restoreCipher(cipherId)
|
||||
.flatMap {
|
||||
vaultSdkSource.encryptCipher(
|
||||
userId = userId,
|
||||
cipherView = cipherView.copy(
|
||||
deletedDate = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
.onSuccess { cipher ->
|
||||
vaultDiskSource.saveCipher(
|
||||
userId = userId,
|
||||
cipher = cipher.toEncryptedNetworkCipherResponse(),
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { RestoreCipherResult.Success },
|
||||
onFailure = { RestoreCipherResult.Error },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateCipher(
|
||||
cipherId: String,
|
||||
cipherView: CipherView,
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
/**
|
||||
* Models result of restoring a cipher.
|
||||
*/
|
||||
sealed class RestoreCipherResult {
|
||||
|
||||
/**
|
||||
* Cipher restored successfully.
|
||||
*/
|
||||
data object Success : RestoreCipherResult()
|
||||
|
||||
/**
|
||||
* Generic error while restoring a cipher.
|
||||
*/
|
||||
data object Error : RestoreCipherResult()
|
||||
}
|
|
@ -38,6 +38,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
|||
import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||
|
@ -68,7 +69,11 @@ fun VaultItemScreen(
|
|||
val confirmDeleteClickAction = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) }
|
||||
}
|
||||
val confirmRestoreAction = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) }
|
||||
}
|
||||
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingRestoreCipher by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
|
@ -128,6 +133,24 @@ fun VaultItemScreen(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (pendingRestoreCipher) {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = R.string.restore),
|
||||
message = stringResource(id = R.string.do_you_really_want_to_restore_cipher),
|
||||
confirmButtonText = stringResource(id = R.string.ok),
|
||||
dismissButtonText = stringResource(id = R.string.cancel),
|
||||
onConfirmClick = {
|
||||
pendingRestoreCipher = false
|
||||
confirmRestoreAction()
|
||||
},
|
||||
onDismissClick = {
|
||||
pendingRestoreCipher = false
|
||||
},
|
||||
onDismissRequest = {
|
||||
pendingRestoreCipher = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
|
@ -144,6 +167,12 @@ fun VaultItemScreen(
|
|||
{ viewModel.trySendAction(VaultItemAction.Common.CloseClick) }
|
||||
},
|
||||
actions = {
|
||||
if (state.isCipherDeleted) {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = R.string.restore),
|
||||
onClick = { pendingRestoreCipher = true },
|
||||
)
|
||||
}
|
||||
// TODO make action list dependent on item being in an organization BIT-1446
|
||||
BitwardenOverflowActionItem(
|
||||
menuItemDataList = persistentListOf(
|
||||
|
@ -184,7 +213,7 @@ fun VaultItemScreen(
|
|||
},
|
||||
floatingActionButton = {
|
||||
AnimatedVisibility(
|
||||
visible = state.viewState is VaultItemState.ViewState.Content,
|
||||
visible = state.isFabVisible,
|
||||
enter = scaleIn(),
|
||||
exit = scaleOut(),
|
||||
) {
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
|
|||
import com.x8bit.bitwarden.data.platform.repository.model.DataState
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VerifyPasswordResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
|
@ -102,6 +103,7 @@ class VaultItemViewModel @Inject constructor(
|
|||
is VaultItemAction.Common.CloneClick -> handleCloneClick()
|
||||
is VaultItemAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
|
||||
is VaultItemAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick()
|
||||
is VaultItemAction.Common.ConfirmRestoreClick -> handleConfirmRestoreClick()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -246,6 +248,33 @@ class VaultItemViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleConfirmRestoreClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultItemState.DialogState.Loading(
|
||||
R.string.restoring.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
onContent { content ->
|
||||
content
|
||||
.common
|
||||
.currentCipher
|
||||
?.let { cipher ->
|
||||
viewModelScope.launch {
|
||||
trySendAction(
|
||||
VaultItemAction.Internal.RestoreCipherReceive(
|
||||
result = vaultRepository.restoreCipher(
|
||||
cipherId = state.vaultItemId,
|
||||
cipherView = cipher,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion Common Handlers
|
||||
|
||||
//region Login Type Handlers
|
||||
|
@ -414,6 +443,7 @@ class VaultItemViewModel @Inject constructor(
|
|||
is VaultItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
|
||||
is VaultItemAction.Internal.VerifyPasswordReceive -> handleVerifyPasswordReceive(action)
|
||||
is VaultItemAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
|
||||
is VaultItemAction.Internal.RestoreCipherReceive -> handleRestoreCipherReceive(action)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -545,6 +575,26 @@ class VaultItemViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleRestoreCipherReceive(action: VaultItemAction.Internal.RestoreCipherReceive) {
|
||||
when (action.result) {
|
||||
RestoreCipherResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RestoreCipherResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
sendEvent(VaultItemEvent.ShowToast(message = R.string.item_restored.asText()))
|
||||
sendEvent(VaultItemEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion Internal Type Handlers
|
||||
|
||||
private inline fun onContent(
|
||||
|
@ -594,6 +644,21 @@ data class VaultItemState(
|
|||
val dialog: DialogState?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Whether or not the cipher has been deleted.
|
||||
*/
|
||||
val isCipherDeleted: Boolean
|
||||
get() = (viewState as? ViewState.Content)
|
||||
?.common
|
||||
?.currentCipher
|
||||
?.deletedDate != null
|
||||
|
||||
/**
|
||||
* Whether or not the fab is visible.
|
||||
*/
|
||||
val isFabVisible: Boolean
|
||||
get() = viewState is ViewState.Content && !isCipherDeleted
|
||||
|
||||
/**
|
||||
* Represents the specific view states for the [VaultItemScreen].
|
||||
*/
|
||||
|
@ -900,6 +965,11 @@ sealed class VaultItemAction {
|
|||
*/
|
||||
data object ConfirmDeleteClick : Common()
|
||||
|
||||
/**
|
||||
* The user has confirmed to restore the cipher.
|
||||
*/
|
||||
data object ConfirmRestoreClick : Common()
|
||||
|
||||
/**
|
||||
* The user has clicked to dismiss the dialog.
|
||||
*/
|
||||
|
@ -1060,5 +1130,12 @@ sealed class VaultItemAction {
|
|||
data class DeleteCipherReceive(
|
||||
val result: DeleteCipherResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the restore cipher result has been received.
|
||||
*/
|
||||
data class RestoreCipherReceive(
|
||||
val result: RestoreCipherResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -101,6 +101,14 @@ class CiphersServiceTest : BaseServiceTest() {
|
|||
result.getOrThrow(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoreCipher should execute the restoreCipher API`() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
val cipherId = "cipherId"
|
||||
val result = ciphersService.restoreCipher(cipherId = cipherId)
|
||||
assertEquals(Unit, result.getOrThrow())
|
||||
}
|
||||
}
|
||||
|
||||
private const val CREATE_UPDATE_CIPHER_SUCCESS_JSON = """
|
||||
|
|
|
@ -65,6 +65,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
|||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
||||
|
@ -156,6 +157,8 @@ class VaultRepositoryTest {
|
|||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(Uri::class)
|
||||
unmockkStatic(Instant::class)
|
||||
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -1680,7 +1683,6 @@ class VaultRepositoryTest {
|
|||
} returns createMockSdkCipher(number = 1).asSuccess()
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess()
|
||||
coEvery { vaultDiskSource.deleteCipher(userId, cipherId) } just runs
|
||||
coEvery {
|
||||
vaultDiskSource.saveCipher(
|
||||
userId = userId,
|
||||
|
@ -1701,6 +1703,64 @@ class VaultRepositoryTest {
|
|||
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `restoreCipher with ciphersService restoreCipher failure should return RestoreCipherResult Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val cipherId = "mockId-1"
|
||||
coEvery {
|
||||
ciphersService.restoreCipher(cipherId = cipherId)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val result = vaultRepository.restoreCipher(
|
||||
cipherId = cipherId,
|
||||
cipherView = createMockCipherView(number = 1),
|
||||
)
|
||||
|
||||
assertEquals(RestoreCipherResult.Error, result)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `restoreCipher with ciphersService restoreCipher success should return RestoreCipherResult success`() =
|
||||
runTest {
|
||||
mockkStatic(Cipher::toEncryptedNetworkCipherResponse)
|
||||
every {
|
||||
createMockSdkCipher(number = 1).toEncryptedNetworkCipherResponse()
|
||||
} returns createMockCipher(number = 1)
|
||||
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
|
||||
val userId = "mockId-1"
|
||||
val cipherId = "mockId-1"
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCipher(
|
||||
userId = userId,
|
||||
cipherView = createMockCipherView(number = 1)
|
||||
.copy(
|
||||
deletedDate = null,
|
||||
),
|
||||
)
|
||||
} returns createMockSdkCipher(number = 1).asSuccess()
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
coEvery { ciphersService.restoreCipher(cipherId = cipherId) } returns Unit.asSuccess()
|
||||
coEvery {
|
||||
vaultDiskSource.saveCipher(
|
||||
userId = userId,
|
||||
cipher = createMockCipher(number = 1),
|
||||
)
|
||||
} returns Unit
|
||||
val cipherView = createMockCipherView(number = 1)
|
||||
mockkStatic(Instant::class)
|
||||
every { Instant.now() } returns fixedInstant
|
||||
|
||||
val result = vaultRepository.restoreCipher(
|
||||
cipherId = cipherId,
|
||||
cipherView = cipherView,
|
||||
)
|
||||
|
||||
assertEquals(RestoreCipherResult.Success, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSend with encryptSend failure should return CreateSendResult failure`() =
|
||||
runTest {
|
||||
|
|
|
@ -26,9 +26,11 @@ import androidx.compose.ui.test.performTextInput
|
|||
import androidx.core.net.toUri
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
|
||||
import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist
|
||||
import com.x8bit.bitwarden.ui.util.isProgressBar
|
||||
import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll
|
||||
|
@ -47,6 +49,7 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class VaultItemScreenTest : BaseComposeTest() {
|
||||
|
@ -631,6 +634,145 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Restore click should send show restore confirmation dialog`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_IDENTITY_VIEW_STATE
|
||||
.copy(
|
||||
common = DEFAULT_COMMON
|
||||
.copy(
|
||||
currentCipher = createMockCipherView(1).copy(
|
||||
deletedDate = Instant.MIN,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Restore")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Do you really want to restore this item?")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Restore")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Restore dialog cancel click should hide restore confirmation menu`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_IDENTITY_VIEW_STATE
|
||||
.copy(
|
||||
common = DEFAULT_COMMON
|
||||
.copy(
|
||||
currentCipher = createMockCipherView(1).copy(
|
||||
deletedDate = Instant.MIN,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Restore")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Do you really want to restore this item?")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Restore")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Restore dialog ok click should close the dialog and send ConfirmRestoreClick`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_IDENTITY_VIEW_STATE
|
||||
.copy(
|
||||
common = DEFAULT_COMMON
|
||||
.copy(
|
||||
currentCipher = createMockCipherView(1).copy(
|
||||
deletedDate = Instant.MIN,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Restore")
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Do you really want to restore this item?")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Restore")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Attachments option menu click should send AttachmentsClick action`() {
|
||||
// Confirm dropdown version of item is absent
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
|||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent
|
||||
|
@ -170,6 +171,65 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `ConfirmRestoreClick with RestoreCipherResult Success should should ShowToast and NavigateBack`() =
|
||||
runTest {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE)
|
||||
coEvery {
|
||||
vaultRepo.restoreCipher(
|
||||
cipherId = VAULT_ITEM_ID,
|
||||
cipherView = createMockCipherView(number = 1),
|
||||
)
|
||||
} returns RestoreCipherResult.Success
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
VaultItemEvent.ShowToast(R.string.item_restored.asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
VaultItemEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `ConfirmRestoreClick with RestoreCipherResult Failure should should Show generic error`() =
|
||||
runTest {
|
||||
val mockCipherView = mockk<CipherView> {
|
||||
every { toViewState(isPremiumUser = true) } returns DEFAULT_VIEW_STATE
|
||||
}
|
||||
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE)
|
||||
coEvery {
|
||||
vaultRepo.restoreCipher(
|
||||
cipherId = VAULT_ITEM_ID,
|
||||
cipherView = createMockCipherView(number = 1),
|
||||
)
|
||||
} returns RestoreCipherResult.Error
|
||||
|
||||
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = DEFAULT_VIEW_STATE,
|
||||
dialog = VaultItemState.DialogState.Generic(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on EditClick should do nothing when ViewState is not Content`() = runTest {
|
||||
val initialState = DEFAULT_STATE
|
||||
|
|
Loading…
Reference in a new issue