From 16ef9f7732fe435a68265d3d53d085b621c95c51 Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 6 Dec 2023 10:24:20 -0600 Subject: [PATCH] Add VaultItemView tests (#315) --- .../ui/vault/feature/item/VaultItemScreen.kt | 29 +- .../bitwarden/ui/util/ComposeTestHelpers.kt | 54 ++ .../vault/feature/item/VaultItemScreenTest.kt | 692 +++++++++++++++++- .../feature/item/VaultItemViewModelTest.kt | 564 +++++++++++++- 4 files changed, 1325 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 5e39771561..cb31c339cd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.ui.vault.feature.item import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -106,17 +109,23 @@ fun VaultItemScreen( ) }, floatingActionButton = { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.primaryContainer, - onClick = remember(viewModel) { - { viewModel.trySendAction(VaultItemAction.EditClick) } - }, - modifier = Modifier.padding(bottom = 16.dp), + AnimatedVisibility( + visible = state.viewState is VaultItemState.ViewState.Content, + enter = scaleIn(), + exit = scaleOut(), ) { - Icon( - painter = painterResource(id = R.drawable.ic_edit), - contentDescription = stringResource(id = R.string.edit_item), - ) + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.primaryContainer, + onClick = remember(viewModel) { + { viewModel.trySendAction(VaultItemAction.EditClick) } + }, + modifier = Modifier.padding(bottom = 16.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_edit), + contentDescription = stringResource(id = R.string.edit_item), + ) + } } }, ) { innerPadding -> diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt new file mode 100644 index 0000000000..489e6ae8b0 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/util/ComposeTestHelpers.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.ui.util + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode +import org.junit.jupiter.api.assertThrows + +/** + * A [SemanticsMatcher] used to find progressbar nodes. + */ +val isProgressBar: SemanticsMatcher + get() = SemanticsMatcher("ProgressBar") { + it.config + .getOrNull(SemanticsProperties.ProgressBarRangeInfo) + ?.let { true } + ?: false + } + +/** + * A helper that asserts that the node does not exist in the scrollable list. + */ +fun ComposeContentTestRule.assertScrollableNodeDoesNotExist(text: String) { + val scrollableNodeInteraction = onNode(hasScrollToNodeAction()) + assertThrows { + // throws since it cannot find the node. + scrollableNodeInteraction.performScrollToNode(hasText(text)) + } +} + +/** + * A helper used to scroll to and get the matching node in a scrollable list. This is intended to + * be used with lazy lists that would otherwise fail when calling [performScrollToNode]. + */ +fun ComposeContentTestRule.onNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction { + onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text)) + return onNodeWithText(text) +} + +/** + * A helper used to scroll to and get a thr first matching node in a scrollable list. This is + * intended to be used with lazy lists that would otherwise fail when calling [performScrollToNode]. + */ +fun ComposeContentTestRule.onFirstNodeWithTextAfterScroll(text: String): SemanticsNodeInteraction { + onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text)) + return onAllNodesWithText(text).onFirst() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 6cf268535e..ae6e6a1035 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -1,14 +1,38 @@ package com.x8bit.bitwarden.ui.vault.feature.item +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onSiblings import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.core.net.toUri import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString +import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist +import com.x8bit.bitwarden.ui.util.isProgressBar +import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll +import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -17,6 +41,9 @@ class VaultItemScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false private var onNavigateToVaultEditItemId: String? = null + private val clipboardManager = mockk() + private val intentHandler = mockk() + private val mutableEventFlow = MutableSharedFlow( extraBufferCapacity = Int.MAX_VALUE, ) @@ -33,6 +60,8 @@ class VaultItemScreenTest : BaseComposeTest() { viewModel = viewModel, onNavigateBack = { onNavigateBackCalled = true }, onNavigateToVaultEditItem = { onNavigateToVaultEditItemId = it }, + clipboardManager = clipboardManager, + intentHandler = intentHandler, ) } } @@ -45,13 +74,609 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `clicking close button should send CloseClick action`() { + fun `on close click should send CloseClick`() { composeTestRule.onNodeWithContentDescription(label = "Close").performClick() verify { viewModel.trySendAction(VaultItemAction.CloseClick) } } + + @Test + fun `CopyToClipboard event should invoke setText`() { + val textString = "text" + val text = textString.asText() + every { clipboardManager.setText(textString.toAnnotatedString()) } just runs + + mutableEventFlow.tryEmit(VaultItemEvent.CopyToClipboard(text)) + + verify(exactly = 1) { + clipboardManager.setText(textString.toAnnotatedString()) + } + } + + @Test + fun `NavigateBack event should invoke onNavigateBack`() { + mutableEventFlow.tryEmit(VaultItemEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `NavigateToUri event should invoke launchUri`() { + val uriString = "http://www.example.com" + val uri = uriString.toUri() + every { intentHandler.launchUri(uri) } just runs + + mutableEventFlow.tryEmit(VaultItemEvent.NavigateToUri(uriString)) + + verify(exactly = 1) { + intentHandler.launchUri(uri) + } + } + + @Test + fun `basic dialog should be displayed according to state`() { + val message = "message" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(message).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.Generic("message".asText())) + } + + composeTestRule + .onNodeWithText(message) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + } + + @Test + fun `Ok click on generic dialog should emit DismissDialogClick`() { + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.Generic("message".asText())) + } + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.DismissDialogClick) + } + } + + @Test + fun `loading dialog should be displayed according to state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText("Loading").assertDoesNotExist() + + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.Loading) + } + + composeTestRule + .onNodeWithText("Loading") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + } + + @Test + fun `MasterPassword dialog should be displayed according to state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText("Master password confirmation").assertDoesNotExist() + + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + + composeTestRule + .onNodeWithText("Master password confirmation") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + } + + @Test + fun `Ok click on master password dialog should emit DismissDialogClick`() { + val enteredPassword = "pass1234" + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + + composeTestRule.onNodeWithText("Master password").performTextInput(enteredPassword) + composeTestRule + .onAllNodesWithText("Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit(enteredPassword)) + } + } + + @Test + fun `in login state, on username copy click should send CopyUsernameClick`() { + val username = "username1234" + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(username = username)) + } + + composeTestRule + .onNodeWithTextAfterScroll(username) + .onSiblings() + .filterToOne(hasContentDescription("Copy username")) + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick) + } + } + + @Test + fun `in login state, on breach check click should send CheckForBreachClick`() { + val passwordData = VaultItemState.ViewState.Content.PasswordData( + password = "12345", + isVisible = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData)) + } + + composeTestRule + .onNodeWithTextAfterScroll(passwordData.password) + .onSiblings() + .filterToOne(hasContentDescription("Check known data breaches for this password")) + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick) + } + } + + @Test + fun `in login state, on show password click should send CopyPasswordClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } + + composeTestRule + .onNodeWithTextAfterScroll("Password") + .onChildren() + .filterToOne(hasContentDescription("Show")) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true)) + } + } + + @Test + fun `in login state, on copy password click should send CopyPasswordClick`() { + val passwordData = VaultItemState.ViewState.Content.PasswordData( + password = "12345", + isVisible = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordData = passwordData)) + } + + composeTestRule + .onNodeWithTextAfterScroll(passwordData.password) + .onSiblings() + .filterToOne(hasContentDescription("Copy password")) + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick) + } + } + + @Test + fun `in login state, launch uri button should be displayed according to state`() { + val uriData = VaultItemState.ViewState.Content.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + } + + composeTestRule + .onNodeWithTextAfterScroll(uriData.uri) + .onSiblings() + .filterToOne(hasContentDescription("Launch")) + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + uris = listOf(uriData.copy(isLaunchable = false)), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll(uriData.uri) + .onSiblings() + .filterToOne(hasContentDescription("Launch")) + .assertDoesNotExist() + } + + @Test + fun `in login state, copy uri button should be displayed according to state`() { + val uriData = VaultItemState.ViewState.Content.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + } + + composeTestRule + .onNodeWithTextAfterScroll(uriData.uri) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + uris = listOf(uriData.copy(isCopyable = false)), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll(uriData.uri) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .assertDoesNotExist() + } + + @Test + fun `in login state, on launch URI click should send LaunchClick`() { + val uriData = VaultItemState.ViewState.Content.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + } + + composeTestRule + .onNodeWithTextAfterScroll(uriData.uri) + .onSiblings() + .filterToOne(hasContentDescription("Launch")) + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uriData.uri)) + } + } + + @Test + fun `in login state, on copy URI click should send CopyUriClick`() { + val uriData = VaultItemState.ViewState.Content.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(uris = listOf(uriData))) + } + + composeTestRule + .onNodeWithTextAfterScroll(uriData.uri) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uriData.uri)) + } + } + + @Test + fun `in login state, on show hidden field click should send HiddenFieldVisibilityClicked`() { + val textField = VaultItemState.ViewState.Content.Custom.HiddenField( + name = "hidden", + value = "hidden password", + isCopyable = true, + isVisible = false, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField))) + } + + composeTestRule + .onNodeWithTextAfterScroll(textField.name) + .onChildren() + .filterToOne(hasContentDescription("Show")) + .performClick() + + verify { + viewModel.trySendAction( + VaultItemAction.Login.HiddenFieldVisibilityClicked( + field = textField, + isVisible = true, + ), + ) + } + } + + @Test + fun `in login state, copy hidden field button should be displayed according to state`() { + val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField( + name = "hidden", + value = "hidden password", + isCopyable = true, + isVisible = false, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField))) + } + + composeTestRule + .onNodeWithTextAfterScroll(hiddenField.name) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + customFields = listOf(hiddenField.copy(isCopyable = false)), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll(hiddenField.name) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .assertDoesNotExist() + } + + @Test + fun `in login state, on copy hidden field click should send CopyCustomHiddenFieldClick`() { + val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField( + name = "hidden", + value = "hidden password", + isCopyable = true, + isVisible = false, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(hiddenField))) + } + + composeTestRule + .onNodeWithTextAfterScroll(hiddenField.name) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .performClick() + + verify { + viewModel.trySendAction( + VaultItemAction.Login.CopyCustomHiddenFieldClick(hiddenField.value), + ) + } + } + + @Test + fun `in login state, on copy text field click should send CopyCustomTextFieldClick`() { + val textField = VaultItemState.ViewState.Content.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField))) + } + + composeTestRule + .onNodeWithTextAfterScroll(textField.name) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .performClick() + + verify { + viewModel.trySendAction( + VaultItemAction.Login.CopyCustomTextFieldClick(textField.value), + ) + } + } + + @Test + fun `in login state, text field copy button should be displayed according to state`() { + val textField = VaultItemState.ViewState.Content.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ) + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(customFields = listOf(textField))) + } + + composeTestRule + .onNodeWithTextAfterScroll(textField.name) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + viewState = EMPTY_LOGIN_VIEW_STATE.copy( + customFields = listOf(textField.copy(isCopyable = false)), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll(textField.name) + .onSiblings() + .filterToOne(hasContentDescription("Copy")) + .assertDoesNotExist() + } + + @Test + fun `in login state, on password history click should send PasswordHistoryClick`() { + mutableStateFlow.update { + it.copy(viewState = EMPTY_LOGIN_VIEW_STATE.copy(passwordHistoryCount = 5)) + } + + composeTestRule.onNodeWithTextAfterScroll("5") + composeTestRule.onNodeWithText("5").performClick() + + verify { + viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick) + } + } + + @Test + fun `fab should be displayed according state`() { + mutableStateFlow.update { + it.copy(viewState = VaultItemState.ViewState.Loading) + } + composeTestRule.onNodeWithContentDescription("Edit item").assertDoesNotExist() + + mutableStateFlow.update { + it.copy(viewState = VaultItemState.ViewState.Error("Fail".asText())) + } + composeTestRule.onNodeWithContentDescription("Edit item").assertDoesNotExist() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + } + composeTestRule.onNodeWithContentDescription("Edit item").performClick() + verify(exactly = 1) { + viewModel.trySendAction(VaultItemAction.EditClick) + } + } + + @Test + fun `error text and retry should be displayed according to state`() { + val message = "message" + mutableStateFlow.update { + it.copy(viewState = VaultItemState.ViewState.Error(message.asText())) + } + + composeTestRule.onNodeWithText(message).assertIsDisplayed() + composeTestRule.onNodeWithText("Try again").assertIsDisplayed() + } + + @Test + fun `progressbar should be displayed according to state`() { + mutableStateFlow.update { + it.copy(viewState = VaultItemState.ViewState.Loading) + } + composeTestRule.onNode(isProgressBar).assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = VaultItemState.ViewState.Error("Fail".asText())) + } + composeTestRule.onNode(isProgressBar).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + } + composeTestRule.onNode(isProgressBar).assertDoesNotExist() + } + + @Test + fun `in login state, username should be displayed according to state`() { + val username = "the username" + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(username).assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(username = null)) + } + + composeTestRule.assertScrollableNodeDoesNotExist(username) + } + + @Test + fun `in login state, uris should be displayed according to state`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll("URIs").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("URI").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("www.example.com").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(uris = emptyList())) + } + + composeTestRule.assertScrollableNodeDoesNotExist("URIs") + composeTestRule.assertScrollableNodeDoesNotExist("URI") + composeTestRule.assertScrollableNodeDoesNotExist("www.example.com") + } + + @Test + fun `in login state, notes should be displayed according to state`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } + composeTestRule.onFirstNodeWithTextAfterScroll("Notes").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("Lots of notes").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(notes = null)) + } + + composeTestRule.assertScrollableNodeDoesNotExist("Notes") + composeTestRule.assertScrollableNodeDoesNotExist("Lots of notes") + } + + @Test + fun `in login state, custom views should be displayed according to state`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll("Custom fields").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("text").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("value").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("hidden").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("boolean").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("linked username").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("linked password").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(customFields = emptyList())) + } + + composeTestRule.assertScrollableNodeDoesNotExist("Custom fields") + composeTestRule.assertScrollableNodeDoesNotExist("text") + composeTestRule.assertScrollableNodeDoesNotExist("value") + composeTestRule.assertScrollableNodeDoesNotExist("hidden") + composeTestRule.assertScrollableNodeDoesNotExist("boolean") + composeTestRule.assertScrollableNodeDoesNotExist("linked username") + composeTestRule.assertScrollableNodeDoesNotExist("linked password") + } + + @Test + fun `in login state, password updated should be displayed according to state`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll("Password updated: ").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("4/14/83 3:56 PM").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordRevisionDate = null)) + } + + composeTestRule.assertScrollableNodeDoesNotExist("Password updated: ") + composeTestRule.assertScrollableNodeDoesNotExist("4/14/83 3:56 PM") + } + + @Test + fun `in login state, password history should be displayed according to state`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll("Password history: ").assertIsDisplayed() + composeTestRule.onNodeWithTextAfterScroll("1").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE.copy(passwordHistoryCount = null)) + } + + composeTestRule.assertScrollableNodeDoesNotExist("Password history: ") + composeTestRule.assertScrollableNodeDoesNotExist("1") + } } private const val VAULT_ITEM_ID = "vault_item_id" @@ -61,3 +686,68 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState( viewState = VaultItemState.ViewState.Loading, dialog = null, ) + +private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = + VaultItemState.ViewState.Content.Login( + name = "login cipher", + lastUpdated = "12/31/69 06:16 PM", + passwordHistoryCount = 1, + notes = "Lots of notes", + isPremiumUser = true, + customFields = listOf( + VaultItemState.ViewState.Content.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ), + VaultItemState.ViewState.Content.Custom.HiddenField( + name = "hidden", + value = "hidden password", + isCopyable = true, + isVisible = false, + ), + VaultItemState.ViewState.Content.Custom.BooleanField( + name = "boolean", + value = true, + ), + VaultItemState.ViewState.Content.Custom.LinkedField( + name = "linked username", + id = 100U, + ), + VaultItemState.ViewState.Content.Custom.LinkedField( + name = "linked password", + id = 101U, + ), + ), + requiresReprompt = true, + username = "the username", + passwordData = VaultItemState.ViewState.Content.PasswordData( + password = "the password", + isVisible = false, + ), + uris = listOf( + VaultItemState.ViewState.Content.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ), + ), + passwordRevisionDate = "4/14/83 3:56 PM", + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + ) + +private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = + VaultItemState.ViewState.Content.Login( + name = "login cipher", + lastUpdated = "12/31/69 06:16 PM", + passwordHistoryCount = null, + notes = null, + isPremiumUser = true, + customFields = emptyList(), + requiresReprompt = true, + username = null, + passwordData = null, + uris = emptyList(), + passwordRevisionDate = null, + totp = null, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 57f646d566..9d353774ee 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -3,16 +3,31 @@ package com.x8bit.bitwarden.ui.vault.feature.item import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.repository.VaultRepository 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.DEFAULT_EMPTY_LOGIN_VIEW_STATE +import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test class VaultItemViewModelTest : BaseViewModelTest() { @@ -27,6 +42,16 @@ class VaultItemViewModelTest : BaseViewModelTest() { every { getVaultItemStateFlow(VAULT_ITEM_ID) } returns mutableVaultItemFlow } + @BeforeEach + fun setup() { + mockkStatic(CIPHER_VIEW_EXTENSIONS_PATH) + } + + @AfterEach + fun tearDown() { + unmockkStatic(CIPHER_VIEW_EXTENSIONS_PATH) + } + @Test fun `initial state should be correct when not set`() { val viewModel = createViewModel(state = null) @@ -45,16 +70,495 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `on BackClick should emit NavigateBack`() = runTest { - val viewModel = createViewModel() + fun `on CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel(state = DEFAULT_STATE) viewModel.eventFlow.test { viewModel.trySendAction(VaultItemAction.CloseClick) assertEquals(VaultItemEvent.NavigateBack, awaitItem()) } } + @Test + fun `on DismissDialogClick should clear the dialog state`() = runTest { + val initialState = DEFAULT_STATE.copy(dialog = VaultItemState.DialogState.Loading) + val viewModel = createViewModel(state = initialState) + assertEquals(initialState, viewModel.stateFlow.value) + + viewModel.trySendAction(VaultItemAction.DismissDialogClick) + assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) + } + + @Test + fun `on EditClick should do nothing when ViewState is not Content`() = runTest { + val initialState = DEFAULT_STATE + val viewModel = createViewModel(state = initialState) + + assertEquals(initialState, viewModel.stateFlow.value) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.EditClick) + expectNoEvents() + } + assertEquals(initialState, viewModel.stateFlow.value) + } + + @Test + fun `on EditClick should prompt for master password when required`() = runTest { + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val viewModel = createViewModel(state = loginState) + assertEquals(loginState, viewModel.stateFlow.value) + + viewModel.trySendAction(VaultItemAction.EditClick) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on EditClick should navigate password is not required`() = runTest { + val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val viewModel = createViewModel(state = loginState) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.EditClick) + assertEquals(VaultItemEvent.NavigateToEdit(VAULT_ITEM_ID), awaitItem()) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on MasterPasswordSubmit should verify the password`() = runTest { + val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val viewModel = createViewModel(state = loginState) + + viewModel.stateFlow.test { + assertEquals(loginState, awaitItem()) + viewModel.trySendAction(VaultItemAction.MasterPasswordSubmit("password")) + assertEquals(loginState.copy(dialog = VaultItemState.DialogState.Loading), awaitItem()) + assertEquals( + loginState.copy(viewState = loginViewState.copy(requiresReprompt = false)), + awaitItem(), + ) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on RefreshClick should sync`() = runTest { + every { vaultRepo.sync() } just runs + val viewModel = createViewModel(state = DEFAULT_STATE) + + viewModel.trySendAction(VaultItemAction.RefreshClick) + + verify(exactly = 1) { + vaultRepo.sync() + } + } + + @Nested + inner class LoginActions { + private lateinit var viewModel: VaultItemViewModel + + @BeforeEach + fun setup() { + viewModel = createViewModel( + state = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE), + ) + } + + @Test + fun `on CheckForBreachClick should process a password`() = runTest { + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val breachCount = 5 + coEvery { + authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD) + } returns BreachCountResult.Success(breachCount = breachCount) + + viewModel.stateFlow.test { + assertEquals(loginState, awaitItem()) + viewModel.trySendAction(VaultItemAction.Login.CheckForBreachClick) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.Loading), + awaitItem(), + ) + assertEquals( + loginState.copy( + dialog = VaultItemState.DialogState.Generic( + message = R.string.password_exposed.asText(breachCount), + ), + ), + awaitItem(), + ) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + coVerify(exactly = 1) { + authRepo.getPasswordBreachCount(password = DEFAULT_LOGIN_PASSWORD) + } + } + + @Test + fun `on CopyPasswordClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on CopyPasswordClick should emit CopyToClipboard when re-prompt is not required`() = + runTest { + val mockCipherView = mockk { + every { + toViewState(isPremiumUser = true) + } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Login.CopyPasswordClick) + assertEquals( + VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_PASSWORD.asText()), + awaitItem(), + ) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on CopyCustomHiddenFieldClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick("field")) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on CopyCustomHiddenFieldClick should emit CopyToClipboard when re-prompt is not required`() = + runTest { + val field = "field" + val mockCipherView = mockk { + every { + toViewState(isPremiumUser = true) + } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Login.CopyCustomHiddenFieldClick(field)) + assertEquals( + VaultItemEvent.CopyToClipboard(field.asText()), + awaitItem(), + ) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on CopyCustomTextFieldClick should emit CopyToClipboard`() = runTest { + val field = "field" + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Login.CopyCustomTextFieldClick(field)) + assertEquals(VaultItemEvent.CopyToClipboard(field.asText()), awaitItem()) + } + } + + @Test + fun `on CopyUriClick should emit CopyToClipboard`() = runTest { + val uri = "uri" + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Login.CopyUriClick(uri)) + assertEquals(VaultItemEvent.CopyToClipboard(uri.asText()), awaitItem()) + } + } + + @Test + fun `on CopyUsernameClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on CopyUsernameClick should emit CopyToClipboard when re-prompt is not required`() = + runTest { + val mockCipherView = mockk { + every { + toViewState(isPremiumUser = true) + } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Login.CopyUsernameClick) + assertEquals( + VaultItemEvent.CopyToClipboard(DEFAULT_LOGIN_USERNAME.asText()), + awaitItem(), + ) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on LaunchClick should emit NavigateToUri`() = runTest { + val uri = "uri" + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Login.LaunchClick(uri)) + assertEquals(VaultItemEvent.NavigateToUri(uri), awaitItem()) + } + } + + @Test + fun `on PasswordHistoryClick should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on PasswordHistoryClick should emit NavigateToPasswordHistory when re-prompt is not required`() = + runTest { + val mockCipherView = mockk { + every { + toViewState(isPremiumUser = true) + } returns DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Login.PasswordHistoryClick) + assertEquals( + VaultItemEvent.NavigateToPasswordHistory(VAULT_ITEM_ID), + awaitItem(), + ) + } + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on PasswordVisibilityClicked should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true)) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on PasswordVisibilityClicked should update password visibility when re-prompt is not required`() = + runTest { + val loginViewState = DEFAULT_LOGIN_VIEW_STATE.copy(requiresReprompt = false) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.Login.PasswordVisibilityClicked(true)) + assertEquals( + loginState.copy( + viewState = loginViewState.copy( + passwordData = loginViewState.passwordData!!.copy(isVisible = true), + ), + ), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on HiddenFieldVisibilityClicked should show password dialog when re-prompt is required`() = + runTest { + val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns DEFAULT_LOGIN_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultItemAction.Login.HiddenFieldVisibilityClicked( + field = VaultItemState.ViewState.Content.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ), + isVisible = true, + ), + ) + assertEquals( + loginState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on HiddenFieldVisibilityClicked should update hidden field visibility when re-prompt is not required`() = + runTest { + val hiddenField = VaultItemState.ViewState.Content.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ) + val loginViewState = DEFAULT_EMPTY_LOGIN_VIEW_STATE.copy( + requiresReprompt = false, + customFields = listOf(hiddenField), + ) + val loginState = DEFAULT_STATE.copy(viewState = loginViewState) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns loginViewState + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(loginState, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultItemAction.Login.HiddenFieldVisibilityClicked( + field = hiddenField, + isVisible = true, + ), + ) + assertEquals( + loginState.copy( + viewState = loginViewState.copy( + customFields = listOf(hiddenField.copy(isVisible = true)), + ), + ), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + } + private fun createViewModel( - state: VaultItemState? = DEFAULT_STATE, + state: VaultItemState?, vaultItemId: String = VAULT_ITEM_ID, authRepository: AuthRepository = authRepo, vaultRepository: VaultRepository = vaultRepo, @@ -68,7 +572,12 @@ class VaultItemViewModelTest : BaseViewModelTest() { ) } +private const val CIPHER_VIEW_EXTENSIONS_PATH: String = + "com.x8bit.bitwarden.ui.vault.feature.item.util.CipherViewExtensionsKt" + private const val VAULT_ITEM_ID = "vault_item_id" +private const val DEFAULT_LOGIN_PASSWORD = "password" +private const val DEFAULT_LOGIN_USERNAME = "username" private val DEFAULT_STATE: VaultItemState = VaultItemState( vaultItemId = VAULT_ITEM_ID, @@ -89,3 +598,52 @@ private val DEFAULT_USER_STATE: UserState = UserState( ), ), ) + +private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content.Login = + VaultItemState.ViewState.Content.Login( + name = "login cipher", + lastUpdated = "12/31/69 06:16 PM", + passwordHistoryCount = 1, + notes = "Lots of notes", + isPremiumUser = true, + customFields = listOf( + VaultItemState.ViewState.Content.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ), + VaultItemState.ViewState.Content.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ), + VaultItemState.ViewState.Content.Custom.BooleanField( + name = "boolean", + value = true, + ), + VaultItemState.ViewState.Content.Custom.LinkedField( + name = "linked username", + id = 100U, + ), + VaultItemState.ViewState.Content.Custom.LinkedField( + name = "linked password", + id = 101U, + ), + ), + requiresReprompt = true, + username = DEFAULT_LOGIN_USERNAME, + passwordData = VaultItemState.ViewState.Content.PasswordData( + password = DEFAULT_LOGIN_PASSWORD, + isVisible = false, + ), + uris = listOf( + VaultItemState.ViewState.Content.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ), + ), + passwordRevisionDate = "12/31/69 06:16 PM", + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + )