BIT-1057: Vault item listing functionality (#379)

This commit is contained in:
Ramsey Smith
2023-12-13 14:31:15 -07:00
committed by GitHub
parent 7aa879d49e
commit f15ebd7cb8
6 changed files with 967 additions and 31 deletions

View File

@@ -18,9 +18,17 @@ import java.time.LocalDateTime
import java.time.ZoneOffset
/**
* Create a mock [CipherView] with a given [number].
* Create a mock [CipherView].
*
* @param number the number to create the cipher with.
* @param isDeleted whether or not the cipher has been deleted.
* @param cipherType the type of cipher to create.
*/
fun createMockCipherView(number: Int): CipherView =
fun createMockCipherView(
number: Int,
isDeleted: Boolean = true,
cipherType: CipherType = CipherType.LOGIN,
): CipherView =
CipherView(
id = "mockId-$number",
organizationId = "mockOrganizationId-$number",
@@ -29,14 +37,18 @@ fun createMockCipherView(number: Int): CipherView =
key = "mockKey-$number",
name = "mockName-$number",
notes = "mockNotes-$number",
type = CipherType.LOGIN,
type = cipherType,
login = createMockLoginView(number = number),
creationDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
deletedDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),
deletedDate = if (isDeleted) {
LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC)
} else {
null
},
revisionDate = LocalDateTime
.parse("2023-10-27T12:00:00")
.toInstant(ZoneOffset.UTC),

View File

@@ -2,15 +2,33 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
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.concat
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockItemListingDisplayItem
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultItemListingViewModelTest : BaseViewModelTest() {
private val mutableVaultDataStateFlow =
MutableStateFlow<DataState<VaultData>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
every { sync() } returns Unit
}
private val initialState = createVaultItemListingState()
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
vaultItemListingType = VaultItemListingType.Login,
@@ -63,15 +81,330 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
}
@Test
fun `RefreshClick should emit ShowToast`() = runTest {
fun `RefreshClick should sync`() = runTest {
val viewModel = createVaultItemListingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultItemListingsAction.RefreshClick)
viewModel.actionChannel.trySend(VaultItemListingsAction.RefreshClick)
verify { vaultRepository.sync() }
}
@Test
fun `vaultDataStateFlow Loaded with items should update ViewState to Content`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
),
),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
VaultItemListingEvent.ShowToast("Not yet implemented".asText()),
awaitItem(),
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createMockItemListingDisplayItem(number = 1),
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Loaded with empty items should update ViewState to NoItems`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Loaded with trash items should update ViewState to NoItems`() =
runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Loaded(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Loading should update state to Loading`() = runTest {
mutableVaultDataStateFlow.tryEmit(value = DataState.Loading)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.Loading),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Pending with data should update state to Content`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Pending(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createMockItemListingDisplayItem(number = 1),
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Pending with empty data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Pending(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Pending with trash data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Pending(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(viewState = VaultItemListingState.ViewState.NoItems),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Error without data should update state to Error`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
error = IllegalStateException(),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Error with data should update state to Content`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
error = IllegalStateException(),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createMockItemListingDisplayItem(number = 1),
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Error with empty data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
),
error = IllegalStateException(),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow Error with trash data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.Error(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
error = IllegalStateException(),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow NoNetwork without data should update state to Error`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow NoNetwork with data should update state to Content`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)),
folderViewList = listOf(createMockFolderView(number = 1)),
)),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createMockItemListingDisplayItem(number = 1),
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow NoNetwork with empty data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = emptyList(),
folderViewList = emptyList(),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
),
viewModel.stateFlow.value,
)
}
@Test
fun `vaultDataStateFlow NoNetwork with trash data should update state to NoItems`() = runTest {
mutableVaultDataStateFlow.tryEmit(
value = DataState.NoNetwork(
data = VaultData(
cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)),
folderViewList = listOf(createMockFolderView(number = 1)),
),
),
)
val viewModel = createVaultItemListingViewModel()
assertEquals(
createVaultItemListingState(
viewState = VaultItemListingState.ViewState.NoItems,
),
viewModel.stateFlow.value,
)
}
private fun createSavedStateHandleWithVaultItemListingType(
@@ -103,9 +436,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
private fun createVaultItemListingViewModel(
savedStateHandle: SavedStateHandle = initialSavedStateHandle,
vaultRepository: VaultRepository = this.vaultRepository,
): VaultItemListingViewModel =
VaultItemListingViewModel(
savedStateHandle = savedStateHandle,
vaultRepository = vaultRepository,
)
@Suppress("MaxLineLength")

View File

@@ -0,0 +1,327 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
import com.bitwarden.core.CipherType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
import org.junit.Assert.assertEquals
import org.junit.Test
class VaultItemListingDataExtensionsTest {
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for non trash Login cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = false,
cipherType = CipherType.LOGIN,
)
mapOf(
VaultItemListingState.ItemListingType.Login to true,
VaultItemListingState.ItemListingType.Card to false,
VaultItemListingState.ItemListingType.SecureNote to false,
VaultItemListingState.ItemListingType.Identity to false,
VaultItemListingState.ItemListingType.Trash to false,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for trash Login cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = true,
cipherType = CipherType.LOGIN,
)
mapOf(
VaultItemListingState.ItemListingType.Login to false,
VaultItemListingState.ItemListingType.Card to false,
VaultItemListingState.ItemListingType.SecureNote to false,
VaultItemListingState.ItemListingType.Identity to false,
VaultItemListingState.ItemListingType.Trash to true,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to false,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for non trash Card cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = false,
cipherType = CipherType.CARD,
)
mapOf(
VaultItemListingState.ItemListingType.Login to false,
VaultItemListingState.ItemListingType.Card to true,
VaultItemListingState.ItemListingType.SecureNote to false,
VaultItemListingState.ItemListingType.Identity to false,
VaultItemListingState.ItemListingType.Trash to false,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for trash Card cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = true,
cipherType = CipherType.CARD,
)
mapOf(
VaultItemListingState.ItemListingType.Login to false,
VaultItemListingState.ItemListingType.Card to false,
VaultItemListingState.ItemListingType.SecureNote to false,
VaultItemListingState.ItemListingType.Identity to false,
VaultItemListingState.ItemListingType.Trash to true,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to false,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for non trash Identity cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = false,
cipherType = CipherType.IDENTITY,
)
mapOf(
VaultItemListingState.ItemListingType.Login to false,
VaultItemListingState.ItemListingType.Card to false,
VaultItemListingState.ItemListingType.SecureNote to false,
VaultItemListingState.ItemListingType.Identity to true,
VaultItemListingState.ItemListingType.Trash to false,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for trash Identity cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = true,
cipherType = CipherType.IDENTITY,
)
mapOf(
VaultItemListingState.ItemListingType.Login to false,
VaultItemListingState.ItemListingType.Card to false,
VaultItemListingState.ItemListingType.SecureNote to false,
VaultItemListingState.ItemListingType.Identity to false,
VaultItemListingState.ItemListingType.Trash to true,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to false,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for non trash SecureNote cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = false,
cipherType = CipherType.SECURE_NOTE,
)
mapOf(
VaultItemListingState.ItemListingType.Login to false,
VaultItemListingState.ItemListingType.Card to false,
VaultItemListingState.ItemListingType.SecureNote to true,
VaultItemListingState.ItemListingType.Identity to false,
VaultItemListingState.ItemListingType.Trash to false,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to true,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
@Suppress("MaxLineLength")
fun `determineListingPredicate should return the correct predicate for trash SecureNote cipherView`() {
val cipherView = createMockCipherView(
number = 1,
isDeleted = true,
cipherType = CipherType.SECURE_NOTE,
)
mapOf(
VaultItemListingState.ItemListingType.Login to false,
VaultItemListingState.ItemListingType.Card to false,
VaultItemListingState.ItemListingType.SecureNote to false,
VaultItemListingState.ItemListingType.Identity to false,
VaultItemListingState.ItemListingType.Trash to true,
VaultItemListingState.ItemListingType.Folder(folderId = "mockId-1") to false,
)
.forEach { (type, expected) ->
val result = cipherView.determineListingPredicate(
itemListingType = type,
)
assertEquals(
expected,
result,
)
}
}
@Test
fun `toViewState should transform a list of CipherViews into a ViewState`() {
val cipherViewList = listOf(
createMockCipherView(
number = 1,
isDeleted = false,
cipherType = CipherType.LOGIN,
),
createMockCipherView(
number = 2,
isDeleted = false,
cipherType = CipherType.CARD,
),
createMockCipherView(
number = 3,
isDeleted = false,
cipherType = CipherType.SECURE_NOTE,
),
createMockCipherView(
number = 4,
isDeleted = false,
cipherType = CipherType.IDENTITY,
),
)
val result = cipherViewList.toViewState()
assertEquals(
VaultItemListingState.ViewState.Content(
displayItemList = listOf(
createMockItemListingDisplayItem(
number = 1,
cipherType = CipherType.LOGIN,
),
createMockItemListingDisplayItem(
number = 2,
cipherType = CipherType.CARD,
),
createMockItemListingDisplayItem(
number = 3,
cipherType = CipherType.SECURE_NOTE,
),
createMockItemListingDisplayItem(
number = 4,
cipherType = CipherType.IDENTITY,
),
),
),
result,
)
}
@Test
fun `updateWithAdditionalDataIfNecessary should update a folder itemListingType`() {
val folderViewList = listOf(
createMockFolderView(number = 1),
createMockFolderView(number = 2),
createMockFolderView(number = 3),
)
val result = VaultItemListingState.ItemListingType.Folder(
folderId = "mockId-1",
folderName = "wrong name",
)
.updateWithAdditionalDataIfNecessary(folderList = folderViewList)
assertEquals(
VaultItemListingState.ItemListingType.Folder(
folderId = "mockId-1",
folderName = "mockName-1",
),
result,
)
}
@Test
fun `updateWithAdditionalDataIfNecessary should not change a non folder itemListingType`() {
val folderViewList = listOf(
createMockFolderView(number = 1),
createMockFolderView(number = 2),
createMockFolderView(number = 3),
)
val result = VaultItemListingState.ItemListingType.Login
.updateWithAdditionalDataIfNecessary(folderList = folderViewList)
assertEquals(
VaultItemListingState.ItemListingType.Login,
result,
)
}
}

View File

@@ -0,0 +1,54 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
import com.bitwarden.core.CipherType
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
/**
* Create a mock [VaultItemListingState.DisplayItem] with a given [number].
*/
fun createMockItemListingDisplayItem(
number: Int,
cipherType: CipherType = CipherType.LOGIN,
): VaultItemListingState.DisplayItem =
when (cipherType) {
CipherType.LOGIN -> {
VaultItemListingState.DisplayItem(
id = "mockId-$number",
title = "mockName-$number",
subtitle = "mockUsername-$number",
iconRes = R.drawable.ic_login_item,
uri = "mockUri-$number",
)
}
CipherType.SECURE_NOTE -> {
VaultItemListingState.DisplayItem(
id = "mockId-$number",
title = "mockName-$number",
subtitle = null,
iconRes = R.drawable.ic_secure_note_item,
uri = null,
)
}
CipherType.CARD -> {
VaultItemListingState.DisplayItem(
id = "mockId-$number",
title = "mockName-$number",
subtitle = "er-$number",
iconRes = R.drawable.ic_card_item,
uri = null,
)
}
CipherType.IDENTITY -> {
VaultItemListingState.DisplayItem(
id = "mockId-$number",
title = "mockName-$number",
subtitle = "mockFirstName-${number}mockLastName-$number",
iconRes = R.drawable.ic_identity_item,
uri = null,
)
}
}