mirror of
https://github.com/bitwarden/android.git
synced 2024-11-27 12:00:19 +03:00
BIT-769: Move to organization functionality (#670)
This commit is contained in:
parent
413677852b
commit
9ba6474c37
15 changed files with 1008 additions and 188 deletions
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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>,
|
||||
)
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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].
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 = """
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue