diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index ef7745d62..8a5c6a52a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -54,6 +54,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS import com.x8bit.bitwarden.data.auth.util.toSdkParams +import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -71,7 +72,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import javax.inject.Singleton @@ -95,6 +98,7 @@ class AuthRepositoryImpl( private val settingsRepository: SettingsRepository, private val vaultRepository: VaultRepository, private val userLogoutManager: UserLogoutManager, + private val pushManager: PushManager, dispatcherManager: DispatcherManager, private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() }, ) : AuthRepository { @@ -117,7 +121,9 @@ class AuthRepositoryImpl( * use of [Dispatchers.Unconfined] allows for this to happen synchronously whenever any of * these flows changes. */ - private val collectionScope = CoroutineScope(dispatcherManager.unconfined) + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + private val ioScope = CoroutineScope(dispatcherManager.io) override var twoFactorResponse: TwoFactorRequired? = null @@ -136,7 +142,7 @@ class AuthRepositoryImpl( ?: AuthState.Unauthenticated } .stateIn( - scope = collectionScope, + scope = unconfinedScope, started = SharingStarted.Eagerly, initialValue = AuthState.Uninitialized, ) @@ -169,7 +175,7 @@ class AuthRepositoryImpl( !mutableHasPendingAccountDeletionStateFlow.value } .stateIn( - scope = collectionScope, + scope = unconfinedScope, started = SharingStarted.Eagerly, initialValue = authDiskSource .userState @@ -199,6 +205,22 @@ class AuthRepositoryImpl( override var hasPendingAccountAddition: Boolean by mutableHasPendingAccountAdditionStateFlow::value + init { + pushManager + .syncOrgKeysFlow + .onEach { + val userId = activeUserId ?: return@onEach + refreshAccessTokenSynchronously(userId) + vaultRepository.sync() + } + .launchIn(ioScope) + + pushManager + .logoutFlow + .onEach { logout() } + .launchIn(unconfinedScope) + } + override fun clearPendingAccountDeletion() { mutableHasPendingAccountDeletionStateFlow.value = false } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index f71a9b24b..c3f1a3acf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl +import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -49,6 +50,7 @@ object AuthRepositoryModule { settingsRepository: SettingsRepository, vaultRepository: VaultRepository, userLogoutManager: UserLogoutManager, + pushManager: PushManager, ): AuthRepository = AuthRepositoryImpl( accountsService = accountsService, authRequestsService = authRequestsService, @@ -65,5 +67,6 @@ object AuthRepositoryModule { settingsRepository = settingsRepository, vaultRepository = vaultRepository, userLogoutManager = userLogoutManager, + pushManager = pushManager, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt index 101d9ebdf..5a9129396 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt @@ -149,6 +149,8 @@ class PushManagerImpl @Inject constructor( SyncCipherUpsertData( cipherId = payload.id, revisionDate = payload.revisionDate, + organizationId = payload.organizationId, + collectionIds = payload.collectionIds, isUpdate = type == NotificationType.SYNC_CIPHER_UPDATE, ), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt index 1b3388116..2eefc909a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt @@ -23,7 +23,7 @@ sealed class NotificationPayload { @SerialName("id") val id: String, @SerialName("userId") override val userId: String?, @SerialName("organizationId") val organizationId: String?, - @SerialName("collectionIds") val collectionIds: List, + @SerialName("collectionIds") val collectionIds: List?, @Contextual @SerialName("revisionDate") val revisionDate: ZonedDateTime, ) : NotificationPayload() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SyncCipherUpsertData.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SyncCipherUpsertData.kt index 8e20270f9..02f1bce7f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SyncCipherUpsertData.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SyncCipherUpsertData.kt @@ -13,5 +13,7 @@ import java.time.ZonedDateTime data class SyncCipherUpsertData( val cipherId: String, val revisionDate: ZonedDateTime, + val organizationId: String?, + val collectionIds: List?, val isUpdate: Boolean, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index fdcbe7b33..b58dac0de 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -19,8 +19,11 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData +import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData +import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow @@ -91,11 +94,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -236,16 +241,36 @@ class VaultRepositoryImpl( } .launchIn(unconfinedScope) + pushManager + .fullSyncFlow + .onEach { syncIfNecessary() } + .launchIn(unconfinedScope) + + pushManager + .syncCipherDeleteFlow + .onEach(::deleteCipher) + .launchIn(unconfinedScope) + pushManager .syncCipherUpsertFlow .onEach(::syncCipherIfNecessary) .launchIn(ioScope) + pushManager + .syncSendDeleteFlow + .onEach(::deleteSend) + .launchIn(unconfinedScope) + pushManager .syncSendUpsertFlow .onEach(::syncSendIfNecessary) .launchIn(ioScope) + pushManager + .syncFolderDeleteFlow + .onEach(::deleteFolder) + .launchIn(unconfinedScope) + pushManager .syncFolderUpsertFlow .onEach(::syncFolderIfNecessary) @@ -1231,19 +1256,76 @@ class VaultRepositoryImpl( .onEach { mutableSendDataStateFlow.value = it } //region Push notification helpers + /** + * Deletes the cipher specified by [syncCipherDeleteData] from disk. + */ + private suspend fun deleteCipher(syncCipherDeleteData: SyncCipherDeleteData) { + val userId = activeUserId ?: return + + val cipherId = syncCipherDeleteData.cipherId + vaultDiskSource.deleteCipher( + userId = userId, + cipherId = cipherId, + ) + } + /** * Syncs an individual cipher contained in [syncCipherUpsertData] to disk if certain criteria * are met. If the resource cannot be found cloud-side, and it was updated, delete it from disk * for now. */ + @Suppress("ReturnCount") private suspend fun syncCipherIfNecessary(syncCipherUpsertData: SyncCipherUpsertData) { val userId = activeUserId ?: return - - // TODO Handle other filtering logic including revision date comparison. This will still be - // handled as part of BIT-1547. - val cipherId = syncCipherUpsertData.cipherId + val organizationId = syncCipherUpsertData.organizationId + val collectionIds = syncCipherUpsertData.collectionIds + val revisionDate = syncCipherUpsertData.revisionDate val isUpdate = syncCipherUpsertData.isUpdate + + val localCipher = ciphersStateFlow + .mapNotNull { it.data } + .first() + .find { it.id == cipherId } + + // Return if local cipher is more recent + if (localCipher != null && + localCipher.revisionDate.epochSecond > revisionDate.toEpochSecond() + ) { + return + } + + var shouldUpdate: Boolean + val shouldCheckCollections: Boolean + + when { + isUpdate -> { + shouldUpdate = localCipher != null + shouldCheckCollections = true + } + + collectionIds == null || organizationId == null -> { + shouldUpdate = localCipher == null + shouldCheckCollections = false + } + + else -> { + shouldUpdate = false + shouldCheckCollections = true + } + } + + if (!shouldUpdate && shouldCheckCollections && organizationId != null) { + // Check if there are any collections in common + shouldUpdate = collectionsStateFlow + .mapNotNull { it.data } + .first() + .mapNotNull { it.id } + .any { collectionIds?.contains(it) == true } == true + } + + if (!shouldUpdate) return + ciphersService .getCipher(cipherId) .fold( @@ -1259,6 +1341,19 @@ class VaultRepositoryImpl( ) } + /** + * Deletes the send specified by [syncSendDeleteData] from disk. + */ + private suspend fun deleteSend(syncSendDeleteData: SyncSendDeleteData) { + val userId = activeUserId ?: return + + val sendId = syncSendDeleteData.sendId + vaultDiskSource.deleteSend( + userId = userId, + sendId = sendId, + ) + } + /** * Syncs an individual send contained in [syncSendUpsertData] to disk if certain criteria are * met. If the resource cannot be found cloud-side, and it was updated, delete it from disk for @@ -1266,12 +1361,22 @@ class VaultRepositoryImpl( */ private suspend fun syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) { val userId = activeUserId ?: return - - // TODO Handle other filtering logic including revision date comparison. This will still be - // handled as part of BIT-1547. - val sendId = syncSendUpsertData.sendId val isUpdate = syncSendUpsertData.isUpdate + val revisionDate = syncSendUpsertData.revisionDate + + val localSend = sendDataStateFlow + .mapNotNull { it.data } + .first() + .sendViewList + .find { it.id == sendId } + val isValidCreate = !isUpdate && localSend == null + val isValidUpdate = isUpdate && + localSend != null && + localSend.revisionDate.epochSecond < revisionDate.toEpochSecond() + + if (!isValidCreate && !isValidUpdate) return + sendsService .getSend(sendId) .fold( @@ -1287,17 +1392,44 @@ class VaultRepositoryImpl( ) } + /** + * Deletes the folder specified by [syncFolderDeleteData] from disk. + */ + private suspend fun deleteFolder(syncFolderDeleteData: SyncFolderDeleteData) { + val userId = activeUserId ?: return + + val folderId = syncFolderDeleteData.folderId + clearFolderIdFromCiphers( + folderId = folderId, + userId = userId, + ) + vaultDiskSource.deleteFolder( + folderId = folderId, + userId = userId, + ) + } + /** * Syncs an individual folder contained in [syncFolderUpsertData] to disk if certain criteria * are met. */ private suspend fun syncFolderIfNecessary(syncFolderUpsertData: SyncFolderUpsertData) { val userId = activeUserId ?: return - - // TODO Handle other filtering logic including revision date comparison. This will still be - // handled as part of BIT-1547. - val folderId = syncFolderUpsertData.folderId + val isUpdate = syncFolderUpsertData.isUpdate + val revisionDate = syncFolderUpsertData.revisionDate + + val localFolder = foldersStateFlow + .mapNotNull { it.data } + .first() + .find { it.id == folderId } + val isValidCreate = !isUpdate && localFolder == null + val isValidUpdate = isUpdate && + localFolder != null && + localFolder.revisionDate.epochSecond < revisionDate.toEpochSecond() + + if (!isValidCreate && !isValidUpdate) return + folderService .getFolder(folderId) .onSuccess { vaultDiskSource.saveFolder(userId, it) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index ca1ababfb..8716bb391 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -64,10 +64,12 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUserState import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization @@ -172,6 +174,13 @@ class AuthRepositoryTest { every { logout(any()) } just runs } + private val mutableLogoutFlow = bufferedMutableSharedFlow() + private val mutableSyncOrgKeysFlow = bufferedMutableSharedFlow() + private val pushManager: PushManager = mockk { + every { logoutFlow } returns mutableLogoutFlow + every { syncOrgKeysFlow } returns mutableSyncOrgKeysFlow + } + private var elapsedRealtimeMillis = 123456789L private val repository = AuthRepositoryImpl( @@ -190,6 +199,7 @@ class AuthRepositoryTest { vaultRepository = vaultRepository, userLogoutManager = userLogoutManager, dispatcherManager = dispatcherManager, + pushManager = pushManager, elapsedRealtimeMillisProvider = { elapsedRealtimeMillis }, ) @@ -2770,6 +2780,43 @@ class AuthRepositoryTest { ) } + @Suppress("MaxLineLength") + @Test + fun `logOutFlow emission for action account should call logout on the UserLogoutManager and clear the user's in memory vault data`() { + val userId = USER_ID_1 + fakeAuthDiskSource.userState = MULTI_USER_STATE + + mutableLogoutFlow.tryEmit(Unit) + + coVerify(exactly = 1) { + userLogoutManager.logout(userId = userId) + vaultRepository.clearUnlockedData() + } + } + + @Test + fun `syncOrgKeysFlow emissions should refresh access token and sync`() { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + } returns REFRESH_TOKEN_RESPONSE_JSON.asSuccess() + every { + REFRESH_TOKEN_RESPONSE_JSON.toUserStateJson( + userId = USER_ID_1, + previousUserState = SINGLE_USER_STATE_1, + ) + } returns SINGLE_USER_STATE_1 + + coEvery { vaultRepository.sync() } just runs + + mutableSyncOrgKeysFlow.tryEmit(Unit) + + coVerify(exactly = 1) { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + vaultRepository.sync() + } + } + companion object { private const val UNIQUE_APP_ID = "testUniqueAppId" private const val EMAIL = "test@bitwarden.com" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt index fa604a9ac..563e12e53 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt @@ -184,6 +184,8 @@ class PushManagerTest { assertEquals( SyncCipherUpsertData( cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321", + organizationId = "6a41d965-ed95-4eae-98c3-5f1ec609c2c1", + collectionIds = listOf(), revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"), isUpdate = false, ), @@ -214,6 +216,8 @@ class PushManagerTest { assertEquals( SyncCipherUpsertData( cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321", + organizationId = "6a41d965-ed95-4eae-98c3-5f1ec609c2c1", + collectionIds = listOf(), revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"), isUpdate = true, ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 6764c7ce0..6d8d3c131 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.repository import android.net.Uri import app.cash.turbine.test +import app.cash.turbine.turbineScope import com.bitwarden.core.Cipher import com.bitwarden.core.CipherView import com.bitwarden.core.CollectionView @@ -24,8 +25,11 @@ import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData +import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData +import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow @@ -115,6 +119,7 @@ import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -125,6 +130,7 @@ import retrofit2.HttpException import java.net.UnknownHostException import java.time.Clock import java.time.Instant +import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -159,12 +165,20 @@ class VaultRepositoryTest { every { lockVaultForCurrentUser() } just runs } + private val mutableFullSyncFlow = bufferedMutableSharedFlow() + private val mutableSyncCipherDeleteFlow = bufferedMutableSharedFlow() private val mutableSyncCipherUpsertFlow = bufferedMutableSharedFlow() + private val mutableSyncSendDeleteFlow = bufferedMutableSharedFlow() private val mutableSyncSendUpsertFlow = bufferedMutableSharedFlow() + private val mutableSyncFolderDeleteFlow = bufferedMutableSharedFlow() private val mutableSyncFolderUpsertFlow = bufferedMutableSharedFlow() private val pushManager: PushManager = mockk { + every { fullSyncFlow } returns mutableFullSyncFlow + every { syncCipherDeleteFlow } returns mutableSyncCipherDeleteFlow every { syncCipherUpsertFlow } returns mutableSyncCipherUpsertFlow + every { syncSendDeleteFlow } returns mutableSyncSendDeleteFlow every { syncSendUpsertFlow } returns mutableSyncSendUpsertFlow + every { syncFolderDeleteFlow } returns mutableSyncFolderDeleteFlow every { syncFolderUpsertFlow } returns mutableSyncFolderUpsertFlow } @@ -3619,48 +3633,297 @@ class VaultRepositoryTest { } @Test - fun `syncCipherUpsertFlow success should make a request for a cipher and then store it`() = - runTest { - val cipherId = "mockId-1" - val cipher: SyncResponseJson.Cipher = mockk() + fun `fullSyncFlow emission should trigger sync if necessary`() { + val userId = "mockId-1" + fakeAuthDiskSource.userState = MOCK_USER_STATE + every { + settingsDiskSource.getLastSyncTime(userId = userId) + } returns null + coEvery { syncService.sync() } just awaits - fakeAuthDiskSource.userState = MOCK_USER_STATE + mutableFullSyncFlow.tryEmit(Unit) - coEvery { - ciphersService.getCipher(cipherId) - } returns cipher.asSuccess() + coVerify { syncService.sync() } + } - coEvery { - vaultDiskSource.saveCipher(any(), any()) - } just runs + @Test + fun `syncCipherDeleteFlow should delete cipher from disk`() { + val userId = "mockId-1" + val cipherId = "mockId-1" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) } just runs + + mutableSyncCipherDeleteFlow.tryEmit( + SyncCipherDeleteData(cipherId = cipherId), + ) + + coVerify { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) } + } + + @Suppress("MaxLineLength") + @Test + fun `syncCipherUpsertFlow create with local cipher with no common collections should do nothing`() = runTest { + val number = 1 + val cipherId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val cipherView = createMockCipherView(number = number) + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(createMockSdkCipher(number = number)), + ) + } returns listOf(cipherView).asSuccess() + + val ciphersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + + vaultRepository.ciphersStateFlow.test { + // Populate and consume items related to the ciphers flow + awaitItem() + ciphersFlow.tryEmit(listOf(createMockCipher(number = number))) + awaitItem() mutableSyncCipherUpsertFlow.tryEmit( SyncCipherUpsertData( cipherId = cipherId, revisionDate = ZonedDateTime.now(), isUpdate = false, + collectionIds = null, + organizationId = null, ), ) - coVerify(exactly = 1) { - ciphersService.getCipher(cipherId) - vaultDiskSource.saveCipher( - userId = MOCK_USER_STATE.activeUserId, - cipher = cipher, - ) - } } + coVerify(exactly = 0) { + ciphersService.getCipher(any()) + vaultDiskSource.saveCipher(any(), any()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncCipherUpsertFlow create with local cipher, and with common collections, should make a request and save cipher to disk`() = runTest { + val number = 1 + val cipherId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val cipherView = createMockCipherView(number = number) + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(createMockSdkCipher(number = number)), + ) + } returns listOf(cipherView).asSuccess() + val collectionView = createMockCollectionView(number = number) + coEvery { + vaultSdkSource.decryptCollectionList( + userId = MOCK_USER_STATE.activeUserId, + collectionList = listOf(createMockSdkCollection(number = number)), + ) + } returns listOf(collectionView).asSuccess() + + val cipher: SyncResponseJson.Cipher = mockk() + coEvery { + ciphersService.getCipher(cipherId) + } returns cipher.asSuccess() + coEvery { + vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher) + } just runs + + val ciphersFlow = bufferedMutableSharedFlow>() + val collectionsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows( + ciphersFlow = ciphersFlow, + collectionsFlow = collectionsFlow, + ) + + turbineScope { + val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope) + val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope) + + // Populate and consume items related to the ciphers flow + ciphersStateFlow.awaitItem() + ciphersFlow.tryEmit(listOf(createMockCipher(number = number))) + ciphersStateFlow.awaitItem() + + // Populate and consume items related to the collections flow + collectionsStateFlow.awaitItem() + collectionsFlow.tryEmit(listOf(createMockCollection(number = number))) + collectionsStateFlow.awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.now(), + isUpdate = false, + collectionIds = listOf("mockId-1"), + organizationId = "mock-id", + ), + ) + } + + coVerify(exactly = 1) { + ciphersService.getCipher(any()) + vaultDiskSource.saveCipher(any(), any()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncCipherUpsertFlow update with no local cipher, but with common collections, should make a request save cipher to disk`() = runTest { + val number = 1 + val cipherId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(), + ) + } returns listOf().asSuccess() + val collectionView = createMockCollectionView(number = number) + coEvery { + vaultSdkSource.decryptCollectionList( + userId = MOCK_USER_STATE.activeUserId, + collectionList = listOf(createMockSdkCollection(number = number)), + ) + } returns listOf(collectionView).asSuccess() + + val cipher: SyncResponseJson.Cipher = mockk() + coEvery { + ciphersService.getCipher(cipherId) + } returns cipher.asSuccess() + coEvery { + vaultDiskSource.saveCipher(userId = MOCK_USER_STATE.activeUserId, cipher = cipher) + } just runs + + val ciphersFlow = bufferedMutableSharedFlow>() + val collectionsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows( + ciphersFlow = ciphersFlow, + collectionsFlow = collectionsFlow, + ) + + turbineScope { + val ciphersStateFlow = vaultRepository.ciphersStateFlow.testIn(backgroundScope) + val collectionsStateFlow = vaultRepository.collectionsStateFlow.testIn(backgroundScope) + + // Populate and consume items related to the ciphers flow + ciphersStateFlow.awaitItem() + ciphersFlow.tryEmit(listOf()) + ciphersStateFlow.awaitItem() + + // Populate and consume items related to the collections flow + collectionsStateFlow.awaitItem() + collectionsFlow.tryEmit(listOf(createMockCollection(number = number))) + collectionsStateFlow.awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + collectionIds = listOf("mockId-1"), + organizationId = "mock-id", + ), + ) + } + + coVerify(exactly = 1) { + ciphersService.getCipher(any()) + vaultDiskSource.saveCipher(any(), any()) + } + } + + @Test + fun `syncCipherUpsertFlow update with no local cipher should do nothing`() = runTest { + val number = 1 + val cipherId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(), + ) + } returns listOf().asSuccess() + + val ciphersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + + vaultRepository.ciphersStateFlow.test { + // Populate and consume items related to the ciphers flow + awaitItem() + ciphersFlow.tryEmit(listOf()) + awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + collectionIds = null, + organizationId = null, + ), + ) + } + + coVerify(exactly = 0) { + ciphersService.getCipher(any()) + vaultDiskSource.saveCipher(any(), any()) + } + } + + @Test + fun `syncCipherUpsertFlow update with more recent local cipher should do nothing`() = runTest { + val number = 1 + val cipherId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val cipherView = createMockCipherView(number = number) + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(createMockSdkCipher(number = number)), + ) + } returns listOf(cipherView).asSuccess() + + val ciphersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + + vaultRepository.ciphersStateFlow.test { + // Populate and consume items related to the cipher flow + awaitItem() + ciphersFlow.tryEmit(listOf(createMockCipher(number = number))) + awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.ofInstant( + Instant.ofEpochSecond(0), ZoneId.of("UTC"), + ), + isUpdate = true, + collectionIds = null, + organizationId = null, + ), + ) + } + + coVerify(exactly = 0) { + ciphersService.getCipher(any()) + vaultDiskSource.saveCipher(any(), any()) + } + } + @Suppress("MaxLineLength") @Test fun `syncCipherUpsertFlow update failure with 404 code should make a request for a cipher and then delete it`() = runTest { - every { - pushManager.syncCipherUpsertFlow - } - - val cipherId = "mockId-1" - - fakeAuthDiskSource.userState = MOCK_USER_STATE + val number = 1 + val cipherId = "mockId-$number" val response: HttpException = mockk { every { code() } returns 404 @@ -3673,13 +3936,35 @@ class VaultRepositoryTest { vaultDiskSource.deleteCipher(any(), any()) } just runs - mutableSyncCipherUpsertFlow.tryEmit( - SyncCipherUpsertData( - cipherId = cipherId, - revisionDate = ZonedDateTime.now(), - isUpdate = true, - ), - ) + fakeAuthDiskSource.userState = MOCK_USER_STATE + val cipherView = createMockCipherView(number = number) + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(createMockSdkCipher(number = number)), + ) + } returns listOf(cipherView).asSuccess() + + val ciphersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + + vaultRepository.ciphersStateFlow.test { + // Populate and consume items related to the ciphers flow + awaitItem() + ciphersFlow.tryEmit(listOf(createMockCipher(number = number))) + awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + collectionIds = null, + organizationId = null, + ), + ) + } + coVerify(exactly = 1) { ciphersService.getCipher(cipherId) vaultDiskSource.deleteCipher( @@ -3704,13 +3989,33 @@ class VaultRepositoryTest { ciphersService.getCipher(cipherId) } returns response.asFailure() - mutableSyncCipherUpsertFlow.tryEmit( - SyncCipherUpsertData( - cipherId = cipherId, - revisionDate = ZonedDateTime.now(), - isUpdate = false, - ), - ) + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(), + ) + } returns emptyList().asSuccess() + + val ciphersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + + vaultRepository.ciphersStateFlow.test { + // Populate and consume items related to the ciphers flow + awaitItem() + ciphersFlow.tryEmit(emptyList()) + awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.now(), + isUpdate = false, + collectionIds = null, + organizationId = null, + ), + ) + } + coVerify(exactly = 1) { ciphersService.getCipher(cipherId) } @@ -3722,22 +4027,151 @@ class VaultRepositoryTest { } } + @Suppress("MaxLineLength") @Test - fun `syncSendUpsertFlow success should make a request for a send and then store it`() = + fun `syncCipherUpsertFlow valid create success should make a request for a cipher and then store it`() = runTest { - val sendId = "mockId-1" - val send: SyncResponseJson.Send = mockk() + val number = 1 + val cipherId = "mockId-$number" fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(), + ) + } returns listOf().asSuccess() + + val ciphersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + + val cipher: SyncResponseJson.Cipher = mockk() + coEvery { + ciphersService.getCipher(cipherId) + } returns cipher.asSuccess() coEvery { - sendsService.getSend(sendId) - } returns send.asSuccess() - - coEvery { - vaultDiskSource.saveSend(any(), any()) + vaultDiskSource.saveCipher(any(), any()) } just runs + vaultRepository.ciphersStateFlow.test { + // Populate and consume items related to the ciphers flow + awaitItem() + ciphersFlow.tryEmit(listOf()) + awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.now(), + isUpdate = false, + collectionIds = null, + organizationId = null, + ), + ) + } + + coVerify(exactly = 1) { + ciphersService.getCipher(cipherId) + vaultDiskSource.saveCipher( + userId = MOCK_USER_STATE.activeUserId, + cipher = cipher, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncCipherUpsertFlow valid update success should make a request for a cipher and then store it`() = + runTest { + val number = 1 + val cipherId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val cipherView = createMockCipherView(number = number) + coEvery { + vaultSdkSource.decryptCipherList( + userId = MOCK_USER_STATE.activeUserId, + cipherList = listOf(createMockSdkCipher(number = number)), + ) + } returns listOf(cipherView).asSuccess() + + val ciphersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(ciphersFlow = ciphersFlow) + + val cipher: SyncResponseJson.Cipher = mockk() + coEvery { + ciphersService.getCipher(cipherId) + } returns cipher.asSuccess() + + coEvery { + vaultDiskSource.saveCipher(any(), any()) + } just runs + + vaultRepository.ciphersStateFlow.test { + // Populate and consume items related to the ciphers flow + awaitItem() + ciphersFlow.tryEmit(listOf(createMockCipher(number = number))) + awaitItem() + + mutableSyncCipherUpsertFlow.tryEmit( + SyncCipherUpsertData( + cipherId = cipherId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + collectionIds = null, + organizationId = null, + ), + ) + } + + coVerify(exactly = 1) { + ciphersService.getCipher(cipherId) + vaultDiskSource.saveCipher( + userId = MOCK_USER_STATE.activeUserId, + cipher = cipher, + ) + } + } + + @Test + fun `syncSendDeleteFlow should delete send from disk`() { + val userId = "mockId-1" + val sendId = "mockId-1" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) } just runs + + mutableSyncSendDeleteFlow.tryEmit( + SyncSendDeleteData(sendId = sendId), + ) + + coVerify { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) } + } + + @Test + fun `syncSendUpsertFlow create with local send should do nothing`() = runTest { + val number = 1 + val sendId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val sendView = createMockSendView(number = number) + coEvery { + vaultSdkSource.decryptSendList( + userId = MOCK_USER_STATE.activeUserId, + sendList = listOf(createMockSdkSend(number = number)), + ) + } returns listOf(sendView).asSuccess() + + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(sendsFlow = sendsFlow) + + vaultRepository.sendDataStateFlow.test { + // Populate and consume items related to the sends flow + awaitItem() + sendsFlow.tryEmit(listOf(createMockSend(number = number))) + awaitItem() + mutableSyncSendUpsertFlow.tryEmit( SyncSendUpsertData( sendId = sendId, @@ -3745,22 +4179,97 @@ class VaultRepositoryTest { isUpdate = false, ), ) - coVerify(exactly = 1) { - sendsService.getSend(sendId) - vaultDiskSource.saveSend( - userId = MOCK_USER_STATE.activeUserId, - send = send, - ) - } } + coVerify(exactly = 0) { + sendsService.getSend(any()) + vaultDiskSource.saveSend(any(), any()) + } + } + + @Test + fun `syncSendUpsertFlow update with no local send should do nothing`() = runTest { + val number = 1 + val sendId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.decryptSendList( + userId = MOCK_USER_STATE.activeUserId, + sendList = listOf(), + ) + } returns listOf().asSuccess() + + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(sendsFlow = sendsFlow) + + vaultRepository.sendDataStateFlow.test { + // Populate and consume items related to the sends flow + awaitItem() + sendsFlow.tryEmit(listOf()) + awaitItem() + + mutableSyncSendUpsertFlow.tryEmit( + SyncSendUpsertData( + sendId = sendId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + ), + ) + } + + coVerify(exactly = 0) { + sendsService.getSend(any()) + vaultDiskSource.saveSend(any(), any()) + } + } + + @Test + fun `syncSendUpsertFlow update with more recent local send should do nothing`() = runTest { + val number = 1 + val sendId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val sendView = createMockSendView(number = number) + coEvery { + vaultSdkSource.decryptSendList( + userId = MOCK_USER_STATE.activeUserId, + sendList = listOf(createMockSdkSend(number = number)), + ) + } returns listOf(sendView).asSuccess() + + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(sendsFlow = sendsFlow) + + vaultRepository.sendDataStateFlow.test { + // Populate and consume items related to the send flow + awaitItem() + sendsFlow.tryEmit(listOf(createMockSend(number = number))) + awaitItem() + + mutableSyncSendUpsertFlow.tryEmit( + SyncSendUpsertData( + sendId = sendId, + revisionDate = ZonedDateTime.ofInstant( + Instant.ofEpochSecond(0), ZoneId.of("UTC"), + ), + isUpdate = true, + ), + ) + } + + coVerify(exactly = 0) { + sendsService.getSend(any()) + vaultDiskSource.saveSend(any(), any()) + } + } + @Suppress("MaxLineLength") @Test fun `syncSendUpsertFlow update failure with 404 code should make a request for a send and then delete it`() = runTest { - val sendId = "mockId-1" - - fakeAuthDiskSource.userState = MOCK_USER_STATE + val number = 1 + val sendId = "mockId-$number" val response: HttpException = mockk { every { code() } returns 404 @@ -3773,13 +4282,33 @@ class VaultRepositoryTest { vaultDiskSource.deleteSend(any(), any()) } just runs - mutableSyncSendUpsertFlow.tryEmit( - SyncSendUpsertData( - sendId = sendId, - revisionDate = ZonedDateTime.now(), - isUpdate = true, - ), - ) + fakeAuthDiskSource.userState = MOCK_USER_STATE + val sendView = createMockSendView(number = number) + coEvery { + vaultSdkSource.decryptSendList( + userId = MOCK_USER_STATE.activeUserId, + sendList = listOf(createMockSdkSend(number = number)), + ) + } returns listOf(sendView).asSuccess() + + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(sendsFlow = sendsFlow) + + vaultRepository.sendDataStateFlow.test { + // Populate and consume items related to the sends flow + awaitItem() + sendsFlow.tryEmit(listOf(createMockSend(number = number))) + awaitItem() + + mutableSyncSendUpsertFlow.tryEmit( + SyncSendUpsertData( + sendId = sendId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + ), + ) + } + coVerify(exactly = 1) { sendsService.getSend(sendId) vaultDiskSource.deleteSend( @@ -3804,13 +4333,31 @@ class VaultRepositoryTest { sendsService.getSend(sendId) } returns response.asFailure() - mutableSyncSendUpsertFlow.tryEmit( - SyncSendUpsertData( - sendId = sendId, - revisionDate = ZonedDateTime.now(), - isUpdate = false, - ), - ) + coEvery { + vaultSdkSource.decryptSendList( + userId = MOCK_USER_STATE.activeUserId, + sendList = listOf(), + ) + } returns emptyList().asSuccess() + + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(sendsFlow = sendsFlow) + + vaultRepository.sendDataStateFlow.test { + // Populate and consume items related to the sends flow + awaitItem() + sendsFlow.tryEmit(emptyList()) + awaitItem() + + mutableSyncSendUpsertFlow.tryEmit( + SyncSendUpsertData( + sendId = sendId, + revisionDate = ZonedDateTime.now(), + isUpdate = false, + ), + ) + } + coVerify(exactly = 1) { sendsService.getSend(sendId) } @@ -3824,21 +4371,151 @@ class VaultRepositoryTest { @Suppress("MaxLineLength") @Test - fun `mutableSyncFolderUpsertFlow success should make a request for a folder and then store it`() = + fun `syncSendUpsertFlow valid create success should make a request for a send and then store it`() = runTest { - val folderId = "mockId-1" - val folder: SyncResponseJson.Folder = mockk() + val number = 1 + val sendId = "mockId-$number" fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.decryptSendList( + userId = MOCK_USER_STATE.activeUserId, + sendList = listOf(), + ) + } returns listOf().asSuccess() + + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(sendsFlow = sendsFlow) + + val send: SyncResponseJson.Send = mockk() + coEvery { + sendsService.getSend(sendId) + } returns send.asSuccess() coEvery { - folderService.getFolder(folderId) - } returns folder.asSuccess() - - coEvery { - vaultDiskSource.saveFolder(any(), any()) + vaultDiskSource.saveSend(any(), any()) } just runs + vaultRepository.sendDataStateFlow.test { + // Populate and consume items related to the sends flow + awaitItem() + sendsFlow.tryEmit(listOf()) + awaitItem() + + mutableSyncSendUpsertFlow.tryEmit( + SyncSendUpsertData( + sendId = sendId, + revisionDate = ZonedDateTime.now(), + isUpdate = false, + ), + ) + } + + coVerify(exactly = 1) { + sendsService.getSend(sendId) + vaultDiskSource.saveSend( + userId = MOCK_USER_STATE.activeUserId, + send = send, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncSendUpsertFlow valid update success should make a request for a send and then store it`() = + runTest { + val number = 1 + val sendId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val sendView = createMockSendView(number = number) + coEvery { + vaultSdkSource.decryptSendList( + userId = MOCK_USER_STATE.activeUserId, + sendList = listOf(createMockSdkSend(number = number)), + ) + } returns listOf(sendView).asSuccess() + + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(sendsFlow = sendsFlow) + + val send: SyncResponseJson.Send = mockk() + coEvery { + sendsService.getSend(sendId) + } returns send.asSuccess() + + coEvery { + vaultDiskSource.saveSend(any(), any()) + } just runs + + vaultRepository.sendDataStateFlow.test { + // Populate and consume items related to the sends flow + awaitItem() + sendsFlow.tryEmit(listOf(createMockSend(number = number))) + awaitItem() + + mutableSyncSendUpsertFlow.tryEmit( + SyncSendUpsertData( + sendId = sendId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + ), + ) + } + + coVerify(exactly = 1) { + sendsService.getSend(sendId) + vaultDiskSource.saveSend( + userId = MOCK_USER_STATE.activeUserId, + send = send, + ) + } + } + + @Test + fun `syncFolderDeleteFlow should delete folder from disk and update ciphers`() { + val userId = "mockId-1" + val folderId = "mockId-1" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) } just runs + coEvery { + vaultDiskSource.getCiphers(userId) + } returns flowOf() + + mutableSyncFolderDeleteFlow.tryEmit( + SyncFolderDeleteData(folderId = folderId), + ) + + coVerify { + vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) + vaultDiskSource.getCiphers(userId) + } + } + + @Test + fun `syncFolderUpsertFlow create with local folder should do nothing`() = runTest { + val number = 1 + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = createMockFolderView(number = number) + coEvery { + vaultSdkSource.decryptFolderList( + userId = MOCK_USER_STATE.activeUserId, + folderList = listOf(createMockSdkFolder(number = number)), + ) + } returns listOf(folderView).asSuccess() + + val foldersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(foldersFlow = foldersFlow) + + vaultRepository.foldersStateFlow.test { + // Populate and consume items related to the folders flow + awaitItem() + foldersFlow.tryEmit(listOf(createMockFolder(number = number))) + awaitItem() + mutableSyncFolderUpsertFlow.tryEmit( SyncFolderUpsertData( folderId = folderId, @@ -3846,6 +4523,185 @@ class VaultRepositoryTest { isUpdate = false, ), ) + } + + coVerify(exactly = 0) { + folderService.getFolder(any()) + vaultDiskSource.saveFolder(any(), any()) + } + } + + @Test + fun `syncFolderUpsertFlow update with no local folder should do nothing`() = runTest { + val number = 1 + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.decryptFolderList( + userId = MOCK_USER_STATE.activeUserId, + folderList = listOf(), + ) + } returns listOf().asSuccess() + + val foldersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(foldersFlow = foldersFlow) + + vaultRepository.foldersStateFlow.test { + // Populate and consume items related to the folders flow + awaitItem() + foldersFlow.tryEmit(listOf()) + awaitItem() + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + ), + ) + } + + coVerify(exactly = 0) { + folderService.getFolder(any()) + vaultDiskSource.saveFolder(any(), any()) + } + } + + @Test + fun `syncFolderUpsertFlow update with more recent local folder should do nothing`() = runTest { + val number = 1 + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = createMockFolderView(number = number) + coEvery { + vaultSdkSource.decryptFolderList( + userId = MOCK_USER_STATE.activeUserId, + folderList = listOf(createMockSdkFolder(number = number)), + ) + } returns listOf(folderView).asSuccess() + + val foldersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(foldersFlow = foldersFlow) + + vaultRepository.foldersStateFlow.test { + // Populate and consume items related to the folders flow + awaitItem() + foldersFlow.tryEmit(listOf(createMockFolder(number = number))) + awaitItem() + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.ofInstant( + Instant.ofEpochSecond(0), ZoneId.of("UTC"), + ), + isUpdate = true, + ), + ) + } + + coVerify(exactly = 0) { + folderService.getFolder(any()) + vaultDiskSource.saveFolder(any(), any()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncFolderUpsertFlow valid create success should make a request for a folder and then store it`() = + runTest { + val number = 1 + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { + vaultSdkSource.decryptFolderList( + userId = MOCK_USER_STATE.activeUserId, + folderList = listOf(), + ) + } returns listOf().asSuccess() + + val foldersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(foldersFlow = foldersFlow) + + val folder: SyncResponseJson.Folder = mockk() + coEvery { + folderService.getFolder(folderId) + } returns folder.asSuccess() + + coEvery { + vaultDiskSource.saveFolder(any(), any()) + } just runs + + vaultRepository.foldersStateFlow.test { + // Populate and consume items related to the folders flow + awaitItem() + foldersFlow.tryEmit(listOf()) + awaitItem() + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.now(), + isUpdate = false, + ), + ) + } + + coVerify(exactly = 1) { + folderService.getFolder(folderId) + vaultDiskSource.saveFolder( + userId = MOCK_USER_STATE.activeUserId, + folder = folder, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `syncFolderUpsertFlow valid update success should make a request for a folder and then store it`() = + runTest { + val number = 1 + val folderId = "mockId-$number" + + fakeAuthDiskSource.userState = MOCK_USER_STATE + val folderView = createMockFolderView(number = number) + coEvery { + vaultSdkSource.decryptFolderList( + userId = MOCK_USER_STATE.activeUserId, + folderList = listOf(createMockSdkFolder(number = number)), + ) + } returns listOf(folderView).asSuccess() + + val foldersFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows(foldersFlow = foldersFlow) + + val folder: SyncResponseJson.Folder = mockk() + coEvery { + folderService.getFolder(folderId) + } returns folder.asSuccess() + + coEvery { + vaultDiskSource.saveFolder(any(), any()) + } just runs + + vaultRepository.foldersStateFlow.test { + // Populate and consume items related to the folders flow + awaitItem() + foldersFlow.tryEmit(listOf(createMockFolder(number = number))) + awaitItem() + + mutableSyncFolderUpsertFlow.tryEmit( + SyncFolderUpsertData( + folderId = folderId, + revisionDate = ZonedDateTime.now(), + isUpdate = true, + ), + ) + } + coVerify(exactly = 1) { folderService.getFolder(folderId) vaultDiskSource.saveFolder(