Updated on 2026-08-14
This commit is contained in:
parent
6ca26f33d1
commit
3815e5fdf3
20 changed files with 537 additions and 95 deletions
|
|
@ -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<SyncAddressBooksResponse>
|
||||
|
||||
@PUT("v1/address-books/{walletId}")
|
||||
suspend fun updateAddressBook(
|
||||
@Path("walletId") walletId: String,
|
||||
@Header("If-Match") eTag: String?,
|
||||
@Body body: UpdateAddressBookRequest,
|
||||
): ApiResponse<UpdateAddressBookResponse>
|
||||
}
|
||||
|
|
@ -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<Wallet>,
|
||||
) {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Wallet(
|
||||
@Json(name = "walletId") val walletId: String,
|
||||
@Json(name = "etag") val etag: String? = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<Item>,
|
||||
) {
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<List<Contact>> {
|
||||
return getContactsForWallet(userWalletId)
|
||||
.onStart { syncAddressBooks() }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
|
@ -55,6 +74,7 @@ internal class DefaultAddressBookRepository(
|
|||
}
|
||||
}
|
||||
}
|
||||
.onStart { syncAddressBooks() }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
|
@ -73,16 +93,19 @@ internal class DefaultAddressBookRepository(
|
|||
decryptContacts(blob, userWallet).find { it.name.value == name }
|
||||
}
|
||||
|
||||
override suspend fun saveContact(contact: Contact) = withContext(dispatchers.default) {
|
||||
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
|
||||
withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@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) = withContext(dispatchers.default) {
|
||||
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
|
||||
withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
|
||||
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
|
||||
|
|
@ -90,10 +113,48 @@ internal class DefaultAddressBookRepository(
|
|||
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
|
||||
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<AddressBookSyncError, Unit> = 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<AddressBookSyncError, Unit>) { acc, chunk ->
|
||||
acc.flatMap { syncWalletsChunk(chunk) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncWalletsChunk(wallets: List<UserWallet>): Either<AddressBookSyncError, Unit> {
|
||||
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<Contact> {
|
||||
|
|
@ -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<AddressBookSyncError, Unit> {
|
||||
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<AddressBookSyncError, Unit> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<AddressBookBlob>
|
||||
|
||||
suspend fun deleteBlob(userWalletId: UserWalletId)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal typealias AddressBookBlobs = Map<String, StoredAddressBookBlob>
|
||||
internal typealias AddressBookBlobs = Map<String, AddressBookBlob>
|
||||
|
||||
internal class DefaultAddressBookBlobStore(
|
||||
private val dataStore: DataStore<AddressBookBlobs>,
|
||||
|
|
@ -16,38 +16,23 @@ internal class DefaultAddressBookBlobStore(
|
|||
|
||||
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
|
||||
return dataStore.data
|
||||
.map { it[userWalletId.stringValue]?.blob }
|
||||
.map { it[userWalletId.stringValue] }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>> {
|
||||
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<AddressBookBlob> {
|
||||
return getStoredBlobs().values
|
||||
.filterNot { it.isBESynchronized }
|
||||
.map { it.blob }
|
||||
dataStore.updateData { stored -> stored + (blob.walletId to blob) }
|
||||
}
|
||||
|
||||
override suspend fun deleteBlob(userWalletId: UserWalletId) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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<AddressBook>()
|
||||
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<SyncAddressBooksRequest>()
|
||||
// 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<AddressBookBlob>()
|
||||
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<UpdateAddressBookResponse> =
|
||||
ApiResponse.Success(
|
||||
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse<T> =
|
||||
ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
|
||||
) as ApiResponse<T>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> networkErrorResponse(): ApiResponse<T> =
|
||||
ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse<T>
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,5 +37,6 @@ interface ETagsStore {
|
|||
enum class Key {
|
||||
WalletAccounts,
|
||||
UserTokens,
|
||||
AddressBook,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AddressBookSyncError, Unit>
|
||||
|
||||
suspend fun deleteContact(id: ContactId)
|
||||
suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit>
|
||||
|
||||
suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit>
|
||||
}
|
||||
|
|
@ -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<Contact>()
|
||||
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<Contact>()
|
||||
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<Contact>()
|
||||
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<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
|
||||
|
||||
// Act
|
||||
val result = interactor.updateContact(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue