BIT-769: Move to organization functionality (#670)

This commit is contained in:
Ramsey Smith 2024-01-18 15:59:22 -07:00 committed by Álison Fernandes
parent 413677852b
commit 9ba6474c37
15 changed files with 1008 additions and 188 deletions

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.network.api
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import retrofit2.http.Body
import retrofit2.http.DELETE
@ -28,6 +29,15 @@ interface CiphersApi {
@Body body: CipherJsonRequest,
): Result<SyncResponseJson.Cipher>
/**
* Shares a cipher.
*/
@PUT("ciphers/{cipherId}/share")
suspend fun shareCipher(
@Path("cipherId") cipherId: String,
@Body body: ShareCipherJsonRequest,
): Result<SyncResponseJson.Cipher>
/**
* Deletes a cipher.
*/

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.vault.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents a share cipher request.
*
* @property cipher The cipher to share.
* @property collectionIds A list of collection ids associated with the cipher.
*/
@Serializable
data class ShareCipherJsonRequest(
@SerialName("Cipher")
val cipher: CipherJsonRequest,
@SerialName("CollectionIds")
val collectionIds: List<String>,
)

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
@ -21,6 +22,14 @@ interface CiphersService {
body: CipherJsonRequest,
): Result<UpdateCipherResponseJson>
/**
* Attempt to share a cipher.
*/
suspend fun shareCipher(
cipherId: String,
body: ShareCipherJsonRequest,
): Result<SyncResponseJson.Cipher>
/**
* Attempt to delete a cipher.
*/

View file

@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenErr
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import kotlinx.serialization.json.Json
@ -35,6 +36,15 @@ class CiphersServiceImpl constructor(
?: throw throwable
}
override suspend fun shareCipher(
cipherId: String,
body: ShareCipherJsonRequest,
): Result<SyncResponseJson.Cipher> =
ciphersApi.shareCipher(
cipherId = cipherId,
body = body,
)
override suspend fun deleteCipher(cipherId: String): Result<Unit> =
ciphersApi.deleteCipher(cipherId = cipherId)
}

View file

@ -15,6 +15,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.RemovePasswordSendResult
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
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
@ -169,6 +170,15 @@ interface VaultRepository : VaultLockManager {
cipherView: CipherView,
): UpdateCipherResult
/**
* Attempt to share a cipher to the collections with the given collectionIds.
*/
suspend fun shareCipher(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult
/**
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE].

View file

@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoadin
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateSendResponseJson
@ -38,6 +39,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.RemovePasswordSendResult
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
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
@ -77,7 +79,7 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L
/**
* Default implementation of [VaultRepository].
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
class VaultRepositoryImpl(
private val syncService: SyncService,
private val ciphersService: CiphersService,
@ -423,6 +425,35 @@ class VaultRepositoryImpl(
)
}
override suspend fun shareCipher(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult {
val userId = requireNotNull(activeUserId)
return vaultSdkSource
.encryptCipher(
userId = userId,
cipherView = cipherView,
)
.flatMap { cipher ->
ciphersService.shareCipher(
cipherId = cipherId,
body = ShareCipherJsonRequest(
cipher = cipher.toEncryptedNetworkCipher(),
collectionIds = collectionIds,
),
)
}
.fold(
onFailure = { ShareCipherResult.Error(errorMessage = null) },
onSuccess = {
vaultDiskSource.saveCipher(userId = userId, cipher = it)
ShareCipherResult.Success
},
)
}
override suspend fun createSend(
sendView: SendView,
fileUri: Uri?,

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of sharing a cipher.
*/
sealed class ShareCipherResult {
/**
* Cipher shared successfully.
*/
data object Success : ShareCipherResult()
/**
* Generic error while sharing cipher. The optional [errorMessage] may be displayed directly in
* the UI when present.
*/
data class Error(val errorMessage: String?) : ShareCipherResult()
}

View file

@ -3,14 +3,27 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util.toViewState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -18,13 +31,13 @@ private const val KEY_STATE = "state"
/**
* ViewModel responsible for handling user interactions in the [VaultMoveToOrganizationScreen].
*
* @param savedStateHandle Handles the navigation arguments of this ViewModel.
*/
@HiltViewModel
@Suppress("MaxLineLength")
@Suppress("MaxLineLength", "TooManyFunctions")
class VaultMoveToOrganizationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository,
authRepository: AuthRepository,
) : BaseViewModel<VaultMoveToOrganizationState, VaultMoveToOrganizationEvent, VaultMoveToOrganizationAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
@ -37,16 +50,27 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
) {
init {
// TODO Load real orgs/collections BIT-769
viewModelScope.launch {
@Suppress("MagicNumber")
delay(1500)
mutableStateFlow.update {
it.copy(
viewState = VaultMoveToOrganizationState.ViewState.Empty,
)
}
combine(
vaultRepository.getVaultItemStateFlow(state.vaultItemId),
vaultRepository.collectionsStateFlow,
authRepository.userStateFlow,
) { cipherViewState, collectionsState, userState ->
VaultMoveToOrganizationAction.Internal.VaultDataReceive(
vaultData = combineDataStates(
dataState1 = cipherViewState,
dataState2 = collectionsState,
dataState3 = DataState.Loaded(userState),
) { ciphersData, collectionsData, userData ->
Triple(
first = ciphersData,
second = collectionsData,
third = userData,
)
},
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultMoveToOrganizationAction) {
@ -56,6 +80,13 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
is VaultMoveToOrganizationAction.MoveClick -> handleMoveClick()
is VaultMoveToOrganizationAction.DismissClick -> handleDismissClick()
is VaultMoveToOrganizationAction.OrganizationSelect -> handleOrganizationSelect(action)
is VaultMoveToOrganizationAction.Internal.VaultDataReceive -> {
handleVaultDataReceive(action)
}
is VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive -> {
handleShareCipherResultReceive(action)
}
}
}
@ -76,28 +107,25 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
selectedOrganizationId = currentContentState.selectedOrganizationId,
selectedCollectionId = action.collection.id,
),
)
}
}
private fun handleMoveClick() {
mutableStateFlow.update {
it.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Loading(
message = R.string.saving.asText(),
),
)
}
// TODO implement move organization functionality BIT-769
viewModelScope.launch {
@Suppress("MagicNumber")
delay(1500)
sendEvent(VaultMoveToOrganizationEvent.ShowToast("Not yet implemented!".asText()))
mutableStateFlow.update {
it.copy(dialogState = null)
onContent { contentState ->
contentState.cipherToMove?.let { cipherView ->
if (contentState.selectedOrganization.collections.any { it.isSelected }) {
moveCipher(cipherView = cipherView, contentState = contentState)
} else {
mutableStateFlow.update {
it.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Error(
message = R.string.select_one_collection.asText(),
),
)
}
}
}
sendEvent(VaultMoveToOrganizationEvent.NavigateBack)
}
}
@ -105,6 +133,118 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleVaultDataReceive(
action: VaultMoveToOrganizationAction.Internal.VaultDataReceive,
) {
when (action.vaultData) {
is DataState.Error -> vaultErrorReceive(action.vaultData)
is DataState.Loaded -> vaultLoadedReceive(action.vaultData)
is DataState.Loading -> vaultLoadingReceive()
is DataState.NoNetwork -> vaultNoNetworkReceive(action.vaultData)
is DataState.Pending -> vaultPendingReceive(action.vaultData)
}
}
private fun handleShareCipherResultReceive(
action: VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive,
) {
mutableStateFlow.update { it.copy(dialogState = null) }
when (action.shareCipherResult) {
is ShareCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is ShareCipherResult.Success -> {
sendEvent(VaultMoveToOrganizationEvent.NavigateBack)
}
}
}
private fun vaultErrorReceive(
vaultData: DataState.Error<Triple<CipherView?, List<CollectionView>, UserState?>>,
) {
mutableStateFlow.update {
if (vaultData.data != null) {
it.copy(
viewState = vaultData.data.toViewState(),
dialogState = VaultMoveToOrganizationState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
)
} else {
it.copy(
viewState = VaultMoveToOrganizationState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
dialogState = null,
)
}
}
}
private fun vaultLoadedReceive(
vaultData: DataState.Loaded<Triple<CipherView?, List<CollectionView>, UserState?>>,
) {
mutableStateFlow.update {
it.copy(
viewState = vaultData.data.toViewState(),
dialogState = null,
)
}
}
private fun vaultLoadingReceive() {
mutableStateFlow.update {
it.copy(
viewState = VaultMoveToOrganizationState.ViewState.Loading,
dialogState = null,
)
}
}
private fun vaultNoNetworkReceive(
vaultData: DataState.NoNetwork<Triple<CipherView?, List<CollectionView>, UserState?>>,
) {
mutableStateFlow.update {
if (vaultData.data != null) {
it.copy(
viewState = vaultData.data.toViewState(),
dialogState = VaultMoveToOrganizationState.DialogState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
)
} else {
it.copy(
viewState = VaultMoveToOrganizationState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
dialogState = null,
)
}
}
}
private fun vaultPendingReceive(
vaultData: DataState.Pending<Triple<CipherView?, List<CollectionView>, UserState?>>,
) {
mutableStateFlow.update {
it.copy(
viewState = vaultData.data.toViewState(),
dialogState = null,
)
}
}
private inline fun updateContent(
crossinline block: (
VaultMoveToOrganizationState.ViewState.Content,
@ -116,12 +256,48 @@ class VaultMoveToOrganizationViewModel @Inject constructor(
?: return
mutableStateFlow.update { it.copy(viewState = updatedContent) }
}
private inline fun onContent(
crossinline block: (VaultMoveToOrganizationState.ViewState.Content) -> Unit,
) {
(state.viewState as? VaultMoveToOrganizationState.ViewState.Content)?.let(block)
}
private fun moveCipher(
cipherView: CipherView,
contentState: VaultMoveToOrganizationState.ViewState.Content,
) {
mutableStateFlow.update {
it.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Loading(
message = R.string.saving.asText(),
),
)
}
viewModelScope.launch {
trySendAction(
VaultMoveToOrganizationAction.Internal.ShareCipherResultReceive(
vaultRepository.shareCipher(
cipherId = mutableStateFlow.value.vaultItemId,
cipherView = cipherView.copy(
organizationId = contentState.selectedOrganizationId,
),
collectionIds = contentState
.selectedOrganization
.collections
.filter { it.isSelected }
.map { it.id },
),
),
)
}
}
}
/**
* Models state for the [VaultMoveToOrganizationScreen].
*
* @property vaultItemId Indicates whether the VM is in add or edit mode.
* @property vaultItemId the id for the item being moved.
* @property viewState indicates what view state the screen is in.
* @property dialogState the dialog state.
*/
@ -176,12 +352,16 @@ data class VaultMoveToOrganizationState(
/**
* Represents a loaded content state for the [VaultMoveToOrganizationScreen].
*
* @property selectedOrganizationId the selected organization id.
* @property organizations the organizations available.
* @property cipherToMove the cipher that is being moved to an organization.
*/
@Parcelize
data class Content(
val selectedOrganizationId: String,
val organizations: List<Organization>,
@IgnoredOnParcel
val cipherToMove: CipherView? = null,
) : ViewState() {
val selectedOrganization: Organization
@ -192,7 +372,6 @@ data class VaultMoveToOrganizationState(
*
* @property id the organization id.
* @property name the organization name.
* @property isSelected if the organization is selected or not.
* @property collections the list of collections associated with the organization.
*/
@Parcelize
@ -280,6 +459,26 @@ sealed class VaultMoveToOrganizationAction {
data class CollectionSelect(
val collection: VaultMoveToOrganizationState.ViewState.Content.Collection,
) : VaultMoveToOrganizationAction()
/**
* Models actions that the [VaultMoveToOrganizationViewModel] itself might send.
*/
sealed class Internal : VaultMoveToOrganizationAction() {
/**
* Indicates that the vault item data has been received.
*/
data class VaultDataReceive(
val vaultData: DataState<Triple<CipherView?, List<CollectionView>, UserState?>>,
) : Internal()
/**
* Indicates a result for sharing a cipher has been received.
*/
data class ShareCipherResultReceive(
val shareCipherResult: ShareCipherResult,
) : Internal()
}
}
@Suppress("MaxLineLength")

View file

@ -0,0 +1,63 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.VaultMoveToOrganizationState
/**
* Transforms a triple of [CipherView] (nullable), list of [CollectionView],
* and [UserState] (nullable) into a [VaultMoveToOrganizationState.ViewState].
*/
fun Triple<CipherView?, List<CollectionView>, UserState?>.toViewState():
VaultMoveToOrganizationState.ViewState {
val userOrganizations = third
?.activeAccount
?.organizations
val currentCipher = first
val collections = second
.filter { !it.readOnly }
return when {
(currentCipher == null) -> {
VaultMoveToOrganizationState.ViewState.Error(R.string.generic_error_message.asText())
}
(userOrganizations?.isNotEmpty() == true) -> {
VaultMoveToOrganizationState.ViewState.Content(
selectedOrganizationId = currentCipher
.organizationId
?: userOrganizations
.first()
.id,
organizations = userOrganizations.map { organization ->
VaultMoveToOrganizationState.ViewState.Content.Organization(
id = organization.id,
name = organization
.name
.orEmpty(),
collections = collections
.filter { collection ->
collection.organizationId == organization.id &&
collection.id != null
}
.map { collection ->
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = collection.id.orEmpty(),
name = collection.name,
isSelected = currentCipher
.collectionIds
.contains(collection.id),
)
},
)
},
cipherToMove = currentCipher,
)
}
else -> VaultMoveToOrganizationState.ViewState.Empty
}
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.service
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest
@ -71,6 +72,27 @@ class CiphersServiceTest : BaseServiceTest() {
val result = ciphersService.deleteCipher(cipherId = cipherId)
assertEquals(Unit, result.getOrThrow())
}
@Test
fun `shareCipher should execute the share cipher API`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(CREATE_UPDATE_CIPHER_SUCCESS_JSON),
)
val result = ciphersService.shareCipher(
cipherId = "mockId-1",
body = ShareCipherJsonRequest(
cipher = createMockCipherJsonRequest(number = 1),
collectionIds = listOf("mockId-1"),
),
)
assertEquals(
createMockCipher(number = 1),
result.getOrThrow(),
)
}
}
private const val CREATE_UPDATE_CIPHER_SUCCESS_JSON = """

View file

@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendFileResponseJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SendTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
@ -58,6 +59,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.RemovePasswordSendResult
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
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
@ -2000,6 +2002,111 @@ class VaultRepositoryTest {
assertEquals(DeleteSendResult.Success, result)
}
@Test
@Suppress("MaxLineLength")
fun `shareCipher with cipherService shareCipher success should return ShareCipherResultSuccess`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
coEvery {
ciphersService.shareCipher(
cipherId = "mockId-1",
body = ShareCipherJsonRequest(
cipher = createMockCipherJsonRequest(number = 1),
collectionIds = listOf("mockId-1"),
),
)
} returns createMockCipher(number = 1).asSuccess()
coEvery { vaultDiskSource.saveCipher(userId, createMockCipher(number = 1)) } just runs
val result = vaultRepository.shareCipher(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
assertEquals(
ShareCipherResult.Success,
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `shareCipher with cipherService shareCipher failure should return ShareCipherResultError`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns createMockSdkCipher(number = 1).asSuccess()
coEvery {
ciphersService.shareCipher(
cipherId = "mockId-1",
body = ShareCipherJsonRequest(
cipher = createMockCipherJsonRequest(number = 1),
collectionIds = listOf("mockId-1"),
),
)
} returns Throwable("Fail").asFailure()
coEvery { vaultDiskSource.saveCipher(userId, createMockCipher(number = 1)) } just runs
val result = vaultRepository.shareCipher(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
assertEquals(
ShareCipherResult.Error(errorMessage = null),
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `shareCipher with cipherService encryptCipher failure should return ShareCipherResultError`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
coEvery {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = createMockCipherView(number = 1),
)
} returns Throwable("Fail").asFailure()
coEvery {
ciphersService.shareCipher(
cipherId = "mockId-1",
body = ShareCipherJsonRequest(
cipher = createMockCipherJsonRequest(number = 1),
collectionIds = listOf("mockId-1"),
),
)
} returns createMockCipher(number = 1).asSuccess()
coEvery { vaultDiskSource.saveCipher(userId, createMockCipher(number = 1)) } just runs
val result = vaultRepository.shareCipher(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
assertEquals(
ShareCipherResult.Error(errorMessage = null),
result,
)
}
//region Helper functions
/**

View file

@ -84,11 +84,11 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() {
@Test
fun `selecting an organization should send OrganizationSelect action`() {
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 1")
.onNodeWithContentDescriptionAfterScroll(label = "Organization, mockOrganizationName-1")
.performClick()
// Choose the option from the menu
composeTestRule
.onAllNodesWithText(text = "Organization 2")
.onAllNodesWithText(text = "mockOrganizationName-2")
.onLast()
.performScrollTo()
.performClick()
@ -97,22 +97,12 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() {
viewModel.trySendAction(
VaultMoveToOrganizationAction.OrganizationSelect(
VaultMoveToOrganizationState.ViewState.Content.Organization(
id = "2",
name = "Organization 2",
id = "mockOrganizationId-2",
name = "mockOrganizationName-2",
collections = listOf(
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "1",
name = "Collection 1",
isSelected = true,
),
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "2",
name = "Collection 2",
isSelected = false,
),
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "3",
name = "Collection 3",
id = "mockId-2",
name = "mockName-2",
isSelected = false,
),
),
@ -125,36 +115,36 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() {
@Test
fun `the organization option field should display according to state`() {
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 1")
.onNodeWithContentDescriptionAfterScroll(label = "Organization, mockOrganizationName-1")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "2",
selectedOrganizationId = "mockOrganizationId-2",
),
)
}
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "Organization, Organization 2")
.onNodeWithContentDescriptionAfterScroll(label = "Organization, mockOrganizationName-2")
.assertIsDisplayed()
}
@Test
fun `selecting a collection should send CollectionSelect action`() {
composeTestRule
.onNodeWithText(text = "Collection 2")
.onNodeWithText(text = "mockName-1")
.performClick()
verify {
viewModel.trySendAction(
VaultMoveToOrganizationAction.CollectionSelect(
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "2",
name = "Collection 2",
isSelected = false,
id = "mockId-1",
name = "mockName-1",
isSelected = true,
),
),
)
@ -164,14 +154,8 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() {
@Test
fun `the collection list should display according to state`() {
composeTestRule
.onNodeWithText("Collection 1")
.onNodeWithText("mockName-1")
.assertIsOn()
composeTestRule
.onNodeWithText("Collection 2")
.assertIsOff()
composeTestRule
.onNodeWithText("Collection 3")
.assertIsOff()
mutableStateFlow.update { currentState ->
currentState.copy(
@ -180,31 +164,27 @@ class VaultMoveToOrganizationScreenTest : BaseComposeTest() {
.map { organization ->
organization.copy(
collections =
if (organization.id == "1") {
if (organization.id == "mockOrganizationId-1") {
organization
.collections
.map { collection ->
collection.copy(isSelected = collection.id != "1")
collection.copy(
isSelected = collection.id != "mockId-1",
)
}
} else {
organization.collections
},
)
},
selectedOrganizationId = "1",
selectedOrganizationId = "mockOrganizationId-1",
),
)
}
composeTestRule
.onNodeWithText("Collection 1")
.onNodeWithText("mockName-1")
.assertIsOff()
composeTestRule
.onNodeWithText("Collection 2")
.assertIsOn()
composeTestRule
.onNodeWithText("Collection 3")
.assertIsOn()
}
@Test
@ -251,7 +231,7 @@ private fun createVaultMoveToOrganizationState(): VaultMoveToOrganizationState =
vaultItemId = "mockId",
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "1",
selectedOrganizationId = "mockOrganizationId-1",
),
dialogState = null,
)

View file

@ -2,10 +2,27 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.DataState
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.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
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.movetoorganization.util.createMockOrganizationList
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -17,6 +34,22 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
state = initialState,
)
private val mutableVaultItemFlow = MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val mutableCollectionFlow =
MutableStateFlow<DataState<List<CollectionView>>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { getVaultItemStateFlow(DEFAULT_ITEM_ID) } returns mutableVaultItemFlow
every { collectionsStateFlow } returns mutableCollectionFlow
}
private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
@Test
fun `initial state should be correct when state is null`() = runTest {
val viewModel = createViewModel(
@ -32,14 +65,13 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct`() = runTest {
val initState = createVaultMoveToOrganizationState()
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
state = initialState,
),
)
assertEquals(initState, viewModel.stateFlow.value)
assertEquals(initialState, viewModel.stateFlow.value)
}
@Test
@ -55,27 +87,21 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
@Test
fun `OrganizationSelect should update selected Organization`() = runTest {
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultMoveToOrganizationState(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "1",
),
),
),
)
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1)))
val action = VaultMoveToOrganizationAction.OrganizationSelect(
VaultMoveToOrganizationState.ViewState.Content.Organization(
id = "3",
name = "Organization 3",
id = "mockOrganizationId-3",
name = "mockOrganizationName-3",
collections = emptyList(),
),
)
val expectedState = createVaultMoveToOrganizationState(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "3",
selectedOrganizationId = "mockOrganizationId-3",
cipherToMove = createMockCipherView(number = 1),
),
)
@ -89,50 +115,34 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
@Test
fun `CollectionSelect should update selected Collections`() = runTest {
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultMoveToOrganizationState(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "1",
),
),
),
)
val selectCollection3Action = VaultMoveToOrganizationAction.CollectionSelect(
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "3",
name = "Collection 3",
isSelected = false,
),
)
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1)))
val unselectCollection1Action = VaultMoveToOrganizationAction.CollectionSelect(
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "1",
name = "Collection 1",
id = "mockId-1",
name = "mockName-1",
isSelected = true,
),
)
val expectedState = createVaultMoveToOrganizationState(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList()
.map { organization ->
organization.copy(
collections =
if (organization.id == "1") {
organization.collections.map {
it.copy(isSelected = it.id == "3")
}
} else {
organization.collections
},
)
},
selectedOrganizationId = "1",
organizations = createMockOrganizationList().map { organization ->
organization.copy(
collections = if (organization.id == "mockOrganizationId-1") {
organization.collections.map {
it.copy(isSelected = false)
}
} else {
organization.collections
},
)
},
cipherToMove = createMockCipherView(number = 1),
selectedOrganizationId = "mockOrganizationId-1",
),
)
viewModel.actionChannel.trySend(selectCollection3Action)
viewModel.actionChannel.trySend(unselectCollection1Action)
assertEquals(
@ -142,40 +152,243 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
}
@Test
fun `MoveClick should show dialog, and remove it once an item is moved`() = runTest {
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initialState,
fun `DataState Loading should show loading state`() = runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(value = DataState.Loading)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(viewState = VaultMoveToOrganizationState.ViewState.Loading),
awaitItem(),
)
}
}
@Test
fun `DataState Pending should show content state`() = runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(
value = DataState.Pending(
data = createMockCipherView(number = 1),
),
)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
assertEquals(
initialState.copy(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
), dialogState = null,
),
awaitItem(),
)
}
}
@Test
fun `DataState Error should show Error State`() = runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(
value = DataState.Error(
error = IllegalStateException(),
data = null,
),
)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
viewState = VaultMoveToOrganizationState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `DataState NoNetwork should show Error Dialog`() = runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(
value = DataState.NoNetwork(),
)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
viewState = VaultMoveToOrganizationState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
),
awaitItem(),
)
}
}
@Test
fun `MoveClick with shareCipher success should show loading dialog, and remove it`() = runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1)))
coEvery {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
} returns ShareCipherResult.Success
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick)
assertEquals(
initialState.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Loading(
message = R.string.saving.asText(),
),
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
assertEquals(
initialState,
initialState.copy(
dialogState = null,
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
}
coVerify {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
}
}
@Test
fun `MoveClick with shareCipher error should show error dialog`() = runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1)))
coEvery {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
} returns ShareCipherResult.Error(errorMessage = null)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick)
assertEquals(
initialState.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Loading(
message = R.string.saving.asText(),
),
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = VaultMoveToOrganizationState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
viewState = VaultMoveToOrganizationState.ViewState.Content(
organizations = createMockOrganizationList(),
selectedOrganizationId = "mockOrganizationId-1",
cipherToMove = createMockCipherView(number = 1),
),
),
awaitItem(),
)
}
coVerify {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
}
}
@Test
fun `MoveClick with shareCipher success should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
mutableCollectionFlow.tryEmit(value = DataState.Loaded(DEFAULT_COLLECTIONS))
mutableVaultItemFlow.tryEmit(value = DataState.Loaded(createMockCipherView(number = 1)))
coEvery {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
} returns ShareCipherResult.Success
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultMoveToOrganizationAction.MoveClick)
assertEquals(
VaultMoveToOrganizationEvent.NavigateBack,
awaitItem(),
)
}
coVerify {
vaultRepository.shareCipher(
cipherId = "mockCipherId",
cipherView = createMockCipherView(number = 1),
collectionIds = listOf("mockId-1"),
)
}
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = initialSavedStateHandle,
): VaultMoveToOrganizationViewModel =
VaultMoveToOrganizationViewModel(
savedStateHandle = savedStateHandle,
)
vaultRepo: VaultRepository = vaultRepository,
authRepo: AuthRepository = authRepository,
): VaultMoveToOrganizationViewModel = VaultMoveToOrganizationViewModel(
savedStateHandle = savedStateHandle,
authRepository = authRepo,
vaultRepository = vaultRepo,
)
private fun createSavedStateHandleWithState(
state: VaultMoveToOrganizationState? = null,
vaultItemId: String = "mockId",
vaultItemId: String = "mockCipherId",
) = SavedStateHandle().apply {
set("state", state)
set("vault_move_to_organization_id", vaultItemId)
@ -183,13 +396,51 @@ class VaultMoveToOrganizationViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
private fun createVaultMoveToOrganizationState(
viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Empty,
vaultItemId: String = "mockId",
viewState: VaultMoveToOrganizationState.ViewState = VaultMoveToOrganizationState.ViewState.Loading,
vaultItemId: String = "mockCipherId",
dialogState: VaultMoveToOrganizationState.DialogState? = null,
): VaultMoveToOrganizationState =
VaultMoveToOrganizationState(
vaultItemId = vaultItemId,
viewState = viewState,
dialogState = dialogState,
)
): VaultMoveToOrganizationState = VaultMoveToOrganizationState(
vaultItemId = vaultItemId,
viewState = viewState,
dialogState = dialogState,
)
}
private const val DEFAULT_ITEM_ID: String = "mockCipherId"
private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
organizations = listOf(
Organization(
id = "mockOrganizationId-1",
name = "mockOrganizationName-1",
),
Organization(
id = "mockOrganizationId-2",
name = "mockOrganizationName-2",
),
Organization(
id = "mockOrganizationId-3",
name = "mockOrganizationName-3",
),
),
),
),
)
private val DEFAULT_COLLECTIONS = listOf(
createMockCollectionView(number = 1),
createMockCollectionView(number = 2),
createMockCollectionView(number = 3),
createMockCollectionView(number = 4),
)

View file

@ -0,0 +1,117 @@
package com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
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.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.VaultMoveToOrganizationState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class VaultMoveToOrganizationExtensionsTest {
@Test
@Suppress("MaxLineLength")
fun `toViewState should transform a valid triple of CipherView, CollectionView list, and UserState into Content ViewState`() {
val triple = Triple(
first = createMockCipherView(number = 1),
second = listOf(
createMockCollectionView(number = 1),
createMockCollectionView(number = 2),
createMockCollectionView(number = 3),
),
third = createMockUserState(),
)
val result = triple.toViewState()
assertEquals(
VaultMoveToOrganizationState.ViewState.Content(
selectedOrganizationId = "mockOrganizationId-1",
organizations = createMockOrganizationList(),
cipherToMove = createMockCipherView(number = 1),
),
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `toViewState should transform a triple of null CipherView, CollectionView list, and UserState into Error ViewState`() {
val triple = Triple(
first = null,
second = listOf(
createMockCollectionView(number = 1),
createMockCollectionView(number = 2),
createMockCollectionView(number = 3),
),
third = createMockUserState(),
)
val result = triple.toViewState()
assertEquals(
VaultMoveToOrganizationState.ViewState.Error(R.string.generic_error_message.asText()),
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `toViewState should transform a triple of CipherView, CollectionView list, and UserState without organizations into Empty ViewState`() {
val triple = Triple(
first = createMockCipherView(number = 1),
second = listOf(
createMockCollectionView(number = 1),
createMockCollectionView(number = 2),
createMockCollectionView(number = 3),
),
third = createMockUserState(hasOrganizations = false),
)
val result = triple.toViewState()
assertEquals(
VaultMoveToOrganizationState.ViewState.Empty,
result,
)
}
}
private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
organizations = if (hasOrganizations) {
listOf(
Organization(
id = "mockOrganizationId-1",
name = "mockOrganizationName-1",
),
Organization(
id = "mockOrganizationId-2",
name = "mockOrganizationName-2",
),
Organization(
id = "mockOrganizationId-3",
name = "mockOrganizationName-3",
),
)
} else {
emptyList()
},
),
),
)

View file

@ -3,56 +3,31 @@ package com.x8bit.bitwarden.ui.vault.feature.movetoorganization.util
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.VaultMoveToOrganizationState
/**
* Creates a list of mock organizations.
* Creates a list of mock [VaultMoveToOrganizationState.ViewState.Content.Organization].
*/
fun createMockOrganizationList():
List<VaultMoveToOrganizationState.ViewState.Content.Organization> =
listOf(
VaultMoveToOrganizationState.ViewState.Content.Organization(
id = "1",
name = "Organization 1",
collections = listOf(
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "1",
name = "Collection 1",
isSelected = true,
),
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "2",
name = "Collection 2",
isSelected = false,
),
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "3",
name = "Collection 3",
isSelected = false,
),
createMockOrganization(number = 1),
createMockOrganization(number = 2, isCollectionSelected = false),
createMockOrganization(number = 3, isCollectionSelected = false),
)
/**
* Creates a [VaultMoveToOrganizationState.ViewState.Content.Organization] with a given number.
*/
fun createMockOrganization(
number: Int,
isCollectionSelected: Boolean = true,
): VaultMoveToOrganizationState.ViewState.Content.Organization =
VaultMoveToOrganizationState.ViewState.Content.Organization(
id = "mockOrganizationId-$number",
name = "mockOrganizationName-$number",
collections = listOf(
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "mockId-$number",
name = "mockName-$number",
isSelected = isCollectionSelected,
),
),
VaultMoveToOrganizationState.ViewState.Content.Organization(
id = "2",
name = "Organization 2",
collections = listOf(
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "1",
name = "Collection 1",
isSelected = true,
),
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "2",
name = "Collection 2",
isSelected = false,
),
VaultMoveToOrganizationState.ViewState.Content.Collection(
id = "3",
name = "Collection 3",
isSelected = false,
),
),
),
VaultMoveToOrganizationState.ViewState.Content.Organization(
id = "3",
name = "Organization 3",
collections = emptyList(),
),
)