diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/AddressBookApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/AddressBookApi.kt new file mode 100644 index 0000000000..d2a31680a2 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/AddressBookApi.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.api.addressbook + +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse +import com.tangem.datasource.api.common.response.ApiResponse +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.PUT +import retrofit2.http.POST +import retrofit2.http.Path + +interface AddressBookApi { + + @POST("v1/address-books/sync") + suspend fun syncAddressBooks(@Body body: SyncAddressBooksRequest): ApiResponse + + @PUT("v1/address-books/{walletId}") + suspend fun updateAddressBook( + @Path("walletId") walletId: String, + @Header("If-Match") eTag: String?, + @Body body: UpdateAddressBookRequest, + ): ApiResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksRequest.kt new file mode 100644 index 0000000000..f6b28a0dd6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksRequest.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for `POST /address-books/sync`. + * + * Each [Wallet.etag] is optional: when it matches the backend's etag, that wallet is omitted from the + * response and the local copy is kept. + */ +@JsonClass(generateAdapter = true) +data class SyncAddressBooksRequest( + @Json(name = "wallets") val wallets: List, +) { + + @JsonClass(generateAdapter = true) + data class Wallet( + @Json(name = "walletId") val walletId: String, + @Json(name = "etag") val etag: String? = null, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksResponse.kt new file mode 100644 index 0000000000..c1d186c4fa --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksResponse.kt @@ -0,0 +1,27 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response body for `POST /address-books/sync`. + * + * [items] contains only the wallets whose backend etag differs from the one sent in the request; wallets + * with a matching etag are omitted and their local copy must be kept. + */ +@JsonClass(generateAdapter = true) +data class SyncAddressBooksResponse( + @Json(name = "items") val items: List, +) { + + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "walletId") val walletId: String, + @Json(name = "etag") val etag: String, + @Json(name = "version") val version: String, + @Json(name = "updatedAt") val updatedAt: String, + @Json(name = "nonce") val nonce: String, + @Json(name = "ciphertext") val ciphertext: String, + @Json(name = "authTag") val authTag: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookRequest.kt new file mode 100644 index 0000000000..ddbabd6f2e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookRequest.kt @@ -0,0 +1,13 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Request body for `PUT /address-books/{walletId}`. */ +@JsonClass(generateAdapter = true) +data class UpdateAddressBookRequest( + @Json(name = "version") val version: String, + @Json(name = "nonce") val nonce: String, + @Json(name = "ciphertext") val ciphertext: String, + @Json(name = "authTag") val authTag: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookResponse.kt new file mode 100644 index 0000000000..48dbfc538c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookResponse.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Response body for `PUT /address-books/{walletId}`. */ +@JsonClass(generateAdapter = true) +data class UpdateAddressBookResponse( + @Json(name = "walletId") val walletId: String, + @Json(name = "etag") val etag: String, + @Json(name = "updatedAt") val updatedAt: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index f937877421..cf0af7d6f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.addressbook.AddressBookApi import com.tangem.datasource.api.auth.AuthApi import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.surveysparrow.SurveySparrowApi @@ -118,6 +119,16 @@ internal object NetworkModule { ) } + @Provides + @Singleton + fun provideAddressBookApi(retrofitApiBuilder: RetrofitApiBuilder): AddressBookApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.TangemTech, + applyTimeoutAnnotations = false, + sessionAuth = false, + ) + } + @Provides @Singleton fun provideYieldSupplyApi(retrofitApiBuilder: RetrofitApiBuilder): YieldSupplyApi { diff --git a/data/address-book/build.gradle.kts b/data/address-book/build.gradle.kts index 734a559f8c..31c3011678 100644 --- a/data/address-book/build.gradle.kts +++ b/data/address-book/build.gradle.kts @@ -16,6 +16,10 @@ dependencies { implementation(projects.core.utils) // endregion + // region Project - Data + implementation(projects.data.common) + // endregion + // region Project - Domain implementation(projects.domain.addressBook) implementation(projects.domain.common) diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt index 8ce2414259..ae71fcc84f 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt @@ -1,7 +1,20 @@ package com.tangem.data.addressbook +import arrow.core.Either +import arrow.core.flatMap +import arrow.core.left +import arrow.core.right import com.tangem.data.addressbook.store.AddressBookBlobStore +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.addressbook.AddressBookApi +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.model.AddressBook import com.tangem.domain.addressbook.model.AddressBookBlob import com.tangem.domain.addressbook.model.Contact @@ -12,6 +25,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -19,14 +33,18 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.joda.time.DateTime +@Suppress("LongParameterList") internal class DefaultAddressBookRepository( private val blobStore: AddressBookBlobStore, private val cipher: AddressBookCipher, + private val addressBookApi: AddressBookApi, + private val eTagsStore: ETagsStore, private val userWalletsListRepository: UserWalletsListRepository, private val timestampProvider: IsoTimestampProvider, private val dispatchers: CoroutineDispatcherProvider, @@ -36,6 +54,7 @@ internal class DefaultAddressBookRepository( override fun getContacts(userWalletId: UserWalletId): Flow> { return getContactsForWallet(userWalletId) + .onStart { syncAddressBooks() } .distinctUntilChanged() .flowOn(dispatchers.default) } @@ -55,6 +74,7 @@ internal class DefaultAddressBookRepository( } } } + .onStart { syncAddressBooks() } .distinctUntilChanged() .flowOn(dispatchers.default) } @@ -73,27 +93,68 @@ internal class DefaultAddressBookRepository( decryptContacts(blob, userWallet).find { it.name.value == name } } - override suspend fun saveContact(contact: Contact) = withContext(dispatchers.default) { - writeMutex.withLock { - val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@withLock - val current = currentContacts(contact.walletId, userWallet) - val merged = current.filterNot { it.id == contact.id } + contact - persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged)) - } - } - - override suspend fun deleteContact(id: ContactId) = withContext(dispatchers.default) { - writeMutex.withLock { - userWalletsListRepository.userWalletsSync().forEach { userWallet -> - val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach - val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach - if (addressBook.contacts.none { it.id == id }) return@forEach - - val remaining = addressBook.contacts.filterNot { it.id == id } - persist(userWallet, addressBook.copy(contacts = remaining)) - return@withLock + override suspend fun saveContact(contact: Contact): Either = + withContext(dispatchers.default) { + writeMutex.withLock { + val userWallet = findUserWallet(contact.walletId.stringValue) + ?: return@withLock AddressBookSyncError.Unknown.left() + val current = currentContacts(contact.walletId, userWallet) + val merged = current.filterNot { it.id == contact.id } + contact + persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged)) } } + + override suspend fun deleteContact(id: ContactId): Either = + withContext(dispatchers.default) { + writeMutex.withLock { + userWalletsListRepository.userWalletsSync().forEach { userWallet -> + val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach + val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach + if (addressBook.contacts.none { it.id == id }) return@forEach + + val remaining = addressBook.contacts.filterNot { it.id == id } + return@withLock persist(userWallet, addressBook.copy(contacts = remaining)) + } + // No wallet held the contact — nothing to push, treat as success. + Unit.right() + } + } + + override suspend fun syncAddressBooks(): Either = withContext(dispatchers.default) { + val wallets = userWalletsListRepository.userWalletsSync() + // The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the + // first failed chunk. + wallets.chunked(MAX_SYNC_WALLETS) + .fold(initial = Unit.right() as Either) { acc, chunk -> + acc.flatMap { syncWalletsChunk(chunk) } + } + } + + private suspend fun syncWalletsChunk(wallets: List): Either { + val request = SyncAddressBooksRequest( + wallets = wallets.map { wallet -> + SyncAddressBooksRequest.Wallet( + walletId = wallet.walletId.stringValue, + etag = eTagsStore.getSyncOrNull(wallet.walletId, ETagsStore.Key.AddressBook), + ) + }, + ) + return safeApiCall( + call = { + val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() } + // Only wallets whose etag changed are returned; the rest keep their local copy. + response.items.forEach { item -> + val userWalletId = UserWalletId(stringValue = item.walletId) + blobStore.storeBlob(item.toBlob()) + eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag) + } + Unit.right() + }, + onError = { error -> + TangemLogger.e(messageString = "Failed to sync address books: $error") + error.toSyncError().left() + }, + ) } private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List { @@ -105,12 +166,80 @@ internal class DefaultAddressBookRepository( return decryptContacts(blob, userWallet) } - private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook) { + /** + * Encrypts [addressBook], pushes it to the backend, and persists it locally **only** on success. + * On any failure (encryption, network, etag conflict, …) nothing is written locally. + */ + private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook): Either { val updatedAt = DateTime.parse(timestampProvider.now()) - cipher.encrypt(addressBook, userWallet, updatedAt) - .onRight { blobStore.storeBlob(it) } + return cipher.encrypt(addressBook, userWallet, updatedAt) + .mapLeft { error -> + TangemLogger.e( + messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error", + ) + AddressBookSyncError.Unknown + } + .flatMap { blob -> pushBlob(addressBook.walletId, blob) } + } + + private suspend fun pushBlob( + userWalletId: UserWalletId, + blob: AddressBookBlob, + ): Either { + // Absent etag means the book has not been created on the backend yet → omit If-Match to create it. + val eTag = eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.AddressBook) + return safeApiCall( + call = { + val response = withContext(dispatchers.io) { + addressBookApi.updateAddressBook( + walletId = blob.walletId, + eTag = eTag, + body = UpdateAddressBookRequest( + version = blob.version, + nonce = blob.nonce, + ciphertext = blob.ciphertext, + authTag = blob.authTag, + ), + ).bind() + } + blobStore.storeBlob(blob) + eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, response.etag) + Unit.right() + }, + onError = { error -> + TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error") + error.toSyncError().left() + }, + ) + } + + private fun SyncAddressBooksResponse.Item.toBlob(): AddressBookBlob = AddressBookBlob( + version = version, + walletId = walletId, + updatedAt = updatedAt, + nonce = nonce, + ciphertext = ciphertext, + authTag = authTag, + ) + + private fun ApiResponseError.toSyncError(): AddressBookSyncError = when (this) { + is ApiResponseError.HttpException -> when (code) { + Code.PRECONDITION_FAILED -> AddressBookSyncError.Conflict + Code.NOT_FOUND -> AddressBookSyncError.NotFound + Code.UNAUTHORIZED -> AddressBookSyncError.Unauthorized + Code.BAD_REQUEST -> AddressBookSyncError.BadRequest + else -> AddressBookSyncError.Unknown + } + is ApiResponseError.NetworkException, + is ApiResponseError.TimeoutException, + -> AddressBookSyncError.Network + is ApiResponseError.UnknownException -> AddressBookSyncError.Unknown } private suspend fun findUserWallet(walletId: String): UserWallet? = userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId } + + private companion object { + const val MAX_SYNC_WALLETS = 20 + } } \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt index ce181b3417..118fa554d2 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt @@ -6,9 +6,11 @@ import androidx.datastore.dataStoreFile import com.tangem.data.addressbook.DefaultAddressBookRepository import com.tangem.data.addressbook.store.AddressBookBlobStore import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore -import com.tangem.data.addressbook.store.StoredAddressBookBlob +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.addressbook.AddressBookApi import com.tangem.datasource.utils.KotlinxDataStoreSerializer import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.model.AddressBookBlob import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -39,7 +41,7 @@ internal object AddressBookDataModule { defaultValue = emptyMap(), serializer = MapSerializer( keySerializer = String.serializer(), - valueSerializer = StoredAddressBookBlob.serializer(), + valueSerializer = AddressBookBlob.serializer(), ), ), produceFile = { context.dataStoreFile(fileName = "address_book_blobs") }, @@ -50,9 +52,12 @@ internal object AddressBookDataModule { @Provides @Singleton + @Suppress("LongParameterList") fun provideAddressBookRepository( blobStore: AddressBookBlobStore, cipher: AddressBookCipher, + addressBookApi: AddressBookApi, + eTagsStore: ETagsStore, userWalletsListRepository: UserWalletsListRepository, timestampProvider: IsoTimestampProvider, dispatchers: CoroutineDispatcherProvider, @@ -60,6 +65,8 @@ internal object AddressBookDataModule { return DefaultAddressBookRepository( blobStore = blobStore, cipher = cipher, + addressBookApi = addressBookApi, + eTagsStore = eTagsStore, userWalletsListRepository = userWalletsListRepository, timestampProvider = timestampProvider, dispatchers = dispatchers, diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt index de3e180d81..577fe407fd 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt @@ -12,14 +12,7 @@ interface AddressBookBlobStore { suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? - /** Persists [blob] optimistically with `isBESynchronized = false`. Keyed by [AddressBookBlob.walletId]. */ suspend fun storeBlob(blob: AddressBookBlob) - /** Flips the BE-sync flag to `true` once the backend confirms the push. No-op if the blob is absent. */ - suspend fun markAsSynchronized(userWalletId: UserWalletId) - - /** Blobs still pending a backend push — the entry point for the future sync service. */ - suspend fun getUnsynchronizedBlobs(): List - suspend fun deleteBlob(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt index 8aa6c8558e..829f5d3d68 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -internal typealias AddressBookBlobs = Map +internal typealias AddressBookBlobs = Map internal class DefaultAddressBookBlobStore( private val dataStore: DataStore, @@ -16,38 +16,23 @@ internal class DefaultAddressBookBlobStore( override fun getBlob(userWalletId: UserWalletId): Flow { return dataStore.data - .map { it[userWalletId.stringValue]?.blob } + .map { it[userWalletId.stringValue] } .distinctUntilChanged() } override fun getBlobs(userWalletIds: Set): Flow> { val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue } return dataStore.data - .map { stored -> stored.filterKeys { it in ids }.values.map { it.blob } } + .map { stored -> stored.filterKeys { it in ids }.values.toList() } .distinctUntilChanged() } override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? { - return getStoredBlobs()[userWalletId.stringValue]?.blob + return getStoredBlobs()[userWalletId.stringValue] } override suspend fun storeBlob(blob: AddressBookBlob) { - dataStore.updateData { stored -> - stored + (blob.walletId to StoredAddressBookBlob(blob = blob, isBESynchronized = false)) - } - } - - override suspend fun markAsSynchronized(userWalletId: UserWalletId) { - dataStore.updateData { stored -> - val current = stored[userWalletId.stringValue] ?: return@updateData stored - stored + (userWalletId.stringValue to current.copy(isBESynchronized = true)) - } - } - - override suspend fun getUnsynchronizedBlobs(): List { - return getStoredBlobs().values - .filterNot { it.isBESynchronized } - .map { it.blob } + dataStore.updateData { stored -> stored + (blob.walletId to blob) } } override suspend fun deleteBlob(userWalletId: UserWalletId) { diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt deleted file mode 100644 index 9e82e17205..0000000000 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.data.addressbook.store - -import com.tangem.domain.addressbook.model.AddressBookBlob -import kotlinx.serialization.Serializable - -/** - * [isBESynchronized] tracks whether the blob has already been pushed to the backend. A freshly - * stored blob is written optimistically with `false`; a future BE-sync service flips it to `true` - * once the push is confirmed. - */ -@Serializable -internal data class StoredAddressBookBlob( - val blob: AddressBookBlob, - val isBESynchronized: Boolean, -) \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt index 233b709cfa..d43ca1707f 100644 --- a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt @@ -4,8 +4,17 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.data.addressbook.store.AddressBookBlobStore +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.addressbook.AddressBookApi +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.domain.addressbook.crypto.AddressBookCipher import com.tangem.domain.addressbook.error.AddressBookCryptoError +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.model.AddressBook import com.tangem.domain.addressbook.model.AddressBookBlob import com.tangem.domain.addressbook.model.Contact @@ -19,6 +28,7 @@ import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -35,6 +45,8 @@ internal class DefaultAddressBookRepositoryTest { private val blobStore: AddressBookBlobStore = mockk() private val cipher: AddressBookCipher = mockk() + private val addressBookApi: AddressBookApi = mockk() + private val eTagsStore: ETagsStore = mockk(relaxed = true) private val userWalletsListRepository: UserWalletsListRepository = mockk() private val timestampProvider: IsoTimestampProvider = mockk() @@ -45,6 +57,8 @@ internal class DefaultAddressBookRepositoryTest { private val repository = DefaultAddressBookRepository( blobStore = blobStore, cipher = cipher, + addressBookApi = addressBookApi, + eTagsStore = eTagsStore, userWalletsListRepository = userWalletsListRepository, timestampProvider = timestampProvider, dispatchers = TestingCoroutineDispatcherProvider(), @@ -52,9 +66,11 @@ internal class DefaultAddressBookRepositoryTest { @BeforeEach fun setup() { - clearMocks(blobStore, cipher, userWalletsListRepository, timestampProvider) + clearMocks(blobStore, cipher, addressBookApi, eTagsStore, userWalletsListRepository, timestampProvider) every { timestampProvider.now() } returns TIMESTAMP coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + coEvery { addressBookApi.syncAddressBooks(any()) } returns + ApiResponse.Success(SyncAddressBooksResponse(items = emptyList())) } @Test @@ -72,6 +88,24 @@ internal class DefaultAddressBookRepositoryTest { assertThat(result).containsExactly(contact) } + @Test + fun `GIVEN blob WHEN getContacts THEN syncs before reading contacts`() = runTest { + // Arrange + val contact = createContact(id = "c1", name = "Alice") + val blob = createBlob() + every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) + every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + + // Act + repository.getContacts(UserWalletId(WALLET_A)).first() + + // Assert + coVerifyOrder { + addressBookApi.syncAddressBooks(any()) + cipher.decrypt(blob, userWallet) + } + } + @Test fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest { // Arrange @@ -88,6 +122,25 @@ internal class DefaultAddressBookRepositoryTest { assertThat(result).containsExactly(contact) } + @Test + fun `GIVEN blob WHEN getAllContacts THEN syncs before reading contacts`() = runTest { + // Arrange + val contact = createContact(id = "c1", name = "Alice") + val blob = createBlob() + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob)) + every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + + // Act + repository.getAllContacts().first() + + // Assert + coVerifyOrder { + addressBookApi.syncAddressBooks(any()) + cipher.decrypt(blob, userWallet) + } + } + @Test fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest { // Arrange @@ -115,7 +168,7 @@ internal class DefaultAddressBookRepositoryTest { } @Test - fun `GIVEN new contact WHEN saveContact THEN encrypts merged book and stores blob`() = runTest { + fun `GIVEN backend accepts WHEN saveContact THEN pushes merged book and stores blob and etag`() = runTest { // Arrange val existing = createContact(id = "c1", name = "Alice") val added = createContact(id = "c2", name = "Bob") @@ -127,13 +180,82 @@ internal class DefaultAddressBookRepositoryTest { val newBlob = createBlob() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() coEvery { blobStore.storeBlob(newBlob) } returns Unit + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse() // Act - repository.saveContact(added) + val result = repository.saveContact(added) // Assert + assertThat(result).isEqualTo(Unit.right()) assertThat(bookSlot.captured.contacts).containsExactly(existing, added) coVerify(exactly = 1) { blobStore.storeBlob(newBlob) } + coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) } + } + + @Test + fun `GIVEN no stored etag WHEN saveContact THEN PUT is sent without If-Match`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + val newBlob = createBlob() + every { cipher.encrypt(any(), userWallet, any()) } returns newBlob.right() + coEvery { blobStore.storeBlob(any()) } returns Unit + coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns null + coEvery { addressBookApi.updateAddressBook(WALLET_A, null, any()) } returns successPutResponse() + + // Act + val result = repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + assertThat(result).isEqualTo(Unit.right()) + coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, null, any()) } + } + + @Test + fun `GIVEN stored etag WHEN saveContact THEN PUT carries it in If-Match`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { blobStore.storeBlob(any()) } returns Unit + coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns ETAG_OLD + coEvery { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } returns successPutResponse() + + // Act + repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } + } + + @Test + fun `GIVEN etag conflict WHEN saveContact THEN returns Conflict and does not store locally`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns + errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED) + + // Act + val result = repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left()) + coVerify(exactly = 0) { blobStore.storeBlob(any()) } + coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) } + } + + @Test + fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse() + + // Act + val result = repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.Network.left()) + coVerify(exactly = 0) { blobStore.storeBlob(any()) } } @Test @@ -148,6 +270,7 @@ internal class DefaultAddressBookRepositoryTest { val bookSlot = slot() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right() coEvery { blobStore.storeBlob(any()) } returns Unit + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse() // Act repository.saveContact(updated) @@ -157,7 +280,7 @@ internal class DefaultAddressBookRepositoryTest { } @Test - fun `GIVEN contact in wallet WHEN deleteContact THEN re-stores book without it`() = runTest { + fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest { // Arrange val kept = createContact(id = "c1", name = "Alice") val removed = createContact(id = "c2", name = "Bob") @@ -169,15 +292,55 @@ internal class DefaultAddressBookRepositoryTest { val newBlob = createBlob() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() coEvery { blobStore.storeBlob(newBlob) } returns Unit + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse() // Act - repository.deleteContact(ContactId("c2")) + val result = repository.deleteContact(ContactId("c2")) // Assert + assertThat(result).isEqualTo(Unit.right()) assertThat(bookSlot.captured.contacts).containsExactly(kept) coVerify(exactly = 1) { blobStore.storeBlob(newBlob) } } + @Test + fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest { + // Arrange + val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) } + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB) + val requestSlot = slot() + // Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy. + coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns + ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A)))) + val blobSlot = slot() + coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit + + // Act + val result = repository.syncAddressBooks() + + // Assert + assertThat(result).isEqualTo(Unit.right()) + assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B) + assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A) + coVerify(exactly = 1) { blobStore.storeBlob(any()) } + coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) } + coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) } + } + + @Test + fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest { + // Arrange + coEvery { addressBookApi.syncAddressBooks(any()) } returns + errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED) + + // Act + val result = repository.syncAddressBooks() + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left()) + coVerify(exactly = 0) { blobStore.storeBlob(any()) } + } + @Test fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest { // Arrange @@ -195,6 +358,31 @@ internal class DefaultAddressBookRepositoryTest { assertThat(result).isEqualTo(bob) } + private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse = + ApiResponse.Success( + data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP), + ) + + @Suppress("UNCHECKED_CAST") + private fun errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse = + ApiResponse.Error( + cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null), + ) as ApiResponse + + @Suppress("UNCHECKED_CAST") + private fun networkErrorResponse(): ApiResponse = + ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse + + private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item( + walletId = walletId, + etag = ETAG_NEW, + version = AddressBookBlob.CURRENT_VERSION, + updatedAt = TIMESTAMP, + nonce = "00112233445566778899aabb", + ciphertext = "deadbeef", + authTag = "cafebabecafebabecafebabecafebabe", + ) + private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact( id = ContactId(id), walletId = UserWalletId(WALLET_A), @@ -216,6 +404,9 @@ internal class DefaultAddressBookRepositoryTest { private companion object { const val WALLET_A = "0a0a0a" + const val WALLET_B = "0b0b0b" const val TIMESTAMP = "2026-05-22T09:00:00.000Z" + const val ETAG_OLD = "etag-old" + const val ETAG_NEW = "etag-new" } } \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt index 14f271eaa2..5da46354c3 100644 --- a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt @@ -23,7 +23,7 @@ internal class DefaultAddressBookBlobStoreTest { } @Test - fun `GIVEN blob WHEN storeBlob THEN getBlob emits it AND it is unsynchronized`() = runTest { + fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest { // Arrange val blob = createBlob(walletId = WALLET_A) @@ -33,21 +33,6 @@ internal class DefaultAddressBookBlobStoreTest { // Assert assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob) assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob) - assertThat(store.getUnsynchronizedBlobs()).containsExactly(blob) - } - - @Test - fun `GIVEN stored blob WHEN markAsSynchronized THEN getUnsynchronizedBlobs excludes it`() = runTest { - // Arrange - val blob = createBlob(walletId = WALLET_A) - store.storeBlob(blob) - - // Act - store.markAsSynchronized(UserWalletId(WALLET_A)) - - // Assert - assertThat(store.getUnsynchronizedBlobs()).isEmpty() - assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob) } @Test @@ -63,7 +48,6 @@ internal class DefaultAddressBookBlobStoreTest { // Assert assertThat(result).isEqualTo(blobA) - assertThat(store.getUnsynchronizedBlobs()).containsExactly(blobA, blobB) } @Test diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt index a1cff7234f..294947dab9 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt @@ -37,5 +37,6 @@ interface ETagsStore { enum class Key { WalletAccounts, UserTokens, + AddressBook, } } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt new file mode 100644 index 0000000000..86b76d1a15 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.addressbook.error + +/** + * Failure of a backend address-book operation (`PUT /address-books/{walletId}` or + * `POST /address-books/sync`). The backend is the source of truth, so when one of these is raised the + * local blob is left untouched. + */ +sealed interface AddressBookSyncError { + + /** Etag mismatch on update (HTTP 412) — the book was changed elsewhere. */ + data object Conflict : AddressBookSyncError + + /** The wallet does not exist on the backend (HTTP 404). */ + data object NotFound : AddressBookSyncError + + /** Invalid API key (HTTP 401). */ + data object Unauthorized : AddressBookSyncError + + /** Malformed request or exceeded the wallet limit (HTTP 400). */ + data object BadRequest : AddressBookSyncError + + /** No network or the request could not be completed. */ + data object Network : AddressBookSyncError + + /** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */ + data object Unknown : AddressBookSyncError +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt index 22f8b6a8d9..e2bb14ac88 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt @@ -10,4 +10,6 @@ sealed interface SaveContactError { data class Address(val error: AddressValidation.Error) : SaveContactError data class Signing(val error: SignHashesError) : SaveContactError + + data class Backend(val error: AddressBookSyncError) : SaveContactError } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt index 4bcd36a802..d189e45b01 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt @@ -53,6 +53,8 @@ class SaveContactInteractor( .mapLeft(SaveContactError::Signing) .bind() repository.saveContact(signed) + .mapLeft(SaveContactError::Backend) + .bind() signed } @@ -77,6 +79,8 @@ class SaveContactInteractor( .mapLeft(SaveContactError::Signing) .bind() repository.saveContact(signed) + .mapLeft(SaveContactError::Backend) + .bind() signed } diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt index 41fd877268..27a47e4596 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt @@ -1,5 +1,7 @@ package com.tangem.domain.addressbook.repository +import arrow.core.Either +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.models.wallet.UserWalletId @@ -16,8 +18,9 @@ interface AddressBookRepository { suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? - /** Inserts or updates a [contact]. */ - suspend fun saveContact(contact: Contact) + suspend fun saveContact(contact: Contact): Either - suspend fun deleteContact(id: ContactId) + suspend fun deleteContact(id: ContactId): Either + + suspend fun syncAddressBooks(): Either } \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt index 961e8fb25f..6e90eb258f 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt @@ -4,6 +4,7 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError import com.tangem.domain.addressbook.model.AddressEntry @@ -73,7 +74,7 @@ internal class SaveContactInteractorTest { coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns signatures.right() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) @@ -106,7 +107,7 @@ internal class SaveContactInteractorTest { signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) } returns signatures.right() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", twoEntries) @@ -130,7 +131,7 @@ internal class SaveContactInteractorTest { // Arrange stubNoExistingContacts() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList()) @@ -204,6 +205,22 @@ internal class SaveContactInteractorTest { coVerify(exactly = 0) { repository.saveContact(any()) } } + @Test + fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest { + // Arrange + stubNoExistingContacts() + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns + listOf(byteArrayOf(0x01)).right() + coEvery { repository.saveContact(any()) } returns AddressBookSyncError.Conflict.left() + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Backend(AddressBookSyncError.Conflict)) + } + private fun stubNoExistingContacts() { every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList()) } @@ -224,7 +241,7 @@ internal class SaveContactInteractorTest { coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns signatures.right() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act val result = interactor.updateContact(