Updated on 2026-08-14
This commit is contained in:
commit
81bf4b5296
128 changed files with 4244 additions and 477 deletions
|
|
@ -192,6 +192,7 @@ abstract class BaseTestCase : TestCase(
|
|||
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
|
||||
"AND_15310_ADD_FUNDS_STAGE1" to true,
|
||||
"APP_REDESIGN_ENABLED" to true,
|
||||
"AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.ui.test.performTouchInput
|
|||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
|
||||
import com.tangem.common.extensions.assertVisibility
|
||||
import com.tangem.common.extensions.clickWhenEnabled
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
|
|
@ -294,6 +295,84 @@ fun BaseTestCase.chooseReceiveToken(tokenName: String) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From a clean start: open the main screen (cold by default, or an existing hot wallet when
|
||||
* [seedPhrase] is given), open Swap for [fromTokenName], choose [receiveTokenName] to receive and
|
||||
* enter [amount]. Scenario states stay in the test body.
|
||||
*/
|
||||
fun BaseTestCase.openSwapAmountScreen(
|
||||
fromTokenName: String,
|
||||
receiveTokenName: String,
|
||||
amount: String,
|
||||
seedPhrase: String? = null,
|
||||
) {
|
||||
if (seedPhrase == null) {
|
||||
step("Open 'Main' screen") {
|
||||
openMainScreen()
|
||||
}
|
||||
step("Synchronize addresses") {
|
||||
synchronizeAddresses()
|
||||
}
|
||||
} else {
|
||||
step("Open 'Main' screen with existing hot wallet") {
|
||||
openMainScreenWithExistingHotWallet(seedPhrase)
|
||||
}
|
||||
}
|
||||
step("Click on token with name: '$fromTokenName'") {
|
||||
onMainScreen { tokenWithTitleAndAddress(fromTokenName).clickWithAssertion() }
|
||||
}
|
||||
step("Open 'Swap' screen") {
|
||||
openSwapScreen(from = SwapEntryPoint.TokenDetails)
|
||||
}
|
||||
step("Choose receive token '$receiveTokenName'") {
|
||||
chooseReceiveToken(receiveTokenName)
|
||||
}
|
||||
step("Input swap amount '$amount'") {
|
||||
waitForIdle()
|
||||
onSwapTokenScreen {
|
||||
textInput.clickWithAssertion()
|
||||
textInput.performTextReplacement(amount)
|
||||
}
|
||||
}
|
||||
step("Wait for the receive amount to load") {
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
runCatching { onSwapTokenScreen { receiveAmount.assertIsDisplayed() } }.isSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the swap 'Network fee' bottom sheet, retrying the click until the fee selector shows.
|
||||
* Single action without its own step — wrap the call in a `step(...)`.
|
||||
*/
|
||||
fun BaseTestCase.openSwapNetworkFeeSelector() {
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
|
||||
runCatching { onSwapTokenScreen { networkFeeBlock.performClick() } }
|
||||
runCatching { onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } }.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the fee selector and switches the fee-paying token from [currentFeeToken] to [newFeeToken],
|
||||
* then applies. Works both ways — coin -> stablecoin and back.
|
||||
*/
|
||||
fun BaseTestCase.switchFeeTokenAndApply(currentFeeToken: String, newFeeToken: String) {
|
||||
step("Open the 'Network fee' bottom sheet") {
|
||||
openSwapNetworkFeeSelector()
|
||||
}
|
||||
step("Click on '$currentFeeToken' fee token to open 'Choose token'") {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(currentFeeToken).performClick() }
|
||||
}
|
||||
step("Select '$newFeeToken' as the fee-paying token") {
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(newFeeToken).performClick() } }.isSuccess
|
||||
}
|
||||
}
|
||||
step("Click on 'Apply' button") {
|
||||
onSendFeeSelectorBottomSheet { applyButton.performClick() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds the last BASE_BUTTON; enters [accessCode] if a hot wallet prompts for it. */
|
||||
fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) {
|
||||
val buttonMatcher = hasTestTag(BaseButtonTestTags.BUTTON)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
fun feeBlockCurrency(symbol: String): KNode = child {
|
||||
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
|
||||
hasAnyDescendant(withText(symbol))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val receiveAmountShimmer: KNode = child {
|
||||
hasTestTag(SwapTokenScreenTestTags.RECEIVE_AMOUNT_SHIMMER)
|
||||
}
|
||||
|
|
@ -72,6 +78,11 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val bestRateText: KNode = child {
|
||||
hasText(getResourceString(R.string.express_provider_best_rate))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val errorNotificationTitle: KNode = child {
|
||||
hasTestTag(NotificationTestTags.TITLE)
|
||||
useUnmergedTree = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,542 @@
|
|||
package com.tangem.tests.swap
|
||||
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.R as CommonR
|
||||
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
|
||||
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
|
||||
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.common.extensions.extractText
|
||||
import com.tangem.common.utils.resetWireMockScenarioState
|
||||
import com.tangem.common.utils.setWireMockScenarioState
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.scenarios.*
|
||||
import com.tangem.screens.*
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
import io.qameta.allure.kotlin.AllureId
|
||||
import io.qameta.allure.kotlin.junit4.DisplayName
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Gasless swap on a CEX route, paying the swap network fee with a stablecoin. Covers the best-rate
|
||||
* block / unchanged rate (5117), the fee-token selector bottom sheet (5111), the network-fee
|
||||
* selection for a USDC -> POL swap (5110), the signed swap reaching the provider (5116, hot wallet),
|
||||
* the insufficient-stablecoin-balance-for-fee error (5118), the stablecoin fee shown with its
|
||||
* fiat equivalent (5112), switching the fee token between the coin and the stablecoin (5114), and the
|
||||
* fee-selection options when paying with a token (5115), and the max amount reserving the fee (5113).
|
||||
* Validation cases run on the default (cold) wallet without signing.
|
||||
*/
|
||||
@HiltAndroidTest
|
||||
class GaslessSwapTest : BaseTestCase() {
|
||||
|
||||
private val tokenName = "USDC"
|
||||
private val currencySymbol = "USDC"
|
||||
private val swapTokenName = "Ethereum"
|
||||
private val nativeTokenName = "Polygon"
|
||||
private val inputAmount = "50"
|
||||
private val userTokensState = "PolygonUSDCEthereum"
|
||||
private val quotesState = "PolygonUSDC"
|
||||
private val assetsScenarioName = "express_api_assets"
|
||||
private val assetsExchangeEnabledState = "BitcoinExchangeEnabled"
|
||||
|
||||
@AllureId("5117")
|
||||
@DisplayName("Gasless Swap: CEX best rate stays the same when the fee is paid with a stablecoin")
|
||||
@Test
|
||||
fun checkBestRateUnchangedWithStablecoinFeeTest() {
|
||||
var capturedReceiveAmount: String? = null
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
|
||||
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
|
||||
}
|
||||
step("Assert 'Best rate' label is displayed") {
|
||||
onSwapTokenScreen {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { bestRateText.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Capture the received amount (exchange rate) before changing the fee token") {
|
||||
onSwapTokenScreen { capturedReceiveAmount = receiveAmount.extractText() }
|
||||
}
|
||||
step("Pay the network fee with the stablecoin '$tokenName'") {
|
||||
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
|
||||
}
|
||||
step("Assert the received amount (rate) is unchanged after selecting the stablecoin fee") {
|
||||
val expected = requireNotNull(capturedReceiveAmount) { "Receive amount was not captured" }
|
||||
onSwapTokenScreen {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { receiveAmount.assertTextEquals(expected) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5111")
|
||||
@DisplayName("Gasless Swap: fee-token selector — coin has a speed choice, stablecoin only Market, token is selectable")
|
||||
@Test
|
||||
fun checkFeeSelectorBottomSheetTest() {
|
||||
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
|
||||
val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast)
|
||||
val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow)
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
|
||||
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
|
||||
}
|
||||
step("Open the 'Network fee' bottom sheet") {
|
||||
openSwapNetworkFeeSelector()
|
||||
}
|
||||
step("Assert the '$nativeTokenName' fee coin is displayed") {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).assertIsDisplayed() }
|
||||
}
|
||||
step("Open 'Choose speed' for the '$nativeTokenName' fee") {
|
||||
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
|
||||
}
|
||||
step("Assert 'Choose speed' offers multiple speeds ('$marketSpeed', '$fastSpeed') for the coin") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendSelectNetworkFeeBottomSheet {
|
||||
chooseSpeedTitle.assertIsDisplayed()
|
||||
regularFeeSelectorItem(marketSpeed).assertIsDisplayed()
|
||||
regularFeeSelectorItem(fastSpeed).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("Select '$marketSpeed' speed") {
|
||||
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSpeed).performClick() }
|
||||
}
|
||||
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
|
||||
}
|
||||
}
|
||||
step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") {
|
||||
onSendFeeSelectorBottomSheet {
|
||||
chooseTokenTitle.assertIsDisplayed()
|
||||
feeTokenItem(tokenName).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
step("Select '$tokenName' as the fee-paying token") {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
|
||||
}
|
||||
step("Assert only '$marketSpeed' speed is available for the stablecoin fee") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendFeeSelectorBottomSheet {
|
||||
feeSpeedItemTitle(marketSpeed).assertIsDisplayed()
|
||||
feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed()
|
||||
feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("Click on '$marketSpeed' fee row") {
|
||||
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
|
||||
}
|
||||
step("Assert 'Choose speed' bottom sheet did not open for the stablecoin fee") {
|
||||
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() }
|
||||
}
|
||||
step("Click on 'Apply' button") {
|
||||
onSendFeeSelectorBottomSheet { applyButton.performClick() }
|
||||
}
|
||||
step("Assert the network fee is shown in '$currencySymbol' on the swap screen") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5110")
|
||||
@DisplayName("Gasless Swap: network fee with fee-token selection is shown for a USDC -> POL swap")
|
||||
@Test
|
||||
fun checkNetworkFeeSelectionForSwapTest() {
|
||||
val receiveTokenName = "Polygon"
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
|
||||
step("Open the swap amount screen for '$tokenName' -> '$receiveTokenName'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = receiveTokenName, amount = inputAmount)
|
||||
}
|
||||
step("Assert 'Network fee' block with token selection is displayed") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen {
|
||||
networkFeeBlock.assertIsDisplayed()
|
||||
selectFeeIcon.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("Open the 'Network fee' bottom sheet") {
|
||||
openSwapNetworkFeeSelector()
|
||||
}
|
||||
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
|
||||
}
|
||||
step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendFeeSelectorBottomSheet {
|
||||
chooseTokenTitle.assertIsDisplayed()
|
||||
feeTokenItem(tokenName).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5116")
|
||||
@DisplayName("Gasless Swap: sign a swap paying the fee with the stablecoin and reach the provider")
|
||||
@Test
|
||||
fun checkSignSwapWithStablecoinFeeTest() {
|
||||
val hotWalletTokensState = "PolygonUSDCHotWallet"
|
||||
val receiveTokenName = "Polygon"
|
||||
val providerName = "Changelly"
|
||||
val exchangeStatusScenario = "exchange_status_provider"
|
||||
val changellyStatusState = "Changelly"
|
||||
val expressStatusItemTitle = getResourceString(CommonR.string.express_exchange_by, providerName)
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
resetWireMockScenarioState(exchangeStatusScenario)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") {
|
||||
setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState)
|
||||
}
|
||||
|
||||
step("Open the swap amount screen for '$tokenName' -> '$receiveTokenName' on an existing hot wallet") {
|
||||
openSwapAmountScreen(
|
||||
fromTokenName = tokenName,
|
||||
receiveTokenName = receiveTokenName,
|
||||
amount = inputAmount,
|
||||
seedPhrase = SVS_SEED_PHRASE_12,
|
||||
)
|
||||
}
|
||||
step("Pay the network fee with the stablecoin '$tokenName'") {
|
||||
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
|
||||
}
|
||||
step("Assert the network fee is paid in '$currencySymbol'") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen {
|
||||
feeBlockCurrency(currencySymbol).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("Confirm the swap by holding the 'Swap' button and sign") {
|
||||
confirmSwapByHolding()
|
||||
}
|
||||
step("Assert the 'Swap in progress' screen is displayed (transaction sent to provider)") {
|
||||
onSwapSuccessScreen {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { title.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Click on 'Close' button") {
|
||||
onSwapSuccessScreen { closeButton.performClick() }
|
||||
}
|
||||
step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5118")
|
||||
@DisplayName("Gasless Swap: insufficient stablecoin balance to cover the fee shows an error and blocks the swap")
|
||||
@Test
|
||||
fun checkInsufficientBalanceForFeeTest() {
|
||||
val usdcBalanceScenario = "polygon_usdc_balance"
|
||||
val lowBalanceState = "LowBalance"
|
||||
val lowAmount = "0.0005"
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
resetWireMockScenarioState(usdcBalanceScenario)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
|
||||
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
|
||||
}
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = lowAmount)
|
||||
}
|
||||
step("Open the 'Network fee' bottom sheet") {
|
||||
openSwapNetworkFeeSelector()
|
||||
}
|
||||
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
|
||||
}
|
||||
step("Select '$tokenName' as the fee-paying token") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
|
||||
}
|
||||
}
|
||||
step("Assert 'Not enough funds' error is displayed in the fee selector") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") {
|
||||
onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5112")
|
||||
@DisplayName("Gasless Swap: the stablecoin network fee is shown with its fiat (dollar) equivalent")
|
||||
@Test
|
||||
fun checkStablecoinFeeShownWithFiatTest() {
|
||||
val fiatSign = "$"
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
|
||||
}
|
||||
step("Pay the network fee with the stablecoin '$tokenName'") {
|
||||
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
|
||||
}
|
||||
step("Assert the network fee is shown in '$currencySymbol' with its fiat ('$fiatSign') equivalent") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen {
|
||||
feeBlockCurrency(currencySymbol).assertIsDisplayed()
|
||||
feeAmount.assertTextContains(fiatSign, substring = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5114")
|
||||
@DisplayName("Gasless Swap: switching the fee token from the coin to the stablecoin and back updates the summary")
|
||||
@Test
|
||||
fun checkSwitchFeeTokenBetweenCoinAndStablecoinTest() {
|
||||
val nativeSymbol = "POL"
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
|
||||
}
|
||||
step("Switch the fee token from the coin '$nativeTokenName' to the stablecoin '$tokenName'") {
|
||||
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
|
||||
}
|
||||
step("Assert the network fee is now paid in '$currencySymbol' on the summary") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Switch the fee token from the stablecoin '$tokenName' back to the coin '$nativeTokenName'") {
|
||||
switchFeeTokenAndApply(currentFeeToken = tokenName, newFeeToken = nativeTokenName)
|
||||
}
|
||||
step("Assert the network fee is back to the coin ('$nativeSymbol') on the summary") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5115")
|
||||
@DisplayName("Gasless Swap: fee selector offers token selection and no speed choice when paying with a token")
|
||||
@Test
|
||||
fun checkFeeSelectionOptionsTest() {
|
||||
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
|
||||
val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast)
|
||||
val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow)
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
|
||||
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
|
||||
}
|
||||
step("Open the 'Network fee' bottom sheet") {
|
||||
openSwapNetworkFeeSelector()
|
||||
}
|
||||
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
|
||||
}
|
||||
step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") {
|
||||
onSendFeeSelectorBottomSheet {
|
||||
chooseTokenTitle.assertIsDisplayed()
|
||||
feeTokenItem(tokenName).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
step("Select '$tokenName' as the fee-paying token") {
|
||||
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
|
||||
}
|
||||
step("Assert only '$marketSpeed' speed is available for the token fee") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSendFeeSelectorBottomSheet {
|
||||
feeSpeedItemTitle(marketSpeed).assertIsDisplayed()
|
||||
feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed()
|
||||
feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
step("Click on '$marketSpeed' fee row") {
|
||||
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
|
||||
}
|
||||
step("Assert 'Choose speed' bottom sheet did not open when paying with a token") {
|
||||
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() }
|
||||
}
|
||||
step("Click on 'Apply' button") {
|
||||
onSendFeeSelectorBottomSheet { applyButton.performClick() }
|
||||
}
|
||||
step("Assert the network fee is shown in '$currencySymbol' on the summary") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllureId("5113")
|
||||
@DisplayName("Gasless Swap: the max amount reserves the stablecoin fee and stays valid without an insufficient error")
|
||||
@Test
|
||||
fun checkMaxAmountReservesFeeTest() {
|
||||
val maxInputAmount = "100"
|
||||
|
||||
setupHooks(
|
||||
additionalAfterSection = {
|
||||
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
|
||||
resetWireMockScenarioState(QUOTES_API_SCENARIO)
|
||||
resetWireMockScenarioState(assetsScenarioName)
|
||||
}
|
||||
).run {
|
||||
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
|
||||
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
|
||||
}
|
||||
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
|
||||
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
|
||||
}
|
||||
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
|
||||
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
|
||||
}
|
||||
|
||||
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName' with the max amount '$maxInputAmount'") {
|
||||
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = maxInputAmount)
|
||||
}
|
||||
step("Pay the network fee with the stablecoin '$tokenName'") {
|
||||
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
|
||||
}
|
||||
step("Assert no insufficient-balance error and 'Swap' is enabled (the fee is reserved from the max amount)") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen {
|
||||
insufficientFundsErrorTitle.assertIsNotDisplayed()
|
||||
swapButton.assertIsEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 97ff5929f9ff4da53190eb10e94c45ac3bd05093
|
||||
Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.tap.common.libs.blockchainsdk
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.tangem.Message
|
||||
import com.tangem.TangemSdk
|
||||
import com.tangem.blockchain.common.TransactionSigner
|
||||
|
|
@ -7,22 +8,70 @@ import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm
|
|||
import com.tangem.core.analytics.store.LastSignedWalletFormStore
|
||||
import com.tangem.data.card.TransactionSignerFactory
|
||||
import com.tangem.domain.card.models.TwinKey
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.update
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.tap.domain.TangemSigner
|
||||
import com.tangem.tap.domain.TangemSignerResponse
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class DefaultTransactionSignerFactory(
|
||||
private val lastSignedWalletFormStore: LastSignedWalletFormStore,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val coroutineScope: AppCoroutineScope,
|
||||
) : TransactionSignerFactory {
|
||||
|
||||
override fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner {
|
||||
override fun createTransactionSigner(
|
||||
cardId: String?,
|
||||
sdk: TangemSdk,
|
||||
twinKey: TwinKey?,
|
||||
userWalletId: UserWalletId,
|
||||
): TransactionSigner {
|
||||
return TangemSigner(
|
||||
cardId = cardId,
|
||||
tangemSdk = sdk,
|
||||
initialMessage = Message(),
|
||||
twinKey = twinKey,
|
||||
) { signResponse ->
|
||||
lastSignedWalletFormStore.update(
|
||||
if (signResponse.isRing) WalletForm.Ring else WalletForm.Card,
|
||||
)
|
||||
onSignerResponse(userWalletId, signResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun onSignerResponse(userWalletId: UserWalletId, signResponse: TangemSignerResponse) {
|
||||
lastSignedWalletFormStore.update(
|
||||
if (signResponse.isRing) WalletForm.Ring else WalletForm.Card,
|
||||
)
|
||||
|
||||
coroutineScope.launch {
|
||||
userWalletsListRepository.update(userWalletId) { userWallet ->
|
||||
userWallet.updateSignedHashes(signResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserWallet.updateSignedHashes(signResponse: TangemSignerResponse): UserWallet {
|
||||
if (this !is UserWallet.Cold) return this
|
||||
|
||||
return copy(
|
||||
scanResponse = scanResponse.copy(
|
||||
card = scanResponse.card.copy(
|
||||
wallets = scanResponse.card.wallets.map { wallet ->
|
||||
if (wallet.publicKey.contentEquals(signResponse.signedWalletPublicKey)) {
|
||||
wallet.copy(
|
||||
// Keep previously known counters if the signer response does not provide them,
|
||||
// otherwise we would regress the UI counters to null.
|
||||
totalSignedHashes = signResponse.totalSignedHashes ?: wallet.totalSignedHashes,
|
||||
remainingSignatures = signResponse.remainingSignatures ?: wallet.remainingSignatures,
|
||||
)
|
||||
} else {
|
||||
wallet
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.tangem.data.wallets.hot.TangemHotWalletSigner
|
|||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.supplier.SingleAccountListSupplier
|
||||
import com.tangem.domain.card.repository.CardSdkConfigRepository
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
|
||||
import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase
|
||||
|
|
@ -263,6 +264,7 @@ internal object TransactionDomainModule {
|
|||
getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase,
|
||||
dynamicAddressesRepository: DynamicAddressesRepository,
|
||||
dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
): ReceiveAddressesFactory {
|
||||
return ReceiveAddressesFactory(
|
||||
getEnsNameUseCase = getEnsNameUseCase,
|
||||
|
|
@ -270,6 +272,7 @@ internal object TransactionDomainModule {
|
|||
getDynamicReceiveAddressUseCase = getDynamicReceiveAddressUseCase,
|
||||
dynamicAddressesRepository = dynamicAddressesRepository,
|
||||
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ internal object WalletConnectDomainModule {
|
|||
cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse),
|
||||
userWalletId = wallet.walletId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package com.tangem.tap.di.libs.blockchainsdk
|
|||
|
||||
import com.tangem.core.analytics.store.LastSignedWalletFormStore
|
||||
import com.tangem.data.card.TransactionSignerFactory
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.tap.common.libs.blockchainsdk.DefaultTransactionSignerFactory
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -20,7 +22,13 @@ internal class TransactionSignerFactoryModule {
|
|||
@Singleton
|
||||
fun provideTransactionSignerFactory(
|
||||
lastSignedWalletFormStore: LastSignedWalletFormStore,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
appCoroutineScope: AppCoroutineScope,
|
||||
): TransactionSignerFactory {
|
||||
return DefaultTransactionSignerFactory(lastSignedWalletFormStore)
|
||||
return DefaultTransactionSignerFactory(
|
||||
lastSignedWalletFormStore = lastSignedWalletFormStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
coroutineScope = appCoroutineScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ class TangemSigner(
|
|||
totalSignedHashes = result.data.totalSignedHashes,
|
||||
remainingSignatures = result.data.remainingSignatures,
|
||||
isRing = result.data.batchId?.let(::isRing) == true,
|
||||
signedWalletPublicKey = publicKey.seedKey,
|
||||
),
|
||||
)
|
||||
if (continuation.isActive) {
|
||||
|
|
@ -86,6 +87,7 @@ class TangemSigner(
|
|||
totalSignedHashes = result.data.totalSignedHashes,
|
||||
remainingSignatures = result.data.remainingSignatures,
|
||||
isRing = result.data.batchId?.let(::isRing) == true,
|
||||
signedWalletPublicKey = publicKey.seedKey,
|
||||
),
|
||||
)
|
||||
if (continuation.isActive) {
|
||||
|
|
@ -102,8 +104,10 @@ class TangemSigner(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("ArrayInDataClass")
|
||||
data class TangemSignerResponse(
|
||||
val totalSignedHashes: Int?,
|
||||
val remainingSignatures: Int?,
|
||||
val isRing: Boolean,
|
||||
val signedWalletPublicKey: ByteArray,
|
||||
)
|
||||
|
|
@ -339,7 +339,6 @@ internal class ChildFactory @Inject constructor(
|
|||
cryptoAmount = tangemPayInput.cryptoAmount,
|
||||
fiatAmount = tangemPayInput.fiatAmount,
|
||||
depositAddress = tangemPayInput.depositAddress,
|
||||
isWithdrawal = tangemPayInput.isWithdrawal,
|
||||
)
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
package com.tangem.tap.common.libs.blockchainsdk
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm
|
||||
import com.tangem.core.analytics.store.LastSignedWalletFormStore
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.tap.domain.TangemSignerResponse
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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 DefaultTransactionSignerFactoryTest {
|
||||
|
||||
private val lastSignedWalletFormStore = mockk<LastSignedWalletFormStore>(relaxed = true)
|
||||
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
|
||||
|
||||
private val factory = DefaultTransactionSignerFactory(
|
||||
lastSignedWalletFormStore = lastSignedWalletFormStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
coroutineScope = TestAppCoroutineScope(),
|
||||
)
|
||||
|
||||
private val baseWallet = MockUserWalletFactory.create()
|
||||
|
||||
/** Wallet that will be the target of the signing operation. */
|
||||
private val walletA = baseWallet.scanResponse.card.wallets.first().copy(
|
||||
publicKey = PUBLIC_KEY_A,
|
||||
totalSignedHashes = 0,
|
||||
remainingSignatures = 100,
|
||||
)
|
||||
|
||||
/** Another wallet that must stay untouched after signing with [walletA]'s key. */
|
||||
private val walletB = baseWallet.scanResponse.card.wallets.first().copy(
|
||||
publicKey = PUBLIC_KEY_B,
|
||||
totalSignedHashes = 7,
|
||||
remainingSignatures = 50,
|
||||
)
|
||||
|
||||
private val userWallet = baseWallet.copy(
|
||||
scanResponse = baseWallet.scanResponse.copy(
|
||||
card = baseWallet.scanResponse.card.copy(wallets = listOf(walletA, walletB)),
|
||||
),
|
||||
)
|
||||
|
||||
private val savedWalletSlot = slot<UserWallet>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(lastSignedWalletFormStore, userWalletsListRepository)
|
||||
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||
coEvery { userWalletsListRepository.saveWithoutLock(capture(savedWalletSlot), any()) } answers {
|
||||
savedWalletSlot.captured.right()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates signed hashes only for the wallet matching the signed public key`() {
|
||||
factory.onSignerResponse(
|
||||
userWalletId = userWallet.walletId,
|
||||
signResponse = signerResponse(
|
||||
signedWalletPublicKey = PUBLIC_KEY_A,
|
||||
totalSignedHashes = 5,
|
||||
remainingSignatures = 95,
|
||||
),
|
||||
)
|
||||
|
||||
val savedWallets = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets
|
||||
val savedA = savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_A) }
|
||||
val savedB = savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_B) }
|
||||
|
||||
assertThat(savedA.totalSignedHashes).isEqualTo(5)
|
||||
assertThat(savedA.remainingSignatures).isEqualTo(95)
|
||||
// The non-signed wallet must keep its original values.
|
||||
assertThat(savedB.totalSignedHashes).isEqualTo(7)
|
||||
assertThat(savedB.remainingSignatures).isEqualTo(50)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps previously known counters when the signer response has null values`() {
|
||||
factory.onSignerResponse(
|
||||
userWalletId = userWallet.walletId,
|
||||
signResponse = signerResponse(
|
||||
signedWalletPublicKey = PUBLIC_KEY_A,
|
||||
totalSignedHashes = null,
|
||||
remainingSignatures = null,
|
||||
),
|
||||
)
|
||||
|
||||
val savedA = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets
|
||||
.first { it.publicKey.contentEquals(PUBLIC_KEY_A) }
|
||||
|
||||
// Null response values must not overwrite the known counters.
|
||||
assertThat(savedA.totalSignedHashes).isEqualTo(0)
|
||||
assertThat(savedA.remainingSignatures).isEqualTo(100)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves all wallets untouched when no public key matches`() {
|
||||
factory.onSignerResponse(
|
||||
userWalletId = userWallet.walletId,
|
||||
signResponse = signerResponse(
|
||||
signedWalletPublicKey = UNKNOWN_PUBLIC_KEY,
|
||||
totalSignedHashes = 5,
|
||||
remainingSignatures = 95,
|
||||
),
|
||||
)
|
||||
|
||||
val savedWallets = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets
|
||||
assertThat(savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_A) }.totalSignedHashes).isEqualTo(0)
|
||||
assertThat(savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_B) }.totalSignedHashes).isEqualTo(7)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates last signed wallet form with Card for a non-ring response`() {
|
||||
factory.onSignerResponse(
|
||||
userWalletId = userWallet.walletId,
|
||||
signResponse = signerResponse(signedWalletPublicKey = PUBLIC_KEY_A, isRing = false),
|
||||
)
|
||||
|
||||
verify(exactly = 1) { lastSignedWalletFormStore.update(WalletForm.Card) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates last signed wallet form with Ring for a ring response`() {
|
||||
factory.onSignerResponse(
|
||||
userWalletId = userWallet.walletId,
|
||||
signResponse = signerResponse(signedWalletPublicKey = PUBLIC_KEY_A, isRing = true),
|
||||
)
|
||||
|
||||
verify(exactly = 1) { lastSignedWalletFormStore.update(WalletForm.Ring) }
|
||||
}
|
||||
|
||||
private fun signerResponse(
|
||||
signedWalletPublicKey: ByteArray,
|
||||
totalSignedHashes: Int? = 1,
|
||||
remainingSignatures: Int? = 1,
|
||||
isRing: Boolean = false,
|
||||
) = TangemSignerResponse(
|
||||
totalSignedHashes = totalSignedHashes,
|
||||
remainingSignatures = remainingSignatures,
|
||||
isRing = isRing,
|
||||
signedWalletPublicKey = signedWalletPublicKey,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val PUBLIC_KEY_A = byteArrayOf(1, 2, 3)
|
||||
val PUBLIC_KEY_B = byteArrayOf(4, 5, 6)
|
||||
val UNKNOWN_PUBLIC_KEY = byteArrayOf(9, 9, 9)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue