Updated on 2026-08-14
This commit is contained in:
commit
53ffcc2918
677 changed files with 33091 additions and 6980 deletions
46
data/address-book/build.gradle.kts
Normal file
46
data/address-book/build.gradle.kts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.addressbook"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.addressBook)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region SDK
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Testing
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
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
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
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
|
||||
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,
|
||||
) : AddressBookRepository {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||
return getContactsForWallet(userWalletId)
|
||||
.onStart { syncAddressBooks() }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getAllContacts(): Flow<List<Contact>> {
|
||||
return userWalletsListRepository.userWallets
|
||||
.filterNotNull()
|
||||
.flatMapLatest { wallets ->
|
||||
val walletsById = wallets.associateBy { it.walletId.stringValue }
|
||||
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
|
||||
blobStore.getBlobs(ids).map { blobs ->
|
||||
blobs.flatMap { blob ->
|
||||
walletsById[blob.walletId]?.let { userWallet ->
|
||||
decryptContacts(blob, userWallet)
|
||||
}.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onStart { syncAddressBooks() }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
private fun getContactsForWallet(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||
return blobStore.getBlob(userWalletId).map { blob ->
|
||||
val userWallet = blob?.let { findUserWallet(it.walletId) } ?: return@map emptyList()
|
||||
decryptContacts(blob, userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? =
|
||||
withContext(dispatchers.default) {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null
|
||||
val userWallet = findUserWallet(blob.walletId) ?: return@withContext null
|
||||
decryptContacts(blob, userWallet).find { it.name.value == name }
|
||||
}
|
||||
|
||||
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
|
||||
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<AddressBookSyncError, Unit> =
|
||||
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<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> {
|
||||
return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty()
|
||||
}
|
||||
|
||||
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList()
|
||||
return decryptContacts(blob, userWallet)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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())
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.tangem.data.addressbook.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
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.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
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object AddressBookDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAddressBookBlobStore(
|
||||
@ApplicationContext context: Context,
|
||||
appScope: AppCoroutineScope,
|
||||
): AddressBookBlobStore {
|
||||
return DefaultAddressBookBlobStore(
|
||||
dataStore = DataStoreFactory.create(
|
||||
serializer = KotlinxDataStoreSerializer(
|
||||
defaultValue = emptyMap(),
|
||||
serializer = MapSerializer(
|
||||
keySerializer = String.serializer(),
|
||||
valueSerializer = AddressBookBlob.serializer(),
|
||||
),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
|
||||
scope = appScope,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Suppress("LongParameterList")
|
||||
fun provideAddressBookRepository(
|
||||
blobStore: AddressBookBlobStore,
|
||||
cipher: AddressBookCipher,
|
||||
addressBookApi: AddressBookApi,
|
||||
eTagsStore: ETagsStore,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
timestampProvider: IsoTimestampProvider,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): AddressBookRepository {
|
||||
return DefaultAddressBookRepository(
|
||||
blobStore = blobStore,
|
||||
cipher = cipher,
|
||||
addressBookApi = addressBookApi,
|
||||
eTagsStore = eTagsStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AddressBookBlobStore {
|
||||
|
||||
fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?>
|
||||
|
||||
fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>>
|
||||
|
||||
suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob?
|
||||
|
||||
suspend fun storeBlob(blob: AddressBookBlob)
|
||||
|
||||
suspend fun deleteBlob(userWalletId: UserWalletId)
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal typealias AddressBookBlobs = Map<String, AddressBookBlob>
|
||||
|
||||
internal class DefaultAddressBookBlobStore(
|
||||
private val dataStore: DataStore<AddressBookBlobs>,
|
||||
) : AddressBookBlobStore {
|
||||
|
||||
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
|
||||
return dataStore.data
|
||||
.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.toList() }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
|
||||
return getStoredBlobs()[userWalletId.stringValue]
|
||||
}
|
||||
|
||||
override suspend fun storeBlob(blob: AddressBookBlob) {
|
||||
dataStore.updateData { stored -> stored + (blob.walletId to blob) }
|
||||
}
|
||||
|
||||
override suspend fun deleteBlob(userWalletId: UserWalletId) {
|
||||
dataStore.updateData { stored -> stored - userWalletId.stringValue }
|
||||
}
|
||||
|
||||
private suspend fun getStoredBlobs(): AddressBookBlobs = dataStore.data.first()
|
||||
}
|
||||
|
|
@ -0,0 +1,412 @@
|
|||
package com.tangem.data.addressbook
|
||||
|
||||
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
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
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.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
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
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()
|
||||
|
||||
private val userWallet: UserWallet = mockk {
|
||||
every { walletId } returns UserWalletId(WALLET_A)
|
||||
}
|
||||
|
||||
private val repository = DefaultAddressBookRepository(
|
||||
blobStore = blobStore,
|
||||
cipher = cipher,
|
||||
addressBookApi = addressBookApi,
|
||||
eTagsStore = eTagsStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
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
|
||||
fun `GIVEN decryptable blob WHEN getContacts THEN emits decrypted 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
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
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
|
||||
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
|
||||
val result = repository.getAllContacts().first()
|
||||
|
||||
// Assert
|
||||
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
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN decryption fails WHEN getContacts THEN emits empty`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob()
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
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")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(UserWalletId(WALLET_A), listOf(existing)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
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
|
||||
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
|
||||
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
|
||||
// Arrange
|
||||
val original = createContact(id = "c1", name = "Alice")
|
||||
val updated = createContact(id = "c1", name = "Alice Updated")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(UserWalletId(WALLET_A), listOf(original)).right()
|
||||
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)
|
||||
|
||||
// Assert
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
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")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(UserWalletId(WALLET_A), listOf(kept, removed)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
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
|
||||
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
|
||||
val alice = createContact(id = "c1", name = "Alice")
|
||||
val bob = createContact(id = "c2", name = "Bob")
|
||||
val blob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
|
||||
every { cipher.decrypt(blob, userWallet) } returns
|
||||
AddressBook(UserWalletId(WALLET_A), listOf(alice, bob)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
|
||||
|
||||
// Assert
|
||||
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),
|
||||
name = ContactName(name).getOrNull()!!,
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = TIMESTAMP,
|
||||
updatedAt = TIMESTAMP,
|
||||
addressEntries = emptyList(),
|
||||
)
|
||||
|
||||
private fun createBlob(): AddressBookBlob = AddressBookBlob(
|
||||
walletId = WALLET_A,
|
||||
updatedAt = TIMESTAMP,
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.datastore.MockStateDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultAddressBookBlobStoreTest {
|
||||
|
||||
private lateinit var store: DefaultAddressBookBlobStore
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
store = DefaultAddressBookBlobStore(
|
||||
dataStore = MockStateDataStore(default = emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(walletId = WALLET_A)
|
||||
|
||||
// Act
|
||||
store.storeBlob(blob)
|
||||
|
||||
// Assert
|
||||
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob)
|
||||
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blobs for two wallets WHEN getBlob walletA THEN only walletA blob emitted`() = runTest {
|
||||
// Arrange
|
||||
val blobA = createBlob(walletId = WALLET_A)
|
||||
val blobB = createBlob(walletId = WALLET_B)
|
||||
store.storeBlob(blobA)
|
||||
store.storeBlob(blobB)
|
||||
|
||||
// Act
|
||||
val result = store.getBlob(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(blobA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blobs for two wallets WHEN getBlobs THEN only requested wallets returned`() = runTest {
|
||||
// Arrange
|
||||
val blobA = createBlob(walletId = WALLET_A)
|
||||
val blobB = createBlob(walletId = WALLET_B)
|
||||
store.storeBlob(blobA)
|
||||
store.storeBlob(blobB)
|
||||
|
||||
// Act
|
||||
val result = store.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))).first()
|
||||
val onlyA = store.getBlobs(setOf(UserWalletId(WALLET_A))).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(blobA, blobB)
|
||||
assertThat(onlyA).containsExactly(blobA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored blob WHEN deleteBlob THEN getBlob emits null`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(walletId = WALLET_A)
|
||||
store.storeBlob(blob)
|
||||
|
||||
// Act
|
||||
store.deleteBlob(UserWalletId(WALLET_A))
|
||||
|
||||
// Assert
|
||||
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isNull()
|
||||
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isNull()
|
||||
}
|
||||
|
||||
private fun createBlob(walletId: String): AddressBookBlob = AddressBookBlob(
|
||||
walletId = walletId,
|
||||
updatedAt = "2026-05-22T09:00:00.000Z",
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val WALLET_A = "0a0a0a"
|
||||
const val WALLET_B = "0b0b0b"
|
||||
}
|
||||
}
|
||||
|
|
@ -37,5 +37,6 @@ interface ETagsStore {
|
|||
enum class Key {
|
||||
WalletAccounts,
|
||||
UserTokens,
|
||||
AddressBook,
|
||||
}
|
||||
}
|
||||
|
|
@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
|
|||
) : FeedbackFeatureToggles {
|
||||
|
||||
override val isUsedeskEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED)
|
||||
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_485_USEDESK_ENABLED)
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ internal class DefaultOnrampRepository(
|
|||
override suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry> = withContext(dispatchers.io) {
|
||||
if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList()
|
||||
|
||||
val result = onrampApi.getCountries(
|
||||
val response = onrampApi.getCountries(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
|
|
@ -123,8 +123,12 @@ internal class DefaultOnrampRepository(
|
|||
),
|
||||
)
|
||||
.getOrThrow()
|
||||
.map(countryConverter::convert)
|
||||
|
||||
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
|
||||
expressHistoryDao.upsertCountries(response.map { it.toEntity() })
|
||||
}
|
||||
|
||||
val result = response.map(countryConverter::convert)
|
||||
countriesStore.store(COUNTRIES_KEY, result)
|
||||
|
||||
result
|
||||
|
|
|
|||
|
|
@ -1,54 +1,49 @@
|
|||
package com.tangem.data.pushnotificationpreferences
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.pushnotificationpreferences.converters.PushNotificationPreferencesConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* In-memory cache implementation of [WalletPushNotificationPreferencesRepository].
|
||||
*
|
||||
* Mock-mode (current): defaults are computed locally and writes are kept in-memory only.
|
||||
* Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls.
|
||||
*
|
||||
* Defaults for existing users (until BE migration runs): TX read from
|
||||
* [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false,
|
||||
* isVisible = true for all three.
|
||||
* Preferences are cached in-memory (non-persistent); writes are full-replace PUTs and the server echo
|
||||
* is cached as the source of truth.
|
||||
*/
|
||||
internal class DefaultWalletPushNotificationPreferencesRepository(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
@Suppress("unused") private val tangemTechApi: TangemTechApi,
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : WalletPushNotificationPreferencesRepository {
|
||||
|
||||
private val walletMutexes = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
override suspend fun preload(userWalletId: UserWalletId) {
|
||||
if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return
|
||||
val preferences = withContext(dispatchers.io) {
|
||||
// TODO: uncomment when api is ready
|
||||
// val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
|
||||
// PushNotificationPreferencesConverter.convert(response)
|
||||
loadDefaults(userWalletId)
|
||||
}
|
||||
cache.update(default = emptyMap()) { current ->
|
||||
if (current.containsKey(userWalletId.stringValue)) {
|
||||
current
|
||||
} else {
|
||||
current + (userWalletId.stringValue to preferences)
|
||||
if (isCached(userWalletId)) return
|
||||
mutexFor(userWalletId).withLock {
|
||||
if (isCached(userWalletId)) return
|
||||
val preferences = fetch(userWalletId)
|
||||
cache.update(default = emptyMap()) { current ->
|
||||
if (current.containsKey(userWalletId.stringValue)) {
|
||||
current
|
||||
} else {
|
||||
current + (userWalletId.stringValue to preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,9 +59,11 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
|
|||
category: PushNotificationCategory,
|
||||
isEnabled: Boolean,
|
||||
): Either<Throwable, Unit> = Either.catch {
|
||||
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
|
||||
val updated = current.withCategory(category, isEnabled)
|
||||
putAndCommit(userWalletId, updated)
|
||||
mutexFor(userWalletId).withLock {
|
||||
val current = currentOrFetch(userWalletId)
|
||||
val updated = current.withCategory(category, isEnabled)
|
||||
putAndCommit(userWalletId, updated)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setAllPreferences(
|
||||
|
|
@ -75,39 +72,45 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
|
|||
offersUpdates: Boolean,
|
||||
priceAlerts: Boolean,
|
||||
): Either<Throwable, Unit> = Either.catch {
|
||||
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
|
||||
val updated = current.copy(
|
||||
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
|
||||
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
|
||||
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
|
||||
)
|
||||
putAndCommit(userWalletId, updated)
|
||||
mutexFor(userWalletId).withLock {
|
||||
val current = currentOrFetch(userWalletId)
|
||||
val updated = current.copy(
|
||||
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
|
||||
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
|
||||
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
|
||||
)
|
||||
putAndCommit(userWalletId, updated)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache, or a freshly fetched server snapshot, so a full-replace PUT never carries fabricated defaults.
|
||||
private suspend fun currentOrFetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
|
||||
cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: fetch(userWalletId)
|
||||
|
||||
private suspend fun fetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
|
||||
withContext(dispatchers.io) {
|
||||
val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
|
||||
PushNotificationPreferencesConverter.convert(response)
|
||||
}
|
||||
|
||||
private suspend fun isCached(userWalletId: UserWalletId): Boolean =
|
||||
cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true
|
||||
|
||||
private fun mutexFor(userWalletId: UserWalletId): Mutex =
|
||||
walletMutexes.computeIfAbsent(userWalletId.stringValue) { Mutex() }
|
||||
|
||||
private suspend fun putAndCommit(userWalletId: UserWalletId, updated: WalletPushNotificationPreferences) {
|
||||
withContext(dispatchers.io) {
|
||||
// TODO: uncomment when api is ready
|
||||
// tangemTechApi.updatePushNotificationPreferences(
|
||||
// walletId = userWalletId.stringValue,
|
||||
// body = PushNotificationPreferencesBody(
|
||||
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
|
||||
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
|
||||
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
|
||||
// ),
|
||||
// ).getOrThrow()
|
||||
val applied = withContext(dispatchers.io) {
|
||||
val response = tangemTechApi.updatePushNotificationPreferences(
|
||||
walletId = userWalletId.stringValue,
|
||||
body = PushNotificationPreferencesBody(
|
||||
areTransactionEventsEnabled = updated.transactionAlerts.isEnabled,
|
||||
areOfferUpdatesEnabled = updated.offersUpdates.isEnabled,
|
||||
arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
|
||||
),
|
||||
).getOrThrow()
|
||||
PushNotificationPreferencesConverter.convert(response)
|
||||
}
|
||||
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
|
||||
}
|
||||
|
||||
// TODO remove when api is ready, use api methods to load real settings
|
||||
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
|
||||
val areTransactionAlertsEnabled = appPreferencesStore
|
||||
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
|
||||
false
|
||||
return WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
)
|
||||
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to applied) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.data.pushnotificationpreferences.converters
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
|
|
@ -11,11 +10,8 @@ internal object PushNotificationPreferencesConverter :
|
|||
|
||||
override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences =
|
||||
WalletPushNotificationPreferences(
|
||||
transactionAlerts = value.transactionAlerts.toDomain(),
|
||||
offersUpdates = value.offersUpdates.toDomain(),
|
||||
priceAlerts = value.priceAlerts.toDomain(),
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = value.areTransactionEventsEnabled),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = value.areOfferUpdatesEnabled),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = value.arePriceAlertsEnabled),
|
||||
)
|
||||
|
||||
private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference =
|
||||
PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible)
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.tangem.data.pushnotificationpreferences.di
|
|||
import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
|
|
@ -19,11 +18,9 @@ internal object PushNotificationPreferencesModule {
|
|||
@Singleton
|
||||
@Provides
|
||||
fun providesWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
tangemTechApi: TangemTechApi,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
cache = RuntimeSharedStore(),
|
||||
dispatchers = dispatchers,
|
||||
|
|
|
|||
|
|
@ -1,141 +1,220 @@
|
|||
package com.tangem.data.pushnotificationpreferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import app.cash.turbine.test
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultWalletPushNotificationPreferencesRepositoryTest {
|
||||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val preferencesDataStore: DataStore<Preferences> = mockk()
|
||||
private val appPreferencesStore = AppPreferencesStore(
|
||||
moshi = Moshi.Builder().build(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
preferencesDataStore = preferencesDataStore,
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "0011223344556677")
|
||||
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
|
||||
|
||||
private val repository = DefaultWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
cache = RuntimeSharedStore(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
fun `GIVEN server returns prefs WHEN preload THEN cache holds converted server state`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
|
||||
// Act
|
||||
repository.preload(userWalletId)
|
||||
|
||||
// Assert
|
||||
repository.observePreferences(userWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true))
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = false))
|
||||
}
|
||||
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
fun `GIVEN already preloaded WHEN preload called again THEN no second GET`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
|
||||
// Act
|
||||
repository.preload(userWalletId)
|
||||
repository.preload(userWalletId)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cache miss WHEN updatePreference THEN fetches baseline AND sends full-replace PUT AND caches echo`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
stubPut(userWalletId, transaction = true, offers = true, price = true)
|
||||
|
||||
// Act
|
||||
val result = repository.updatePreference(
|
||||
userWalletId = userWalletId,
|
||||
category = PushNotificationCategory.PriceAlerts,
|
||||
isEnabled = true,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(Either.Right::class.java)
|
||||
// The full-replace body changes only the tapped category on top of the server baseline.
|
||||
coVerify(exactly = 1) {
|
||||
tangemTechApi.updatePushNotificationPreferences(
|
||||
userWalletId.stringValue,
|
||||
PushNotificationPreferencesBody(
|
||||
areTransactionEventsEnabled = true,
|
||||
areOfferUpdatesEnabled = true,
|
||||
arePriceAlertsEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
repository.observePreferences(userWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preloaded state WHEN updatePreference THEN only the tapped category changes in the PUT body`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
stubPut(userWalletId, transaction = true, offers = false, price = false)
|
||||
|
||||
// Act
|
||||
repository.preload(userWalletId)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) {
|
||||
tangemTechApi.updatePushNotificationPreferences(
|
||||
userWalletId.stringValue,
|
||||
PushNotificationPreferencesBody(
|
||||
areTransactionEventsEnabled = true,
|
||||
areOfferUpdatesEnabled = false,
|
||||
arePriceAlertsEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN write fails WHEN updatePreference THEN returns Left`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
repository.preload(userWalletId)
|
||||
coEvery { tangemTechApi.updatePushNotificationPreferences(any(), any()) } throws IllegalStateException("boom")
|
||||
|
||||
// Act
|
||||
val result = repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(Either.Left::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN different wallets WHEN observed THEN each keeps its own server state`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = false, offers = false, price = false)
|
||||
stubGet(otherWalletId, transaction = true, offers = true, price = true)
|
||||
|
||||
// Assert
|
||||
repository.observePreferences(userWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = false, offers = false, price = false))
|
||||
}
|
||||
repository.observePreferences(otherWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN concurrent collectors WHEN preload races THEN a single GET is issued`() = runTest {
|
||||
// Arrange
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } coAnswers {
|
||||
gate.await()
|
||||
ApiResponse.Success(PushNotificationPreferencesResponse(true, true, false))
|
||||
}
|
||||
|
||||
// Act
|
||||
launch { repository.preload(userWalletId) }
|
||||
runCurrent()
|
||||
launch { repository.preload(userWalletId) }
|
||||
runCurrent()
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN concurrent writes WHEN updatePreference races THEN serialized so no update is lost`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) } coAnswers {
|
||||
val body = arg<PushNotificationPreferencesBody>(1)
|
||||
gate.await()
|
||||
ApiResponse.Success(
|
||||
PushNotificationPreferencesResponse(
|
||||
body.areTransactionEventsEnabled,
|
||||
body.areOfferUpdatesEnabled,
|
||||
body.arePriceAlertsEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
repository.preload(userWalletId)
|
||||
|
||||
// Act
|
||||
launch { repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) }
|
||||
runCurrent()
|
||||
launch { repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true) }
|
||||
runCurrent()
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 2) { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) }
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = false, price = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
val result = repository.updatePreference(
|
||||
userWalletId = userWalletId,
|
||||
category = PushNotificationCategory.PriceAlerts,
|
||||
isEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(Either.Right::class.java)
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
assertThat(item.offersUpdates.isEnabled).isTrue()
|
||||
assertThat(item.transactionAlerts.isEnabled).isTrue()
|
||||
}
|
||||
private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
|
||||
coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns
|
||||
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.preload(userWalletId)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.transactionAlerts.isEnabled).isFalse()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
}
|
||||
private fun stubPut(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
|
||||
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(id.stringValue), any()) } returns
|
||||
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
|
||||
runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
|
||||
runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(item.priceAlerts.isEnabled).isFalse()
|
||||
}
|
||||
repository.observePreferences(otherWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isTrue()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
private fun prefs(transaction: Boolean, offers: Boolean, price: Boolean) = WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = transaction),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = offers),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = price),
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ dependencies {
|
|||
implementation(tangemDeps.card.core)
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,23 @@ package com.tangem.data.transaction
|
|||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
|
||||
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
|
||||
import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
|
@ -23,17 +29,19 @@ import java.math.BigInteger
|
|||
|
||||
class DefaultGaslessTransactionRepository(
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
private val isGaslessV2Enabled: Boolean,
|
||||
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : GaslessTransactionRepository {
|
||||
|
||||
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
|
||||
private val allFeeRecipientAddress = mutableSetOf<String>()
|
||||
private val allAddressesMutex = Mutex()
|
||||
private val receiverAddressMutex = Mutex()
|
||||
private var feeReceiverAddress: String? = null
|
||||
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
|
||||
private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled)
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter)
|
||||
private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter)
|
||||
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
|
||||
|
||||
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
||||
|
|
@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository(
|
|||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
|
||||
val response = if (isGaslessV2Enabled) {
|
||||
gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest)
|
||||
} else {
|
||||
gaslessTxServiceApi.signGaslessTransaction(transactionRequest)
|
||||
}.getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
|
|
@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository(
|
|||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = network.toBlockchain()
|
||||
val transactionRequest = gaslessBatchTransactionRequestBuilder.build(
|
||||
gaslessBatchTransaction = gaslessBatchTransactionData,
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
|
||||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
}
|
||||
|
||||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository(
|
|||
}
|
||||
|
||||
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
||||
return allAddressesMutex.withLock {
|
||||
allFeeRecipientAddress.ifEmpty {
|
||||
val allFeeAddresses = getAllFeeRecipientAddresses()
|
||||
allFeeRecipientAddress.addAll(allFeeAddresses)
|
||||
allFeeRecipientAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
|
||||
// TODO Replace with other backend call to get all fee recipient addresses when available
|
||||
return setOf(getTokenFeeReceiverAddress())
|
||||
val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() }
|
||||
.onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) }
|
||||
.getOrNull()
|
||||
return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
|
||||
|
||||
|
||||
val KNOWN_FEE_COLLECTION_ADDRESSES = setOf(
|
||||
"0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c",
|
||||
"0xAf722F46145fbb106379d506ED3a5B96f110c8E5",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import java.math.BigInteger
|
||||
|
|
@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository(
|
|||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override suspend fun signGaslessBatchTransaction(
|
||||
gaslessBatchTransactionData: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
|
||||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converts domain [Eip7702Authorization] to its DTO representation.
|
||||
* Shared by both single-transaction and batch-transaction request builders.
|
||||
*/
|
||||
class Eip7702AuthorizationConverter : Converter<Eip7702Authorization, Eip7702AuthorizationDTO> {
|
||||
|
||||
override fun convert(value: Eip7702Authorization): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = value.chainId,
|
||||
address = value.address,
|
||||
nonce = value.nonce.toString(),
|
||||
yParity = value.yParity,
|
||||
r = value.r,
|
||||
s = value.s,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionDataDTO
|
||||
import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
|
||||
/**
|
||||
* Builder for creating complete [GaslessBatchTransactionRequest] from domain model.
|
||||
* Combines batch transaction data with signature and user information.
|
||||
*
|
||||
* Reuses [GaslessTxDataToGaslessRequestConverter] for transaction and fee conversion
|
||||
* to avoid duplicating mapping logic.
|
||||
*/
|
||||
class GaslessBatchTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Creates complete gasless batch transaction request.
|
||||
*
|
||||
* @param gaslessBatchTransaction domain model of batch transaction
|
||||
* @param signature transaction signature in hex format (with 0x prefix)
|
||||
* @param userAddress user's Ethereum address
|
||||
* @param chainId blockchain network chain ID
|
||||
* @param eip7702Auth optional EIP-7702 authorization for account abstraction
|
||||
* @return complete request ready for API submission
|
||||
*/
|
||||
fun build(
|
||||
gaslessBatchTransaction: GaslessBatchTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
chainId: Int,
|
||||
eip7702Auth: Eip7702Authorization? = null,
|
||||
): GaslessBatchTransactionRequest {
|
||||
return GaslessBatchTransactionRequest(
|
||||
gaslessTransaction = GaslessBatchTransactionDataDTO(
|
||||
transactions = gaslessBatchTransaction.transactions.map { converter.convertTransaction(it) },
|
||||
fee = converter.convertFee(gaslessBatchTransaction.fee),
|
||||
nonce = gaslessBatchTransaction.nonce.toString(),
|
||||
),
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.tangem.data.transaction.convertes
|
|||
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
|
||||
/**
|
||||
* Builder for creating complete GaslessTransactionRequest from domain model.
|
||||
|
|
@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
|||
*/
|
||||
class GaslessTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder(
|
|||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.toDTO(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domain Eip7702Authorization to DTO.
|
||||
*/
|
||||
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = chainId,
|
||||
address = address,
|
||||
nonce = nonce.toString(),
|
||||
yParity = yParity,
|
||||
r = r,
|
||||
s = s,
|
||||
eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles
|
|||
* Note: This converter only handles the transaction data conversion.
|
||||
* Additional fields (signature, userAddress, chainId) must be added separately
|
||||
* to create complete GaslessTransactionRequest.
|
||||
*
|
||||
* @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the
|
||||
* request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed.
|
||||
*/
|
||||
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
class GaslessTxDataToGaslessRequestConverter(
|
||||
private val shouldIncludeGasLimit: Boolean = true,
|
||||
) : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
|
||||
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
|
||||
return GaslessTransactionDataDTO(
|
||||
|
|
@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData,
|
|||
)
|
||||
}
|
||||
|
||||
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
internal fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
return TransactionData(
|
||||
to = transaction.to,
|
||||
value = transaction.value.toString(),
|
||||
gasLimit = transaction.gasLimit.toString().takeIf { shouldIncludeGasLimit },
|
||||
data = transaction.data.toHexString().formatHex(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
internal fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
return FeeData(
|
||||
feeToken = fee.feeToken,
|
||||
maxTokenFee = fee.maxTokenFee.toString(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
|||
import com.tangem.data.transaction.*
|
||||
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
|
||||
import com.tangem.blockchainsdk.BlockchainSDKFactory
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
|
|
@ -84,10 +87,17 @@ internal object TransactionDataModule {
|
|||
fun provideGaslessTransactionRepository(
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2: GaslessTxServiceApiV2,
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
): GaslessTransactionRepository {
|
||||
return DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
// Single master toggle for the whole gasless v2 protocol (+ yield-withdraw batch).
|
||||
isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED,
|
||||
),
|
||||
coroutineDispatcherProvider = coroutineDispatcherProvider,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
package com.tangem.data.transaction
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2
|
||||
import com.tangem.datasource.api.gasless.models.GaslessFeeRecipient
|
||||
import com.tangem.datasource.api.gasless.models.GaslessServiceResponse
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class DefaultGaslessTransactionRepositoryTest {
|
||||
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi = mockk()
|
||||
private val gaslessTxServiceApiV2: GaslessTxServiceApiV2 = mockk()
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(gaslessTxServiceApi, gaslessTxServiceApiV2, responseCryptoCurrenciesFactory)
|
||||
}
|
||||
|
||||
private fun createRepository() = DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
gaslessTxServiceApiV2 = gaslessTxServiceApiV2,
|
||||
isGaslessV2Enabled = true,
|
||||
coroutineDispatcherProvider = TestingCoroutineDispatcherProvider(),
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
|
||||
private fun stubFeeRecipientSuccess(address: String) {
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns ApiResponse.Success(
|
||||
data = GaslessServiceResponse(
|
||||
result = GaslessFeeRecipient(address = address),
|
||||
isSuccess = true,
|
||||
timestamp = "2026-06-11T00:00:00.000Z",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun stubFeeRecipientFailure() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val error = ApiResponse.Error(cause = ApiResponseError.NetworkException())
|
||||
as ApiResponse<GaslessServiceResponse<GaslessFeeRecipient>>
|
||||
coEvery { gaslessTxServiceApi.getFeeRecipient() } returns error
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend returns recipient WHEN getGaslessFeeAddresses THEN hardcoded plus backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails WHEN getGaslessFeeAddresses THEN hardcoded addresses only`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
val actual = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend fails then recovers WHEN called twice THEN second call includes backend address`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientFailure()
|
||||
val repository = createRepository()
|
||||
val firstResult = repository.getGaslessFeeAddresses()
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
|
||||
// Act
|
||||
val secondResult = repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
assertThat(firstResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2)
|
||||
assertThat(secondResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend succeeds WHEN called twice THEN fee recipient requested once`() = runTest {
|
||||
// Arrange
|
||||
stubFeeRecipientSuccess(BACKEND_ADDRESS)
|
||||
val repository = createRepository()
|
||||
|
||||
// Act
|
||||
repository.getGaslessFeeAddresses()
|
||||
repository.getGaslessFeeAddresses()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { gaslessTxServiceApi.getFeeRecipient() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HARDCODED_ADDRESS_1 = "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c"
|
||||
const val HARDCODED_ADDRESS_2 = "0xAf722F46145fbb106379d506ED3a5B96f110c8E5"
|
||||
const val BACKEND_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessBatchTransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigInteger
|
||||
|
||||
class GaslessBatchTransactionRequestBuilderTest {
|
||||
|
||||
private val builder = GaslessBatchTransactionRequestBuilder()
|
||||
|
||||
// byteArrayOf(0x12, 0x34).toHexString() == "1234" (uppercase), .formatHex() prepends "0x" → "0x1234"
|
||||
private val tx1Data = byteArrayOf(0x12, 0x34)
|
||||
private val tx1DataHex = "0x1234"
|
||||
|
||||
// byteArrayOf(0xAB.toByte(), 0xCD.toByte()).toHexString() == "ABCD", .formatHex() → "0xABCD"
|
||||
private val tx2Data = byteArrayOf(0xAB.toByte(), 0xCD.toByte())
|
||||
private val tx2DataHex = "0xABCD"
|
||||
|
||||
private val tx1 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractA",
|
||||
value = BigInteger("100"),
|
||||
gasLimit = BigInteger("120000"),
|
||||
data = tx1Data,
|
||||
)
|
||||
private val tx2 = GaslessTransactionData.Transaction(
|
||||
to = "0xContractB",
|
||||
value = BigInteger("0"),
|
||||
gasLimit = BigInteger("150000"),
|
||||
data = tx2Data,
|
||||
)
|
||||
private val fee = GaslessTransactionData.Fee(
|
||||
feeToken = "0xFeeToken",
|
||||
maxTokenFee = BigInteger("500"),
|
||||
coinPriceInToken = BigInteger("200"),
|
||||
feeTransferGasLimit = BigInteger("21000"),
|
||||
baseGas = BigInteger("60000"),
|
||||
feeReceiver = "0xFeeReceiver",
|
||||
)
|
||||
private val nonce = BigInteger("42")
|
||||
|
||||
private val batchData = GaslessBatchTransactionData(
|
||||
transactions = listOf(tx1, tx2),
|
||||
fee = fee,
|
||||
nonce = nonce,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `build - transactions list has correct size and order`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.transactions).hasSize(2)
|
||||
assertThat(result.gaslessTransaction.transactions[0].to).isEqualTo("0xContractA")
|
||||
assertThat(result.gaslessTransaction.transactions[1].to).isEqualTo("0xContractB")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - transaction data fields are encoded correctly`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
// data bytes are hex-encoded with 0x prefix (uppercase)
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
assertThat(txDtoList[1].data).isEqualTo(tx2DataHex)
|
||||
// value is BigInteger.toString()
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[1].value).isEqualTo("0")
|
||||
// v2: per-call gasLimit is BigInteger.toString()
|
||||
assertThat(txDtoList[0].gasLimit).isEqualTo("120000")
|
||||
assertThat(txDtoList[1].gasLimit).isEqualTo("150000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - v1 converter omits per-call gasLimit`() {
|
||||
// Arrange: a builder whose converter is in v1 mode (shouldIncludeGasLimit = false)
|
||||
val v1Builder = GaslessBatchTransactionRequestBuilder(
|
||||
converter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = false),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = v1Builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
// Assert: gasLimit is null so Moshi omits it, restoring the legacy v1 {to, value, data} shape
|
||||
val txDtoList = result.gaslessTransaction.transactions
|
||||
assertThat(txDtoList[0].gasLimit).isNull()
|
||||
assertThat(txDtoList[1].gasLimit).isNull()
|
||||
// other fields are unaffected
|
||||
assertThat(txDtoList[0].value).isEqualTo("100")
|
||||
assertThat(txDtoList[0].data).isEqualTo(tx1DataHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - fee fields are all toString of BigInteger inputs`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
val feeDto = result.gaslessTransaction.fee
|
||||
assertThat(feeDto.feeToken).isEqualTo("0xFeeToken")
|
||||
assertThat(feeDto.maxTokenFee).isEqualTo("500")
|
||||
assertThat(feeDto.coinPriceInToken).isEqualTo("200")
|
||||
assertThat(feeDto.feeTransferGasLimit).isEqualTo("21000")
|
||||
assertThat(feeDto.baseGas).isEqualTo("60000")
|
||||
assertThat(feeDto.feeReceiver).isEqualTo("0xFeeReceiver")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - nonce is toString of BigInteger input`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.gaslessTransaction.nonce).isEqualTo("42")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - top-level signature, userAddress, chainId pass through`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xDeadBeef",
|
||||
userAddress = "0xAlice",
|
||||
chainId = 137,
|
||||
)
|
||||
|
||||
assertThat(result.signature).isEqualTo("0xDeadBeef")
|
||||
assertThat(result.userAddress).isEqualTo("0xAlice")
|
||||
assertThat(result.chainId).isEqualTo(137)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth is null when not provided`() {
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
)
|
||||
|
||||
assertThat(result.eip7702Auth).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - eip7702Auth maps correctly when provided`() {
|
||||
val auth = Eip7702Authorization(
|
||||
chainId = 1,
|
||||
address = "0xEntryPoint",
|
||||
nonce = BigInteger("7"),
|
||||
yParity = 0,
|
||||
r = "0xRValue",
|
||||
s = "0xSValue",
|
||||
)
|
||||
|
||||
val result = builder.build(
|
||||
gaslessBatchTransaction = batchData,
|
||||
signature = "0xSig",
|
||||
userAddress = "0xUser",
|
||||
chainId = 1,
|
||||
eip7702Auth = auth,
|
||||
)
|
||||
|
||||
val authDto = result.eip7702Auth
|
||||
assertThat(authDto).isNotNull()
|
||||
assertThat(authDto!!.chainId).isEqualTo(1)
|
||||
assertThat(authDto.address).isEqualTo("0xEntryPoint")
|
||||
assertThat(authDto.nonce).isEqualTo("7")
|
||||
assertThat(authDto.yParity).isEqualTo(0)
|
||||
assertThat(authDto.r).isEqualTo("0xRValue")
|
||||
assertThat(authDto.s).isEqualTo("0xSValue")
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ dependencies {
|
|||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.onramp)
|
||||
implementation(projects.domain.onramp.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.account.status)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.tangem.domain.express.ExpressRepository
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.onramp.repositories.OnrampRepository
|
||||
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
|
||||
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
|
||||
import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher
|
||||
|
|
@ -22,6 +23,7 @@ import javax.inject.Inject
|
|||
internal class DefaultAppTxHistoryFetcher @Inject constructor(
|
||||
private val utils: TxHistoryFetcherUtils,
|
||||
private val expressRepository: ExpressRepository,
|
||||
private val onrampRepository: OnrampRepository,
|
||||
private val getWalletsUseCase: GetWalletsUseCase,
|
||||
private val selectedWalletUseCase: GetSelectedWalletUseCase,
|
||||
private val walletTxHistoryFetcherFactory: DefaultWalletTxHistoryFetcher.Factory,
|
||||
|
|
@ -30,9 +32,6 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
|
|||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal val fetchers = ConcurrentHashMap<UserWalletId, WalletTxHistoryFetcher>()
|
||||
|
||||
/** Wallets whose express providers were already loaded — to load them at most once per wallet. */
|
||||
private val providersLoadedWallets = mutableSetOf<UserWalletId>()
|
||||
|
||||
init {
|
||||
defaultLaunchIn(buildFlow())
|
||||
}
|
||||
|
|
@ -53,11 +52,14 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
|
|||
.stateIn(this)
|
||||
|
||||
walletsFlow.value.keys.createForNewWallets()
|
||||
walletsFlow.value.values.firstOrNull()?.let { wallet ->
|
||||
loadExpressProviders(wallet)
|
||||
loadOnrampCountries(wallet)
|
||||
}
|
||||
|
||||
selectedWalletUseCase.selectedFlow()
|
||||
.filter { wallet -> wallet.isMultiCurrency }
|
||||
// todo txhistory some init trigger?
|
||||
.onEach { wallet -> loadExpressProviders(wallet) }
|
||||
.launchIn(this)
|
||||
|
||||
walletsFlow
|
||||
|
|
@ -79,13 +81,17 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor(
|
|||
}
|
||||
|
||||
private fun ProducerScope<*>.loadExpressProviders(wallet: UserWallet) {
|
||||
// Load once per wallet: `add` returns false if this walletId was already loaded.
|
||||
if (!providersLoadedWallets.add(wallet.walletId)) return
|
||||
flow { emit(expressRepository.getProviders(userWallet = wallet, filterProviderTypes = emptyList())) }
|
||||
.retryThreeTimes()
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
private fun ProducerScope<*>.loadOnrampCountries(wallet: UserWallet) {
|
||||
flow { emit(onrampRepository.fetchCountries(userWallet = wallet)) }
|
||||
.retryThreeTimes()
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
private fun Flow<Set<UserWalletId>>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() }
|
||||
|
||||
private fun Set<UserWalletId>.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) }
|
||||
|
|
|
|||
|
|
@ -5,9 +5,16 @@ import com.tangem.data.common.converter.ExpressProviderConverter
|
|||
import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper
|
||||
import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter
|
||||
import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
|
||||
import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter
|
||||
import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory
|
||||
import com.tangem.data.txhistory.repository.factory.toAssetId
|
||||
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
|
||||
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
|
|
@ -36,6 +43,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
|||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val txHistoryItemsStore: TxHistoryItemsStore,
|
||||
private val expressHistoryDao: ExpressHistoryDao,
|
||||
private val expressTransactionAssetFactory: ExpressTransactionAssetFactory,
|
||||
private val cacheRegistry: CacheRegistry,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : TxHistoryRepositoryV2 {
|
||||
|
|
@ -44,6 +52,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
|||
private val expressProviderConverter = ExpressProviderConverter()
|
||||
private val swapConverter = ExpressSwapConverter()
|
||||
private val onrampConverter = ExpressOnrampConverter()
|
||||
private val onrampCountryConverter = OnrampCountryConverter()
|
||||
private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency)
|
||||
|
||||
override fun getExpressHistory(
|
||||
|
|
@ -84,38 +93,80 @@ internal class RefactoredTxHistoryRepository @Inject constructor(
|
|||
activeStatuses = ExpressStatusMapper.activeOnrampStatuses,
|
||||
).distinctUntilChanged(),
|
||||
flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(),
|
||||
transform = { outgoingSwaps, incomingSwaps, onramps, providers ->
|
||||
buildList<ExpressTx> {
|
||||
fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert)
|
||||
outgoingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = true,
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
incomingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = false,
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
onramps.forEach { entity ->
|
||||
val input = ExpressOnrampConverter.Input(entity, entity.providerId.expressProvider())
|
||||
add(onrampConverter.convert(input))
|
||||
}
|
||||
}
|
||||
// An exchange row may satisfy both swap queries only in degenerate cases;
|
||||
// keep the outgoing interpretation (added first).
|
||||
.distinctBy { it.txId }
|
||||
flow5 = expressHistoryDao.getCountriesByCode().distinctUntilChanged(),
|
||||
transform = { outgoingSwaps, incomingSwaps, onramps, providers, countries ->
|
||||
buildExpressHistory(
|
||||
userWalletId = userWalletId,
|
||||
sources = ExpressHistorySources(
|
||||
outgoingSwaps = outgoingSwaps,
|
||||
incomingSwaps = incomingSwaps,
|
||||
onramps = onramps,
|
||||
providers = providers,
|
||||
countries = countries,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
emitAll(flow)
|
||||
}.flowOn(dispatchers.io)
|
||||
|
||||
/** The reactive express-history inputs gathered from the DB in a single [combine] tick. */
|
||||
private data class ExpressHistorySources(
|
||||
val outgoingSwaps: List<ExpressExchangeEntity>,
|
||||
val incomingSwaps: List<ExpressExchangeEntity>,
|
||||
val onramps: List<ExpressOnrampEntity>,
|
||||
val providers: Map<String, ExpressProviderEntity>,
|
||||
val countries: Map<String, OnrampCountryEntity>,
|
||||
)
|
||||
|
||||
private suspend fun buildExpressHistory(
|
||||
userWalletId: UserWalletId,
|
||||
sources: ExpressHistorySources,
|
||||
): List<ExpressTx> {
|
||||
val currencies = expressTransactionAssetFactory.create(
|
||||
userWalletId = userWalletId,
|
||||
outgoingSwaps = sources.outgoingSwaps,
|
||||
incomingSwaps = sources.incomingSwaps,
|
||||
onramps = sources.onramps,
|
||||
)
|
||||
fun String.expressProvider() = sources.providers[this]?.let(expressProviderConverter::convert)
|
||||
fun String.onrampCountry() = sources.countries[this]?.let(onrampCountryConverter::convert)
|
||||
return buildList {
|
||||
sources.outgoingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = true,
|
||||
fromCurrency = currencies[entity.from.toAssetId()],
|
||||
toCurrency = currencies[entity.to.toAssetId()],
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
sources.incomingSwaps.forEach { entity ->
|
||||
val input = ExpressSwapConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
isOutgoing = false,
|
||||
fromCurrency = currencies[entity.from.toAssetId()],
|
||||
toCurrency = currencies[entity.to.toAssetId()],
|
||||
)
|
||||
add(swapConverter.convert(input))
|
||||
}
|
||||
sources.onramps.forEach { entity ->
|
||||
val input = ExpressOnrampConverter.Input(
|
||||
entity = entity,
|
||||
provider = entity.providerId.expressProvider(),
|
||||
toCurrency = currencies[entity.to.toAssetId()],
|
||||
country = entity.countryCode.onrampCountry(),
|
||||
)
|
||||
add(onrampConverter.convert(input))
|
||||
}
|
||||
}
|
||||
// An exchange row may satisfy both swap queries only in degenerate cases;
|
||||
// keep the outgoing interpretation (added first).
|
||||
.distinctBy { it.txId }
|
||||
}
|
||||
|
||||
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
|
||||
return BatchListSource(
|
||||
fetchDispatcher = dispatchers.io,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import com.tangem.domain.express.models.ExpressOnrampStatus
|
|||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.express.models.OnrampTransaction
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.onramp.model.OnrampCountry
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.tokens.model.AmountType
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
|
|
@ -26,7 +28,7 @@ import java.math.BigDecimal
|
|||
internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, ExpressTx.Swap> {
|
||||
|
||||
override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap(
|
||||
tx = convertExchangeTransaction(value.entity, value.provider),
|
||||
tx = convertExchangeTransaction(value),
|
||||
isOutgoing = value.isOutgoing,
|
||||
txInfo = null,
|
||||
)
|
||||
|
|
@ -35,6 +37,8 @@ internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, Expr
|
|||
val entity: ExpressExchangeEntity,
|
||||
val provider: ExpressProvider?,
|
||||
val isOutgoing: Boolean,
|
||||
val fromCurrency: CryptoCurrency? = null,
|
||||
val toCurrency: CryptoCurrency? = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -51,47 +55,62 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
|||
payoutHash = entity.payoutHash,
|
||||
fromFiat = Amount(
|
||||
currencySymbol = entity.fromCurrencyCode,
|
||||
value = entity.fromAmount.toBigDecimalOrZero(),
|
||||
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
|
||||
decimals = entity.fromPrecision,
|
||||
type = AmountType.FiatType(code = entity.fromCurrencyCode),
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount)?.toScaledBigDecimal(entity.to.decimals),
|
||||
decimals = entity.to.decimals,
|
||||
cryptoCurrency = value.toCurrency,
|
||||
),
|
||||
country = value.country,
|
||||
),
|
||||
txInfo = null,
|
||||
)
|
||||
}
|
||||
|
||||
data class Input(val entity: ExpressOnrampEntity, val provider: ExpressProvider?)
|
||||
data class Input(
|
||||
val entity: ExpressOnrampEntity,
|
||||
val provider: ExpressProvider?,
|
||||
val toCurrency: CryptoCurrency? = null,
|
||||
val country: OnrampCountry? = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertExchangeTransaction(entity: ExpressExchangeEntity, provider: ExpressProvider?): ExchangeTransaction {
|
||||
private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): ExchangeTransaction {
|
||||
val entity = value.entity
|
||||
return ExchangeTransaction(
|
||||
txId = entity.txId,
|
||||
status = ExpressExchangeStatus.fromRaw(entity.status),
|
||||
createdAtMillis = parseIsoMillis(entity.createdAt),
|
||||
provider = provider,
|
||||
provider = value.provider,
|
||||
payinHash = entity.payinHash,
|
||||
payoutHash = entity.payoutHash,
|
||||
fromAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
|
||||
amount = entity.from.amount.toBigDecimalOrZero(),
|
||||
amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
|
||||
decimals = entity.from.decimals,
|
||||
cryptoCurrency = value.fromCurrency,
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
|
||||
amount = (entity.to.actualAmount ?: entity.to.amount).toScaledBigDecimal(entity.to.decimals),
|
||||
decimals = entity.to.decimals,
|
||||
cryptoCurrency = value.toCurrency,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseIsoMillis(iso: String): Long = DateTime.parse(iso).millis
|
||||
|
||||
private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
/**
|
||||
* Parses a raw minimal-unit amount string from the express backend and scales it to the human-readable
|
||||
* value promised by [ExpressTransactionAsset.amount] (and the onramp fiat [Amount.value]).
|
||||
*/
|
||||
private fun String.toScaledBigDecimal(decimals: Int): BigDecimal =
|
||||
(this.toBigDecimalOrNull() ?: BigDecimal.ZERO).movePointLeft(decimals)
|
||||
|
||||
/**
|
||||
* Active (non-terminal) RAW status values passed to the DAO `observe…` queries so in-progress deals
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.data.txhistory.repository.converter
|
||||
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity
|
||||
import com.tangem.domain.onramp.model.OnrampCountry
|
||||
import com.tangem.domain.onramp.model.OnrampCurrency
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/** Maps a persisted [OnrampCountryEntity] into the domain [OnrampCountry]. */
|
||||
internal class OnrampCountryConverter : Converter<OnrampCountryEntity, OnrampCountry> {
|
||||
|
||||
override fun convert(value: OnrampCountryEntity): OnrampCountry {
|
||||
return OnrampCountry(
|
||||
id = "${value.alpha3}-${value.name}",
|
||||
name = value.name,
|
||||
code = value.code,
|
||||
image = value.image,
|
||||
alpha3 = value.alpha3,
|
||||
continent = value.continent,
|
||||
defaultCurrency = OnrampCurrency(
|
||||
name = value.defaultCurrency.name,
|
||||
code = value.defaultCurrency.code,
|
||||
image = value.defaultCurrency.image,
|
||||
precision = value.defaultCurrency.precision,
|
||||
unit = value.defaultCurrency.unit,
|
||||
),
|
||||
onrampAvailable = value.isOnrampAvailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.tangem.data.txhistory.repository.factory
|
||||
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.common.currency.CryptoCurrencyFactory
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Resolves a portfolio [CryptoCurrency] for every express asset (network id + contract address) referenced by a
|
||||
* batch of exchange/onramp entities.
|
||||
*
|
||||
* Strategy: read every account of every wallet ONCE (via [MultiAccountListSupplier]) and match each express asset
|
||||
* against the flattened portfolio currencies by network id + contract address. When nothing matches — notably
|
||||
* tokens that are not present in any portfolio — a coin is built for the asset's network as a fallback (for now).
|
||||
*/
|
||||
internal class ExpressTransactionAssetFactory @Inject constructor(
|
||||
private val multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
) {
|
||||
|
||||
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
|
||||
|
||||
/**
|
||||
* Builds a `assetId -> resolved currency` map covering both legs of every swap and the to-leg of every onramp.
|
||||
* Entries whose currency could not be resolved at all (no match and no fallback coin) are omitted.
|
||||
*/
|
||||
suspend fun create(
|
||||
userWalletId: UserWalletId,
|
||||
outgoingSwaps: List<ExpressExchangeEntity>,
|
||||
incomingSwaps: List<ExpressExchangeEntity>,
|
||||
onramps: List<ExpressOnrampEntity>,
|
||||
): Map<ExpressAsset.ID, CryptoCurrency> {
|
||||
val assetIds = buildSet {
|
||||
(outgoingSwaps + incomingSwaps).forEach { entity ->
|
||||
add(entity.from.toAssetId())
|
||||
add(entity.to.toAssetId())
|
||||
}
|
||||
onramps.forEach { entity -> add(entity.to.toAssetId()) }
|
||||
}
|
||||
if (assetIds.isEmpty()) return emptyMap()
|
||||
|
||||
val portfolioCurrencies = multiAccountListSupplier.invoke()
|
||||
.first()
|
||||
.flatMap { accountList -> accountList.flattenCurrencies() }
|
||||
|
||||
val userWallet = userWalletsListRepository.userWalletsSync()
|
||||
.firstOrNull { it.walletId == userWalletId }
|
||||
|
||||
return buildMap {
|
||||
assetIds.forEach { id ->
|
||||
val currency = portfolioCurrencies.findMatching(id) ?: createFallbackCoin(id, userWallet)
|
||||
if (currency != null) put(id, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<CryptoCurrency>.findMatching(id: ExpressAsset.ID): CryptoCurrency? {
|
||||
val isCoin = id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
|
||||
return firstOrNull { currency ->
|
||||
currency.network.rawId == id.networkId &&
|
||||
if (isCoin) {
|
||||
currency is CryptoCurrency.Coin
|
||||
} else {
|
||||
currency is CryptoCurrency.Token &&
|
||||
currency.contractAddress.equals(id.contractAddress, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO txHistory: tokens that are not in any portfolio cannot be resolved yet — fall back to a coin on the asset's
|
||||
// network.
|
||||
private fun createFallbackCoin(id: ExpressAsset.ID, userWallet: UserWallet?): CryptoCurrency.Coin? {
|
||||
userWallet ?: return null
|
||||
return cryptoCurrencyFactory.createCoin(
|
||||
networkId = id.networkId,
|
||||
extraDerivationPath = null,
|
||||
userWallet = userWallet,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||
|
||||
internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID =
|
||||
ExpressAsset.ID(networkId = network, contractAddress = contractAddress)
|
||||
|
|
@ -7,6 +7,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.express.ExpressRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.onramp.repositories.OnrampRepository
|
||||
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
|
||||
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
|
|
@ -14,7 +15,6 @@ import io.mockk.*
|
|||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
|
@ -29,14 +29,16 @@ internal class DefaultAppTxHistoryFetcherTest {
|
|||
private val selectedWalletUseCase: GetSelectedWalletUseCase = mockk()
|
||||
private val walletFetcherFactory: DefaultWalletTxHistoryFetcher.Factory = mockk()
|
||||
private val expressRepository: ExpressRepository = mockk()
|
||||
private val onrampRepository: OnrampRepository = mockk()
|
||||
|
||||
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository)
|
||||
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository, onrampRepository)
|
||||
every { selectedWalletUseCase.selectedFlow() } returns emptyFlow()
|
||||
coEvery { expressRepository.getProviders(any(), any()) } returns emptyList()
|
||||
coEvery { onrampRepository.fetchCountries(any()) } returns emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -147,15 +149,16 @@ internal class DefaultAppTxHistoryFetcherTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `loads express providers when selected wallet is multi-currency`() = runTest {
|
||||
fun `loads express providers and onramp countries for the first wallet on init`() = runTest {
|
||||
// Arrange
|
||||
val utils = createUtils()
|
||||
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
|
||||
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
|
||||
every { isMultiCurrency } returns true
|
||||
every { walletId } returns WALLET_ID_1
|
||||
}
|
||||
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
|
||||
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
|
||||
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
|
||||
every { walletFetcherFactory.create(WALLET_ID_1) } returns relaxedWalletFetcher()
|
||||
|
||||
// Act
|
||||
createFetcher(utils)
|
||||
|
|
@ -163,48 +166,40 @@ internal class DefaultAppTxHistoryFetcherTest {
|
|||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
|
||||
coVerify(exactly = 1) { onrampRepository.fetchCountries(wallet) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loads express providers only once per wallet`() = runTest {
|
||||
fun `does not load express data when there are no wallets`() = runTest {
|
||||
// Arrange
|
||||
val utils = createUtils()
|
||||
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf())
|
||||
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
|
||||
every { isMultiCurrency } returns true
|
||||
every { walletId } returns WALLET_ID_1
|
||||
}
|
||||
// Same wallet selected several times.
|
||||
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet, wallet, wallet)
|
||||
|
||||
// Act
|
||||
createFetcher(utils)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) }
|
||||
coVerify(inverse = true) { expressRepository.getProviders(any(), any()) }
|
||||
coVerify(inverse = true) { onrampRepository.fetchCountries(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provider loading failure does not break the wallet pipeline`() = runTest {
|
||||
// Arrange
|
||||
val utils = createUtils()
|
||||
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>())
|
||||
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
|
||||
val wallet = mockk<UserWallet.Cold>(relaxed = true) {
|
||||
every { isMultiCurrency } returns true
|
||||
every { walletId } returns WALLET_ID_1
|
||||
}
|
||||
every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet)
|
||||
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns
|
||||
MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet))
|
||||
coEvery { expressRepository.getProviders(any(), any()) } throws RuntimeException("boom")
|
||||
val walletFetcher1 = relaxedWalletFetcher()
|
||||
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
|
||||
|
||||
val fetcher = createFetcher(utils)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act — the provider error is swallowed, so the wallet pipeline must keep working.
|
||||
walletsFlow.value = linkedMapOf(WALLET_ID_1 to mockk())
|
||||
val fetcher = createFetcher(utils)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
|
|
@ -220,6 +215,7 @@ internal class DefaultAppTxHistoryFetcherTest {
|
|||
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultAppTxHistoryFetcher(
|
||||
utils = utils,
|
||||
expressRepository = expressRepository,
|
||||
onrampRepository = onrampRepository,
|
||||
getWalletsUseCase = getWalletsUseCase,
|
||||
selectedWalletUseCase = selectedWalletUseCase,
|
||||
walletTxHistoryFetcherFactory = walletFetcherFactory,
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ internal class ExpressTxHistoryConverterTest {
|
|||
assertThat(swap.txInfo).isNull()
|
||||
assertThat(swap.tx.status).isEqualTo(ExpressExchangeStatus.Waiting)
|
||||
assertThat(swap.createdAtMillis).isEqualTo(DateTime.parse(CREATED_AT).millis)
|
||||
assertThat(swap.tx.fromAsset.amount).isEqualTo(BigDecimal("1.5"))
|
||||
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.001"))
|
||||
// Raw backend amounts are scaled by decimals into the human-readable value the domain model promises.
|
||||
assertThat(swap.tx.fromAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("1.5"))
|
||||
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.001"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -50,14 +51,14 @@ internal class ExpressTxHistoryConverterTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN exchange entity with actual amount WHEN toOutgoingSwap THEN to-asset uses actual amount`() {
|
||||
// Arrange
|
||||
val entity = createExchangeEntity(toAmount = "0.001", toActualAmount = "0.00099")
|
||||
// Arrange (raw minimal-unit amounts, to-asset decimals = 8)
|
||||
val entity = createExchangeEntity(toAmount = "100000", toActualAmount = "99000")
|
||||
|
||||
// Act
|
||||
val swap = swapConverter.convert(ExpressSwapConverter.Input(entity, provider = null, isOutgoing = true))
|
||||
|
||||
// Assert
|
||||
assertThat(swap.tx.toAsset.amount).isEqualTo(BigDecimal("0.00099"))
|
||||
assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -73,17 +74,18 @@ internal class ExpressTxHistoryConverterTest {
|
|||
assertThat(onramp.txInfo).isNull()
|
||||
assertThat(onramp.tx.status).isEqualTo(ExpressOnrampStatus.Finished)
|
||||
assertThat(onramp.tx.fromFiat.currencySymbol).isEqualTo("USD")
|
||||
assertThat(onramp.tx.fromFiat.value).isEqualTo(BigDecimal("100.0"))
|
||||
assertThat(onramp.tx.fromFiat.value).isEquivalentAccordingToCompareTo(BigDecimal("100"))
|
||||
assertThat(onramp.tx.fromFiat.decimals).isEqualTo(2)
|
||||
assertThat(onramp.tx.fromFiat.type).isEqualTo(AmountType.FiatType("USD"))
|
||||
assertThat(onramp.tx.toAsset.amount).isEqualTo(BigDecimal("0.5"))
|
||||
assertThat(onramp.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
|
||||
}
|
||||
|
||||
private fun createExchangeEntity(
|
||||
payinHash: String? = "payin",
|
||||
payoutHash: String? = "payout",
|
||||
status: String = "waiting",
|
||||
toAmount: String = "0.001",
|
||||
// Raw minimal-unit amount (to-asset decimals = 8) → 0.001
|
||||
toAmount: String = "100000",
|
||||
toActualAmount: String? = null,
|
||||
) = ExpressExchangeEntity(
|
||||
txId = "tx-1",
|
||||
|
|
@ -111,7 +113,8 @@ internal class ExpressTxHistoryConverterTest {
|
|||
contractAddress = "",
|
||||
network = "ethereum",
|
||||
decimals = 18,
|
||||
amount = "1.5",
|
||||
// Raw minimal-unit amount (decimals = 18) → 1.5
|
||||
amount = "1500000000000000000",
|
||||
actualAmount = null,
|
||||
),
|
||||
to = ExpressExchangeEntity.AssetEmbedded(
|
||||
|
|
@ -139,13 +142,15 @@ internal class ExpressTxHistoryConverterTest {
|
|||
createdAt = CREATED_AT,
|
||||
updatedAt = CREATED_AT,
|
||||
fromCurrencyCode = "USD",
|
||||
fromAmount = "100.0",
|
||||
// Raw minimal-unit fiat amount (precision = 2) → 100
|
||||
fromAmount = "10000",
|
||||
fromPrecision = 2,
|
||||
to = ExpressOnrampEntity.AssetEmbedded(
|
||||
contractAddress = "0xtoken",
|
||||
network = "ethereum",
|
||||
decimals = 18,
|
||||
amount = "0.5",
|
||||
// Raw minimal-unit amount (decimals = 18) → 0.5
|
||||
amount = "500000000000000000",
|
||||
actualAmount = null,
|
||||
),
|
||||
paymentMethod = "card",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.data.walletmanager.utils
|
||||
|
||||
import com.tangem.domain.models.network.SdkAmount
|
||||
import com.tangem.domain.models.network.SdkAmountType
|
||||
import com.tangem.blockchain.common.Amount as BlockchainAmount
|
||||
import com.tangem.blockchain.common.AmountType as BlockchainAmountType
|
||||
|
||||
/** Maps the blockchain SDK [BlockchainAmount] to the serializable domain [SdkAmount]. */
|
||||
internal fun BlockchainAmount.toDomain(): SdkAmount = SdkAmount(
|
||||
currencySymbol = currencySymbol,
|
||||
value = value,
|
||||
decimals = decimals,
|
||||
type = type.toDomain(),
|
||||
)
|
||||
|
||||
private fun BlockchainAmountType.toDomain(): SdkAmountType = when (this) {
|
||||
BlockchainAmountType.Coin -> SdkAmountType.Coin
|
||||
BlockchainAmountType.Reserve -> SdkAmountType.Reserve
|
||||
is BlockchainAmountType.FeeResource -> SdkAmountType.FeeResource(name = name)
|
||||
is BlockchainAmountType.Token -> SdkAmountType.Token(contractAddress = token.contractAddress, id = token.id)
|
||||
is BlockchainAmountType.TokenYieldSupply -> SdkAmountType.Token(
|
||||
contractAddress = token.contractAddress,
|
||||
id = token.id,
|
||||
)
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ internal class SdkTransactionHistoryItemConverter(
|
|||
},
|
||||
type = typeConverter.convert(value),
|
||||
amount = requireNotNull(value.amount.value) { "Transaction amount value must not be null" },
|
||||
fee = value.fee.toDomain(),
|
||||
)
|
||||
|
||||
private fun SdkTransactionHistoryItem.SourceType.toDomain(): TxInfo.SourceType = when (this) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ internal class TransactionDataToTxHistoryItemConverter(
|
|||
},
|
||||
type = getTransactionType(value),
|
||||
amount = amount,
|
||||
fee = value.fee?.amount?.toDomain(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package com.tangem.data.walletmanager.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.walletmanager.model.SmartContractMethod
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SdkTransactionTypeConverterTest {
|
||||
|
||||
private val converter = SdkTransactionTypeConverter(
|
||||
smartContractMethods = mapOf(
|
||||
GASLESS_METHOD_ID to SmartContractMethod(info = null, source = null, name = "gaslessTransaction"),
|
||||
),
|
||||
yieldSupplyAddresses = emptySet(),
|
||||
gaslessFeeAddresses = setOf(FEE_RECIPIENT),
|
||||
)
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun convert(model: ConvertModel) {
|
||||
// Act
|
||||
val actual = converter.convert(gaslessItem(model.destination))
|
||||
|
||||
// Assert
|
||||
assertThat(actual).isEqualTo(model.expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
ConvertModel(
|
||||
destination = singleUser(RECIPIENT),
|
||||
expected = TxInfo.TransactionType.UnknownOperation,
|
||||
),
|
||||
ConvertModel(
|
||||
destination = singleUser(FEE_RECIPIENT),
|
||||
expected = TxInfo.TransactionType.GaslessFee,
|
||||
),
|
||||
ConvertModel(
|
||||
destination = singleUser(FEE_RECIPIENT.uppercase()),
|
||||
expected = TxInfo.TransactionType.GaslessFee,
|
||||
),
|
||||
ConvertModel(
|
||||
destination = TransactionHistoryItem.DestinationType.Multiple(
|
||||
addressTypes = listOf(
|
||||
TransactionHistoryItem.AddressType.User(RECIPIENT),
|
||||
TransactionHistoryItem.AddressType.User(FEE_RECIPIENT),
|
||||
),
|
||||
),
|
||||
expected = TxInfo.TransactionType.UnknownOperation,
|
||||
),
|
||||
)
|
||||
|
||||
private fun gaslessItem(destination: TransactionHistoryItem.DestinationType) = TransactionHistoryItem(
|
||||
txHash = "0xhash",
|
||||
timestamp = 0L,
|
||||
isOutgoing = true,
|
||||
destinationType = destination,
|
||||
sourceType = TransactionHistoryItem.SourceType.Single(SENDER),
|
||||
status = TransactionHistoryItem.TransactionStatus.Confirmed,
|
||||
type = TransactionHistoryItem.TransactionType.ContractMethod(id = GASLESS_METHOD_ID),
|
||||
amount = mockk(),
|
||||
fee = mockk(),
|
||||
)
|
||||
|
||||
private fun singleUser(address: String) = TransactionHistoryItem.DestinationType.Single(
|
||||
addressType = TransactionHistoryItem.AddressType.User(address),
|
||||
)
|
||||
|
||||
internal data class ConvertModel(
|
||||
val destination: TransactionHistoryItem.DestinationType,
|
||||
val expected: TxInfo.TransactionType,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val GASLESS_METHOD_ID = "0x6234d42b"
|
||||
const val SENDER = "0x9ffd974772bda94d288240c1b22f367ce75ccd7f"
|
||||
const val RECIPIENT = "0x2222222222222222222222222222222222222222"
|
||||
const val FEE_RECIPIENT = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ dependencies {
|
|||
implementation(projects.core.analytics)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.transaction)
|
||||
implementation(projects.domain.yieldSupply)
|
||||
implementation(projects.domain.yieldSupply.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.tangem.blockchain.common.*
|
|||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
|
|
@ -240,6 +241,37 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
|
||||
}
|
||||
|
||||
override suspend fun getYieldModuleVersionStatus(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
): YieldModuleVersionStatus = withContext(dispatchers.io) {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = network.toBlockchain(),
|
||||
derivationPath = network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found for $network")
|
||||
walletManager.checkModuleVersionStatus()
|
||||
}
|
||||
|
||||
override suspend fun createPartialWithdrawCallData(
|
||||
userWalletId: UserWalletId,
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
amount: Amount,
|
||||
): SmartContractCallData = withContext(dispatchers.io) {
|
||||
require(cryptoCurrency is CryptoCurrency.Token)
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = cryptoCurrency.network.toBlockchain(),
|
||||
derivationPath = cryptoCurrency.network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found")
|
||||
val withdrawCallData = YieldSupplyContractCallDataProviderFactory.getWithdrawCallData(
|
||||
tokenContractAddress = cryptoCurrency.contractAddress,
|
||||
amount = amount,
|
||||
)
|
||||
val versionStatus = walletManager.checkModuleVersionStatus()
|
||||
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, withdrawCallData)
|
||||
}
|
||||
|
||||
private suspend fun getYieldTokenStatus(
|
||||
walletManager: WalletManager,
|
||||
cryptoCurrency: CryptoCurrency.Token,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
|
|||
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
|
||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
|
||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
||||
import com.tangem.domain.transaction.GaslessYieldRepository
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
|
||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
|
|
@ -41,6 +42,14 @@ internal object YieldSupplyDataModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGaslessYieldRepository(
|
||||
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
|
||||
): GaslessYieldRepository {
|
||||
return yieldSupplyTransactionRepository
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideYieldSupplyMarketRepository(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
package com.tangem.data.yield.supply
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||
import com.tangem.blockchain.common.WalletManager
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultYieldModuleAddressProviderTest {
|
||||
|
||||
private val walletManager: WalletManager = mockk()
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk()
|
||||
|
||||
private val provider = DefaultYieldModuleAddressProvider(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId("abcdef012345")
|
||||
private val otherWalletId = UserWalletId("fedcba543210")
|
||||
private val network = network()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(walletManager, walletManagersFacade)
|
||||
provider.invalidate(null)
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(any(), any(), any())
|
||||
} returns walletManager
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-zero address WHEN getOrFetch THEN returns and caches it`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
|
||||
|
||||
// Act
|
||||
val first = provider.getOrFetch(userWalletId, network)
|
||||
val second = provider.getOrFetch(userWalletId, network)
|
||||
|
||||
// Assert
|
||||
assertThat(first).isEqualTo(ADDRESS)
|
||||
assertThat(second).isEqualTo(ADDRESS)
|
||||
coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero address WHEN getOrFetch THEN returns null and does not cache`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManager.getYieldModuleAddress() } returns EthereumUtils.ZERO_ADDRESS
|
||||
|
||||
// Act
|
||||
val first = provider.getOrFetch(userWalletId, network)
|
||||
val second = provider.getOrFetch(userWalletId, network)
|
||||
|
||||
// Assert — null result is never cached, so the manager is queried again
|
||||
assertThat(first).isNull()
|
||||
assertThat(second).isNull()
|
||||
coVerify(exactly = 2) { walletManager.getYieldModuleAddress() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing wallet manager WHEN getOrFetch THEN throws`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null
|
||||
|
||||
// Act
|
||||
val error = runCatching { provider.getOrFetch(userWalletId, network) }.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isInstanceOf(IllegalStateException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached address WHEN invalidate for that wallet THEN it is refetched`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
|
||||
provider.getOrFetch(userWalletId, network)
|
||||
|
||||
// Act
|
||||
provider.invalidate(userWalletId)
|
||||
provider.getOrFetch(userWalletId, network)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 2) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two cached wallets WHEN invalidate one THEN only that one is refetched`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
|
||||
provider.getOrFetch(userWalletId, network)
|
||||
provider.getOrFetch(otherWalletId, network)
|
||||
|
||||
// Act
|
||||
provider.invalidate(userWalletId)
|
||||
provider.getOrFetch(userWalletId, network) // refetched
|
||||
provider.getOrFetch(otherWalletId, network) // still cached
|
||||
|
||||
// Assert — 2 initial fetches + 1 refetch for the invalidated wallet only
|
||||
coVerify(exactly = 3) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached addresses WHEN invalidate all THEN every wallet is refetched`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
|
||||
provider.getOrFetch(userWalletId, network)
|
||||
provider.getOrFetch(otherWalletId, network)
|
||||
|
||||
// Act
|
||||
provider.invalidate(null)
|
||||
provider.getOrFetch(userWalletId, network)
|
||||
provider.getOrFetch(otherWalletId, network)
|
||||
|
||||
// Assert — 2 initial + 2 after a full invalidation
|
||||
coVerify(exactly = 4) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two concurrent fetches for the same key WHEN one is in flight THEN manager is created once`() = runTest {
|
||||
// Arrange — io dispatcher we control so both callers reach the mutex before the cache is populated
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
val concurrentProvider = DefaultYieldModuleAddressProvider(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
),
|
||||
)
|
||||
val proceed = CompletableDeferred<Unit>()
|
||||
coEvery { walletManager.getYieldModuleAddress() } coAnswers {
|
||||
proceed.await()
|
||||
ADDRESS
|
||||
}
|
||||
|
||||
// Act — both pass the lock-free pre-check; one holds the lock and fetches, the other waits on it
|
||||
val first = launch { concurrentProvider.getOrFetch(userWalletId, network) }
|
||||
val second = launch { concurrentProvider.getOrFetch(userWalletId, network) }
|
||||
runCurrent()
|
||||
// At the barrier both callers have passed the lock-free pre-check (cache still empty): one holds the mutex and
|
||||
// awaits the gate, the other is blocked on the lock. Asserting neither completed proves the second did NOT
|
||||
// short-circuit on the outer pre-check, so it must hit the in-lock double-check once released.
|
||||
assertThat(first.isCompleted).isFalse()
|
||||
assertThat(second.isCompleted).isFalse()
|
||||
proceed.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
first.join()
|
||||
second.join()
|
||||
|
||||
// Assert — the second caller is served from cache via the in-lock double-check
|
||||
coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
|
||||
coVerify(exactly = 1) { walletManager.getYieldModuleAddress() }
|
||||
}
|
||||
|
||||
private fun network(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.data.yield.supply
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.yield.supply.YieldSupplyError
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
|
||||
internal class DefaultYieldSupplyErrorResolverTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN a YieldSupplyError WHEN resolve THEN returns the same instance`() {
|
||||
// Arrange
|
||||
val error = YieldSupplyError.DataError(IOException("boom"))
|
||||
|
||||
// Act
|
||||
val result = DefaultYieldSupplyErrorResolver.resolve(error)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isSameInstanceAs(error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a generic throwable WHEN resolve THEN wraps it into DataError`() {
|
||||
// Arrange
|
||||
val throwable = IllegalStateException("unexpected")
|
||||
|
||||
// Act
|
||||
val result = DefaultYieldSupplyErrorResolver.resolve(throwable)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(YieldSupplyError.DataError(throwable))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
package com.tangem.data.yield.supply
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.TransactionStatus
|
||||
import com.tangem.blockchain.common.WalletManager
|
||||
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
|
||||
import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
|
||||
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
|
||||
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.get
|
||||
import com.tangem.datasource.local.preferences.utils.store
|
||||
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.io.IOException
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultYieldSupplyRepositoryTest {
|
||||
|
||||
private val yieldSupplyApi: YieldSupplyApi = mockk()
|
||||
private val store: YieldMarketsStore = mockk(relaxed = true)
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk()
|
||||
private val analyticsExceptionHandler: AnalyticsExceptionHandler = mockk(relaxed = true)
|
||||
private val appPreferencesStore: AppPreferencesStore = mockk()
|
||||
|
||||
private val repository = DefaultYieldSupplyRepository(
|
||||
yieldSupplyApi = yieldSupplyApi,
|
||||
store = store,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
analyticsExceptionHandler = analyticsExceptionHandler,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId("abcdef012345")
|
||||
private val token = token()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(yieldSupplyApi, store, walletManagersFacade, analyticsExceptionHandler)
|
||||
}
|
||||
|
||||
// region markets
|
||||
@Test
|
||||
fun `GIVEN cached dtos WHEN getCachedMarkets THEN returns enriched domain`() = runTest {
|
||||
// Arrange
|
||||
coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = 1))
|
||||
|
||||
// Act
|
||||
val result = repository.getCachedMarkets()
|
||||
|
||||
// Assert — chainId 1 is enriched to its network id
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat(result.first().backendId).isEqualTo("ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty cache WHEN getCachedMarkets THEN returns empty list`() = runTest {
|
||||
// Arrange
|
||||
coEvery { store.getSyncOrNull() } returns null
|
||||
|
||||
// Act
|
||||
val result = repository.getCachedMarkets()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached dto with unmapped chain id WHEN getCachedMarkets THEN backend id is null`() = runTest {
|
||||
// Arrange — chainId -1 (the converter's default for a DTO without a chainId) maps to no network
|
||||
coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = -1))
|
||||
|
||||
// Act
|
||||
val result = repository.getCachedMarkets()
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat(result.first().backendId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api returns markets WHEN updateMarkets THEN stores dtos and returns domain`() = runTest {
|
||||
// Arrange
|
||||
val dto = marketDto(chainId = 1)
|
||||
coEvery { yieldSupplyApi.getYieldMarkets(any()) } returns ApiResponse.Success(
|
||||
YieldMarketsResponse(marketDtos = listOf(dto), lastUpdated = "now"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = repository.updateMarkets()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(YieldMarketTokenConverter.convert(dto))
|
||||
coVerify(exactly = 1) { store.store(listOf(dto)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN store flow WHEN getMarketsFlow THEN emits enriched domain`() = runTest {
|
||||
// Arrange
|
||||
every { store.get() } returns flowOf(listOf(marketDto(chainId = 1)))
|
||||
|
||||
// Act
|
||||
val result = repository.getMarketsFlow().first()
|
||||
|
||||
// Assert
|
||||
assertThat(result.first().backendId).isEqualTo("ethereum")
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region token status / chart
|
||||
@Test
|
||||
fun `GIVEN evm token WHEN getTokenStatus THEN returns converted market token`() = runTest {
|
||||
// Arrange
|
||||
val dto = marketDto(chainId = 1)
|
||||
coEvery { yieldSupplyApi.getYieldTokenStatus(1, token.contractAddress) } returns ApiResponse.Success(dto)
|
||||
|
||||
// Act
|
||||
val result = repository.getTokenStatus(token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(YieldMarketTokenConverter.convert(dto))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-evm token WHEN getTokenStatus THEN throws`() = runTest {
|
||||
// Arrange
|
||||
val nonEvm = token(rawId = "unknown-network-xyz")
|
||||
|
||||
// Act
|
||||
val error = runCatching { repository.getTokenStatus(nonEvm) }.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isInstanceOf(IllegalStateException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN evm token WHEN getTokenChart THEN returns converted chart`() = runTest {
|
||||
// Arrange
|
||||
coEvery { yieldSupplyApi.getYieldTokenChart(1, token.contractAddress) } returns ApiResponse.Success(
|
||||
chartResponse(),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = repository.getTokenChart(token)
|
||||
|
||||
// Assert
|
||||
assertThat(result.avr).isEqualTo(4.25)
|
||||
assertThat(result.y).containsExactly(3.5).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-evm token WHEN getTokenChart THEN throws`() = runTest {
|
||||
// Arrange
|
||||
val nonEvm = token(rawId = "unknown-network-xyz")
|
||||
|
||||
// Act
|
||||
val error = runCatching { repository.getTokenChart(nonEvm) }.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isInstanceOf(IllegalStateException::class.java)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region isYieldSupplySupported
|
||||
@Test
|
||||
fun `GIVEN supported yield provider WHEN isYieldSupplySupported THEN returns true`() = runTest {
|
||||
// Arrange — WalletManager itself implements YieldSupplyProvider
|
||||
val walletManager = mockk<WalletManager> { every { isSupported() } returns true }
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager
|
||||
|
||||
// Act
|
||||
val result = repository.isYieldSupplySupported(userWalletId, token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unsupported yield provider WHEN isYieldSupplySupported THEN returns false`() = runTest {
|
||||
// Arrange
|
||||
val walletManager = mockk<WalletManager> { every { isSupported() } returns false }
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager
|
||||
|
||||
// Act
|
||||
val result = repository.isYieldSupplySupported(userWalletId, token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no wallet manager WHEN isYieldSupplySupported THEN sends analytics and returns false`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null
|
||||
|
||||
// Act
|
||||
val result = repository.isYieldSupplySupported(userWalletId, token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isFalse()
|
||||
verify { analyticsExceptionHandler.sendException(any()) }
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region activate / deactivate
|
||||
@Test
|
||||
fun `GIVEN api returns active WHEN activateProtocol THEN returns true`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
yieldSupplyApi.activateYieldModule(body = any(), userWalletId = any())
|
||||
} returns ApiResponse.Success(statusResponse(isActive = true))
|
||||
|
||||
// Act
|
||||
val result = repository.activateProtocol(userWalletId, token, ADDRESS)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-evm token WHEN activateProtocol THEN throws`() = runTest {
|
||||
// Arrange
|
||||
val nonEvm = token(rawId = "unknown-network-xyz")
|
||||
|
||||
// Act
|
||||
val error = runCatching { repository.activateProtocol(userWalletId, nonEvm, ADDRESS) }.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isInstanceOf(IllegalStateException::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api returns inactive WHEN deactivateProtocol THEN returns false`() = runTest {
|
||||
// Arrange
|
||||
coEvery { yieldSupplyApi.deactivateYieldModule(any()) } returns ApiResponse.Success(
|
||||
statusResponse(isActive = false),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = repository.deactivateProtocol(token, ADDRESS)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-evm token WHEN deactivateProtocol THEN throws`() = runTest {
|
||||
// Arrange
|
||||
val nonEvm = token(rawId = "unknown-network-xyz")
|
||||
|
||||
// Act
|
||||
val error = runCatching { repository.deactivateProtocol(nonEvm, ADDRESS) }.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isInstanceOf(IllegalStateException::class.java)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region pending status (in-memory)
|
||||
@Test
|
||||
fun `GIVEN saved pending status WHEN getTokenProtocolPendingStatus THEN returns it`() = runTest {
|
||||
// Arrange
|
||||
val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L)
|
||||
repository.saveTokenProtocolPendingStatus(userWalletId, token, status)
|
||||
|
||||
// Act
|
||||
val result = repository.getTokenProtocolPendingStatus(userWalletId, token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN saved then cleared WHEN getTokenProtocolPendingStatus THEN returns null`() = runTest {
|
||||
// Arrange
|
||||
repository.saveTokenProtocolPendingStatus(
|
||||
userWalletId,
|
||||
token,
|
||||
YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L),
|
||||
)
|
||||
|
||||
// Act
|
||||
repository.saveTokenProtocolPendingStatus(userWalletId, token, null)
|
||||
val result = repository.getTokenProtocolPendingStatus(userWalletId, token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN saved status WHEN flow collected THEN emits the status`() = runTest {
|
||||
// Arrange
|
||||
val status = YieldSupplyPendingStatus.Exit(txIds = listOf("0x9"), createdAt = 1L)
|
||||
repository.saveTokenProtocolPendingStatus(userWalletId, token, status)
|
||||
|
||||
// Act
|
||||
val emitted = repository.getTokenProtocolPendingStatusFlow(userWalletId, token).first()
|
||||
|
||||
// Assert
|
||||
assertThat(emitted).isEqualTo(status)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region pending tx hashes
|
||||
@Test
|
||||
fun `GIVEN unconfirmed and confirmed txs WHEN getPendingTxHashes THEN returns only unconfirmed hashes`() = runTest {
|
||||
// Arrange
|
||||
val walletManager = mockk<WalletManager> {
|
||||
every { wallet.recentTransactions } returns mutableListOf(
|
||||
tx(TransactionStatus.Unconfirmed, "0xUnconfirmed"),
|
||||
tx(TransactionStatus.Confirmed, "0xConfirmed"),
|
||||
)
|
||||
}
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any<Network>()) } returns walletManager
|
||||
|
||||
// Act
|
||||
val result = repository.getPendingTxHashes(userWalletId, token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly("0xUnconfirmed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no wallet manager WHEN getPendingTxHashes THEN returns empty`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any<Network>()) } returns null
|
||||
|
||||
// Act
|
||||
val result = repository.getPendingTxHashes(userWalletId, token)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region promo banner preference
|
||||
@Test
|
||||
fun `GIVEN stored flag WHEN getShouldShowYieldPromoBanner THEN emits it`() = runTest {
|
||||
// Arrange
|
||||
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
try {
|
||||
every {
|
||||
appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true)
|
||||
} returns flowOf(false)
|
||||
|
||||
// Act
|
||||
val result = repository.getShouldShowYieldPromoBanner().first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isFalse()
|
||||
} finally {
|
||||
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN setShouldShowYieldPromoBanner THEN stores the value`() = runTest {
|
||||
// Arrange
|
||||
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
try {
|
||||
coEvery {
|
||||
appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false)
|
||||
} returns Unit
|
||||
|
||||
// Act
|
||||
repository.setShouldShowYieldPromoBanner(false)
|
||||
|
||||
// Assert
|
||||
coVerify { appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false) }
|
||||
} finally {
|
||||
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
private fun tx(status: TransactionStatus, hash: String): TransactionData.Uncompiled = mockk {
|
||||
every { this@mockk.status } returns status
|
||||
every { this@mockk.hash } returns hash
|
||||
}
|
||||
|
||||
private fun marketDto(chainId: Int) = YieldSupplyMarketTokenDto(
|
||||
tokenAddress = "0xToken",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether",
|
||||
apy = BigDecimal("5.5"),
|
||||
decimals = 6,
|
||||
isActive = true,
|
||||
chainId = chainId,
|
||||
maxFeeNative = BigDecimal("0.005"),
|
||||
maxFeeUSD = BigDecimal("12.34"),
|
||||
)
|
||||
|
||||
private fun chartResponse() = YieldTokenChartResponse(
|
||||
underlying = "USDT",
|
||||
market = "aave",
|
||||
bucketSizeDays = 1,
|
||||
period = "30d",
|
||||
data = listOf(YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5"))),
|
||||
averageApy = BigDecimal("4.25"),
|
||||
)
|
||||
|
||||
private fun statusResponse(isActive: Boolean) = YieldModuleStatusResponse(
|
||||
tokenAddress = "0xToken",
|
||||
chainId = 1,
|
||||
isActive = isActive,
|
||||
activatedAt = null,
|
||||
deactivatedAt = null,
|
||||
)
|
||||
|
||||
private fun token(rawId: String = "ethereum", contractAddress: String = "0xToken"): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = rawId, derivationPath = derivationPath),
|
||||
name = "Net",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawId),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawId),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = contractAddress,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,14 @@ package com.tangem.data.yield.supply
|
|||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.AmountType
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.WalletManager
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory
|
||||
import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -306,4 +309,35 @@ class DefaultYieldSupplyTransactionRepositoryTest {
|
|||
Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java)
|
||||
Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createPartialWithdrawCallData returns withdraw call data when module is up to date`() = runTest {
|
||||
coEvery { walletManager.checkModuleVersionStatus() } returns YieldModuleVersionStatus.UpToDate
|
||||
|
||||
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { contractAddress } returns mockedContractAddress
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val amount = Amount(
|
||||
currencySymbol = "USDC",
|
||||
value = BigDecimal("1.5"),
|
||||
decimals = 6,
|
||||
type = AmountType.Token(
|
||||
token = Token(
|
||||
symbol = "USDC",
|
||||
contractAddress = mockedContractAddress,
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = repository.createPartialWithdrawCallData(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrency = token,
|
||||
amount = amount,
|
||||
)
|
||||
|
||||
Truth.assertThat(result).isNotNull()
|
||||
Truth.assertThat(result.methodId).isEqualTo("0xf3fef3a3")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.data.yield.supply.converters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
|
||||
import com.tangem.domain.yield.supply.models.YieldMarketToken
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldMarketTokenConverterTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN fully populated dto WHEN convert THEN maps every field`() {
|
||||
// Arrange
|
||||
val dto = YieldSupplyMarketTokenDto(
|
||||
tokenAddress = "0xToken",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether",
|
||||
apy = BigDecimal("5.5"),
|
||||
decimals = 6,
|
||||
isActive = true,
|
||||
chainId = 1,
|
||||
maxFeeNative = BigDecimal("0.005"),
|
||||
maxFeeUSD = BigDecimal("12.34"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = YieldMarketTokenConverter.convert(dto)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(
|
||||
YieldMarketToken(
|
||||
tokenAddress = "0xToken",
|
||||
chainId = 1,
|
||||
apy = BigDecimal("5.5"),
|
||||
isActive = true,
|
||||
maxFeeNative = BigDecimal("0.005"),
|
||||
maxFeeUSD = BigDecimal("12.34"),
|
||||
backendId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dto with null fields WHEN convert THEN applies defaults`() {
|
||||
// Arrange
|
||||
val dto = YieldSupplyMarketTokenDto()
|
||||
|
||||
// Act
|
||||
val result = YieldMarketTokenConverter.convert(dto)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(
|
||||
YieldMarketToken(
|
||||
tokenAddress = "",
|
||||
chainId = -1,
|
||||
apy = BigDecimal.ZERO,
|
||||
isActive = false,
|
||||
maxFeeNative = BigDecimal.ZERO,
|
||||
maxFeeUSD = BigDecimal.ZERO,
|
||||
backendId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.tangem.data.yield.supply.converters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldTokenChartConverterTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN response with data points WHEN convert THEN splits avgApy into y and bucketIndex into x preserving order`() {
|
||||
// Arrange
|
||||
val response = response(
|
||||
averageApy = BigDecimal("4.25"),
|
||||
points = listOf(
|
||||
YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5")),
|
||||
YieldTokenChartResponse.DataPoint(bucketIndex = 1, avgApy = BigDecimal("4.0")),
|
||||
YieldTokenChartResponse.DataPoint(bucketIndex = 2, avgApy = BigDecimal("5.0")),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = YieldTokenChartConverter.convert(response)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(
|
||||
YieldSupplyMarketChartData(
|
||||
y = listOf(3.5, 4.0, 5.0),
|
||||
x = listOf(0.0, 1.0, 2.0),
|
||||
avr = 4.25,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN response with empty data WHEN convert THEN returns empty y and x with average`() {
|
||||
// Arrange
|
||||
val response = response(averageApy = BigDecimal("1.0"), points = emptyList())
|
||||
|
||||
// Act
|
||||
val result = YieldTokenChartConverter.convert(response)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(
|
||||
YieldSupplyMarketChartData(y = emptyList(), x = emptyList(), avr = 1.0),
|
||||
)
|
||||
}
|
||||
|
||||
private fun response(averageApy: BigDecimal, points: List<YieldTokenChartResponse.DataPoint>) =
|
||||
YieldTokenChartResponse(
|
||||
underlying = "USDT",
|
||||
market = "aave",
|
||||
bucketSizeDays = 1,
|
||||
period = "30d",
|
||||
data = points,
|
||||
averageApy = averageApy,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
package com.tangem.data.yield.supply.promo
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter
|
||||
import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse
|
||||
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
|
||||
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.yield.supply.models.YieldBoostPromo
|
||||
import com.tangem.domain.yield.supply.models.YieldBoostStatus
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.io.IOException
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultYieldPromoRepositoryTest {
|
||||
|
||||
private val tangemApi: TangemTechApi = mockk()
|
||||
private val promoStore: YieldBoostPromoStore = mockk(relaxed = true)
|
||||
private val statusStore: YieldBoostStatusStore = mockk(relaxed = true)
|
||||
|
||||
private val repository = DefaultYieldPromoRepository(
|
||||
tangemApi = tangemApi,
|
||||
promoStore = promoStore,
|
||||
statusStore = statusStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId("abcdef012345")
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(tangemApi, promoStore, statusStore)
|
||||
}
|
||||
|
||||
// region getYieldBoostPromo
|
||||
@Test
|
||||
fun `GIVEN cached promo and no refresh WHEN getYieldBoostPromo THEN returns cache without api`() = runTest {
|
||||
// Arrange
|
||||
val cached = YieldBoostPromo.None
|
||||
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
|
||||
|
||||
// Act
|
||||
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(cached)
|
||||
coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no cache WHEN getYieldBoostPromo THEN fetches stores and returns converted`() = runTest {
|
||||
// Arrange
|
||||
val dto = matchingPromoDto()
|
||||
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
|
||||
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
|
||||
PromotionsResponse(promotions = listOf(dto)),
|
||||
)
|
||||
val expected = YieldBoostPromoConverter.convert(dto)
|
||||
|
||||
// Act
|
||||
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(expected)
|
||||
coVerify(exactly = 1) { promoStore.store(userWalletId, expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached promo and force refresh WHEN getYieldBoostPromo THEN fetches anyway`() = runTest {
|
||||
// Arrange
|
||||
coEvery { promoStore.getSyncOrNull(userWalletId) } returns YieldBoostPromo.None
|
||||
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
|
||||
PromotionsResponse(promotions = listOf(matchingPromoDto())),
|
||||
)
|
||||
|
||||
// Act
|
||||
repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { tangemApi.getPromotions(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no matching promo name WHEN getYieldBoostPromo THEN returns None`() = runTest {
|
||||
// Arrange
|
||||
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
|
||||
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
|
||||
PromotionsResponse(promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null))),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(YieldBoostPromo.None)
|
||||
coVerify(exactly = 1) { promoStore.store(userWalletId, YieldBoostPromo.None) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fetch fails and cache present WHEN getYieldBoostPromo THEN falls back to cache`() = runTest {
|
||||
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
|
||||
val cached = YieldBoostPromo.None
|
||||
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network")
|
||||
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
|
||||
|
||||
// Act
|
||||
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(cached)
|
||||
coVerify(exactly = 0) { promoStore.store(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fetch fails and no cache WHEN getYieldBoostPromo THEN rethrows`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network")
|
||||
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
|
||||
|
||||
// Act
|
||||
val error = runCatching { repository.getYieldBoostPromo(userWalletId, forceRefresh = true) }
|
||||
.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isInstanceOf(IOException::class.java)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region getYieldBoostStatus
|
||||
@Test
|
||||
fun `GIVEN cached status and no refresh WHEN getYieldBoostStatus THEN returns cache without api`() = runTest {
|
||||
// Arrange
|
||||
val cached = YieldBoostStatus.NotStarted
|
||||
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
|
||||
|
||||
// Act
|
||||
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(cached)
|
||||
coVerify(exactly = 0) { tangemApi.getYieldBoostStatus(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no cache WHEN getYieldBoostStatus THEN fetches stores and returns converted`() = runTest {
|
||||
// Arrange
|
||||
val response = statusResponse()
|
||||
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
|
||||
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(response)
|
||||
val expected = YieldBoostStatusConverter.convert(response)
|
||||
|
||||
// Act
|
||||
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(expected)
|
||||
coVerify(exactly = 1) { statusStore.store(userWalletId, expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached status and force refresh WHEN getYieldBoostStatus THEN fetches anyway`() = runTest {
|
||||
// Arrange
|
||||
coEvery { statusStore.getSyncOrNull(userWalletId) } returns YieldBoostStatus.NotStarted
|
||||
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(statusResponse())
|
||||
|
||||
// Act
|
||||
repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { tangemApi.getYieldBoostStatus(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fetch fails and cache present WHEN getYieldBoostStatus THEN falls back to cache`() = runTest {
|
||||
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
|
||||
val cached = YieldBoostStatus.NotStarted
|
||||
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
|
||||
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
|
||||
|
||||
// Act
|
||||
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(cached)
|
||||
coVerify(exactly = 0) { statusStore.store(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fetch fails and no cache WHEN getYieldBoostStatus THEN rethrows`() = runTest {
|
||||
// Arrange
|
||||
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
|
||||
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
|
||||
|
||||
// Act
|
||||
val error = runCatching { repository.getYieldBoostStatus(userWalletId, forceRefresh = true) }
|
||||
.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isInstanceOf(IOException::class.java)
|
||||
}
|
||||
// endregion
|
||||
|
||||
private fun matchingPromoDto() = PromotionsResponse.PromotionDto(
|
||||
name = "yield-apr-boost",
|
||||
all = PromotionsResponse.PromotionDto.All(
|
||||
timeline = PromotionsResponse.PromotionDto.Timeline(
|
||||
start = "2026-06-15T00:00:00.000Z",
|
||||
end = "2027-06-15T22:00:00.000Z",
|
||||
),
|
||||
tokens = listOf(
|
||||
PromotionsResponse.PromotionDto.PromoToken(
|
||||
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
||||
tokenSymbol = "USDC",
|
||||
tokenName = "USD Coin",
|
||||
networkId = "ethereum",
|
||||
),
|
||||
),
|
||||
status = "active",
|
||||
link = "https://example.com/terms",
|
||||
),
|
||||
)
|
||||
|
||||
private fun statusResponse() = YieldBoostStatusResponse(
|
||||
tokenName = "USD Coin",
|
||||
networkId = "ethereum",
|
||||
moduleAddress = "0xModule",
|
||||
userAddress = "0xUser",
|
||||
contractAddress = "0xContract",
|
||||
promoEnrollmentStatus = "NOT_STARTED",
|
||||
qualificationEndDate = null,
|
||||
disqualificationReason = null,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue