Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-15 11:56:24 +03:00
parent d77875be51
commit e45d91b15e
15 changed files with 389 additions and 67 deletions

View file

@ -2,13 +2,14 @@ package com.tangem.data.common.currency
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
import com.tangem.data.common.wallet.WalletServerBinder
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
@ -27,6 +28,8 @@ class UserTokensSaver(
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
private val addressesEnricher: UserTokensResponseAddressesEnricher,
private val walletServerBinder: WalletServerBinder,
private val appsFlyerConversionStore: AppsFlyerConversionStore,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val pushTokensRetryerPool: RetryerPool,
) {
@ -69,6 +72,8 @@ class UserTokensSaver(
pushNew(userWallet = userWallet, response = enrichedResponse, onFailSend = onFailSend)
} else {
val conversionData = appsFlyerConversionStore.get()
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy(
walletName = userWallet.name,
walletType = WalletType.from(userWallet),
@ -116,7 +121,7 @@ class UserTokensSaver(
apiResponse.cause.isNetworkError(ApiResponseError.HttpException.Code.NOT_FOUND)
if (isWalletNotFound) {
tangemTechApi.createWallet(body = WalletIdBodyConverter.convert(userWallet)).bind()
walletServerBinder.bind(userWallet).bind()
tangemTechApi.saveTokens(
userId = userWallet.walletId.stringValue,

View file

@ -7,7 +7,10 @@ import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.*
import com.tangem.data.common.quote.DefaultQuotesFetcher
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.data.common.wallet.DefaultWalletServerBinder
import com.tangem.data.common.wallet.WalletServerBinder
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
@ -72,6 +75,8 @@ internal object DataCommonModule {
userTokensResponseStore: UserTokensResponseStore,
dispatchers: CoroutineDispatcherProvider,
addressesEnricher: UserTokensResponseAddressesEnricher,
walletServerBinder: WalletServerBinder,
appsFlyerConversionStore: AppsFlyerConversionStore,
accountsFeatureToggles: AccountsFeatureToggles,
): UserTokensSaver {
return UserTokensSaver(
@ -84,6 +89,8 @@ internal object DataCommonModule {
pushTokensRetryerPool = RetryerPool(
coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default),
),
walletServerBinder = walletServerBinder,
appsFlyerConversionStore = appsFlyerConversionStore,
)
}
@ -98,4 +105,20 @@ internal object DataCommonModule {
fun provideETagsStore(appPreferencesStore: AppPreferencesStore): ETagsStore {
return DefaultETagsStore(appPreferencesStore = appPreferencesStore)
}
@Provides
@Singleton
fun provideWalletServerBinder(
userWalletsStore: UserWalletsStore,
appsFlyerConversionStore: AppsFlyerConversionStore,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): WalletServerBinder {
return DefaultWalletServerBinder(
userWalletsStore = userWalletsStore,
appsFlyerConversionStore = appsFlyerConversionStore,
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.data.common.wallet
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultWalletServerBinder(
private val userWalletsStore: UserWalletsStore,
private val appsFlyerConversionStore: AppsFlyerConversionStore,
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletServerBinder {
override suspend fun bind(userWalletId: UserWalletId): ApiResponse<Unit>? {
val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null
return bind(userWallet)
}
override suspend fun bind(userWallet: UserWallet): ApiResponse<Unit> {
val conversionData = appsFlyerConversionStore.get()
return withContext(dispatchers.io) {
tangemTechApi.createWallet(
body = WalletIdBodyConverter.convert(userWallet, conversionData),
)
}
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.data.common.wallet
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
/**
*
[REDACTED_AUTHOR]
*/
interface WalletServerBinder {
/**
* Binds a wallet to the server using its [userWalletId].
* Returns an [ApiResponse] indicating the result of the binding operation,
* or null if the binding was not performed.
*/
suspend fun bind(userWalletId: UserWalletId): ApiResponse<Unit>?
/**
* Binds a wallet to the server using the provided [userWallet].
* Returns an [ApiResponse] indicating the result of the binding operation.
*/
suspend fun bind(userWallet: UserWallet): ApiResponse<Unit>
}

View file

@ -1,10 +1,12 @@
package com.tangem.data.common.currency
import com.tangem.data.common.wallet.WalletServerBinder
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
@ -27,6 +29,8 @@ class UserTokensSaverTest {
private val accountsFeatureToggles = mockk<AccountsFeatureToggles> {
every { this@mockk.isFeatureEnabled } returns true
}
private val walletServerBinder: WalletServerBinder = mockk()
private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk()
private val userTokensSaver: UserTokensSaver = UserTokensSaver(
tangemTechApi = tangemTechApi,
@ -34,13 +38,21 @@ class UserTokensSaverTest {
userTokensResponseStore = userTokensResponseStore,
dispatchers = TestingCoroutineDispatcherProvider(),
addressesEnricher = enricher,
walletServerBinder = walletServerBinder,
appsFlyerConversionStore = appsFlyerConversionStore,
accountsFeatureToggles = accountsFeatureToggles,
pushTokensRetryerPool = mockk(),
)
@BeforeEach
fun resetMocks() {
clearMocks(tangemTechApi, userWalletsStore, userTokensResponseStore, enricher)
clearMocks(
tangemTechApi,
userWalletsStore,
userTokensResponseStore,
enricher,
walletServerBinder,
)
}
@Test
@ -109,7 +121,6 @@ class UserTokensSaverTest {
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { enricher(userWalletId, response) } returns enrichedResponse
coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
coEvery { tangemTechApi.createWallet(body = any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
// WHEN
userTokensSaver.push(

View file

@ -0,0 +1,226 @@
package com.tangem.data.common.wallet
import com.google.common.truth.Truth
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.WalletIdBody
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultWalletServerBinderTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk()
private val tangemTechApi: TangemTechApi = mockk()
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
private val binder = DefaultWalletServerBinder(
userWalletsStore = userWalletsStore,
appsFlyerConversionStore = appsFlyerConversionStore,
tangemTechApi = tangemTechApi,
dispatchers = dispatchers
)
private val userWalletId = UserWalletId("011")
private val userWallet = mockk<UserWallet.Cold> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.name } returns "Wallet"
}
private val conversionData = AppsFlyerConversionData(refcode = "refcode", campaign = "campaign")
@AfterEach
fun tearDown() {
clearMocks(userWalletsStore, appsFlyerConversionStore, tangemTechApi)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class BindUserWalletId {
@Test
fun `bind successfully`() = runTest {
val requestBody = WalletIdBody(
walletId = userWalletId.stringValue,
name = "Wallet",
walletType = WalletType.COLD,
refcode = "refcode",
campaign = "campaign",
)
val apiResponse = ApiResponse.Success(Unit)
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { appsFlyerConversionStore.get() } returns conversionData
coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse
val actual = binder.bind(userWalletId = userWalletId)
Truth.assertThat(actual).isEqualTo(apiResponse)
coVerifyOrder {
userWalletsStore.getSyncOrNull(userWalletId)
appsFlyerConversionStore.get()
tangemTechApi.createWallet(requestBody)
}
}
@Test
fun `bind will skipped if userWalletsStore returns null`() = runTest {
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns null
val actual = binder.bind(userWalletId = userWalletId)
Truth.assertThat(actual).isEqualTo(null)
coVerifyOrder {
userWalletsStore.getSyncOrNull(userWalletId)
}
coVerify(inverse = true) {
appsFlyerConversionStore.get()
tangemTechApi.createWallet(any())
}
}
@Test
fun `bind successfully if appsFlyerConversionStore returns null`() = runTest {
val requestBody = WalletIdBody(
walletId = userWalletId.stringValue,
name = "Wallet",
walletType = WalletType.COLD,
)
val apiResponse = ApiResponse.Success(Unit)
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { appsFlyerConversionStore.get() } returns null
coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse
val actual = binder.bind(userWalletId = userWalletId)
Truth.assertThat(actual).isEqualTo(apiResponse)
coVerifyOrder {
userWalletsStore.getSyncOrNull(userWalletId)
appsFlyerConversionStore.get()
tangemTechApi.createWallet(requestBody)
}
}
@Test
fun `bind returns error if request is failed`() = runTest {
val requestBody = WalletIdBody(
walletId = userWalletId.stringValue,
name = "Wallet",
walletType = WalletType.COLD,
refcode = "refcode",
campaign = "campaign",
)
val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException()) as ApiResponse<Unit>
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { appsFlyerConversionStore.get() } returns conversionData
coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse
val actual = binder.bind(userWalletId = userWalletId)
Truth.assertThat(actual).isEqualTo(apiResponse)
coVerifyOrder {
userWalletsStore.getSyncOrNull(userWalletId)
appsFlyerConversionStore.get()
tangemTechApi.createWallet(requestBody)
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class BindUserWallet {
@Test
fun `bind successfully`() = runTest {
val requestBody = WalletIdBody(
walletId = userWalletId.stringValue,
name = "Wallet",
walletType = WalletType.COLD,
refcode = "refcode",
campaign = "campaign",
)
val apiResponse = ApiResponse.Success(Unit)
coEvery { appsFlyerConversionStore.get() } returns conversionData
coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse
val actual = binder.bind(userWallet = userWallet)
Truth.assertThat(actual).isEqualTo(apiResponse)
coVerifyOrder {
appsFlyerConversionStore.get()
tangemTechApi.createWallet(requestBody)
}
}
@Test
fun `bind successfully if appsFlyerConversionStore returns null`() = runTest {
val requestBody = WalletIdBody(
walletId = userWalletId.stringValue,
name = "Wallet",
walletType = WalletType.COLD,
)
val apiResponse = ApiResponse.Success(Unit)
coEvery { appsFlyerConversionStore.get() } returns null
coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse
val actual = binder.bind(userWallet = userWallet)
Truth.assertThat(actual).isEqualTo(apiResponse)
coVerifyOrder {
appsFlyerConversionStore.get()
tangemTechApi.createWallet(requestBody)
}
}
@Test
fun `bind returns error if request is failed`() = runTest {
val requestBody = WalletIdBody(
walletId = userWalletId.stringValue,
name = "Wallet",
walletType = WalletType.COLD,
refcode = "refcode",
campaign = "campaign",
)
val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException()) as ApiResponse<Unit>
coEvery { appsFlyerConversionStore.get() } returns conversionData
coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse
val actual = binder.bind(userWallet = userWallet)
Truth.assertThat(actual).isEqualTo(apiResponse)
coVerifyOrder {
appsFlyerConversionStore.get()
tangemTechApi.createWallet(requestBody)
}
}
}
}