BIT-513: View Card Item (#573)

This commit is contained in:
Ramsey Smith
2024-01-11 16:16:55 -07:00
committed by GitHub
parent 4de47c5a04
commit 2cf011a3d7
14 changed files with 819 additions and 24 deletions

View File

@@ -15,6 +15,7 @@ import com.bitwarden.core.SecureNoteView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.every
import io.mockk.mockkStatic
@@ -71,6 +72,8 @@ class CipherViewExtensionsTest {
type = VaultAddEditState.ViewState.Content.ItemType.Card(
cardHolderName = "Bit Warden",
number = "4012888888881881",
brand = VaultCardBrand.VISA,
expirationYear = "2030",
securityCode = "123",
),
),

View File

@@ -10,8 +10,10 @@ 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.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onSiblings
@@ -25,7 +27,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
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.onNodeWithContentDescriptionAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.every
import io.mockk.just
@@ -649,7 +653,7 @@ class VaultItemScreenTest : BaseComposeTest() {
}
@Test
fun `in login state, on show password click should send CopyPasswordClick`() {
fun `in login state, on show password click should send PasswordVisibilityClicked`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) }
composeTestRule
@@ -1060,6 +1064,170 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.assertScrollableNodeDoesNotExist(identityName)
}
@Test
fun `in card state, cardholderName should be displayed according to state`() {
val cardholderName = "the cardholder name"
mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(cardholderName).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateCardType(currentState) { copy(cardholderName = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(cardholderName)
}
@Test
fun `in card state the number should be displayed according to state`() {
composeTestRule.assertScrollableNodeDoesNotExist("Number")
mutableStateFlow.update {
it.copy(
viewState = EMPTY_CARD_VIEW_STATE.copy(
type = EMPTY_CARD_TYPE.copy(
number = "number",
),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll("Number")
.assertTextEquals("Number", "••••••")
.assertIsEnabled()
composeTestRule
.onNodeWithContentDescription("Copy number")
.assertIsDisplayed()
composeTestRule
.onNodeWithContentDescription("Show")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Number")
.assertTextEquals("Number", "number")
.assertIsEnabled()
composeTestRule
.onNodeWithContentDescription("Copy number")
.assertIsDisplayed()
composeTestRule
.onNodeWithContentDescription("Hide")
.assertIsDisplayed()
}
@Test
fun `in card state, on copy number click should send CopyNumberClick`() {
val number = "123456789"
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_CARD_VIEW_STATE.copy(
type = EMPTY_CARD_TYPE.copy(
number = number,
expiration = "test",
),
),
)
}
// Scroll so we can see the Copy number button but not have it covered by the FAB
composeTestRule
.onNodeWithTextAfterScroll("Expiration")
composeTestRule
.onNodeWithContentDescription("Copy number")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
}
}
@Test
fun `in card state, brand should be displayed according to state`() {
val visa = "Visa"
mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(visa).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateCardType(currentState) { copy(brand = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(visa)
}
@Test
fun `in card state, expiration should be displayed according to state`() {
val expiration = "the expiration"
mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(expiration).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateCardType(currentState) { copy(expiration = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(expiration)
}
@Test
fun `in card state the security code should be displayed according to state`() {
composeTestRule.assertScrollableNodeDoesNotExist("Security code")
mutableStateFlow.update {
it.copy(
viewState = EMPTY_CARD_VIEW_STATE.copy(
type = EMPTY_CARD_TYPE.copy(
securityCode = "123",
),
),
)
}
composeTestRule
.onNodeWithTextAfterScroll("Security code")
.assertTextEquals("Security code", "•••")
.assertIsEnabled()
composeTestRule
.onNodeWithContentDescription("Copy security code")
.assertIsDisplayed()
composeTestRule
.onAllNodesWithContentDescription("Show")
.onLast()
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Security code")
.assertTextEquals("Security code", "123")
.assertIsEnabled()
composeTestRule
.onNodeWithContentDescription("Copy security code")
.assertIsDisplayed()
composeTestRule
.onNodeWithContentDescription("Hide")
.assertIsDisplayed()
}
@Test
fun `in card state, on copy security code click should send CopySecurityCodeClick`() {
val number = "1234"
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = EMPTY_CARD_VIEW_STATE.copy(
type = EMPTY_CARD_TYPE.copy(
securityCode = number,
),
),
)
}
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy security code")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
}
}
}
//region Helper functions
@@ -1112,6 +1280,30 @@ private fun updateIdentityType(
return currentState.copy(viewState = updatedType)
}
@Suppress("MaxLineLength")
private fun updateCardType(
currentState: VaultItemState,
transform: VaultItemState.ViewState.Content.ItemType.Card.() ->
VaultItemState.ViewState.Content.ItemType.Card,
): VaultItemState {
val updatedType = when (val viewState = currentState.viewState) {
is VaultItemState.ViewState.Content -> {
when (val type = viewState.type) {
is VaultItemState.ViewState.Content.ItemType.Card -> {
viewState.copy(
type = type.transform(),
)
}
else -> viewState
}
}
else -> viewState
}
return currentState.copy(viewState = updatedType)
}
@Suppress("MaxLineLength")
private fun updateCommonContent(
currentState: VaultItemState,
@@ -1196,6 +1388,15 @@ private val DEFAULT_IDENTITY: VaultItemState.ViewState.Content.ItemType.Identity
address = "the address",
)
private val DEFAULT_CARD: VaultItemState.ViewState.Content.ItemType.Card =
VaultItemState.ViewState.Content.ItemType.Card(
cardholderName = "the cardholder name",
number = "the number",
brand = VaultCardBrand.VISA,
expiration = "the expiration",
securityCode = "the security code",
)
private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common =
VaultItemState.ViewState.Content.Common(
name = "cipher",
@@ -1229,6 +1430,15 @@ private val EMPTY_IDENTITY_TYPE: VaultItemState.ViewState.Content.ItemType.Ident
address = "",
)
private val EMPTY_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card =
VaultItemState.ViewState.Content.ItemType.Card(
cardholderName = "",
number = "",
brand = VaultCardBrand.SELECT,
expiration = "",
securityCode = "",
)
private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = EMPTY_COMMON,
@@ -1241,6 +1451,12 @@ private val EMPTY_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content =
type = EMPTY_IDENTITY_TYPE,
)
private val EMPTY_CARD_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = EMPTY_COMMON,
type = EMPTY_CARD_TYPE,
)
private val EMPTY_SECURE_NOTE_VIEW_STATE =
VaultItemState.ViewState.Content(
common = EMPTY_COMMON,
@@ -1259,6 +1475,12 @@ private val DEFAULT_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content =
common = DEFAULT_COMMON,
)
private val DEFAULT_CARD_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
type = DEFAULT_CARD,
common = DEFAULT_COMMON,
)
private val DEFAULT_SECURE_NOTE_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON,

View File

@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent
import com.x8bit.bitwarden.ui.vault.feature.item.util.createLoginContent
import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.coEvery
import io.mockk.coVerify
@@ -588,6 +589,106 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
}
@Nested
inner class CardActions {
private lateinit var viewModel: VaultItemViewModel
@BeforeEach
fun setup() {
viewModel = createViewModel(
state = DEFAULT_STATE.copy(
viewState = CARD_VIEW_STATE,
),
)
}
@Test
fun `on CopyNumberClick should show password dialog when re-prompt is required`() =
runTest {
val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(cardState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
assertEquals(
cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on CopyNumberClick should call setText on the ClipboardManager when re-prompt is not required`() {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
} returns createViewState(
common = DEFAULT_COMMON.copy(requiresReprompt = false),
type = DEFAULT_CARD_TYPE,
)
}
every { clipboardManager.setText(text = "12345436") } just runs
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick)
verify(exactly = 1) {
clipboardManager.setText(text = "12345436")
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Test
fun `on CopySecurityCodeClick should show password dialog when re-prompt is required`() =
runTest {
val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE)
val mockCipherView = mockk<CipherView> {
every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
assertEquals(cardState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
assertEquals(
cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
mockCipherView.toViewState(isPremiumUser = true)
}
}
@Suppress("MaxLineLength")
@Test
fun `on CopySecurityCodeClick should call setText on the ClipboardManager when re-prompt is not required`() {
val mockCipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true)
} returns createViewState(
common = DEFAULT_COMMON.copy(requiresReprompt = false),
type = DEFAULT_CARD_TYPE,
)
}
every { clipboardManager.setText(text = "987") } just runs
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick)
verify(exactly = 1) {
clipboardManager.setText(text = "987")
mockCipherView.toViewState(isPremiumUser = true)
}
}
}
private fun createViewModel(
state: VaultItemState?,
vaultItemId: String = VAULT_ITEM_ID,
@@ -665,6 +766,15 @@ class VaultItemViewModelTest : BaseViewModelTest() {
isPremiumUser = true,
)
private val DEFAULT_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card =
VaultItemState.ViewState.Content.ItemType.Card(
cardholderName = "mockName",
number = "12345436",
brand = VaultCardBrand.VISA,
expiration = "03/2027",
securityCode = "987",
)
private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
VaultItemState.ViewState.Content.Common(
name = "login cipher",
@@ -703,5 +813,11 @@ class VaultItemViewModelTest : BaseViewModelTest() {
common = DEFAULT_COMMON,
type = DEFAULT_LOGIN_TYPE,
)
private val CARD_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON,
type = DEFAULT_CARD_TYPE,
)
}
}

View File

@@ -0,0 +1,31 @@
package com.x8bit.bitwarden.ui.vault.model
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultCardBrandTest {
@Test
fun `findVaultCardBrandWithNameOrNull should return matching brand, regardless of format`() {
val names = listOf(
"UNIONpay",
"AMERICAN_EXPRESS",
"diNERs cLub",
"rupay",
"nothing card",
)
val result = names.map { it.findVaultCardBrandWithNameOrNull() }
assertEquals(
listOf(
VaultCardBrand.UNIONPAY,
VaultCardBrand.AMERICAN_EXPRESS,
VaultCardBrand.DINERS_CLUB,
VaultCardBrand.RUPAY,
null,
),
result,
)
}
}